Build a UI page
Add a custom page to the dashboard with form submission via a UI action.
Pages are declared in the manifest with a render script path; form submissions are handled by a separate UI action declared under ui.actions. This guide walks through the pattern using basic-tax-calcs’s nexus address form as the reference.
1. Declare in the manifest
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: "page",
},
],
ui: {
actions: [
{
id: "save-nexus-address",
label: "Save",
page: "nexus-address-form",
run: "src/pages/actions/save-nexus-address.ts",
},
],
},
2. The list page
// src/pages/nexus-addresses.tsx
/** @jsxImportSource @backfill-io/sdk */
import { Page, Section, Toolbar, Link, Table, EmptyState } 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" toolbar={<Toolbar><Link href="/nexus-address-form">Add</Link></Toolbar>}>
{rows.length === 0 ? (
<EmptyState title="No addresses yet" />
) : (
<Table
rows={rows}
columns={[
{ key: "nexusName", label: "Name" },
{ key: "state", label: "State" },
{ key: "city", label: "City" },
{ key: "postalCode", label: "Postal code" },
]}
/>
)}
</Page>
);
}
3. The form page
// src/pages/nexus-address-form.tsx
/** @jsxImportSource @backfill-io/sdk */
import { Page, Form, Field, TextInput, SubmitButton } from "@backfill-io/sdk/ui";
export default function NexusAddressForm() {
return (
<Page title="New Nexus Address">
<Form action="save-nexus-address">
<Field name="nexusName" label="Name" required>
<TextInput name="nexusName" />
</Field>
<Field name="street" label="Street" required>
<TextInput name="street" />
</Field>
<Field name="city" label="City" required>
<TextInput name="city" />
</Field>
<Field name="state" label="State" required>
<TextInput name="state" />
</Field>
<Field name="postalCode" label="Postal code" required>
<TextInput name="postalCode" />
</Field>
<SubmitButton>Save</SubmitButton>
</Form>
</Page>
);
}
Form action="save-nexus-address" references the action by its id.
4. The action handler
// src/pages/actions/save-nexus-address.ts
import type { ExtensionPageActionContext } from "@backfill-io/sdk/ui";
import { redirect, validationError } from "@backfill-io/sdk/ui";
declare const NexusAddress: any;
export default async function (ctx: ExtensionPageActionContext) {
const v = ctx.values;
const errs: { field: string; message: string }[] = [];
if (!v.nexusName) errs.push({ field: "nexusName", message: "Name is required" });
if (!v.street) errs.push({ field: "street", message: "Street is required" });
if (errs.length) return validationError("Please fix the errors below", errs);
NexusAddress.create({
nexusName: v.nexusName,
street: v.street,
city: v.city,
state: v.state,
postalCode: v.postalCode,
});
return redirect("/nexus-addresses", "Saved", "success");
}
Helpers (redirect, validationError, ok, message) are in @backfill-io/sdk/ui.
How submission flows
- Admin clicks Submit. The dashboard collects form values.
- The dashboard invokes the page’s bound action handler server-side, passing values in
ctx.values. - The handler returns one of:
validationError(...)(re-render the form with errors),redirect(...)(navigate elsewhere with an optional toast),ok(...)/message(...)(toast in place), ornull/undefined(no-op).
Available components
Full prop reference: Components. The render script must use only these primitives — passing unknown props or mismatched types throws at runtime.