Block fundamentals
Every component in this section is a block: a builder you call on the backend, which returns a serializable description that the dashboard renders. Blocks share one vocabulary — how they nest, how they read their data, how they write their texts, and how roles reach them. Read this page once; each block page then only covers what is specific to that block.
A block is a registered type
Each block type is registered in the block catalog with its component name, an options schema and editor hints (label, group, widget). ListBlockTypes() from @antelopejs/interface-dms/base/block-types returns the catalog. The page builder, the dms-ai assistant and the previews and generated options tables in these docs all read that catalog.
import { KpiCard } from "@antelopejs/interface-dms/base/kpi-card";
static revenue = KpiCard({ title: "Revenue", fetchUrl: "/api/sales/revenue" });
A block is declared as a static field of a page class. The field key becomes the block's id and part of its permission id (Pages & components). To sketch a page before its data routes exist, use a Placeholder and replace it with the configured block later.
Composing blocks
Containers (Grid, GridRow, HStack, VStack, Tab, Card, Section, FieldRow, Guard) hold other blocks. You nest them with .child():
.child(id: string, component: Component, metadata?: { slot?: string; [key: string]: unknown }): this
idis the child's key under its parent. It must be unique among its siblings; a duplicate logs a warning.componentis any other block, so nesting depth is free.metadatacarries per-child data: aTabor aCardreadsslot, aGridRowchild readscolSpan.
Grid only accepts GridRows as direct children. Every other container takes any block.Blocks that read their data
Most blocks fetch their own data from a route with fetchUrl, so the backend stays the source of truth and the page layout stays static. The route is usually declared on the same page class with @Get. A page class serves its routes under /dms/pages, not at the page's own path (that path is the page): name them with pageRoutePath(slug, route) from @antelopejs/interface-dms/page. A plain Controller the frontend calls sits under /api/<module>/ (Pages and modules).
import { pageRoutePath } from "@antelopejs/interface-dms/page";
static revenue = KpiCard({
title: "Revenue",
fetchUrl: pageRoutePath("/sales", "/revenue"), // "/dms/pages/sales/revenue"
});
@Get("/revenue")
revenueKpi() {
return { value: 48200, previousValue: 42900 };
}
| Option | Type | Purpose |
|---|---|---|
fetchUrl | string | Route answering the block's payload. Accepts {{params.X}} and {{query.X}} tokens. |
fetchUrlMethod | HttpMethod | Method of the request, GET by default. |
periodScope | string | id of a PeriodSelector; its period is added to the request. |
realtimeTopic | string | string[] | Realtime topics whose events make the block read its route again. |
empty | { title, description? } | Empty state of a list block when there is nothing to show. |
skeletonCount | number | Placeholders drawn while the first answer loads. |
List blocks (StatGroup, KeyValueList, NavCardGrid, ActivityFeed) answer { items: [...] } in their own item shape. While the first answer loads the block draws skeletons; a failure shows an error with a retry; an empty list shows empty. A block cannot know how many items will come, so its skeleton guesses: set skeletonCount to the usual count and the block keeps its height when the items land.
The record of the page
On a detail page the route names the record shown. {{params.X}} is filled with the route parameter X of the page URL, {{query.X}} with its query parameter X, both percent-encoded. The block reads its route again when the page URL changes, and requests nothing while a token has no value. The same tokens work in links (to), in a form's fetchUrl, and in a tab set's badgesUrl (Template tokens).
static facts = KeyValueList({
title: "Billing",
fetchUrl: "/api/workspaces/{{params.id}}/billing-info",
});
Not found
A route answering 404 says the record does not exist, which no retry fixes. Every block that reads a fetchUrl then shows a translated "Not found" state instead of an error, and drops the values it showed before. The body of the 404 may say more: its message (an i18n key, or a sentence) becomes the description, and an emptyState object (title, description, icon, tone, actions) is laid over it.
throw new HTTPResult(404, {
message: "$lang.workspace.unknown_hint",
emptyState: { actions: [{ label: "$lang.workspace.all", to: "/modules/lang/workspaces" }] },
});
To show one not-found state in place of several blocks, wrap them in a Guard with when: "notFound".
Refreshing after an action
Once something on the page changes the record, blocks and table views read their data again in place, keeping their values on screen until the new ones land. A page header button does it on its own after a write. A form names the built-in watch function, and a custom component calls refreshPageBlocks() (Component events):
static suspend = Form({ kind: "action", submitUrl: "/api/workspaces/{{params.id}}/suspend", fields })
.watch(FormEvents.SUBMIT_SUCCESS, BlockFunctions.REFRESH_PAGE);
Texts in the reader's language
Every text option — titles, labels, details, button labels — follows the $-prefix convention: "$my_module.billing.title" is translated, anything else is shown as written.
Composed texts
A sentence that names a value — "€922.00 MRR", "Payment failed, retry Oct 10", "3 seats" — is sent as a composed text: the i18n key of the message and the raw values it names. The browser writes it in the reader's language, with amounts, dates and counts formatted for their locale, time zone and clock. One route answers the same JSON to every reader, and the server never formats text. ComposedText comes from @antelopejs/interface-dms/base/types/composed-text.
import type { ComposedText } from "@antelopejs/interface-dms/base/types/composed-text";
const mrr: ComposedText = {
key: "saas.stats.mrr", // "{amount} MRR"; the leading `$` is optional
params: { amount: { type: "money", value: 92200, currency: "EUR" } },
};
// "€922.00 MRR" for an English reader, "922,00 € MRR" for a French one
| Where a composed text goes | Fields |
|---|---|
StatGroup cell | eyebrow, value, detail |
KeyValueList row | label, value, detail |
Banner | title, description |
NavCardGrid card | title, description, state, tag, each readout line |
ActivityFeed entry | title, each meta detail, detail, time; its params take the typed parameters below |
Meter | hint, valueLabel, a segment's label |
TopListCard row | title, description, valueLabel |
| Layout banner | text, and what resolve answers |
| Table cell sub-line | the row field a status pill, an identity or a two-line cell draws as its secondary line |
Each field keeps taking a plain string, so composed texts are opt-in, and they work for items a fetchUrl answers as for static ones.
| Parameter | Written as | Example (en) |
|---|---|---|
"INV-0042" | the string as it is — data, never a key | INV-0042 |
1284 | a number for the locale | 1,284 |
{ type: "money", value, currency } | an amount from its minor units (cents for EUR, yen for JPY) | €922.00 |
{ type: "date", value, format? } | a date; format is day (Oct 10), short, medium (default) or long | Oct 10, 2026 |
{ type: "datetime", value, format? } | a date and its time, in the user's time zone and clock | Oct 10, 2026, 2:05 PM |
{ type: "relative", value } | the distance from now, past or future | in 2 days |
{ type: "number", value, format? } | a number; format is decimal (default), percent (0.25 → 25%) or compact (1.3K) | 1,284.5 |
{ type: "count", value } | a number that also picks the plural form | 3 |
{ key, params? } | a nested composed text, composed first | Business · 3 seats |
A date value is an ISO 8601 string or a timestamp in milliseconds. Plurals follow vue-i18n: the message lists its forms split by |, and the first count parameter picks one; set plural to the name of another numeric parameter to choose it instead. A missing message falls back to the fallback language, then to the bare key.
{
"saas": {
"stats": { "mrr": "{amount} MRR", "seats": "no seat | {count} seat | {count} seats" },
"banner": { "retry": "Payment failed, retry {date}" }
}
}
A custom component writes the same format with useComposedText().
Tones, links and link buttons
- Tones are
neutral,primary,secondary,success,warning,errorandinfo(Tone, from@antelopejs/interface-dms/base/types/tone).toneis for what shows a state — pills, icon wells, banners, meters.coloris for what is clicked — buttons, row actions, tabs. - Links (
to) take a DMS route, an#anchoron the page, or an absolute URL, which opens in a new tab. A link may carry{{params.X}}/{{query.X}}; a query pair whose token has no value is left out, soto: "/modules/shop/orders?store={{query.store}}"keeps the reader's store. - Link buttons (
BlockLinkAction) are{ label, to, icon?, variant?, color? }. The block picks each button's look from its place;variant(solid,outline,soft,subtle,ghost,link) andcoloroverride it.
Permissions
Every declared position is a permission. A page's container field, each nested row or stack, and each leaf append their id to the permission path, and the roles editor lists each one with the block's name and icon. A role needs every level it should render: granting a container alone does not expose its children. A block that reads a fetchUrl reads it with the viewer's session, so the route applies its own checks (Auth & permissions).