[data-reveal]{opacity:1!important;transform:none!important}
CMS Interfaces

Pages and Modules

Overview

A page is a controller class. PageController(id, options, layout?) builds a base class bound to a URL, @RegisterPage() records it, and the components declared as static fields on the class become the page's serialized layout. Categories group pages into the sidebar tree, and modules group a whole set of pages and categories under their own root.

Everything on this page lives in @antelopejs-private/cms/interfaces/cms/page.

Registration happens at import time: the @RegisterPage() decorator runs when the file is loaded, so the order in which you import page files determines the order in which pages, categories and their permissions are registered.

Declare a Page

PageController(id, options, layout?) returns a class extending Controller(fullSlug). Extend it, decorate with @RegisterPage(), and declare components as static fields.

import {
  Category,
  PageController,
  RegisterPage,
  pagesCategory,
} from "@antelopejs-private/cms/interfaces/cms/page";
import { TableView } from "@antelopejs-private/cms/interfaces/cms-base";
import { invoiceDataAPI } from "../data-api/invoices";

export const billing = Category("billing", {
  displayName: "Billing",
  icon: "i-ph-receipt",
  category: pagesCategory,
});

@RegisterPage()
export class InvoicesPage extends PageController("invoices", {
  displayName: "Invoices",
  icon: "i-ph-file-text",
  category: billing,
  order: 10,
}) {
  static table = TableView(invoiceDataAPI, { caption: "Invoices" });
}

PageController requires either category or module in its options and throws otherwise. Since the returned class extends Controller, route decorators from @antelopejs/interface-api work on it directly — a route declared on the page class is served under the page's slug.

The third argument is the page layout, a ComponentInfo produced by a layout builder such as DefaultLayout() from the cms-base interface. When you omit it, the page uses DefaultLayout().

OptionTypeDescription
displayNamestringLabel shown in the sidebar. Required.
categoryCategoryInfo | ControllerClassParent category. A CategoryInfo from Category/RootCategory/RegisterModule, or a controller class produced by PageController/RootPageController.
modulestringId of a module registered with RegisterModule. Attaches the page to that module's tree.
urlSlugstringURL segment for this entry. Defaults to id.
descriptionstringLonger description carried in the page metadata.
ordernumberSort order among siblings.
iconstringIcon name.
hiddenbooleanKeep the entry out of the menu. Inherited from the resolved category when that category is hidden.
permissionPartial<Permission> | ActionOverride the auto-derived page permission, or bind the page to an existing component Action. See Permissions.
publicAccessbooleanServe the page without authentication. Inherited from the resolved category.
authOnlybooleanRequire a logged-in user but no permission check. Inherited from the resolved category.
typeMenuItemType"label" or "link".
validationPageValidationPreconditions for opening the page.
setupIdstringId of a page setup function to run while the page is mounted.
noComponentPermissionsbooleanDo not register per-component permissions for this page.
queryMenuItemQueryQuery parameters carried by the entry's menu link.
variantMenuItemVariantSemantic look of the entry.
statusMenuItemStatusSemantic state dot rendered at the end of the entry.
bypassTenantAccessGatebooleanKeep the page reachable while a tenant access gate denies the tenant. Inherited by the pages of a flagged category.

MenuItemType is "label" | "link".

PageValidation has two optional fields: requiredQueryParams (a string[] of query parameters the page needs) and customFunctionId (the id of a registered validation function).

Menu Entry Appearance

Three types describe how an entry looks, so a consumer never ships raw classes:

type MenuItemVariant = "default" | "accent";
type MenuItemStatus = "success" | "warning" | "error" | "neutral";
type MenuItemQuery = Record<string, string>;

accent renders the entry in the theme's primary color, for a call-to-action entry such as "New project". status maps to the theme's semantic colors.

query appends parameters to the entry's link, which is what lets a single page serve many entries — declare it with validation.requiredQueryParams, then point several entries at it:

@RegisterPage()
export class ProjectPage extends PageController("project", {
  displayName: "Project",
  category: pagesCategory,
  validation: { requiredQueryParams: ["project"] },
  query: { project: "acme-website" },
}) {}

Reaching a Page Behind a Tenant Gate

bypassTenantAccessGate is the page-level mirror of the guard option of the same name (Permissions). It covers page visibility, menu visibility and the page's own layout route, and a flagged category passes it to its pages. Permission checks still apply — unlike authOnly, which drops them entirely.

It does not cover the data routes feeding the page: a data API is not owned by a single page, so it opts out through its own guard, or through TableViewOptions.bypassTenantAccessGate for a table view. Reserve the flag for recovery surfaces — billing, a payment portal — that a blocked tenant must still reach in order to unblock itself.

Group Pages with Categories

Category(id, options) returns a CategoryInfo you pass as another entry's category. Categories nest, and they require a category or a module of their own.

import {
  Category,
  pagesCategory,
} from "@antelopejs-private/cms/interfaces/cms/page";

export const crm = Category("crm", {
  displayName: "CRM",
  icon: "i-ph-users",
  category: pagesCategory,
});

export const contacts = Category("contacts", {
  displayName: "Contacts",
  category: crm,
});

Creating a category registers a permission keyed to its fullId, unless the category sits inside a module — see Permissions.

Root Categories

RootCategory(id, options) creates a top-level group next to the built-in ones. Its options are MenuOptions without category and module. type defaults to "label". The ids pages, modules and settings are reserved and throw.

import { RootCategory } from "@antelopejs-private/cms/interfaces/cms/page";

export const catalogCategory = RootCategory("catalog", {
  displayName: "Catalog",
  icon: "i-ph-books",
  order: 9,
});

The interface exports three built-in roots:

ExportKindURL slug
pagesCategoryCategoryInfo/
modulesCategorycontroller class (RootPageController)/modules
settingsCategorycontroller class (RootPageController)/settings

modulesCategory is the module root: every module registered with RegisterModule attaches under it. A page or category that declares a module cannot resolve to a category under settingsCategory; that combination throws.

RootPageController

RootPageController(id, options, layout?) builds a page controller at the root of the tree. Its options are MenuOptions without category and module, plus an optional isModuleRoot boolean. Unlike PageController it does not require a parent, which is what makes it usable for a top-level landing screen.

Identifiers a Page Carries

Category, PageController and RootPageController compute two identifiers from the entry's own id and its parent chain:

  • fullId — the dot-joined chain of ids, starting at the root category (pages.crm.contacts). This is the page's permission id and the key realtime topics are registered under.
  • fullSlug — the joined URL slugs (/crm/contacts).

CategoryInfo extends MenuOptions with category redeclared as a required CategoryInfo | ControllerClass | undefined holding the resolved parent, and adds id, fullId, fullSlug and an optional isModuleRoot. PageInfo extends CategoryInfo and adds layoutUrl, the URL the page's component tree is served from — the page's fullSlug followed by /pagelayout.

GetPermissionId(target) returns the permission id recorded for a page controller class or a Component, or undefined when the target was never registered. A child component resolves to its full positional path. When the same component instance is mounted more than once, every position registers a permission but the instance resolves to its first registered position, because the instance alone cannot identify which mount the caller means.

import { GetPermissionId } from "@antelopejs-private/cms/interfaces/cms/page";

const pagePermission = GetPermissionId(InvoicesPage); // "pages.billing.invoices"

Declare a Module

A module is a self-contained feature area with its own root in the sidebar. RegisterModule(info) creates that root under modulesCategory and returns its CategoryInfo.

import { RegisterModule } from "@antelopejs-private/cms/interfaces/cms/page";

RegisterModule({
  id: "automation",
  title: "$cms_automation.module.title",
  description: "$cms_automation.module.description",
  icon: "i-ph-flow-arrow",
  defaultCategory: { displayName: "$cms_automation.module.pages", order: 0 },
});

Call RegisterModule before any page that references the module id: a page resolves its module synchronously inside the PageController call of its class declaration, before @RegisterPage() runs, and an unknown module id throws. Registering the same id twice throws as well.

ModuleInfo

OptionTypeDescription
idstringModule id. Also the URL segment under /modules.
titlestringDisplay name of the module root.
descriptionstringDescription carried on the module root.
iconstringIcon name.
landingPagestringOptional landing page for the module.
defaultCategory{ displayName?, icon?, order?, urlSlug? }Groups the module's pages that declare module without an explicit category. Without it, those pages attach directly to the module root.

The generated default category uses urlSlug "pages" unless you override it, and falls back to the label "$menu.section.pages" when displayName is empty or omitted.

Attaching Pages to a Module

Set module on the page options. category stays available to group pages inside the module:

import {
  PageController,
  RegisterPage,
} from "@antelopejs-private/cms/interfaces/cms/page";
import "./module"; // ensures RegisterModule ran first

@RegisterPage()
export class OverviewPage extends PageController("overview", {
  displayName: "$cms_automation.overview.title",
  icon: "i-ph-gauge",
  module: "automation",
  order: 0,
}) {}

When a page declares both module and an explicit category, the category must be a descendant of that module's root; otherwise registration throws.

Module URLs and Detection

MODULE_URL_PREFIX is "/modules". Module pages resolve under /modules/<moduleId>/... because the module root's urlSlug is the module id and its parent is modulesCategory. Their ids follow the same chain: the module root's fullId is modules.<moduleId>, and a page in it is modules.<moduleId>.<pageId>.

isInsideModule(entry) answers whether an entry belongs to a module. It accepts anything carrying module, category and isModuleRoot — a CategoryInfo or PageInfo qualifies — and walks the category chain looking for a module root.

import { isInsideModule } from "@antelopejs-private/cms/interfaces/cms/page";

if (isInsideModule(pageInfo)) {
  // module pages are gated to platform owners
}

Permissions belonging to a module entry are marked module-scoped instead of being registered in the grantable permission tree. Permissions covers what that implies.

Contribute Menu Entries at Request Time

Everything above declares the menu at import time. A dynamic menu provider adds entries resolved per request instead — one entry per project, per workspace, per whatever the tenant happens to own.

import { RegisterDynamicMenuProvider } from "@antelopejs-private/cms/interfaces/cms/page";

RegisterDynamicMenuProvider("pages.projects", async (user, tenantId) => {
  const projects = await listProjects(tenantId);
  return projects.map((project, index) => ({
    id: project.slug,
    displayName: project.name,
    fullSlug: "/projects/detail",
    query: { project: project.id },
    icon: "i-ph-folder",
    order: index,
  }));
});
function RegisterDynamicMenuProvider(
  categoryFullId: string,
  resolver: DynamicMenuResolver,
): () => void;

type DynamicMenuResolver = (
  user: User | undefined,
  tenantId: string,
  permissions: Set<string>,
) => MaybePromise<DynamicMenuItem[]>;

A registration is a DynamicMenuProviderInfo — the categoryFullId and the resolver, paired. The resolver runs during /sitelayout, and its entries are grafted onto that request's copy of the tree only — the global navigation tree is never touched, so one tenant's entries can never reach another's site layout. Dynamic and static children of the category sort together by order.

permissions is the caller's effective permission set as the parent category sees it: empty while a tenant access gate denies the tenant, unless the category is flagged bypassTenantAccessGate, in which case the real set is kept. user is undefined for an unauthenticated request.

A resolver that throws is skipped with a warning and the rest of the menu still resolves. The registration is removed when the registering module unloads; the returned function drops it earlier, for the rare case a module stops providing while staying loaded.

DynamicMenuItem

An item is not a page. It links to an already registered page, which is why one page — typically one with validation.requiredQueryParams — can back any number of entries.

FieldTypeDescription
idstringUnique within the provider's category, and becomes the entry's fullId suffix. Dots separate fullId levels, so an id containing one is skipped with a warning. Required.
displayNamestringEntry label. Required.
fullSlugstringFull slug of the page the entry links to. Required.
queryMenuItemQueryQuery parameters narrowing that page.
descriptionstringLonger description.
iconstringIcon name.
ordernumberSort order among the category's children, dynamic and static alike.
variantMenuItemVariantSemantic look.
statusMenuItemStatusSemantic state dot.
permissionIdstringPermission required to see the entry, as a raw id. Unset means always visible.

An entry is listed only when the caller can access the page it points at, so it never offers a link its target would refuse.

permissionId is a raw id here, unlike MenuOptions.permission, which takes a whole permission or an Action. It is matched against the permission set the caller's browser actually receives, the way the browser matches it — the wildcard or a literal grant, never a defaultGranted fallback — so the sidebar never offers an entry whose controls would be inert. While a gate denies the tenant that set holds only the surfaces flagged bypassTenantAccessGate, so a recovery entry declares its permission under its target page, as in <recoveryPageFullId>.settle.

NotifyMenuChanged

const NotifyMenuChanged: (tenantId?: string) => void;

Call it when the data behind a provider changes — a project created or deleted — so connected clients resync. The signal carries no data: it is an invalidation, and each client re-fetches its own /sitelayout, resolved against its own permissions.

Pass the tenantId whose data changed so only that tenant's sessions re-fetch. Omitting it invalidates the menu for every connected session, which is meant for changes that are not tenant-scoped.

Extend a Page from Another Module

@RegisterPageExtension(target) injects components into a page owned by someone else. The decorated class is not a page: every static component field on it becomes a component of target, keyed by its field name.

import { RegisterPageExtension } from "@antelopejs-private/cms/interfaces/cms/page";
import { CustomComponent } from "@antelopejs-private/cms/interfaces/cms-base/custom";
import { TeamPage } from "@acme/cms-teams/pages";

@RegisterPageExtension(TeamPage)
export class SeatQuotaExtension {
  static seatQuota = CustomComponent("SeatQuotaBanner")
    .meta({ name: "Seat quota" })
    .before(TeamPage.table);
}

The target is the page controller class itself, imported from the module that owns it — which is also what makes the anchor available: TeamPage.table is the static field the target declared its table under.

Because the field name is the component key, an injected component is permissioned as <target fullId>.<field name> in the target page's own permission tree — exactly like a component the target declares itself. The target's own fields and every extension of it share that one namespace, so a field name already taken by the target or by another extension is a registration error.

Placement is declared on the component with .before(anchor) / .after(anchor), where the anchor is a static component field of the target page class. Without one, the component is appended after the page's own components. When several land at the same spot .order(n) decides, and equal orders fall back to the extension class name and then to declaration order — never to module start order. Components covers those three builder methods.

The injection applies as soon as the target page has finished registering — it is held until then whichever module starts first, so anchors always resolve against the page's complete component set — and is removed when the extending module stops. An extension class declaring no component field is a warning, not an error, and injects nothing. Passing a class that is not a page throws.

interface PageExtensionInfo {
  extensionName: string;
  targetFullId: string;
  components: PageExtensionComponent[];
}

interface PageExtensionComponent {
  key: string;
  component: Component;
  side: PlacementSide;
  anchorKey?: string;
  order: number;
}

PageExtensionInfo is the registration handle: it is the object that gets unregistered when the extending module stops. key is the static field name the component was declared under, and also the last segment of its permission id on the target page.

Page Layouts

A page layout is the serialized description of the page's components:

interface PageLayout<T = unknown> {
  components: Record<string, ComponentInfoSerialized<T>>;
  layout?: ComponentInfo<T>;
}

components is keyed by the static field name on the page class; each value is the serialized form of the component (Components). layout is the layout component passed as PageController's third argument.

Registering a page installs a PageLayoutHandler for its fullSlug:

type PageLayoutHandler = (
  user: User | undefined,
  memberModel: TenantMemberModel,
  roleModel: RoleModel,
  tenantId: string,
) => Promise<PageLayout>;

The handler drops the components the caller has no permission for, applies each component's own filter, and stamps upload tokens into the result. A page with publicAccess, authOnly or noComponentPermissions skips the filtering step and returns every component. Look a handler up with GetPageLayoutBySlug(slug), which returns undefined for an unknown slug:

import { GetModel } from "@antelopejs/interface-database-decorators";
import {
  type PageLayout,
  GetPageLayoutBySlug,
} from "@antelopejs-private/cms/interfaces/cms/page";
import {
  RoleModel,
  TenantMemberModel,
} from "@antelopejs-private/cms/interfaces/cms/db";
import type { User } from "@antelopejs-private/cms/interfaces/cms-auth/db";

async function renderLayout(
  slug: string,
  user: User | undefined,
  tenantId: string,
): Promise<PageLayout | undefined> {
  const handler = GetPageLayoutBySlug(slug);
  return handler?.(
    user,
    GetModel(TenantMemberModel, tenantId),
    GetModel(RoleModel, tenantId),
    tenantId,
  );
}

ClearPageLayoutBySlug(slug) drops the handler again. A page unregisters through it: the handler is reachable by slug from /cms/pagelayout, so leaving it behind keeps serving the layout of a page that is already gone from the registry, the navigation tree and its own route — an unloaded module's screens would still answer.

PageMetadata

PageMetadata is the per-controller record the decorator fills in. GetMetadata(controllerClass, PageMetadata) returns it. It holds pageInfo, layout and components, and exposes:

MemberDescription
SetInfo(id, fullSlug, menuOptions, pageLayout?)Compute and store the PageInfo and the layout.
SetComponent(id, component)Attach a component under a key.
inherit(parent)Merge another PageMetadata's info, layout and components into this one.
Register()Register the page: permissions, layout handler, /pagelayout route and auth decorators.
targetThe controller class the metadata belongs to.

@RegisterPage() does this for you: it scans the class's own static properties, records every Component it finds under its property name, and calls Register(). Static properties whose name starts with _ are skipped, which is how you keep a helper value on the class without turning it into a layout entry. Reach for PageMetadata directly only when you generate pages programmatically.

Registration also runs each component's onCreated callback with this record, and registers a permission per component and per component action unless the page opts out (Permissions).

Page Access

Registering a page installs an auth decorator on the controller, chosen from the options:

OptionsDecorator installed
publicAccess: truenone — the page is served to anonymous callers
authOnly: trueAuthUser from the cms-auth interface: a logged-in user, no permission check
page under /modules/AuthOwnerOnly from the cms-auth interface: module pages are platform-owner-only
defaultAuthUserWithPermission(pageClass) — the page's own permission (Permissions)

On a page that is neither publicAccess nor authOnly, registration also binds roleModel, memberModel and tenantId properties on the controller, scoped to the request's tenant (Tenancy). Routes you declare on the page class inherit that gating: a route under a default page already requires the page's permission.

Declaring the injected property on the class is enough to read it in a handler — the provider runs per request whether or not the handler reads it:

import { Get } from "@antelopejs/interface-api";
import {
  PageController,
  RegisterPage,
} from "@antelopejs-private/cms/interfaces/cms/page";
import type { User } from "@antelopejs-private/cms/interfaces/cms-auth/db";
import { crm } from "./categories";

@RegisterPage()
export class MyContactsPage extends PageController("mine", {
  displayName: "My contacts",
  category: crm,
}) {
  declare user: User;

  @Get("/count")
  async count() {
    return { owner: this.user._id };
  }
}

Page Setup Functions

setupId names a function that runs while the page is mounted. The backend only carries the id; the function itself is registered on the frontend side and receives a PageSetupContext.

type PageSetupFunction = (
  context: PageSetupContext,
) => MaybePromise<PageSetupCleanup | void>;
Member of PageSetupContextDescription
pageInfoThe PageInfo of the page being mounted.
permissionsSet<string> of the caller's effective permission ids.
on(component, event, handler)Subscribe to a component event. Returns an unsubscribe function.
emit(component, event, data?)Dispatch an event on the shared event bus.

PageSetupCleanup is () => void, returned by the setup function to release what it allocated when the page unmounts. MaybePromise<T> is T | Promise<T>, re-exported here from @antelopejs-private/cms/interfaces/cms/types.

import {
  PageController,
  RegisterPage,
} from "@antelopejs-private/cms/interfaces/cms/page";
import { billing } from "./category";

@RegisterPage()
export class LifecyclePage extends PageController("lifecycle", {
  displayName: "Lifecycle",
  category: billing,
  setupId: "billing:invoice-lifecycle",
}) {}

Ship a Nuxt Layer

AddNuxtLayer(config) registers a Nuxt layer your module ships. Call it from your module's construct().

import path from "node:path";
import { AddNuxtLayer } from "@antelopejs-private/cms/interfaces/cms/page";

export async function construct(): Promise<void> {
  await AddNuxtLayer({
    name: "@acme/cms-billing",
    path: path.join(__dirname, "../nuxt-layer"),
    configKey: "cmsBilling",
    priority: 0,
    options: { prefix: "Billing" },
  });
}

AddNuxtLayerOptions

OptionTypeDescription
namestringLayer name. Layers are keyed by it, so registering the same name twice replaces the earlier entry. Required.
pathstringAbsolute path to the layer directory. Required.
optionsRecord<string, any>Values handed to the layer's configuration. Defaults to {}.
privateOptionsRecord<string, any>A second set of values handed to the layer, kept separate from options. Defaults to {}.
prioritynumberOrdering among the registered layers. Defaults to 0.
configKeystringConfiguration key the layer is published under.

Stamp Upload Tokens

StampUploadTokens(components) takes a serialized components map and returns it with a signed upload token stamped into every file and image field, binding each field to the storage and path it declares so a client cannot presign an upload elsewhere. The page-layout resolver calls it before returning a layout, so pages you declare with @RegisterPage() are covered already. Call it yourself only when you build and serve a PageLayout outside that path.

import { StampUploadTokens } from "@antelopejs-private/cms/interfaces/cms/page";

const components = await StampUploadTokens(layout.components);

A map with no upload field comes back unchanged.

Next Steps

Components covers the builders that produce the values you assign to those static fields.