[data-reveal]{opacity:1!important;transform:none!important}
Extending The Dashboard

Custom data types

Register your own DataType — a backend type declaration paired with the frontend Vue that renders its values.

The built-in catalog (Data types) covers most fields; a custom DataType covers the rest. A custom type is two halves joined by one id:

  • The backend declares the type's identity: its validation, its compare modes, and the names of its input and filter widgets. None of this is Vue — components are referred to by string only.
  • The frontend — a plugin in your frontend layer — supplies the read side: how a stored value renders in table cells and read views.

The two halves never share code. Use the same id on both sides and the wiring is automatic; the per-field options object travels from the backend to every frontend hook.

The backend half

Subclass DataType, decorate the class with @RegisterDataType("<id>"), and import the file once in your module — the decorator registers the type as an import-time side effect. The constructor passes three things up to super(...): the compare modes the type offers, its default mode, and the per-field options. The class then implements two members:

  • getValidation() — the Zod schema validating the value before it is persisted.
  • defaultInputComponent() — the input widget, built with a FormComponents.* helper (InputText, InputNumber, InputCheckbox, …). Each helper returns a componentName string plus its options — never a Vue component. The form input and the filter inputs both derive from this one implementation; override the public inputComponent() / filterComponents() only when forms and filters need different widgets.

Three optional hooks refine the behavior: filter() customizes how a filter value becomes a database predicate, filterComponents() gives specific compare modes their own widget, and decorateField() attaches extra field decorators (the relation types use it).

The frontend half

From a plugin in your layer, register the same id with useDataTypes().registerDataType({...}). Registration is last-wins per id, and every field besides id is optional — they all concern reading values:

FieldPurpose
formatterA map of (value, locale, options?) => string | VNode functions, keyed by compare mode with a required default. Renders table cells; per-mode entries format filter chips.
displayComponentA read-only Vue component for the form's read view — it receives :model-value and takes precedence over formatter there. Any component works; wrap a heavy one in defineAsyncComponent to keep its code out of the startup bundle.
beforeStateMapper(value, options?) => value — reshapes the server value before the form state is seeded. Must be idempotent: the form reset re-applies it to values that may already be mapped. The built-in relation type uses it to turn populated objects back into bare ids.
The frontend object carries noinputComponent or filterComponents — input and filter widgets are decided on the backend and shipped as componentName strings. The frontend half only owns read rendering.

One subtlety on formatter: in table cells its options argument is the field's backend options, but in the form read view the render site passes its own props object instead — don't write a formatter that relies on backend options being present.

Worked example: a rating type

A 0–5 star rating. The backend declares the type and reuses the built-in numeric input; the frontend renders filled stars in cells and read views.

Backend — identity, validation, and the input widget:

src/data-types/rating-type.ts
import { z } from "zod";
import { FormComponents } from "@antelopejs-private/cms/interfaces/cms-base/form";
import {
  DataType,
  DefaultDataCompareTypes,
  RegisterDataType,
} from "@antelopejs-private/cms/interfaces/cms-base/data-types";

@RegisterDataType("rating")
export class RatingType extends DataType {
  constructor(public readonly options: { max?: number } = { max: 5 }) {
    super(
      [DefaultDataCompareTypes.Is, DefaultDataCompareTypes.IsNot], // compare modes
      DefaultDataCompareTypes.Is, // default mode
      options as Record<string, unknown>,
    );
  }

  protected defaultInputComponent() {
    // Reuse the built-in numeric input.
    const component = FormComponents.InputNumber({
      min: 0,
      max: this.options.max ?? 5,
      step: 1,
    });
    component.options = { ...component.options, ...this.options };
    return component;
  }

  getValidation() {
    return z
      .number()
      .min(0)
      .max(this.options.max ?? 5);
  }
}

Import the file once and use the type like any built-in: type: new RatingType({ max: 5 }) on a Form field (Forms) or a TableView column (Tables). If the type's options carry $-prefixed labels, add the matching i18n keys to every locale file (Localization).

Frontend — the matching id, a star formatter, and an async display component:

app/plugins/register-rating.ts
import { defineAsyncComponent, h } from "vue";
import UIcon from "@nuxt/ui/components/Icon.vue";

export default defineNuxtPlugin(() => {
  const { registerDataType } = useDataTypes();

  registerDataType({
    id: "rating", // must equal @RegisterDataType("rating")
    formatter: {
      default: (value, _locale, options) => {
        const max = (options as { max?: number })?.max ?? 5;
        const filled = Math.round(Number(value) || 0);
        return h(
          "span",
          { class: "inline-flex items-center gap-0.5" },
          Array.from({ length: max }, (_, i) =>
            h(UIcon, {
              name: i < filled ? "i-ph-star-fill" : "i-ph-star",
              class: "size-4",
            }),
          ),
        );
      },
    },
    displayComponent: defineAsyncComponent(() => import("../components/DisplayRating.vue")),
  });
});

Note what is absent: the frontend names no input or filter component — those came from the backend's defaultInputComponent() and resolve by name.