Slotted Pages
Customize record detail pages by overriding only the slots you care about — let the default synthesizer handle the rest.
Slotted Pages
Every record detail page in ObjectUI is synthesized from the object
definition by default (see buildDefaultPageSchema). You get a
Lightning-style page — header, highlights, tabs, related lists,
history — without writing a single line of page schema.
When you want to customize part of that page, you have two options:
- Full page (
kind: "full", the default): author the entireregions[].components[]tree. Total control, total responsibility — you re-author every region. - Slotted page (
kind: "slotted"): provide overrides for only the slots you care about. The default-page synthesizer fills in every slot you didn't override.
For "I love the default page but want to customize just the header," slotted pages are the right tool.
Slot menu (v1)
| Slot | Replaces |
|---|---|
header | page:header |
actions | record:quick_actions (the action bar) |
highlights | The chips + chevron path strip |
details | The Details tab body (other tabs stay synthesized) |
tabs | The entire page:tabs node — use to add or reorder tabs (wins over details) |
discussion | record:discussion (the inline conversation footer) |
Objects with enable.files: true also get a synthesized Attachments tab
(record:attachments, with a count badge) beside Details/Related. It is not a
slot of its own — override tabs to reshape it, or pass
hideAttachments: true to the synthesizer to drop it.
Each slot accepts a single component schema or an array (arrays are flattened in place). Each slot is a full replacement at the slot boundary — there is no deep-merge or JSON-Patch in v1.
Example: customize only the header
import type { Page } from '@objectstack/spec/ui';
export const AccountDetailPage: Page = {
name: 'account_detail_page',
label: 'Account Detail',
type: 'record',
object: 'account',
kind: 'slotted',
regions: [], // slotted pages don't author regions
slots: {
header: {
type: 'page:header',
id: 'account_header_slotted',
properties: {
title: '{name}',
subtitle: '{industry} · {type}',
eyebrow: 'ACCOUNT',
icon: 'building-2',
breadcrumb: true,
},
},
},
};Result: the account record page renders with your custom header, and
the synthesizer fills in the highlights, tabs, and discussion regions as
if you'd authored nothing else. The default tab strip is Details → one
tab per related list flagged relatedList: 'primary' on its relationship → a
shared Related tab for the rest → History; so promoting a child table to
its own tab is a one-word change on the relationship, not a page-authoring task.
relatedLayout: 'tabs' | 'stack' remains an app-level override (force
all-own-tabs / all-stacked).
Related lists are additionally gated on the current user's object-level
read permission for the child object: a relationship whose child the user
cannot read produces no section and no tab (and the record:related_list
renderer suppresses itself the same way on hand-authored pages). The server
always enforced data access — this keeps the UI from rendering an empty
grid with a "New" button that would be rejected on save.
Related lists paginate by default: when a record:related_list node
does not declare limit, the renderer applies the spec default of 5
records per page (RecordRelatedListProps.limit), fetching one page at a
time from the server ($top/$skip) with the count badge showing the
full collection size. Authors can raise the page size per list
(limit: 20) and pin the initial order with sort
('-created_at' or [{ field, order }]); a user's column sort is sent
to the server so ordering stays global across pages.
Header actions: inline vs. overflow
page:header renders the record's record_header actions (authored
business actions plus the host-injected system set — Edit / Share /
Delete) as a button row. Up to maxVisible actions render inline,
side by side (default 3 on desktop, mobileMaxVisible, default
1, on mobile); the rest collapse into a ⋯ "More actions" menu.
Declaring placement
An authored action renders here only if its locations declares
record_header (inline) or record_more (straight into the ⋯ menu).
There is no default: an action that declares no location renders in no
located surface — not here, not the list toolbar, not the row menu. That
one rule is shared by every surface that places actions by location
(action:bar, action:group, record:quick_actions, related lists, the
metadata-admin toolbars and the action engine), so an action behaves the
same wherever it is drawn.
actions: [
{ name: 'convert_lead', label: 'Convert', locations: ['record_header'] },
{ name: 'export_pdf', label: 'Export', locations: ['record_more'] },
]Two placements come from somewhere other than locations, and neither
needs an entry here:
- Host chrome — the Edit / Share / Delete set the host injects is placed by the host, not authored, so it is never location-filtered.
- Selection actions — an action named in a list view's
bulkActionsorbulkActionDefsis placed by that declaration. An action that only makes sense over a selection (an aggregate export, say) can therefore declare nolocationsat all and still be reachable from the selection bar.
Which actions claim the inline slots is declared in metadata, using the
same rules as action:bar:
order(number, default0) — actions are stable-sorted ascending before the split, so a lowerorderpromotes an action into the inline slots and a higherorderpushes it toward the⋯menu. The synthesized system actions useorder: 100+, so authored business actions outrank them by default.variant: 'primary'— tie-break within equalorder: a primary action outranks its unordered siblings without needing an explicitorder.component: 'action:menu'— pins the action inside the⋯menu regardless of how few actions exist (the synthesized Share / Delete use this).
slots: {
header: {
type: 'page:header',
properties: {
title: '{name}',
maxVisible: 4, // allow four inline buttons on this page
},
},
},The refresh button is chrome, not an action
Past the ⋯ menu, at the far end of the row, a record page shows a ⟳
refresh button. It is deliberately not part of the action row:
- Nothing to author. It appears when the page host supplies
RecordContext.refresh— the standalone record route does, so every record page has it, in the same place, whatever actions the object declares. There is no metadata key for it and nolocationsto write. - Never collapsed. Because it is outside the action list, it is not
part of the
maxVisiblebudget and can never be pushed into the⋯menu by an object that declares many actions. - Not permission-gated. Re-reading a record already on screen is not
a privileged operation, so it skips the
requiredPermissionsgate the action pipeline applies.
Clicking it invalidates data on the client bus (notifyDataChanged from
@object-ui/react) with the wildcard scope '*': the record, every
related list and the tab-count badges refetch in place. Nothing
remounts, so the open tab, the scroll position and any in-progress
inline edit survive the refresh — the point of the button is to see
another user's writes without an F5.
A host that supplies no refresh (an embedded drawer, a designer
preview, a non-record page) renders no button.
Composing default + custom
When you want "the default actions plus one custom button," you have
to replace the whole actions slot — there's no append/insert
operation in v1. To avoid copying the synthesizer's internals,
@object-ui/plugin-detail exports the sub-builders the
synthesizer uses internally:
import {
buildDefaultHeader,
buildDefaultActions,
buildDefaultHighlights,
buildDefaultDetails,
buildDefaultTabs,
buildDefaultDiscussion,
} from '@object-ui/plugin-detail';You can call them with the object definition (and any options you'd
normally pass to buildDefaultPageSchema) and spread the result into
your slot override.
When to use slotted vs full
- Slotted — customizing 1–2 regions of an otherwise standard detail page. Most "I want a fancier header" / "add a banner" / "swap the Details layout" requests.
- Full — building a record page that doesn't look like the default at all (e.g. a multi-column dashboard-style record). Author every region yourself.
The two modes are mutually exclusive: a page either has kind: "full"
(default) and uses regions[], or kind: "slotted" and uses
slots.
Note: the default-page synthesizer is the only render path for record detail pages. The legacy monolithic DetailView fallback and its
renderViaSchemakill-switch (objectDef.detail.renderViaSchema,?renderViaSchema=0) were removed by ADR-0085 PR4.