Pages

Custom dashboard pages declared in backfill.config.ts and rendered with the SDK's JSX runtime.

UI pages are the only script type not discovered by filename — the platform needs metadata (id, title, route, kind, nav order) it can’t read off the filesystem, so pages are declared in the manifest with a render path pointing at the .tsx source.

Manifest declaration

interface ExtensionPageConfig {
  id: string;
  title: string;
  path: string;
  render: string;
  kind?: 'page' | 'settings' | 'list' | 'dashboard' | 'detail';
  parentPage?: string;
  access?: { roles?: string[] };
  adminOnly?: boolean;
  nav?: { label: string; section?: string; order?: number };
}

A real example:

pages: [
  {
    id: "nexus-addresses",
    title: "Nexus Addresses",
    path: "nexus-addresses",
    render: "src/pages/nexus-addresses.tsx",
    kind: "list",
    nav: { label: "Nexus Addresses", section: "Tax", order: 10 },
  },
  {
    id: "nexus-address-form",
    title: "Nexus Address",
    path: "nexus-address-form",
    render: "src/pages/nexus-address-form.tsx",
    kind: "detail",
    parentPage: "nexus-addresses",
  },
],

Field reference

FieldEffect
idUnique within the extension. Used to reference the page from ui.actions[].page.
titleBrowser title and page header.
pathURL segment under the extension’s namespace. Cannot contain ...
renderPath to the .tsx render script. Discovery resolves it against compiled files.
kind"page", "settings", "list", "dashboard", or "detail". Affects layout and where the page surfaces.
parentPageRequired for kind: "detail". References another page id in this extension, and Backfill renders the default back link.
access.rolesRestrict to users in specific roles.
adminOnlyConvenience: equivalent to access: { roles: ["admin"] }. Cannot combine with access.
nav.labelSidebar label (defaults to no nav entry if omitted).
nav.sectionSidebar section grouping. Defaults to "Extensions".
nav.orderInteger for sorting within the section.

All fields are validated at deploy.

Render script

Render scripts use the SDK’s JSX runtime (@backfill-io/sdk/jsx-runtime) and component library (@backfill-io/sdk/ui):

/** @jsxImportSource @backfill-io/sdk */
import { Page, Section, Card, Stat, Table } from "@backfill-io/sdk/ui";

declare const NexusAddress: any;

export default function NexusAddresses() {
  const rows = NexusAddress.query({ where: { active: true } });

  return (
    <Page title="Nexus Addresses">
      <Section>
        <Card>
          <Stat label="Active addresses" value={rows.length} />
        </Card>
      </Section>

      <Table
        rows={rows}
        columns={[
          { key: "nexusName", label: "Name" },
          { key: "state", label: "State" },
          { key: "city", label: "City" },
        ]}
      />
    </Page>
  );
}

Available components

Full prop reference for every primitive: Components.

Quick list — exported from @backfill-io/sdk/ui:

Fragment, Page, Section, Stack, Grid, Card, Stat, Divider, Text, Badge, EmptyState, Toolbar, Button, Link, Table, Form, Field, HiddenInput, NumberInput, DateInput, DateTimeInput, TextInput, Textarea, Select, Checkbox, Toggle, SubmitButton, ResetButton.

Each has a typed prop set. Passing an unsupported prop or a value of the wrong type throws at runtime.

Render context

The platform invokes your render with a context object:

interface ExtensionPageRenderContext {
  tenant: { id: string; slug?: string | null; name?: string | null };
  settings: Record<string, any>;
  page: {
    id: string;
    title: string;
    path: string;
    kind?: string | null;
    parentPageId?: string | null;
    params?: RouteParams;
  };
  routes?: ExtensionPageRoutesContext;
  actor?: { id?: string; email?: string; role?: string };
  extension?: { id?: string; versionId?: string; key?: string };
}

If your render takes no params, ignore it; otherwise:

export default function NexusAddressForm(ctx: ExtensionPageRenderContext) {
  const id = ctx.page.params?.id;
  const existing = id ? NexusAddress.get(String(id)) : null;
  // ...
}

Route helpers

Use Routes.page(ctx, pageId, { query }) to link between pages in the same extension. Use Routes.entity(ctx, entityName, id) to link to the canonical Backfill detail page for a standard entity.

import { Link, Routes } from "@backfill-io/sdk/ui";

<Link href={Routes.page(ctx, "nexus-address-form", { query: { id: row.id } })}>
  Edit
</Link>

<Link href={Routes.entity(ctx, "Invoice", invoiceId)}>
  Open invoice
</Link>

Form actions

Forms post back to a separate handler script declared as a UI action — see Contributions.

What the render runs in

UI render scripts are not React components. The runtime walks the returned BackfillElementNode tree, validates props server-side, and produces the dashboard’s UI. There’s no useState, no client-side reactivity in the script — re-render happens by re-running the render with new params.