Custom data types
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 aFormComponents.*helper (InputText,InputNumber,InputCheckbox, …). Each helper returns acomponentNamestring plus its options — never a Vue component. The form input and the filter inputs both derive from this one implementation; override the publicinputComponent()/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:
| Field | Purpose |
|---|---|
formatter | A 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. |
displayComponent | A 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. |
inputComponent 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:
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:
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.
Theming & branding
Put your logo and palette on the dashboard — the branding app-config surface, Nuxt UI color tokens, CSS variables, fonts, and interface scale.
TableView displays
Register an alternate TableView body renderer — grid, kanban, cards, calendar — that inherits the table's search, filters, sort, tabs, pagination, selection, and row actions.