Skip to content
Reference

React components and hooks

The components, hooks, and registration API published by @nexia/sdk for App frontend code.

React components and hooks

Choose a task below, then copy the matching import and example. The package root supplies pure contracts and helpers; /host supplies host-bound runtime APIs; /testing supplies test helpers. A registered screen runs inside the configured NEXIA host.

Find an API

TaskStart with
Register routes and inspectorsautoRegisterPackageSurfaces
Lay out a screenWorkSurface, NxPageFrame, NxAdaptiveSplit
Build formsNxFormField, NxTextInput, hierarchical choices
Query and render a listResourceTable, useResourceListParams
Show alternate Resource viewsBoard, calendar, scheduler
Import or migrate dataData migration workspaces and operation status
Fetch and authorizeapi, query hooks, permission helpers
Open related workInspector and work tabs
Report errors and format valuesMessages and formatters
Connect business workflowsApproval, Signature, agent bindings
Use the test hostappTestServer, appTestI18n

Task-specific host APIs

Import these APIs from @nexia/sdk/host. Core connects components, hooks, runtime functions and registries in resources/js/app-sdk/configure-host.ts. SDK exports and Core bindings do not establish tenant/user availability or authorization. Pure helpers need no binding; SDK-composed helpers such as useFeedbackAction and useDataMigrationBackgroundOperation use existing host APIs/hooks.

Public APIRole
NxCanonicalRoutePane, useWorkSurfaceLabelsCanonical route pane and shared title/breadcrumb labels
NxLinkButton, NxIconLink, NxDropdownMenu, NxTooltip, NxHelpTooltipRoute links, action menus and contextual help
NxInsightSurface, NxInsightFilterBar, NxOverviewBand, useInsightFilterStateAnalytical page framing, filter state and summary cards
NxOrgChart, OperatingUnitManagementSurfaceOrganization chart renderer and host Operating Unit management surface
NxOrganizationTargetSelector, useOrganizationTargetsQuery, useOrganizationListScopeRead-authorized organization targets and ordinary list URL scope; put a page-owned selector in the Route Surface organizationScope slot
useLegalEntityApplicabilityQuery, useLegalEntityMembersQuery, useOperatingUnitSelectionApplicability, member lookup and Operating Unit selection for the supplied permission/context
NxMissingRequiredReferencesAlert, ResourceInspectorState, ResourceListToolbarMoreMenuMissing-reference guidance, Inspector loading/error/empty presentation and list overflow actions
useRegisterResourceCreateReceiver, useResourceCreateCompletionRegister a contextual create receiver and report completion back to its originating surface
SignatureAuthenticationMethodSelector, SignatureInvitationChannelSelector, useTrustedAssetsQuerySignature authentication/invitation choices and authorized trusted assets
useApprovalSharedLinesQuery, processUserTaskFormRegistryShared Approval lines query and App Process user-task form registration

Signature

Import pure contracts from the package root. Import host-bound components, hooks, registries, and the HTTP client from the host subpath:

Code example
TypeScript
import type { ResourceListSchema } from "@nexia/sdk";
import {
    WorkSurface,
    ResourceTable,
    NxSearchField,
    useResourceListParams,
    api,
    can,
} from "@nexia/sdk/host";

The package declares its runtime libraries as peer dependencies, so your App shares the host's copies rather than bundling its own:

Code example
JSON
{
    "peerDependencies": {
        "@nexia/sdk": "0.5.0",
        "@tanstack/react-query": "^5.99.0",
        "axios": "^1.11.0",
        "react": "^19.1.0"
    }
}

Never add these to your App's dependencies. A second React or Query client in the bundle breaks context.

App frontend boundary rules

Every external package imported by App frontend source must be declared in that App's own package.json. A package supplied or hoisted by Core is not an implicit dependency. For example, importing axios without declaring it is a phantom dependency even when the current host installation happens to resolve it.

Runtime singletons belong in peerDependencies: react, react-dom, @tanstack/react-query, and @nexia/sdk. Declare axios as a peer with the repository convention ^1.11.0 when the App imports it. Test-only tools may be devDependencies; runtime imports may not rely on them.

Cross-boundary interaction goes through SDK contracts only. Use the package root for pure contracts and the /host subpath for host-bound APIs. #app/* resolves inside resources/js; #lang/* resolves inside the fixed resources/lang contract path. Both are App-internal package imports. Neither authorizes Core aliases, cross-App imports, or imports into another package. Because package imports targets are exact, include the source extension in #app specifiers, for example #app/resources/orders/order-resource-contract.ts.

The host discovers an App only through these fixed shapes:

  • composer.json → extra.nexia.app
  • resources/js/index.ts
  • resources/js/resources/*/*-resource-contract.ts
  • resources/lang

Do not move or rename those discovery surfaces. Their contents and every other module under the App remain App-private; discovery is not a general import API.

The SDK itself resolves through its exports conditions, never through a path alias. The development condition serves TypeScript source directly, so the dev server, Vitest, and typecheck need no built artifact; production builds resolve the import condition against dist, which task frontend:build compiles first. If a subpath fails to resolve, check the installed SDK version and its package exports before changing imports.

Minimal example

An App entry registers its surfaces and its Inspector adapters lazily. Two calls cover the conventional layout:

Code example
TypeScript
import {
    autoRegisterPackageResourceInspectors,
    autoRegisterPackageSurfaces,
} from "@nexia/sdk/host";

autoRegisterPackageResourceInspectors(
    import.meta.glob("./resources/*/inspector/register-*-inspector.ts", {
        eager: true,
    })
);

autoRegisterPackageSurfaces({
    appPrefix: "Workshop",
    appKey: "workshop",
    overview: () => import("./overview/WorkshopOverviewSurface"),
    surfaces: import.meta.glob("./resources/*/surface/*Surface.tsx"),
});

appComponentRegistry is host-only. It exists in the platform at resources/js/shell/runtime/app-component-registry.ts, but @nexia/sdk does not export it — so an App Package can only reach it through a forbidden @shell/ alias. Older documentation shows appComponentRegistry.register(...); use autoRegisterPackageSurfaces instead, and registerAgentComponent for agent components.

Both registrations are filesystem-derived. autoRegisterPackageResourceInspectors rejects a path outside ./resources/{resource}/inspector/register-{resource}-inspector.ts, so a hand-maintained list of Inspector side-effect imports is never needed.

Parameters

AutoRegisterPackageSurfacesOptions:

FieldTypeRequiredBehavior
appPrefixstringYesPascalCase prefix for derived component names, e.g. Workshop
appKeystringNoApp key; used to scope registrations
overviewPackageSurfaceLoaderNoThe App's required overview surface
surfacesRecord<string, () => Promise<unknown>>YesPath → lazy loader map, normally from import.meta.glob
overridesRecord<string, PackageSurfaceOverride>NoExplicit name → loader, or loader plus an eager route prefetcher, for surfaces off the derivable path

Component names are derived from the surface file path. A surface that has moved or been renamed needs an overrides entry, or the backend's published pageElements() name will have no binding.

Route prefetch overrides

PackageSurfaceOverride accepts either the existing bare lazy loader or an explicit registration with load and an optional routePrefetcher:

This Note detail example uses the generated endpoint and cache key. Put noteDetailQueryOptions in a Resource-local query module and import that same function in the entry and NoteShowSurface; remove the surface’s original local copy. The endpoint authorizes the stored owner. Do not infer a list target from Shell context to prefetch a Legal Entity list.

Code example
TypeScript
import { queryOptions } from "@tanstack/react-query";
import { api, autoRegisterPackageSurfaces } from "@nexia/sdk/host";
import {
    type NoteRecord,
    noteApiRoute,
} from "#app/resources/notes/note-resource-contract.ts";

function noteDetailQueryOptions(tenantId: string, id: string) {
    return queryOptions({
        queryKey: ["workshop-note", tenantId, tenantId, id],
        queryFn: async ({ signal }) => {
            const { data } = await api.get<{ note: NoteRecord }>(
                noteApiRoute(id),
                { signal },
            );
            return data;
        },
    });
}

autoRegisterPackageSurfaces({
    appPrefix: "Workshop",
    appKey: "workshop",
    overview: () => import("./overview/WorkshopOverviewSurface"),
    surfaces: import.meta.glob("./resources/*/surface/*Surface.tsx"),
    overrides: {
        WorkshopNoteShowSurface: {
            load: () => import("./resources/notes/surface/NoteShowSurface"),
            routePrefetcher: ({ queryClient, tenantId, params }) => {
                if (!tenantId || !params.id) return false;
                void queryClient.prefetchQuery(
                    noteDetailQueryOptions(tenantId, params.id),
                );
                return true;
            },
        },
    },
});

The host starts this prefetcher on the same navigation tick as the surface chunk request. Returning true marks the route as handled and suppresses the surface module's later prefetch fallback. Returning false keeps that fallback, which is appropriate when a permission or route parameter is missing. The context also supplies Legal Entity, pathname, search parameters, route pattern, and parsed route parameters.

Keep the prefetcher in a module that does not eagerly import the surface component. Importing that component defeats code splitting by pulling the surface into the App entry chunk.

Responsive layout parameters

NxResponsiveRegion accepts normal HTMLAttributes<HTMLDivElement> and makes its allocated inline size available to descendant CSS container queries. NxAdaptiveSplit establishes that region itself and adds these options:

PropTypeRequiredDefaultBehavior
childrenReactNodeYes—Renders in source order; conventionally the primary region followed by an aside
densitycompact | defaultNodefaultUses a 1rem or 1.5rem gap
asideWidthnarrow | default | wideNodefaultReserves 18rem, 22.5rem, or 26rem for the second column
twoColumnAtcompact | default | wideNodefaultEnables two columns at an allocated width of 48rem, 56rem, or 64rem
classNamestringNo—Adds classes to the adaptive grid
containerClassNamestringNo—Adds classes to the outer container-query boundary

Output or return: Layout primitives

Import these instead of writing layout markup. They carry the Shell's spacing, density, and dark-mode behavior.

ComponentUse for
WorkSurface, WorkSectionThe standard App page frame and its sections
NxPageFrame, AppRouteFramePage-level frames
NxResponsiveRegion, NxAdaptiveSplitLayouts that respond to allocated tab or pane width instead of viewport width
NxTabNavUnderline tabs for switching sections inside one App workspace
NxSectionStack, NxSectionCard, NxDetailRow, NxActionButtonSection rhythm, card sections, labeled values, and row actions
NxFieldGroup, NxFieldset, NxFieldLegend, NxFormSection, NxFormFieldForm structure
NxResourceInformationView, NxResourceInformationFormRead and write projections of one ordered Resource information schema
NxEmptyView, NxLoadingBlockEmpty and loading states
NxModalDialog, NxAlertOverlays and inline notices

In-place tab navigation

NxTabNav renders the host's shared underline-tab interaction while the App owns the selected value and the content below it. It does not navigate, fetch, or persist state. Keep URL synchronization in the App when a tab must survive a reload or be shareable.

Code example
TSX
import type { NxTabNavItem } from "@nexia/sdk";
import { NxTabNav } from "@nexia/sdk/host";

const tabs: NxTabNavItem[] = [
    { id: "daily", label: t("attendance.tabs.daily") },
    {
        id: "issues",
        label: t("attendance.tabs.issues"),
        badge: openIssueCount,
    },
];

<NxTabNav
    aria-label={t("attendance.tabs.label")}
    items={tabs}
    value={activeTab}
    onChange={setActiveTab}
    scrollable
/>;

items, value, and onChange are required. Each NxTabNavItem has an id and label, with optional badge, disabled, and testId. Use scrollable when the complete tab set may not fit the allocated pane width, and supply an aria-label that names the group rather than repeating one tab label.

Section spacing ownership

AppRouteFrame owns the gap between its direct section children. A reusable component that renders two or more complete sibling sections uses NxSectionStack; do not reproduce that contract with space-y-section or a local gap. NxFormSection requires an explicit spacing choice: use spacing="stack" for fields within one section and spacing="section" when its direct children are independent section cards.

Shared Resource information schema

An ordinary CRUD Resource declares its sections and fields once with defineResourceInformationSchema. Each field owns one label key, help or definition keys, requirement classification, responsive span, projection modes, read value, and Edit control. NxResourceInformationView and NxResourceInformationForm then preserve the same section and field order.

For unsaved form automation, pass binding={{ effect: 'draft', disabled: pending }} to NxResourceInformationForm. A published Resource route supplies its resource key, mode, record identity and route scope. Outside that route owner, supply resourceKey, resourceId and scopeKey explicitly. Do not repeat schema fields in a separate Agent map or add an Agent registration to every Resource.

Use the controls' public value writers, such as NxTextInput.onValueChange below; an event-only onChange does not expose a safe draft setter. Existing field labels and current selectable options describe the mounted action. Hidden, sensitive, read-only, disabled and unsupported controls are excluded. Missing bindings and effect: 'autosave' do not expose draft actions. Keep the page's permission gate. The shared batch validates up to 32 supplied fields before writes, protects user edits and reports an unsaved draft after render; it does not invoke Save.

Agent-visible screen callbacks declare effect: 'read' | 'draft'. Shared list, option-search and form hosts provide this at their common registration point; Resource authors do not duplicate it. Unclassified callbacks and generic DOM event filling/clicking are not executable. An explicitly requested save uses an existing server mutation tool with its operation policy and confirmation flow.

For remote single-choice controls, NxCombobox.searchOptions accepts a scopeKey and search(query, signal?) returning authorized options. Reuse the picker's existing lookup and cache; searching neither selects nor saves a value. The draft setter accepts only an enabled option in the currently loaded list. Multi-select is not exposed by this search contract.

Nested information schemas reuse their existing section and field keys for identity. Repeated sections need stable row keys; array positions are not stable when rows reorder. A bound NxOrgChart uses its existing selectable nodes and controlled selectedId/onSelect for draft selection. This does not authorize moving nodes, editing the hierarchy or automating read-only Inspector charts.

Composed forms without an information schema may declare the same binding on NxFormSection and canonical NxFormField.fieldKey identities for ordinary fields. Schema forms already supply field identity: do not repeat it. sensitive excludes a composed field. Existing typed writers and choice lists remain authoritative. Disable bindings when permission is absent, submission is pending, or an embedded change callback can persist data. Repeated rows still need stable identities.

Code example
TSX
import {
    defineResourceInformationSchema,
    resourceInformationValue,
} from "@nexia/sdk";
import {
    NxTextInput,
} from "@nexia/sdk/host";

interface DetailContext { item: { name: string } }
interface FormContext { name: string; setName: (value: string) => void }

const schema = defineResourceInformationSchema<DetailContext, FormContext>()({
    sections: [
        {
            key: "basics",
            titleKey: "orders.sections.basics",
            fields: [
                {
                    key: "name",
                    labelKey: "orders.name.label",
                    helpKey: "orders.name.help",
                    requirement: "save",
                    view: ({ item }) => resourceInformationValue(item.name),
                    edit: {
                        kind: "control",
                        render: ({ context }, slot) => (
                            <NxTextInput
                                id={slot.id}
                                aria-describedby={slot.describedBy}
                                value={context.name}
                                onValueChange={context.setName}
                            />
                        ),
                    },
                },
            ],
        },
    ],
});

Use modes for static View/Create/Edit omissions; context-dependent visibility.view, visibility.create, and visibility.edit run only after that mode contract. Use span: "full" for wide fields. A section may set one or two formColumns and an optional aggregate/projection source; View remains an ordered detail list. visibleEmptyRequirements controls which empty requirement classes remain visible in NxResourceInformationView. Use a custom Edit projection for a compound control, while the field keeps its single View value and canonical position. Route loading, mutations, permission decisions, related-resource actions, and Inspector density remain Surface-owned. Do not create separate Show and Form field lists or surface-specific section translation keys for the same information.

Container-responsive layouts

Use NxAdaptiveSplit for the common primary-plus-supporting layout. It stays a single column until the component itself receives enough width, so opening the Activity Center or moving the tab into a narrow split pane moves the aside below the primary content even when the browser remains wide.

Code example
TSX
import {
    NxAdaptiveSplit,
    NxSectionCard,
    NxSectionStack,
} from "@nexia/sdk/host";

export function OrderDetail() {
    return (
        <NxAdaptiveSplit>
            <NxSectionStack>
                <NxSectionCard title="Order">Order fields</NxSectionCard>
            </NxSectionStack>
            <aside>
                <NxSectionStack>
                    <NxSectionCard title="Actions">Order actions</NxSectionCard>
                    <NxSectionCard title="History">Order history</NxSectionCard>
                </NxSectionStack>
            </aside>
        </NxAdaptiveSplit>
    );
}

Use NxResponsiveRegion when the layout is not a two-column split. Container variants on descendants measure the nearest region, not window.innerWidth:

Code example
TSX
import { NxResponsiveRegion } from "@nexia/sdk/host";

<NxResponsiveRegion>
    <div className="grid gap-4 @min-[36rem]:grid-cols-2">
        <section>Summary</section>
        <section>Evidence</section>
    </div>
</NxResponsiveRegion>;

Core binds both facades to the native CSS implementation. The live approval detail at resources/js/core/approval/ApprovalCaseSurface.tsx is the source example: its actions and history move below the document when the work region falls below the split threshold. No resize listener or ResizeObserver is involved.

Organization selection

The SDK already provides the generic NxCombobox; the organization controls reuse shared choice primitives and add organization-specific presentation.

Use NxOrganizationTargetSelector for a page-owned list scope. Import it and useOrganizationListScope from @nexia/sdk/host; pass the page's read permission explicitly. The hook signature is useOrganizationListScope(tenantId, permission, mode = "legal-entity-list", enabled = true).

Selector modeInput and selection
legal-entity-listAuthorized options; value: string[] and onChange(ids) for multiple Legal Entities
legal-entity-operating-unit-listAuthorized exact targets pairs; value: OrganizationTargetQuery and onChange(query) for Legal Entity and Operating Unit dimensions
legal-entity-singleAuthorized options; value: string | null and onChange(id) for one create or execution target

With one authorized option, the selector displays a summary without setting value or calling onChange. The Surface owns any single-target default; create and recovery forms that require a selection use the form contracts below.

The hook supports the two list modes. Inside a Route Surface, connect its selectorProps directly to the frame slot:

Code example
TSX
import {
    NxOrganizationTargetSelector,
    NxPageFrame,
    useOrganizationListScope,
} from "@nexia/sdk/host";

const scope = useOrganizationListScope(tenantId, readPermission);

<NxPageFrame
    organizationScope={<NxOrganizationTargetSelector {...scope.selectorProps} />}
>
    {children}
</NxPageFrame>;

tenantId, readPermission, and children above belong to the consuming Surface. Use "legal-entity-operating-unit-list" as the third hook argument for an LE/OU list. NxPageFrame places the selector after the breadcrumb and before actions; AppRouteFrame also accepts organizationScope. The frame provides placement, while the Surface owns the permission, supported axes, and list query. Do not repeat the selector in actions, the table toolbar, or ordinary column filters.

Fetch the list only when scope.requestParams !== undefined; pass those URLSearchParams to the request and include the selected scope in its cache key. An empty parameter set means all authorized targets. An undefined set means the scope is loading, invalid, unavailable, or disabled: do not replace it with an empty set and fetch a broader list. The selector's status presents the non-ready state. Canonical list keys are legal_entity_public_ids[] and operating_unit_public_ids[].

The target catalog comes from useOrganizationTargetsQuery for the requested permission. Preserve the returned exact LE/OU pairs; never form a Cartesian product from independent lists. Backend authorization remains authoritative. Custom relationship or multi-permission pages may keep their own URL adapter and target loading while reusing the selector. Standard tenant common-data pages have no selector; a tenant-owned custom relation or Operating Unit page may explicitly declare organization read scope. Record ownership alone does not determine list scope.

For forms, use the body rather than the header scope slot:

  • NxResourceInformationForm.legalEntity accepts the root-exported ResourceFormLegalEntity: create uses { kind: "select", value, options, onChange } with optional disabled and error; edit uses { kind: "stored", label } from the saved record or an authorized lookup. The host places it first inside the first schema section at full width. Omit it when unnecessary and remove a duplicate form-schema owner field; retain the View/Inspector owner field.
  • Custom forms use NxResourceFormLegalEntityField from /host inside their first input section, passing target and mode="create" or mode="edit".
  • NxOperatingUnitSelector from /host is a single-choice, full-width OU form input with localized defaults. Supply authorized options, value, and onChange; the App owns target loading and clearing dependent values when the Legal Entity changes. It does not change page or Shell scope.

The older OperatingUnitScopeField takes an OperatingUnitScopeResult through its scope prop and renders a selected-OU scope panel. It is not the LE/OU multi-select list contract or the form input above. Keep existing workflows that need that contract explicit; use NxOrganizationTargetSelector for new page-owned list scopes. Detail and edit display the saved owner, and no selector creates a Shell-global active organization.

Form and input controls

ComponentNotes
NxTextInput, NxTextAreaText entry
NxSelect, NxComboboxChoice; NxComboboxOption / NxSelectOption type the options
NxCheckboxBoolean
NxRadio, NxRadioGroupOne choice with vertical or horizontal group orientation
NxDatePicker, NxDateRangePickerLocale-aware single-date and bounded date-range selection
NxFileDropzoneUploads; pair with useUploadAttachment or useUploadMedia
NxColumnBrowserEmbedded Miller-column navigation for hierarchical choices
NxColumnPickerModal selection flow composed from NxColumnBrowser
NxOperatingUnitSelector, NxResourceFormLegalEntityFieldAuthorized OU input and shared Legal Entity form field
OperatingUnitScopeFieldOperating Unit scope selection
NxButton, NxIconButton, NxActionButtonText, icon-only, and typed-intent actions; icon-only buttons require aria-label
NxLinkButton, NxIconLinkButton-styled and icon-only route links; icon-only links require aria-label
NxRefreshControlHost-owned manual refresh for compact, section, or snapshot placement

Use NxButton, NxIconButton, or NxActionButton for commands such as submit, retry, open a dialog, or mutate data. Use NxLinkButton href or NxIconLink href when activating the control changes the route. Their real link destination preserves browser link behavior and adds the Shell's right-click Open in new work tab action for internal routes. onNavigate on NxLinkButton is an optional navigation side effect; it does not replace href.

With NxCombobox multiple, the host adds a localized close action to the fixed panel footer. Set showSelectedItems when selected values must remain visible as removable chips below the field. Use selectedItemsAriaLabel and removeSelectedItemLabel when the default accessible labels are not specific enough for the field.

Radio cards

NxRadio accepts variant="default" | "card"; omitting it keeps the existing circular radio. Use card for a single-choice option that needs a larger label or explanation. Both variants use the same native radio and NxRadioGroup value/name/onChange contract, including keyboard navigation and disabled state. inputSize controls only the circular indicator in the default variant.

The label accepts React content, so put a short description inside it rather than adding a separate description property. Do not nest buttons, links, or other inputs inside the label. Give the group an accessible name and use a grid inside the group for equal-width cards. Multi-choice fields still use checkboxes; a button that immediately runs an action is not a radio.

Code example
TSX
import { NxRadio, NxRadioGroup } from "@nexia/sdk/host";

<NxRadioGroup value={mode} onChange={setMode} aria-label="Entry mode">
  <div className="grid gap-3 md:grid-cols-2">
    <NxRadio variant="card" value="new" label={
      <>
        New entry
        <span className="mt-1 block font-normal">Enter the details for a new record.</span>
      </>
    } />
    <NxRadio variant="card" value="existing" label="Use existing entry" />
  </div>
</NxRadioGroup>

Apps adopting this option require both the updated SDK and a Core host that implements the card variant. Existing callers require no changes.

Favorites

Use the host-bound FavoriteButton when an App presents a durable target that Core has already declared favoriteable. Import the component and its target type from the package root:

Code example
TSX
import { FavoriteButton, type FavoriteTarget } from "@nexia/sdk";

const target: FavoriteTarget = {
    kind: "record",
    resourceKey: "workshop.note",
    resourceId: note.public_id,
};

<FavoriteButton target={target} size="xs" testId="note-favorite" />;

FavoriteTarget is either a record identity (resourceKey and resourceId) or a server-declared page identity (key). The only button props are target, optional size ("xs", "sm", or "md"), and optional testId. The host owns state, requests, labels, and the server binding. An App must not send or store a title, href, permission, or arbitrary URL with this control; favoriting never grants access.

For a detail header, place the compact control in NxPageFrame's breadcrumbActions slot. For a list primary cell, pass server metadata as favoriteTarget, or pass the owner record identity as favoriteCandidate so the host can authorize it first. Set favoriteable={false} for a cell whose link identifies a related record rather than its row owner. These inputs do not create another App-owned favorites state or a custom paging contract.

Hierarchical choices

Use NxColumnBrowser when the hierarchy is part of the page. Use NxColumnPicker when choosing one leaf or several leaves is a blocking modal step. Both consume the same NxColumnNode[] and search the same leaf labels and keywords. The browser owns no overlay chrome; the picker composes the browser inside the host's centered NxModalDialog and commits the pending leaf through its Select action.

Import the portable node and props types from the package root and the host-bound renderers from /host:

Code example
TSX
import type { NxColumnNode } from "@nexia/sdk";
import { NxColumnBrowser, NxColumnPicker } from "@nexia/sdk/host";

const roots: NxColumnNode[] = [
    {
        value: "platform",
        label: t("apps.platform"),
        children: [{ value: "tenant.user", label: t("resources.users") }],
    },
];

<NxColumnBrowser
    roots={roots}
    value={resourceKey}
    onSelect={setResourceKey}
    ariaLabel={t("resources.choose")}
/>;

<NxColumnPicker
    open={pickerOpen}
    onClose={() => setPickerOpen(false)}
    title={t("resources.choose")}
    roots={roots}
    value={resourceKey}
    onSelect={setResourceKey}
/>;

The App owns translated labels, node values, disabled state, the selected value, and any data loading or mutation triggered by selection. onSelect on the embedded browser fires when a leaf is chosen; branch rows only navigate the columns. onActivate can add a separate Enter or double-click action. Use renderPreview for the final column and onClearSelection when changing a branch should clear the current leaf. For multi-selection, set multiple, pass values, and receive the committed leaf list through onSelectMany(values, nodes). Use renderSelectionPreview for a review pane whose onRemove(value) callback removes a pending choice; single mode instead uses value, onSelect, and renderPreview.

Table and list

SymbolKindPurpose
ResourceTablecomponentThe standard resource list table
ResourceTableColumn, ResourceTableSorttypeColumn and sort declarations
ResourcePrimaryCell, ResourceTextCellcomponentConventional cell renderers
ResourceStatusBadge, NxStatusBadgecomponentStatus display
ResourceRowActionsMenucomponentPer-row action menu
NxPaginationcomponentPagination control
useResourceListParamshookTenant-, context-, and URL-scoped list state
readResourceListParams, serializeResourceTableSortfunctionURL serialization
keepPreviousResourceListDatafunctionQuery option for smooth pagination

Column sorting and visibility

ResourceTable is also the canonical table for dashboard, dialog, and detail rows. Omit toolbar and footer for that chrome-free form. Add a stable tableId only when the logical view should persist per-table display or column overrides. The host combines it with the current tenant and user, so do not derive it from translated text:

Surface excerpt: use the generated Note list’s data, search state, refetch, translation function t, and formatDateTime from useDateTimeFormatter(). Add these columns after the loading/error guards.

Code example
TSX
import type { ResourceTableColumn } from "@nexia/sdk";
import { ResourceTable, ResourcePrimaryCell, NxSearchField } from "@nexia/sdk/host";
import { type NoteRecord, noteResourceRef } from "#app/resources/notes/note-resource-contract.ts";

const columns: ResourceTableColumn<NoteRecord>[] = [
    {
        key: "name",
        header: t("workshop.note.name.label"),
        size: "primary",
        cell: (row) => (
            <ResourcePrimaryCell
                resource={noteResourceRef(row)}
                label={row.name}
            />
        ),
    },
    {
        key: "created",
        sortKey: "created_at",
        header: t("workshop.note.created_at.label"),
        cell: (row) => formatDateTime(row.created_at),
    },
    {
        key: "updated_at",
        defaultVisible: false,
        header: t("workshop.note.updated_at.label"),
        cell: (row) => formatDateTime(row.updated_at),
    },
];

<ResourceTable
    tableId="workshop.notes"
    label={t("workshop.note.list.title")}
    columns={columns}
    rows={data.data}
    schema={data.meta.list_schema}
    toolbar={{ leading: <NxSearchField value={search} onChange={setSearch} /> }}
    footer
    onRetry={() => void refetch()}
    getKey={(row) => row.public_id}
    renderCard={(row, index) => (
        <article className="space-y-2">
            {columns[0].cell(row, index)}
            <p>{columns[1].cell(row, index)}</p>
        </article>
    )}
/>;

Toolbar and footer chrome are opt-in. An enabled toolbar derives the standard Filter, scoped Refresh, and final More utilities; onRetry is reused as the owning-query refresh callback unless an explicit refresh config overrides it. The toolbar leading slot accepts any query control, including search and date pickers. showLabel renders the semantic caption visibly, while labelTooltip adds contextual help beside it. Per-table style and density overrides win over the optional UserContext.table_style and UserContext.table_density global defaults and can be reset to “use global”. Use summaryRow.cells for an aggregate aligned to declared column keys; its optional label supplies the accessible row label. A table-specific refresh callback overrides the onRetry fallback without changing error retry behavior.

The backend schema.sortable list is the positive sorting authority. Do not repeat sortable: true in a visual column. Use sortKey only when the visual key differs from the backend query key, and sortable: false only to suppress sorting that the schema would otherwise allow.

For a resource that has a complete compact representation, add optional renderCard to the preceding ResourceTable example. Enable toolbar (an empty object is sufficient) to expose the standard More menu, which then offers Table and Cards, with Table as the default. The host retains the same query, filters, paging, sorting, refresh, selection, and activation in either mode. View and Auto/2/3/4 card-column preferences are scoped by tableId, tenant, and user. Sorting remains the caller's existing URL or query state; card sort only invokes the same onSortChange contract. renderCard owns layout: reuse existing column cells or shared permission-filtered action renderers so table and card callbacks and authorization stay defined once. Do not opt into cards for tree or summaryRow lists; those semantics are table-only. Keep nested buttons and links inside the card body so they do not activate the card row.

Set defaultView="cards" alongside renderCard to start a list in card mode. A saved view preference takes priority; tables without renderCard and lists with summaryRow retain table rendering. The Resource table live example shows both starting views and reuses the same cells and actions.

Every non-structural column appears in More > Configure columns. A size: "primary" identity column is always visible; use hideable: false for another essential presentation-only column. defaultVisible: false starts a hideable column unchecked until the user chooses otherwise. The stored choice is scoped by tenant, user, and tableId; newly added columns adopt their declared default. Hiding the currently sorted visual column clears that sort so the table never remains ordered by an invisible column. These visibility flags are frontend-only and never belong in meta.list_schema.

Resource action semantics

When one operation appears in both a row menu and a detail or Inspector action rail, derive both presentations from one action set after effective permission filtering. Keep the visible resource actions in the same order: View, Edit, management actions, send or reset actions, lifecycle actions, then Delete. Host-owned chrome such as Open in tab and Close may surround that sequence, but must not reorder it. Frontend permission checks control visibility only; the backend still authorizes every operation.

Each ResourceRowActionsMenu item keeps behavior and presentation separate:

  • id is a stable, non-localized operation identifier. The host uses known IDs such as show, view, edit, archive, retire, deactivate, and delete to select the canonical leading icon.
  • label is the localized text visible to the operator.
  • onSelect remains App-owned and performs the actual navigation or command. The host never infers behavior from the ID or label.
  • destructive declares destructive treatment; disabled preserves an unavailable or pending command.

An App does not need an SDK iconKey or an icon-library dependency for a new operation. ResourceRowActionsMenu supplies a neutral leading icon when it does not recognize an ID. A recurring platform operation can later receive a canonical host mapping without changing the App contract. An explicitly passed icon remains authoritative.

destructive and dangerGhost describe different layers:

  • NxDropdownMenuItem.destructive: true is the semantic fact that a menu item performs a destructive operation. ResourceRowActionsMenu uses it to apply destructive menu treatment.
  • NxButton variant="dangerGhost" is the visual projection of that same fact when the operation is rendered as a secondary button in an Inspector or detail action rail.

Do not store a button variant in a shared domain action or infer destructive intent from a localized label. Store the operation's meaning once, then map it to destructive: true for menu items and dangerGhost for buttons. Ordinary View, Edit, and management actions remain neutral.

useResourceListParams is the one to reach for first: it keeps filters, sort, search, and page in the URL, so a list view is shareable and survives reload.

Resource projections

Board, Calendar, Resource Scheduler, and Schedule are host-bound React components. Import their pure projection and owner-action types from the package root, and import the renderers from /host:

Code example
TSX
import type {
    NxCalendarOccurrence,
    NxCalendarSelection,
    NxResourceSchedulerOccurrence,
    NxResourceSchedulerSelection,
    ResourceBoardProjection,
    ResourceScheduleProjection,
} from "@nexia/sdk";
import {
    NxCalendar,
    NxResourceScheduler,
    ResourceBoard,
    ResourceSchedule,
} from "@nexia/sdk/host";

The host binds each facade lazily, so the browser loads the EventCalendar, drag-and-drop, or Gantt chunk only when that view renders.

ComponentUse forApp supplies
ResourceBoardKanban lanes and cardsResourceBoardProjection, localized labels, and an optional typed onMove action
NxCalendarMonth, week, day, and list occurrencesNxCalendarOccurrence[], timezone, locale, state labels, optional reschedule/resize actions, and optional empty-range selection
NxResourceSchedulerResource timeline and resource time-grid occurrencesResource lanes, NxResourceSchedulerOccurrence[], timezone, locale, state labels, optional reschedule/resize/reassign actions, and optional empty-range selection
ResourceScheduleGantt or schedule-timeline workResourceScheduleProjection, selected view, optional typed owner actions, and an optional caller-owned creation callback

NxCalendar can render filled bands or text-only occurrences through eventAppearance, keep the grid without a separate notice through showEmptyState: false, and use onOpenOccurrence for an occurrence that has no Resource link. Calendar and Scheduler onOpenOccurrence callbacks open the caller-owned detail flow and do not grant record access.

Creation and selection contracts:

SymbolExact contractOwnership behavior
NxCalendarSelectionNxCalendarTemporalRange: start, end, all_day, and timezoneCarries the empty calendar range selected by the user; it does not represent a persisted occurrence
NxResourceSchedulerSelectionNxCalendarSelection plus resource_ids: string[]Carries the empty range and selected resource lane or lanes; it does not create an App record
NxCalendarProps.onSelectRange(selection: NxCalendarSelection) => voidOpens or seeds the caller-owned occurrence creation flow
NxResourceSchedulerProps.onSelectRange(selection: NxResourceSchedulerSelection) => voidOpens or seeds the caller-owned creation flow with the selected resource context
ResourceScheduleProps.onCreateResource(parent?: ResourceProjectionReference) => voidOpens the caller-owned resource creation flow, optionally under the selected parent

These components do not fetch or mutate App records by themselves. The App loads an actor-authorized projection from its own API and implements the typed callback against an App-owned business action. Empty-range selection and native schedule add affordances only invoke the callbacks above; the shared renderers never create or persist domain records. Core supplies rendering and reversible optimistic feedback. See Resource projections for the complete ownership boundary.

useResourceProjectionChange({ tenantId, resource, onChanged, enabled }) subscribes to the exact app_key / resource_key / resource_id identity. The owning App emits the PII-free ResourceProjectionChanged event only after its projection commits; the callback is a refetch hint, not data or authorization.

Import and Export

Import components from /host and the following props types from the package root.

Component / propsRequired input and behavior
NxResourceTransferActions / NxResourceTransferActionsPropstransferMeta, onImported; navigates to available transfer screens. Current Core handles navigation and does not invoke onImported from this component
NxResourceImportDialog / NxResourceImportDialogPropsopen, onClose, previewUrl, importUrl, templateUrl, onImported; generic Resource upload, preview, execution and polling
NxDatasetImportWorkspace / NxDatasetImportWorkspacePropsresourceKey; optional onCompleted, onQueued and reference-create actions. Reads organization context from the host; it accepts no organization ID props
NxDatasetExportWorkspace / NxDatasetExportWorkspacePropsresourceKey; optional legalEntityPublicId, operatingUnitPublicId, asOf, triggerVariant, overflowItems

Core supplies common UI and file handling; the App defines schema, permissions, scope filtering, export rows, validation, business writes and retry behavior. See Resource Transfer to connect export sources and import recipes/pipelines.

Data migration

Register host-composed migration entries through dataMigrationRegistry:

Code example
TypeScript
import { dataMigrationRegistry } from "@nexia/sdk/host";

dataMigrationRegistry.registerProvider(provider);
dataMigrationRegistry.registerTarget(target);
dataMigrationRegistry.registerStage({
    ...stage,
    loader: () => import("./migration/OrdersStage"),
});

An App can register providers, product targets, and lazy App-owned stages but cannot inspect or compose the host registry. A stage declares its provider and target, optional requiredPermissions with permissionMode, optional prerequisiteStageIds, and reports the public id of a server-owned migration run through onCompleted({ runPublicId }). The callback only asks the host to refetch GET /api/data-migrations/runs; browser state is never completion evidence. For a stage backed by NxDatasetImportWorkspace, the App backend declares DataMigrationStageIdentity on its ResourceImportPipelineDefinition. The host attaches that server-owned identity to the queued execution and records the actual terminal import result; the browser cannot choose or spoof it.

Migration stages use useDataMigrationBackgroundOperation() for cancellable polling of the standard accepted operation (ticket, status, optional status_url) and dataMigrationUploadAccept(formats) to derive the file-picker contract from server metadata. createDataMigrationSessionCheckpointStore() is a bounded, memory-only checkpoint for a stage that temporarily leaves to create a referenced Resource; it is not durable execution evidence. Resolve that create route by stable Resource Key through resourceCreateDestinationRegistry instead of importing or hard-coding another App's route. Identical registry declarations may be repeated during boot; a conflicting duplicate fails immediately.

Data access

SymbolPurpose
apiConfigured HTTP client; carries tenant and Legal Entity context
canPermission check in frontend code
canForPopulation, canForAnyPopulationPermission checks against one or several SubjectPopulation values
useContextQueryCurrent tenant, user, Legal Entity, and workspace context
usePermissionsQueryThe actor's permissions for tenant, optional Legal Entity, and optional Operating Unit scope
useLegalEntitiesQuery, useOperatingUnitsQueryOrganization lookups
useApprovalRoutePoliciesQueryCore-owned Approval route-policy metadata for an App reference picker
useOperatingUnitScope, useAssignableOperatingUnits, useAffiliatedOperatingUnitsOperating Unit scoping
usePartiesParty lookups
useAttachments, useUploadAttachment, useDeleteAttachmentAttachment lifecycle
useUploadFileTransport-neutral host-issued upload intent; returns a cancellable and retryable UploadTask
useUploadMediaStandalone Media upload; returns a MediaItem mutation result
sendUserInvitationHost-owned login invitation; accepts UserInvitationPayload and returns UserInvitationResponse

Context types are published for typing your own code: TenantContext, UserContext, LegalEntityContext, WorkspaceContext, PermissionsResponse. UserInvitationPayload accepts an email or Person Party public id plus optional Legal Entity and Operating Unit role assignments. Core remains responsible for validating and authorizing the invitation request.

usePermissionsQuery(tenantId, legalEntityPublicId, operatingUnitPublicId?, enabled?) falls back to the current context when the Legal Entity argument is nullish, includes the optional Operating Unit in both the cache key and request, and runs only when enabled is true and a tenant id exists. It is still a UI affordance snapshot; the backend authorizes every protected request again.

useApprovalRoutePoliciesQuery(tenantId, legalEntityPublicId, enabled?) reads the active-context policy catalog only when it is enabled and both identifiers are present. ApprovalRoutePoliciesQueryResult returns items with key, version, name, status, scope, and legal_entity_public_id. An App may show that metadata in a reference picker and store the stable policy key, but the backend still validates the selected policy when the App command runs.

useUploadFile(): UploadFileClient starts a host-issued upload intent without exposing storage or transport policy to the App. Call uploadFile() with UploadFileInput: a required purpose and browser File, plus optional checksum and onProgress({ loaded, total, percent }). It returns an UploadTask with a result promise, cancel(), and retry().

The promise resolves to UploadIntentResponse, whose upload is the public UploadIntent projection: id, purpose, state, optional transport (direct or proxy), nullable expires_at, and optional opaque media. Core chooses direct object-storage transfer or proxy transfer; App code never selects the transport and must not depend on host-only upload URLs or storage fields. The related public types are UploadFileInput, UploadProgress, UploadIntent, UploadIntentResponse, UploadTask, and UploadFileClient.

can decides what to render, never what is allowed. Every request it guards is authorized again in the backend. A frontend permission check is a UX affordance.

Inspector and work tabs

SymbolPurpose
ResourceInspectorPanel, ResourceInspectorStackHost, ResourceInspectorStackProviderInspector shell
resourceInspectorAdapterRegistryRegister a Resource's inspector adapter
autoRegisterPackageResourceInspectorsDeclare the App's filesystem-owned inspector registration set in its entry
ResourceInspectorAdapter, ResourceInspectorAdapterPropsAdapter types
InspectorActionThe single permission-aware Inspector action contract for edit, delete, lifecycle, and App-owned commands
ResourceActionDescriptor, InspectorResourceActionsOne open-ended action set shared by a row menu and its Inspector projection
ResourceLinkOpen a referenced Resource in the active Inspector stack, with route fallback and a work-tab context menu
useOpenResourceWorkTabOpen a record in a work tab
useMarkTabDirty, useWorkTabLabel, useRegisterTabActionsWork tab state and actions
useActiveWorkTabIdStable ID of the Work Tab that owns the surrounding App surface
useIsActiveWorkTabVisibletrue only while the surrounding Work Tab is the visible pane; gate steady polling in custom renderers
useRegisterListLocateActionBind the backend locate action to a list

Use useActiveWorkTabId() to key session-only drafts that must survive a route-tree remount inside one Work Tab. A surface outside a Work Tab receives null. Do not use the ID as a tenant, actor, authorization, or durable browser-storage key.

ResourceInspectorStackHost owns View and Close. An adapter adds Edit, Delete, or activate/deactivate only when the serialized backend action decision permits that operation, then supplies the same route or command used by the row menu via InspectorAction. Delete remains destructive and lifecycle labels describe the current transition; the host supplies canonical icons and header placement. The stable action id follows the same convention as ResourceRowActionsMenu. Known IDs receive canonical icons; an App may use any new resource-specific ID without changing the SDK or host, and the host renders the neutral fallback unless the App supplies an explicit icon.

Define one resource-local factory that returns ResourceActionDescriptor[], then pass its result to the row menu and InspectorResourceActions. The factory owns membership, order, labels, permissions, destructive state, and pending state; each caller supplies the callbacks appropriate to its context. The Inspector projection omits view and show because the host already owns the Inspector View action. This is data synchronization, not a closed action enum: a one-off ID remains valid without an SDK change. Use singular InspectorAction only when an action genuinely has no matching row presentation.

Wrap the Primary Section and Inspector host in one ResourceInspectorStackProvider. Keep ResourceInspectorStackHost mounted even when the provider's root is null: a ResourceLink in the Primary Section can then create the first stack entry. In a clickable table row, the row background and its own identity select the row Resource, while a different referenced value uses ResourceLink without an onActivate override so it pushes that referenced Resource. More menus, links, buttons, and form controls do not also activate the row. When resource.route is an internal route, right-clicking the ResourceLink also offers Open in new work tab.

An Inspector adapter exposed as a ResourceLink target must support an id-only ResourceRef. Treat embedded resource.data as an immediate optimization, then fetch the Resource detail by resource.id when that data is absent. This keeps cross-Resource drill-in correct instead of rendering an empty Inspector.

Dashboard renderer hooks

Dashboard renderers must follow the board's shared lifecycle instead of creating their own viewport or transport policy:

SymbolPurpose
useAgentToolDataFreshness({ enabled, refreshMs, preview? })Returns TanStack Query refetchInterval, refetchOnWindowFocus, and staleTime; the host clamps polling to at least ten minutes, honors slower hints, and pauses parked tabs, previews, and disabled queries
useDashboardWidgetAutoHeight(widgetId)Returns a ref for the widget scroll container. Inside a board it reports natural content height so a newly added widget can settle; outside a board it is inert
useAgentToolDataInvalidation(enabled?)Compatibility no-op. Shell-wide Reverb ingress owns tool-data invalidation, so a widget never opens a transport subscription

Spread the freshness result into useQuery. A spec's queryRef.refresh is a requested cadence, not permission to poll faster than the host policy.

Errors, messages, and formatting

SymbolPurpose
parseMutationFormError, firstFieldError, withoutFieldErrorMap API validation errors to fields
MutationFormError, FormFieldErrorsError types
useMessage, MessageApi, ToastOptionsToasts and messages
useFeedbackAction, FeedbackUndoOptionsStandard operation feedback with an optional one-shot server-backed inverse action
useDateTimeFormatterLocale- and timezone-correct formatting; accepts a fallback option and returns locale and timezone
formatNumber, NumberLikeLocale-aware numeric formatting; invalid or absent values render as —
useDebouncedValueDebounced input
NxStatCard, NxLineChart, NxBarChart, NxDonutChartMetrics and common comparisons
NxHeatmap, NxGauge, NxSankeyMatrix intensity, target progress, and flow analysis; Apps pass semantic data, not ECharts options

Use useDateTimeFormatter rather than formatting dates yourself — it applies the tenant's business timezone.

Set href on NxStatCard or an NxOverviewBand card when the summary opens a canonical route. Omit it for a static summary. The former onOpen card callback is retired so route cards keep native link and Shell work-tab behavior.

For a reversible mutation, add undo to useFeedbackAction with a resolved label, the inverse operation, and success/error feedback. The inverse receives the original successful result and argument tuple and runs at most once. A custom success Toast action keeps the single action slot. Do not present a client-only visual rollback as Undo.

Approval, Signature, and agent

SymbolPurpose
approvalComposerBusinessFormRegistryRegister an Approval business form widget
ApprovalBusinessFormSlotPropsV2Props your composer widget receives at slot API version 2
ApprovalBusinessFormController, ApprovalBusinessFormSubmitEnvelopeSubmission contract
ApprovalCase, ApprovalDocument, ApprovalStep, ApprovalStateApproval read models
ApprovalResourceSummary, ApprovalResourceSummaryFieldAuthorized Resource summary projection attached to an Approval case
SignatureRequestPreparationEditor, SignatureRequestPreparationExactPreviewCore-owned request-local editing and protected exact-PDF review surfaces
SignatureRequestPreparationPlacementEditor, SignatureSingleRequestDialogPlacement-only editing and the app-neutral single-request flow
registerAgentComponentRegister an agent-renderable component
registerLazyAgentComponentRegister the same renderer behind a dynamic import() loader
useAgentToolDataInvalidationRetain compatibility with existing renderers; shell-wide Reverb ingress performs invalidation

ApprovalBusinessFormSlotPropsV2 is the prop contract named by slotApiVersion: 2 on a SlotWidgetDescriptor. Consume only these props. An ApprovalResourceSummaryField may carry the semantic role subtitle, meta, or status. Treat it as a compact-presentation hint; field order and host layout remain host-owned.

Testing

SymbolPurpose
configureNexiaAppFrontendTestHostBind a test host
appTestServerRequest mocking
appTestI18nLocale catalog for tests
NexiaAppFrontendTestHostBindingsBinding type

configureNexiaAppFrontendHost and NexiaAppFrontendHostBindings are the runtime equivalents, supplied by the host.

App Packages consume these bindings; the host configures them. Import test helpers from @nexia/sdk/testing and use the configured harness in Test an App.

Errors

SymptomCauseResolution
Module not found: @/..., @core/..., @shell/...An App imported a platform aliasImport from @nexia/sdk; the coupling ratchet rejects new platform references
Surface route renders blankComponent name published by pageElements() has no registrationAdd the surface under the derivable path, or add an overrides entry
Invalid hook call or context errorsA duplicate React or React Query in the App bundleMove the library to peerDependencies
appComponentRegistry cannot be imported from the SDKIt is a host symbol behind @shell/, not an SDK exportUse autoRegisterPackageSurfaces
A two-column surface stays squeezed inside a narrow work tabIts md: or lg: classes measure the browser viewportWrap the custom layout in NxResponsiveRegion, or use NxAdaptiveSplit for a primary-plus-supporting layout
An App component throws while renderingThe route boundary isolated the App failureRead the console error, fix the component, or use the rendered retry action

Use in your App

Register the entry through autoRegisterPackageSurfaces, then open its route in the host. Use Build an App screen for the screen workflow and Test an App for the frontend test harness.

Source of truth: docs/developers/content/en/app-sdk/frontend-host-contracts.md