Pages & components
A page is a class: components attach to it as static fields, and the shared Nuxt frontend renders the resulting description. For standard screens you write no Vue at all.
Declare a page
A page extends PageController(id, options[, layout]) and carries the @RegisterPage() decorator. Registration happens at import time: your module must import the file during startup — directly or through a barrel like import "./pages" — or the decorator never runs and the page never appears.
The smallest page is an id plus a few menu options:
import {
PageController,
RegisterPage,
pagesCategory,
} from "@antelopejs-private/cms/interfaces/cms/page";
@RegisterPage()
export class WelcomePage extends PageController("welcome", {
displayName: "Welcome",
icon: "i-ph-house",
category: pagesCategory, // group under the dashboard's Pages section
order: 0,
description: "A welcome page",
}) {}
Page options (MenuOptions)
The second argument is a MenuOptions object. The table covers the fields you'll actually set; the MenuOptions type — exported next to PageController — is the authoritative shape.
| Field | Purpose |
|---|---|
displayName | Sidebar label (use a $cms_x.y i18n key in real modules). |
icon | Iconify name, e.g. i-ph-table. |
category | Group the page under a Category (the built-in pagesCategory, or your own) — a project's usual anchor. |
module | Place the page in a RegisterModule'd module (by its id); it then lives in that module's own sidebar. Works together with category. |
urlSlug | URL segment; defaults to the page id. |
order | Sort order within its group. |
description | Help text. |
hidden | Register the page but keep it out of the nav. |
publicAccess / authOnly / permission / noComponentPermissions | Auth — see Auth & permissions. |
bypassTenantAccessGate | Keep the page reachable while a tenant access gate denies the tenant — see Tenant access gate. |
validation / setupId | Page-level validation and a mount-time setup function — see Page setup and validation. |
type | Menu-item type: "link" (the default — a navigable page) or "label" (a non-navigable heading entry). |
query | Query parameters carried by the page's menu link, so one page can back several entries — see Entries sharing one page. |
variant / status | Semantic look of the menu entry: "accent" for a call to action, and a state dot — see Entry appearance. |
Page setup and validation
Two MenuOptions fields hook into the page's frontend lifecycle. Both name a function your frontend layer registers under a string id with useDefinedFunctions().registerFunction(id, fn) (Component events):
validation—{ requiredQueryParams?, customFunctionId? }. Before showing the page, the renderer checks that the listed query params exist and runs the custom function when one is named; a failure renders the error state instead. Use it for pages that are meaningless without context — a detail page opened without its?id.setupId— names a page setup function, run when the page mounts. The function receives an event-bus view of the page — thepageInfo, the user'spermissions, andon/emitfunctions keyed by component and event — and may return a cleanup callback, run on unmount. Use it to orchestrate several components at once without shipping a custom component. Page context has the contract and a worked example.
Layouts
The optional third argument to PageController wraps the page in a layout. A layout is just another component description — the same ComponentInfo shape your components compile to — so any registered frontend component can play the role. Omit it and the page uses the default layout.
Three built-in layouts ship:
DefaultLayout(options?)— the standard dashboard frame.fullWidthcontrols the content max-width and defaults totrue;hideHeadersuppresses the page header.FormPageLayout(options?)— the same frame withfullWidth: false, for a page that only holds a form.EmptyLayout()— no chrome at all; the built-in auth and onboarding pages use it. Reach for it when a page must fill the viewport on its own.
Dashboard pages span the whole panel by default — tables, dashboards and card grids all want the room. Pages that only hold a form keep a constrained column, which stays easier to read: the built-in profile, notifications, appearance and shortcuts settings pages do that, and so do the new / edit / view pages a TableView generates for formContainer: { type: "page" }.
import { DefaultLayout } from "@antelopejs-private/cms/interfaces/cms-base/layouts";
// full-width, page header shown: no layout argument needed
export class TasksPage extends PageController("tasks", {
displayName: "Tasks",
icon: "i-ph-table",
category: pagesCategory,
}) {
/* … */
}
// full-width, but the component draws its own header
export class BoardPage extends PageController(
"board",
{ displayName: "Board", icon: "i-ph-kanban", category: pagesCategory },
DefaultLayout({ hideHeader: true }),
) {
/* … */
}
A page that only holds a form takes FormPageLayout() — DefaultLayout({ fullWidth: false }) with a name:
import { FormPageLayout } from "@antelopejs-private/cms/interfaces/cms-base/layouts";
export class PreferencesPage extends PageController(
"preferences",
{ displayName: "Preferences", icon: "i-ph-sliders", category: pagesCategory },
FormPageLayout(),
) {
/* … */
}
Because a layout is only a component description, a page can render inside a component from your own Nuxt layer. Point the layout at it by componentName — the same name your layer registers it under:
export class ReportPage extends PageController(
"report",
{ displayName: "Report", category: pagesCategory },
// a custom layout — your Nuxt layer registers a component under this name:
{ componentName: "MyLayout", options: { fullWidth: true } },
) {}
Where a page lives
category places the page in the navigation tree on the left: nested under the built-in pagesCategory, or under a Category of your own.
A page that also sets module moves into that module's own, separate sidebar, with category still grouping it inside (Distributable module). The tree itself — root groups, categories, quick actions — is covered in Navigation.
The category chain also fixes the page's URL. A page resolves at its fullSlug: the urlSlugs of its category chain joined with /, ending in the page's own urlSlug (default: its id). The Pages root contributes no segment — a page in a crm category under pagesCategory lives at /crm/<page> — while the Settings root prefixes /settings and module pages prefix /modules/<moduleId>.
Composing the page
Every static field on the page class whose value is a component becomes a named component on the page. The field key is the component's key — and part of its permission id (next section).
Underneath every component sits the ComponentBuilder class (from cms/component), instantiated with new ComponentBuilder(componentName). Its fluent API builds the component's description:
.options(props)sets the component's props..child(id, child, { slot? })nests a child component, optionally in a named slot..meta({ name, icon?, description? })sets the component's metadata, which titles its permission..action(id, definition),.watch(…), and.watchOn(childId, …)add behavior — permissioned actions and cross-component reactivity, covered in Actions & reactivity.
The cms-base builders — Form, TableView, Grid, KpiCard, … — are ComponentBuilders preconfigured for a specific componentName. Reach for them for standard UI; when a screen needs your own Vue, CustomComponent(name) returns the bare builder for a component your layer registers.
Assign a builder to a static field and the page has its component — here a Form:
import { Form } from "@antelopejs-private/cms/interfaces/cms-base/form";
import { DefaultDataTypes } from "@antelopejs-private/cms/interfaces/cms-base/data-types";
@RegisterPage()
export class ContactPage extends PageController("contact", {
displayName: "Contact Form",
category: pagesCategory,
}) {
static form = Form({
title: "Contact Us",
description: "Send us a message and we'll get back to you",
fields: [
{
id: "name",
label: "Full Name",
type: new DefaultDataTypes.StringType({ placeholder: "Jane Doe", maxLength: 100 }),
},
{ id: "email", label: "Email", type: new DefaultDataTypes.EmailType() },
{ id: "subscribe", label: "Subscribe", type: new DefaultDataTypes.BooleanType() },
],
submitUrl: "/api/contact",
submitUrlMethod: "POST",
});
}
A field's type is always a DataType instance — see Data types.
Richer screens nest builders with .child: a Grid holds GridRow children, and each row holds its own components — here two KPI cards.
static kpis = Grid({ gap: "1rem" }).child("row",
GridRow()
.child("revenue", KpiCard({ title: "Revenue", fetchUrl: "/api/kpi/revenue", valueFormat: "currency" }))
.child("orders", KpiCard({ title: "Orders", fetchUrl: "/api/kpi/orders" })),
);
When no cms-base builder fits, drop to CustomComponent and render your own Vue — .meta() names the component and titles its permission:
static widget = CustomComponent("MyWidget").meta({ name: "My widget" });
Each cms-base builder renders something specific, with its own options and features. Those references live in the Components section:
| Group | Chapter |
|---|---|
| Field & column types | Data types |
| File uploads & storage | File storage |
| Forms | Form |
| Tables & CRUD | Tables |
| Charts & dashboard widgets | Charts & widgets |
| Layout & containers | Layout & containers |
| Hierarchical data | Tree |
| Freeform Vue | Custom component |
Access & permissions
Permissions gate the page and every declared component position. The page's permission derives from its id; a top-level component appends its field key, and each .child() appends its child id. .meta() supplies the title, icon, and description. All of them register together when the @RegisterPage decorator runs, and each level must be granted for the nested branch to render.
publicAccess / authOnly / noComponentPermissions), roles, and owners — see Auth & permissions.Extending another module's page
@RegisterPageExtension(TargetPage) lets a module add components to a page another module owns — a billing block on the SaaS billing page, a quota banner on the members page. The decorated class is not a page: it is a plain class whose static component fields become components of the target.
import { RegisterPageExtension } from "@antelopejs-private/cms/interfaces/cms/page";
import { MembersSettingsController } from "@antelopejs-private/cms/pages";
import { CustomComponent } from "@antelopejs-private/cms/interfaces/cms-base/custom";
@RegisterPageExtension(MembersSettingsController)
export class MembersQuotaExtension {
static seatQuota = CustomComponent("SeatQuotaBanner")
.meta({ name: "Seat quota", icon: "i-ph-users-three" })
.before(MembersSettingsController.table);
}
The target is the page class, imported from its owning package — never a hardcoded page id, so a renamed page breaks at compile time. A page meant to be extended must therefore be exported from its package's public surface; the CMS's own pages live behind @antelopejs-private/cms/pages.
Placement
Placement is declared on the component itself, anchored on a static component field of the target page:
| Call | Effect |
|---|---|
.before(anchor) | Inject just above anchor. |
.after(anchor) | Inject just below anchor. |
| (neither) | Append after every component the target page declares. |
.order(n) | Break the tie when several components land at the same spot; lower first, defaults to 0. |
When several components land at the same spot, order decides; equal orders fall back to the extension class name, then to the declaration order inside that class. Every criterion is intrinsic to the declaration, so the result never depends on the order the modules started in — a restart cannot reshuffle the page. Use order when a block must sit at a specific place regardless of who else injects there.
Permissions
An injected component joins the target page's own permission tree as <target fullId>.<field name> and is filtered exactly like a component the page declares itself — its .meta() titles the permission, and the extending module adds no second gate. Reaching the page is the target page's permission; showing the block is the block's own. A field name already used by the target page, or already injected by another extension of it, is a registration error.
Lifecycle
The injection applies as soon as the target page has finished registering. If the extending module starts first — or starts while the page is registering — the injection is held until the page has serialized its own components, so module start order does not matter and anchors always resolve against the page's complete component set; if the target page never registers — its module is not installed, or it registers conditionally — the extension stays inert and logs, it never fails the boot. Everything a module injected is removed when that module stops.
A component that throws while registering is dropped on its own: it is logged at error level and left out of the layout, while the rest of its extension — and the page — still apply. It is not retried, so a side effect that already ran is never replayed.
Configuration
Every option the DMS module accepts in antelope.config.ts — URLs, auth token lifetimes, HTML rendering, realtime driver, and dashboard presentation.
Navigation
How the dashboard sidebar is built — the Pages/Modules/Settings root groups, categories for your own screens, page URLs, and the command-palette quick actions.