[data-reveal]{opacity:1!important;transform:none!important}
Block fundamentals
Components

Block fundamentals

What every DMS block shares — how blocks nest, read their data, write their texts in the reader's language, and carry their own permission.

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.

src/pages/overview.ts
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():

signature
.child(id: string, component: Component, metadata?: { slot?: string; [key: string]: unknown }): this
  • id is the child's key under its parent. It must be unique among its siblings; a duplicate logs a warning.
  • component is any other block, so nesting depth is free.
  • metadata carries per-child data: a Tab or a Card reads slot, a GridRow child reads colSpan.
A 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).

src/pages/sales.ts
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 };
}
OptionTypePurpose
fetchUrlstringRoute answering the block's payload. Accepts {{params.X}} and {{query.X}} tokens.
fetchUrlMethodHttpMethodMethod of the request, GET by default.
periodScopestringid of a PeriodSelector; its period is added to the request.
realtimeTopicstring | 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.
skeletonCountnumberPlaceholders 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).

src/pages/workspaces/detail.ts
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.

src/controllers/workspaces.ts
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):

src/pages/workspaces/detail.ts
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 goesFields
StatGroup celleyebrow, value, detail
KeyValueList rowlabel, value, detail
Bannertitle, description
NavCardGrid cardtitle, description, state, tag, each readout line
ActivityFeed entrytitle, each meta detail, detail, time; its params take the typed parameters below
Meterhint, valueLabel, a segment's label
TopListCard rowtitle, description, valueLabel
Layout bannertext, and what resolve answers
Table cell sub-linethe 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.

ParameterWritten asExample (en)
"INV-0042"the string as it is — data, never a keyINV-0042
1284a number for the locale1,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 longOct 10, 2026
{ type: "datetime", value, format? }a date and its time, in the user's time zone and clockOct 10, 2026, 2:05 PM
{ type: "relative", value }the distance from now, past or futurein 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 form3
{ key, params? }a nested composed text, composed firstBusiness · 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.

frontend-vue/i18n/locales/saas-en-GB.json
{
  "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 are neutral, primary, secondary, success, warning, error and info (Tone, from @antelopejs/interface-dms/base/types/tone). tone is for what shows a state — pills, icon wells, banners, meters. color is for what is clicked — buttons, row actions, tabs.
  • Links (to) take a DMS route, an #anchor on 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, so to: "/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) and color override 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).

Describe a page as many small blocks rather than one large component. Each block then becomes a line in the roles editor that you can grant or withhold on its own. See Best practices.

Next steps

Layout & containers

Arrange blocks in grids, stacks, tabs and cards.

Charts & widgets

Build dashboards from KPI cards, charts and rankings.

Display blocks

Summaries and notices: stats, facts, banners, empty states.

Custom component

Build only the block the catalog is missing.