---
name: protobase
description: Protobase's documentation, for building, configuring, deploying and extending a Protobase admin app on a Postgres database (data.ts, ui.ts, composed pages, API functions, plugins, themes, the protobase CLI). Use it when working in a project that depends on @protobase packages, or when asked about Protobase.
---

<!-- Generated by scripts/protobase-skill.ts from website/src/content/docs: run `bun run docs:skill`, do not edit. -->

# Protobase

Protobase's getting-started guides from https://docs.protobase.net follow in full. They describe its latest release; a project may be on an older one, so check the versions of its `@protobase` packages.

## Getting started

Install the repository, run the ERP, real estate and property portal examples and learn the development commands.

Protobase is a config-driven admin panel framework for existing Postgres databases. Data config lives in `data.ts`, UI config in `ui.ts`; a Hono server serves the API and a React app renders the admin.

An app's own React code, its components, field types and actions, is a [plugin](https://docs.protobase.net/reference/plugins/): built on its own against a plugin API that Protobase's releases keep, and loaded by the admin app when it starts. Protobase maintains the admin app, the API and the server, and a plugin keeps working as the app moves to newer releases, with their fixes and features, without changes of its own. Below 1.0 a new minor version may still change the config, and the bundle is built again for it; the plugins' code stays as it is (see [Who maintains what](https://docs.protobase.net/reference/plugins/#who-maintains-what)).

### Setup

Requires Bun 1.4.2, which installs the repository and runs its scripts and tests, and Node 22.12 or later (`.nvmrc`) for the smoke test on Node.

```sh
bun install
```

### Run the ERP example

`examples/erp` is a sample ERP on a real Postgres database, seeded with three years of history. It needs Docker for the database: `bun run db:up` starts the Postgres container and gives each example its database and a `.env` from its `.env.example`.

```sh
bun run db:up && bun run --cwd examples/erp db:migrate && bun run --cwd examples/erp db:seed --scale small   # the ERP's tables and Better Auth's
bun run --cwd examples/erp protobase users create you@example.com --generate-password
bun run --cwd examples/erp dev                          # http://localhost:5173
```

`BETTER_AUTH_SECRET` in `examples/erp/.env` signs the session cookies; see [Login with Better Auth](https://docs.protobase.net/reference/auth/). `protobase dev` serves the admin app and the API on one port and reloads both on every change ([CLI](https://docs.protobase.net/reference/cli/#dev)).

### Run the real estate example

`examples/real-estate` is a rental and property management firm: owners, properties and units, leases and tenants, monthly rent, payments and arrears, and maintenance. Its database sits beside the ERP's in the same Postgres container, and it keeps its own `.env`.

```sh
bun run db:up && bun run --cwd examples/real-estate db:migrate && bun run --cwd examples/real-estate db:seed --scale small
bun run --cwd examples/real-estate protobase users create you@example.com --role admin --generate-password
bun run --cwd examples/real-estate dev                  # http://localhost:5173
```

### Run the property portal example

`examples/property-portal` is a public portal of homes for sale and for rent in the Netherlands and Spain, in the style of Funda and Idealista. Visitors browse and filter the listings without signing in, through the [anonymous role](https://docs.protobase.net/reference/access/#anonymous-access); members save searches of the listings that mail them new homes, and keep favorites and notes that only they see. The seed makes two members, `anna@portal.example` and `bram@portal.example`, and an admin, `admin@portal.example`, all with the password `property-portal-demo`.

```sh
bun run db:up && bun run --cwd examples/property-portal db:migrate && bun run --cwd examples/property-portal db:seed --scale small
bun run --cwd examples/property-portal dev              # http://localhost:5173
```

### Commands

| Command | Purpose |
| --- | --- |
| `bun run db:up` / `bun run db:down` | start or stop the Postgres container; `db:up` also creates the examples' databases and `.env` files |
| `bun run test` | the unit tests and the snapshot tests in `packages/*/tests/` and `tests/` (Vitest projects `unit*` and `snapshot*`): no Postgres server, subprocess or network. A snapshot test replays the SQL recorded for it from Postgres in `<test>.sql.json` |
| `bun run test:record` | runs the snapshot tests on the Postgres container instead and writes their recordings, after building their templates (`bun run test:db snapshots`); run it when a change makes a snapshot test fail with `stale snapshot` |
| `bun run test:unit` | only the unit tests, next to the code (Vitest projects `unit`, whose files share their modules, and `unit-isolated`, for those that mock a module or change a global), fully parallel; starting a Postgres client there throws |
| `bun run test:watch` | `bun run test` in watch mode |
| `bun run test:integration` | the database, subprocess and build tests (projects `integration*`), against the Postgres container, among them the real-Postgres tests (`*.postgres.test.ts`) |
| `bun run test:smoke` / `node --run test:smoke` | the CLI on Bun or on Node builds the ERP and serves its bundle, whose `€` must come back intact; part of `test:integration` on Bun |
| `bun run presets:write <version> <dir>` | writes each preset to `<dir>/<name>`: a standalone copy of its example with the `@protobase` packages in the range of `<version>`'s major (`>=0.y.z <1` below 1.0, `^x.y.z` from 1.0) and its own `bun.lock` at exactly `<version>`, which must be on npm |
| `bun run images:context` | the build contexts of the `protobase` and `protobase-dev` images in `dist/images`, at the version in `package.json` (see [Deploy](https://docs.protobase.net/guides/deploy/#images)) |
| `bun run test:presets` | writes each preset as `bun run presets:write` does it and installs it with Bun from npm (so at a published version), then migrates, seeds, checks, tests and builds it on a database of its own, as a role without superuser rights; needs the Postgres container |
| `bun run test:postgres` | the Postgres suite in `tests/postgres/` (Vitest project `postgres`): the ERP's medium seed and the planner's statistics on it, and the jobs and event delivery through PgBouncer; needs the Postgres and PgBouncer containers and `bun run test:db postgres` |
| `bun run test:db` | builds the template databases the database tests clone for each run (`protobase_test_template`, `real_estate_test_template`, `property_portal_test_template`, all at small scale, and the empty, auth and jobs `snapshot_*_test_template` the real-Postgres and end-to-end tests clone), when missing or older than what they are built from; without them those tests skip. `bun run test:db postgres` builds the Postgres suite's (`protobase_medium_test_template`, the ERP at medium scale), `bun run test:db snapshots` the ones `test:record` records on (`snapshot_*_test_template`). Tests never use the `protobase`, `real_estate` or `property_portal` databases |
| `bun run typecheck` | `tsc --noEmit` |
| `bun run check:boundaries` | dependency-cruiser package rules |
| `bun run check` | typecheck + test + test:integration + check:boundaries |
| `bun run storybook` | Storybook on port 6006 |
| `bun run build-storybook` | static Storybook build; its toolbar switches every story between light and dark |
| `bun run screenshots` / `bun run screenshots:dark` | screenshots of the stories tagged `website`, in light into `screenshots/`, in dark into `screenshots/dark/` |
| `bun run build:serve` | the serve runtime, `dist/protobase-serve.js` (see [Deploy](https://docs.protobase.net/guides/deploy/)) |
| `bun run docs:dev` | this site on port 4321 |
| `bun run docs:build` | this site, built into `website/dist/`; fails on broken links |
| `bun run release --dry-run` | the next version and its changelog, from the commits since the last release (see [Versioning](https://docs.protobase.net/reference/versioning/)) |
| `bun run docs:check` | opens every page of the built site in Chromium and fails on console errors and failed requests; `BASE=https://docs.protobase.net` checks the live site |

Each public import is a package in `packages/` (`@protobase/schema`, `layout`, `query`, `client`, `ui`, `server`, and `cli` for the `protobase` command); `packages/presets` writes the projects a new app starts from and is not published. See [Architecture](https://docs.protobase.net/reference/architecture/) for the layout and import rules.

## Deploy

Ship a project as one bundle folder and run it with the serve runtime.

A project ships as one bundle folder; the host runs protobase around it.

```sh
bun run --cwd examples/erp protobase build    # the bundle to ship: examples/erp/dist/
bun run build:serve                     # the runtime the host carries: dist/protobase-serve.js
bun --no-install /opt/protobase/protobase-serve.js /app/protobase.config.js   # on the host: the API
```

Both builds write plain JavaScript that Node and Bun load as it is. For a host that runs Bun, as the images below do, add `--bun` to either: Bun's bundler then writes the final file, the form Bun loads without transpiling, and the build needs `bun` on `PATH` ([details](https://docs.protobase.net/reference/cli/#the-config-module)).

The bundle holds three things, four when the config uses native add-ons:

```
dist/
  protobase.config.js     the config module, run by the serve runtime
  node_modules/           only with native add-ons: those packages, with linux x64 and arm64 binaries
  public/                 the admin app (index.html and content-hashed assets/), the project's UI as plugins in
                          plugins/, its theme, protobase.theme.css, and its favicon, protobase.favicon.svg
  protobase.bundle.json   the manifest: which paths go to the server, which Protobase version built it, the
                          plugins and the app's version
```

The host serves `public/` itself and sends only the manifest's `api` paths to the runtime (and `/.well-known/webauthn`, for an app on [several origins](https://docs.protobase.net/reference/auth/#several-origins)), so loading the UI never needs a running container. The runtime serves the API alone; `protobase serve dist` serves the whole folder the same way, to check a bundle locally. The host passes `DATABASE_URL`, `PORT` (default 8787) and optionally `REQUEST_LOG`, `PROTOBASE_SMTP_URL` and `PROTOBASE_MAIL_FROM` (the mail server and sender for [password reset](https://docs.protobase.net/reference/auth/#password-reset) and emailed sign-in codes) `PROTOBASE_APP_NAME` (the app's name in that mail, see [`appName`](https://docs.protobase.net/reference/auth/#configuration)) `PROTOBASE_SIGN_IN_*` (a provider people with an account sign in with, the [platform sign-in provider](https://docs.protobase.net/reference/auth/#platform-sign-in-provider)) `PROTOBASE_OPERATOR_*` (the provider its staff sign in with for [staff sign-in](https://docs.protobase.net/reference/auth/#staff-sign-in)), `PROTOBASE_ORIGINS` and `PROTOBASE_PASSKEY_DOMAIN` (the app's [other origins](https://docs.protobase.net/reference/auth/#several-origins) and its passkey domain) `PROTOBASE_CORS_ORIGINS` (the websites that may call its API, see [CORS](https://docs.protobase.net/reference/api/#cors)) and `PROTOBASE_SENTRY_DSN` (the app's [error tracker](https://docs.protobase.net/reference/api/#error-reporting)), plus the project's own settings (`BETTER_AUTH_SECRET`, ...). The runtime and the config module are plain JavaScript for Bun: no install, no TypeScript, no writes. The runtime supplies protobase and the dependencies it runs on the server (`pg`, `postgres`, `kysely`, `better-auth`, ...), so the config module carries only the project's code and the other packages it uses. The runtime serves only bundles built by a compatible Protobase version, below 1.0 the same minor (see [Versioning](https://docs.protobase.net/reference/versioning/#bundles-and-runtimes)), so rebuild the bundle when the host's runtime moves to another one. Nothing in the bundle or the runtime changes the database: run the project's `db:migrate`, which applies its migrations and those of the auth schema, before a new version serves it, with no other writes while it runs. The runtime refuses to start while the auth schema lacks a table or column the new version needs (see [migrations](https://docs.protobase.net/reference/auth/#migrations)). The manifest format and the routing rules are in the CLI reference: [`build`](https://docs.protobase.net/reference/cli/#build), [`build-serve`](https://docs.protobase.net/reference/cli/#build-serve) and [`serve`](https://docs.protobase.net/reference/cli/#serve).

### Images

Every release pushes two public images to GitHub's registry, for `linux/amd64` and `linux/arm64`, tagged with its version:

| Image | What it holds |
| --- | --- |
| `ghcr.io/prototeamhq/protobase:<version>` | the serve runtime, compiled with Bun into `/opt/protobase/protobase-serve`, which serves the API of the bundle mounted at `/app`; the release's prebuilt admin app at `/opt/protobase/shell`, which a host may serve for the bundle's [shell paths](https://docs.protobase.net/reference/plugins/#the-shell) instead of the bundle's copy; Bun for its healthcheck on `/health`, and the C++ runtime native add-ons link. It runs as `nonroot` (65532) and writes nothing, so it runs with a read-only root filesystem |
| `ghcr.io/prototeamhq/protobase-dev:<version>` | Bun and the presets at `/opt/protobase/presets/<name>` (`erp`, `real-estate`, `scratch`), with the dependencies they share installed in `/workspace` and Bun's cache in `/opt/bun-cache`. It has no Node: the presets' scripts and the `protobase` command run on Bun. It runs as `bun` (1000) |

```sh
docker run -v "$PWD/dist:/app:ro" -p 8787:8787 -e DATABASE_URL -e BETTER_AUTH_SECRET ghcr.io/prototeamhq/protobase:0.2.0
```

In `protobase-dev`, copy a preset into `/workspace` and `bun install --frozen-lockfile` has nothing to do; installed anywhere else, it comes from the cache, without the network.

The Dockerfiles are `docker/protobase/Dockerfile` and `docker/protobase-dev/Dockerfile`. To build them from a clone, at the version in `package.json` (whose `@protobase` packages must be on npm: the serve runtime and the shell come from `@protobase/cli` and `@protobase/ui` installed from npm, and the presets' `bun.lock` files resolve the packages there):

```sh
bun run images:context    # dist/images/protobase and dist/images/protobase-dev
docker build -f docker/protobase/Dockerfile -t protobase dist/images/protobase
docker build -f docker/protobase-dev/Dockerfile -t protobase-dev dist/images/protobase-dev
```

## From a table to an admin

Pip's bike shop goes from a repairs table to an admin the front desk and the workshop both like, in five steps.

Pip runs a bike repair shop. The repairs live in a Postgres table that a booking site writes to and a spreadsheet export reads from, and the front desk keeps a paper card per bike because nothing else shows what they need. Pip wants an admin on top of that table, without moving the data or writing an app.

This guide follows that admin from nothing to something the front desk and the workshop both like. Each step is a small change to two config files, and each has a live story you can click through: the same admin, on fourteen repairs of a made-up week.

### 1. The table, as it is

Everything starts with the data config: what the tables are, as they are. `protobase scaffold` writes it from the database, and Pip tidies it up:

```ts title="config/data.ts"
import { f, resource } from '@protobase/schema'

// Pip's bike shop: customers bring bikes in, the workshop repairs them. Two tables of a Postgres database the
// shop already has, described as they are.

/** shop.customers */
export const customers = resource('customers')
  .table('shop.customers')
  .fields({
    id: f.integer().readOnly().filterable().sortable().dbDefault(),
    name: f.text().min(2).filterable().sortable(),
    phone: f.text().regex(/^\+?[0-9 ]{6,}$/, 'Use digits, spaces and an optional +').optional(),
    email: f.text().optional().filterable(),
    country: f.country().default('NL'),
    created_at: f.timestamp().readOnly().filterable().sortable().dbDefault(),
  })
  .primaryKey((r) => r.id)
  .search((r) => [r.name, r.email])

/** shop.repairs */
export const repairs = resource('repairs')
  .table('shop.repairs')
  .fields({
    id: f.integer().readOnly().filterable().sortable().dbDefault(),
    number: f.text().readOnly().filterable().sortable().dbDefault(),
    customer_id: f.relation('customers').filterable().sortable(),
    bike: f.text(),
    problem: f.text(),
    status: f.enum(['booked', 'waiting', 'working', 'ready', 'collected']).default('booked').filterable().sortable(),
    mechanic: f.text().optional().filterable().sortable(),
    estimate: f.decimal({ precision: 8, scale: 2 }).filterable().sortable(),
    currency: f.currency().default('EUR'),
    paid: f.boolean().default(false).filterable(),
    booked_on: f.date().filterable().sortable(),
    notes: f.text().optional().column('internal_notes'),
    legacy_ref: null,
    updated_at: f.timestamp().readOnly().dbDefault(),
  })
  .primaryKey((r) => r.id)
  .search((r) => [r.number, r.bike, r.problem])
  .validate((repair) => (repair.status === 'collected' && !repair.paid ? [{ field: 'paid', message: 'A bike leaves the shop paid' }] : undefined))
```

The fields follow the columns, with what the database does not say added: which fields can be filtered and sorted, which ones the database fills in (`dbDefault`), which are read-only. `notes` lives in a column called `internal_notes`, and `legacy_ref: null` keeps an old column out for good. The `validate` rule holds the shop's one firm rule: a bike does not leave unpaid.

For the admin to list repairs, a view is enough, even an empty one:

```ts title="config/ui.ts"
import { view } from '@protobase/schema'
import type { customers, repairs } from './data'

// Step 1: an entry in the sidebar for each table, and nothing else. Lists and record pages work from the data config.

export const customersView = view<typeof customers>('customers')

export const repairsView = view<typeof repairs>('repairs')
```

That is already a working admin: every column, sorting, paging, a record page and a "New" button.

### 2. Names and columns

The list shows everything, including the problem descriptions nobody reads in a list. The front desk wants the repair number, whose bike, what state it is in, and who is on it, newest first.

```ts title="config/ui.ts"
import { view } from '@protobase/schema'
import type { customers, repairs } from './data'

// Step 2: names, what a record is called, the columns the workshop reads, and how values look.

export const customersView = view<typeof customers>('customers')
  .names({ singular: 'Customer', plural: 'Customers' })
  .title((r) => r.name)
  .list((r) => ({ columns: [r.name, r.phone, r.email], sort: [[r.name, 'asc']] }))

export const repairsView = view<typeof repairs>('repairs')
  .names({ singular: 'Repair', plural: 'Repairs' })
  .title((r) => r.number)
  .fields((r) => ({
    number: r.number.format('code'),
    customer_id: r.customer_id.label('Customer'),
    status: r.status.format('badge'),
    estimate: r.estimate.prefix('€').decimals(2),
    booked_on: r.booked_on.label('Booked on'),
  }))
  .list((r) => ({
    columns: [r.number, r.customer_id, r.bike, r.status, r.mechanic, r.estimate, r.booked_on],
    sort: [[r.booked_on, 'desc']],
    search: [r.number, r.bike],
  }))
```

`title` makes "R-303" the name of a repair everywhere: breadcrumbs, links, the record page. `fields` adjusts how values look: statuses become badges, the estimate gets a euro sign and two decimals, and `customer_id` shows as "Customer" (with the customer's name, since it is a relation). `list` picks the columns, the starting order, and what the search box looks in.

### 3. Filters

On Saturdays the desk asks the same questions over and over: what is ready, what is Sem working on, what came in this week, what is not paid. Filters answer them in one click.

```ts title="config/ui.ts"
import { customersView, repairsView as named } from './step-2'

// Step 3: the filters the front desk reaches for, and a chart of bookings above the list.

export { customersView }

export const repairsView = named
  .filters((r, w) => [
    w.facets(r.status),
    w.facets(r.mechanic, { search: true }),
    w.dateRange(r.booked_on, { presets: ['7d', '30d', 'month'] }),
    w.range(r.estimate, { histogram: true }),
    w.toggle(r.paid),
  ])
  .chart((r) => ({ field: r.booked_on, range: '30d', granularity: 'day' }))
```

From here on each step starts from the view of the step before (`named` is step 2's), so the file shows only what changes; in a project it is one view in one `ui.ts`. Each widget writes into the list's `?filter=`, so "ready and unpaid" is a link someone can send. The chart above the list, a daily graph, counts repairs per day, following the same filters; [UI config](https://docs.protobase.net/reference/ui-config/#charts-graphs-records-per-day-week-or-month-or-per-value-above-a-list) has its ranges and granularities. In the story, switch on "Paid" and watch the list drop to six.

### 4. The repair card

The record page still lists every field in one long column. The paper card the desk uses has three parts: the bike and its problem, the workshop's side, and the money. The layout copies it.

```ts title="config/ui.ts"
import { l } from '@protobase/schema'
import { customersView, repairsView as filtered } from './step-3'

// Step 4: a record page that reads like the paper repair card: what is wrong, who works on it, what it costs.

export { customersView }

export const repairsView = filtered
  .fields((r) => ({
    problem: r.problem.help('In the customer’s words; the mechanic adds findings to the notes.'),
    notes: r.notes.label('Workshop notes').help('Only the workshop reads these.'),
    paid: r.paid.help('Set when the customer pays at the desk.'),
  }))
  .layout((r) => [
    l.section('The bike', [r.customer_id, r.bike, r.problem]),
    l.section('In the workshop', [r.status, r.mechanic, r.notes], { help: 'Pick up a repair by putting your name on it.' }),
    l.section('Money', [r.estimate, r.currency, r.paid]),
    l.sidebar([r.status, r.estimate, r.booked_on]),
  ])
  .saveFeedback('button')
```

Sections group fields under a title, with help where the shop has a habit to explain. The sidebar repeats what matters at a glance and stays read-only. `saveFeedback('button')` makes Save say when it is done, since the desk prefers that to a toast.

The form and the [read view](https://docs.protobase.net/reference/ui-config/#form-and-read-view), which a user who may change nothing gets, share the layout. An item or field meant for one of them says so with `when`: `'editing'` keeps it to the form, `'reading'` to the read view. A table of the parts used, for whoever fills in the repair:

```ts
l.related('Parts used', { resource: 'repair_parts', field: 'repair_id', when: 'editing' })
```

### 5. Daily moves, and the workshop's own view

Two things happen to every repair: it becomes ready, and it gets collected (and paid). Typing a status into a dropdown is how mistakes happen, so they become buttons. And the mechanics would rather not see prices at all.

```ts title="config/ui.ts"
import { l } from '@protobase/schema'
import { customersView, repairsView as laidOut } from './step-4'

// Step 5: buttons for the moves the shop makes all day, and a quieter view for the mechanics.

export { customersView }

export const repairsView = laidOut.actions((a) => [
  a.update('ready', { label: 'Ready for pickup', icon: 'send', set: { status: 'ready' } }),
  a.update('collect', { label: 'Collected and paid', icon: 'wallet', set: { status: 'collected', paid: true }, confirm: 'Has the customer paid and taken the bike?' }),
  a.link('customerRepairs', { label: 'Other repairs of this customer', icon: 'file-text', to: { kind: 'list', resource: 'repairs', filter: 'customer_id = {customer_id}' } }),
])

/** What mechanics get instead: no money, and their own move. */
export const workshopRepairsView = laidOut
  .forRoles(['mechanic'])
  .list((r) => ({ columns: [r.number, r.bike, r.problem, r.status, r.mechanic], sort: [[r.booked_on, 'asc']], search: [r.number, r.bike] }))
  .filters((r, w) => [w.facets(r.status), w.facets(r.mechanic, { search: true }), w.dateRange(r.booked_on, { presets: ['7d', '30d', 'month'] })])
  .layout((r) => [l.section('The bike', [r.bike, r.problem]), l.section('In the workshop', [r.status, r.mechanic, r.notes]), l.sidebar([r.status, r.booked_on])])
  .actions((a) => [a.update('ready', { label: 'Ready for pickup', icon: 'send', set: { status: 'ready' } })])
```

`a.update` sets fields when its button is pressed, through the same API as Save, so the access rules and the `validate` rule apply. "Collected and paid" asks first. `a.link` goes `to` the list of this customer's repairs, an address with the customer filled in from the record. Try the three buttons in the story, on R-303:

`forRoles(['mechanic'])` gives users with the `mechanic` role their own view: oldest first, no money columns, no money filter, a card without the money section, and only the "Ready for pickup" button. It is built from the step 4 view, so everything else stays the same. A view only decides what is shown; to make the prices truly unreadable for mechanics, give the field an access rule as well (see [Data config](https://docs.protobase.net/reference/data-config/#access)).

### Where Pip is now

Two short files describe a table and how the shop wants to see it, and the admin follows: lists, filters, a record page, buttons and a view per role. Next steps:

- [Data config](https://docs.protobase.net/reference/data-config/) and [UI config](https://docs.protobase.net/reference/ui-config/) list every option used here, and the ones that were not.
- [Your first composed page](https://docs.protobase.net/guides/first-composed-page/) puts what needs attention on one page.

## Your first composed page

Build the sales team's start page in four steps, from four numbers to a row of leads you can add to.

It is Monday morning at Veldhuis Supply. Sam, who runs sales, opens the admin and starts the usual round: Orders filtered to drafts, Orders again for deliveries nobody paid, Invoices filtered to overdue, Companies filtered to leads. Four lists, four filters, every week.

What Sam wants is one page that says what needs attention. In Protobase that is a **composed page**: a layout in the config, made of blocks that read the same API as the lists, under the same access rules. No endpoints to write and no React to maintain. By the end of this guide the page looks like the last step below, and every step is a live story you can click through.

The page lives in one file next to the rest of the config, `config/overview/page.tsx`, exported from `config/index.ts` like a resource or a view:

```ts
// config/index.ts
export { salesOverview } from './overview/page'
```

### 1. Four numbers

Start with what Sam counts every Monday.

```tsx title="config/overview/page.tsx"
/** @jsxImportSource @protobase/layout */
import { Grid, Page, Stat, page } from '@protobase/layout'

export const overview = page(
  'overview',
  <Page title="Sales overview" description="What needs attention today.">
    <Grid columns={4}>
      <Stat label="Draft orders" resource="orders" filter="status = 'draft'" />
      <Stat label="Delivered, not paid" resource="orders" filter="status = 'delivered' AND paid = false" />
      <Stat label="Overdue invoices" resource="invoices" filter="status = 'overdue'" />
      <Stat label="Open leads" resource="companies" filter="status = 'lead'" />
    </Grid>
  </Page>,
  { nav: { group: 'Sales', order: 0 }, icon: 'layout-dashboard' },
)
```

That first comment matters. `@jsxImportSource @protobase/layout` makes the JSX build plain data instead of React elements: `<Stat label="Draft orders" ... />` becomes `{ type: 'Stat', props: { label: 'Draft orders', ... }, children: [] }`. The server sends that to the browser in `/api/meta`, and the admin renders it. So a layout holds no functions and no state, and the build refuses one that tries (see [Layouts](https://docs.protobase.net/reference/layouts/#what-a-layout-may-hold)).

Each `Stat` counts the records of `resource` that match `filter`, written in the same AIP-160 syntax as the list's `?filter=`. The count is a link to exactly that filtered list. `page('overview', ...)` puts it at `/overview`, and `nav` puts it first in the Sales group of the sidebar.

### 2. The lists behind the numbers

A number says *how many*; Sam also wants to see *which*. Two tables under the numbers:

```tsx title="config/overview/page.tsx"
/** @jsxImportSource @protobase/layout */
import { Grid, Page, Stat, Table, page } from '@protobase/layout'

export const overview = page(
  'overview',
  <Page title="Sales overview" description="What needs attention today.">
    <Grid columns={4}>
      <Stat label="Draft orders" resource="orders" filter="status = 'draft'" />
      <Stat label="Delivered, not paid" resource="orders" filter="status = 'delivered' AND paid = false" />
      <Stat label="Overdue invoices" resource="invoices" filter="status = 'overdue'" />
      <Stat label="Open leads" resource="companies" filter="status = 'lead'" />
    </Grid>
    <Grid columns={2}>
      <Table title="Overdue invoices" resource="invoices" filter="status = 'overdue'" sort="issued_at" columns={['number', 'company_id', 'due_at', 'total']} limit={2} empty="Nothing is overdue." />
      <Table title="Latest orders" resource="orders" sort="created_at desc" columns={['number', 'company_id', 'status', 'total']} limit={5} />
    </Grid>
  </Page>,
  { nav: { group: 'Sales', order: 0 }, icon: 'layout-dashboard' },
)
```

A `Table` takes the same `resource`, `filter` and `sort` (AIP-132, like `?order_by=`), plus the `columns` to show and a `limit`. Relations such as `company_id` show the company's name, a row opens the record, and **View all** opens the list with the same filter and order. Try it on the overdue invoices: there are three, and the table shows two.

### 3. One order to chase

Hollis & Rowe took a large delivery and has not paid. Sam wants that order on top, with a button that records the payment when it comes in.

```tsx title="config/overview/page.tsx"
/** @jsxImportSource @protobase/layout */
import { Action, Field, Grid, Page, RecordCard, Show, Stat, Table, page } from '@protobase/layout'

export const overview = page(
  'overview',
  <Page title="Sales overview" description="What needs attention today.">
    <Grid columns={4}>
      <Stat label="Draft orders" resource="orders" filter="status = 'draft'" />
      <Stat label="Delivered, not paid" resource="orders" filter="status = 'delivered' AND paid = false" />
      <Stat label="Overdue invoices" resource="invoices" filter="status = 'overdue'" />
      <Stat label="Open leads" resource="companies" filter="status = 'lead'" />
    </Grid>
    <RecordCard
      title="Biggest unpaid delivery"
      resource="orders"
      filter="status = 'delivered' AND paid = false"
      sort="total desc"
      empty="Every delivered order is paid."
      actions={<Action name="markPaid" variant="primary" />}
    >
      <Grid columns={4}>
        <Field name="number" />
        <Field name="total" />
        <Field name="company_id" label="Customer" />
        <Field name="company_id.city" label="City" />
      </Grid>
      <Show when="company_id.status = 'dormant'">The customer has gone quiet: call before sending a reminder.</Show>
    </RecordCard>
    <Grid columns={2}>
      <Table title="Overdue invoices" resource="invoices" filter="status = 'overdue'" sort="issued_at" columns={['number', 'company_id', 'due_at', 'total']} limit={2} empty="Nothing is overdue." />
      <Table title="Latest orders" resource="orders" sort="created_at desc" columns={['number', 'company_id', 'status', 'total']} limit={5} />
    </Grid>
  </Page>,
  { nav: { group: 'Sales', order: 0 }, icon: 'layout-dashboard' },
)
```

A `RecordCard` shows one record: the first that matches its `filter` in `sort` order, here the biggest unpaid delivery. Inside it, `Field` shows a field of that record. A dotted name follows a relation: `company_id.city` is the city of the order's company, fetched the way the record page fetches it.

The button is `<Action name="markPaid" />`, a named action of the orders view. The layout only names it; what it does is declared once, in the view:

```ts title="config/orders/ui.ts"
import { view } from '@protobase/schema'
import type { orders } from './data'

export const ordersView = view<typeof orders>('orders')
  .title((r) => r.number)
  .names({ singular: 'Order', plural: 'Orders' })
  .nav({
    recent: {
      status: 'status',
      tones: { draft: 'neutral', confirmed: 'info', picking: 'warning', shipped: 'success' },
      pulse: ['picking'],
      filter: 'status != "delivered" AND status != "cancelled"',
      orderBy: 'created_at desc',
    },
  })
  .fields((r) => ({
    status: r.status.help('Where the order is in its life. Moving it forward can notify the customer.').format('badge')
      .valueLabels({ draft: 'Draft', confirmed: 'Confirmed', picking: 'Picking', shipped: 'Shipped', delivered: 'Delivered', cancelled: 'Cancelled' }),
    total: r.total.help('Order total including VAT.').prefix('€').decimals(2),
    discount_percent: r.discount_percent.label('Discount').help('Percentage taken off the order total.').format('percent'),
    paid: r.paid.help('Set when the payment has been received.'),
    created_at: r.created_at.label('Created').format('absolute'),
  }))
  .list((r) => ({
    columns: [r.number, r.company_id, r.status, r.total, r.discount_percent, r.paid, r.owner_id, r.created_at],
    sort: [[r.created_at, 'desc']],
    search: [r.number],
  }))
  .filters((r, w) => [
    w.facets(r.status),
    w.facets(r.owner_id),
    w.dateRange(r.created_at, { presets: ['7d', '30d', '90d', 'quarter', 'year'] }),
    w.range(r.total, { histogram: true }),
    w.toggle(r.paid),
  ])
  .chart((r) => ({ field: r.created_at, range: '30d', granularity: 'day' }))
  .saveFeedback('toast')
  .actions((a) => [
    a.update('markPaid', { label: 'Mark as paid', icon: 'wallet', set: { paid: true }, confirm: 'Record that the customer paid this order?' }),
    a.action('createInvoice', { label: 'Create invoice', icon: 'file-text' }),
  ])
```

`a.update('markPaid', { set: { paid: true } })` patches the record with its ETag, after the `confirm` question. Because it is an ordinary update, the access rules decide whether this user may do it, and a user who may not sees the button disabled.

`Show` keeps its children only while `when` holds. The condition reads fields of the record around it, through relations too: the warning appears because Hollis & Rowe is `dormant`. Mark the order as paid in the story: the card moves on to the next unpaid delivery, the warning disappears, and the number above drops to one.

### 4. Leads, and a way to add them

Last on Sam's list: new leads, with a quick way to add the one met at a trade fair.

```tsx title="config/overview/page.tsx"
/** @jsxImportSource @protobase/layout */
import { Action, CardRow, Field, Grid, ModalForm, Page, RecordCard, Show, Stat, Table, page } from '@protobase/layout'

export const overview = page(
  'overview',
  <Page title="Sales overview" description="What needs attention today.">
    <Grid columns={4}>
      <Stat label="Draft orders" resource="orders" filter="status = 'draft'" />
      <Stat label="Delivered, not paid" resource="orders" filter="status = 'delivered' AND paid = false" />
      <Stat label="Overdue invoices" resource="invoices" filter="status = 'overdue'" />
      <Stat label="Open leads" resource="companies" filter="status = 'lead'" />
    </Grid>
    <RecordCard
      title="Biggest unpaid delivery"
      resource="orders"
      filter="status = 'delivered' AND paid = false"
      sort="total desc"
      empty="Every delivered order is paid."
      actions={<Action name="markPaid" variant="primary" />}
    >
      <Grid columns={4}>
        <Field name="number" />
        <Field name="total" />
        <Field name="company_id" label="Customer" />
        <Field name="company_id.city" label="City" />
      </Grid>
      <Show when="company_id.status = 'dormant'">The customer has gone quiet: call before sending a reminder.</Show>
    </RecordCard>
    <Grid columns={2}>
      <Table title="Overdue invoices" resource="invoices" filter="status = 'overdue'" sort="issued_at" columns={['number', 'company_id', 'due_at', 'total']} limit={2} empty="Nothing is overdue." />
      <Table title="Latest orders" resource="orders" sort="created_at desc" columns={['number', 'company_id', 'status', 'total']} limit={5} />
    </Grid>
    <CardRow
      title="New leads"
      description="Companies that have not ordered yet."
      resource="companies"
      filter="status = 'lead'"
      sort="created_at desc"
      actions={<ModalForm mode="create" label="Add lead" resource="companies" fields={['name', 'city', 'email']} values={{ status: 'lead' }} />}
    >
      <Field name="name" label={false} />
      <Field name="city" />
      <Field name="email" />
      <ModalForm mode="edit" label="Edit" fields={['name', 'city', 'email', 'status']} />
    </CardRow>
  </Page>,
  { nav: { group: 'Sales', order: 0 }, icon: 'layout-dashboard' },
)
```

A `CardRow` shows a few records side by side, and its children are the template of each card. Inside a card, `Field`, `Action` and `ModalForm` work on that card's record. The header's `ModalForm mode="create"` opens a form for a new company, and `values` adds `status: 'lead'` to what is filled in. The `ModalForm mode="edit"` in each card edits that lead. Add a lead in the story, then edit another one to `active`: it leaves the row, and the count of open leads follows.

### What the page did without

There is no endpoint for "biggest unpaid delivery" and no component for "lead card". Every block reads the regular API with Sam's token, so the access rules apply exactly as on the lists. A sales rep with `orders.read.own` sees only their own orders in every count, table and card, and a field they may not read is not on their page at all (see [Access](https://docs.protobase.net/reference/layouts/#access)).

The ERP example carries this page, with a few more blocks, in `examples/erp/config/overview/page.tsx`. Next:

- [A billing page](https://docs.protobase.net/guides/billing-page/) adds forms, delete and link actions, and a React component of your own.
- [Blocks](https://docs.protobase.net/reference/blocks/) lists every block and its props.

## A billing page

Give your customers one page for their plan, billing details, cards and invoices, with forms, actions and a component of your own.

Northwind Labs hosts its apps on your platform. Ada, who looks after their money, signs in once a month with four questions: what are we on, who do you bill, which card do you charge, and what did we pay. Today those answers sit in four lists. This guide gives Ada one page with all four, plus the buttons to act on them.

It builds on [Your first composed page](https://docs.protobase.net/guides/first-composed-page/), so `page`, `Stat`, `Table` and `RecordCard` are familiar. New here: forms in a dialog, actions that delete records or open links, conditions on a record's own fields, and a component written in React. The data is a small billing schema: `plans`, `subscriptions` (with a `plan` relation), `billing_profiles`, `payment_methods` and `invoices`.

### 1. Your plan

```tsx title="config/billing/page.tsx"
/** @jsxImportSource @protobase/layout */
import { Action, Field, Grid, Page, Progress, RecordCard, page } from '@protobase/layout'

export const billing = page(
  'billing',
  <Page title="Billing" description="Your plan, how you pay, and what you paid.">
    <RecordCard
      title="Your plan"
      resource="subscriptions"
      filter="status = 'active'"
      empty="You have no active plan."
      actions={[<Action name="cancel" variant="danger" />, <Action name="upgrade" variant="primary" />]}
    >
      <Grid columns={3}>
        <Field name="plan.name" label="Plan" />
        <Field name="plan.price" label="Per month" />
        <Field name="renews_at" />
      </Grid>
      <Progress label="Seats" value="seats_used" max="plan.seats" />
    </RecordCard>
  </Page>,
  { icon: 'credit-card' },
)
```

The card shows the active subscription. `plan.name` and `plan.price` reach through the `plan` relation, and `Progress` draws `seats_used` out of `plan.seats`: a string names a field of the record, a number is used as is. The bar turns amber from 90%.

The two header buttons are named actions of the subscriptions view, each with its own kind of behaviour. The views of the billing schema declare every action this page uses:

```ts title="config/billing/views.ts"
import { view } from '@protobase/schema'
import type { billingProfiles, invoices, paymentMethods, plans, subscriptions } from './billing-config'

// The views of the billing schema: names, labels, and the named actions the billing page's buttons run.

export const plansView = view<typeof plans>('plans').title((r) => r.name).nav({ hidden: true }).fields((r) => ({ price: r.price.prefix('€').decimals(2) }))

export const subscriptionsView = view<typeof subscriptions>('subscriptions')
  .nav({ hidden: true })
  .fields((r) => ({ renews_at: r.renews_at.label('Renews on'), status: r.status.valueLabels({ active: 'Active', canceled: 'Canceled' }) }))
  .actions((a) => [
    a.update('cancel', { label: 'Cancel plan', set: { status: 'canceled' }, confirm: 'Your plan stays active until the end of the period.' }),
    a.link('upgrade', { label: 'Upgrade', href: 'mailto:sales@example.com?subject=Upgrade%20from%20plan%20{plan}' }),
  ])

export const billingProfilesView = view<typeof billingProfiles>('billing_profiles').nav({ hidden: true }).fields((r) => ({ vat_number: r.vat_number.label('VAT number') }))

export const paymentMethodsView = view<typeof paymentMethods>('payment_methods')
  .nav({ hidden: true })
  .actions((a) => [
    a.update('makeDefault', { label: 'Make default', set: { is_default: true } }),
    a.remove('remove', { label: 'Remove', confirm: 'The card is removed from your account.' }),
  ])

export const invoicesView = view<typeof invoices>('invoices')
  .title((r) => r.number)
  .names({ singular: 'Invoice', plural: 'Invoices' })
  .fields((r) => ({ total: r.total.prefix('€').decimals(2), issued_at: r.issued_at.label('Date'), status: r.status.valueLabels({ open: 'Open', paid: 'Paid' }) }))
  .list((r) => ({ columns: [r.number, r.issued_at, r.total, r.status] }))
  .actions((a) => [a.action('download', { label: 'Download PDF' })])
```

`a.update('cancel', ...)` sets `status` after its `confirm` question. `a.link` opens its `href`, with `{plan}` filled in from the record: a path in the app (`/plans`) stays in the app, anything else (`https:`, `mailto:`) leaves it. Cancel the plan in the story and the card says there is no active plan, because its `filter` no longer matches.

### 2. Who we bill

Ada's company moved offices. The details card gets an edit button:

```tsx title="config/billing/page.tsx"
/** @jsxImportSource @protobase/layout */
import { Action, Field, Grid, ModalForm, Page, Progress, RecordCard, page } from '@protobase/layout'

export const billing = page(
  'billing',
  <Page title="Billing" description="Your plan, how you pay, and what you paid.">
    <Grid columns={2}>
      <RecordCard
        title="Your plan"
        resource="subscriptions"
        filter="status = 'active'"
        empty="You have no active plan."
        actions={[<Action name="cancel" variant="danger" />, <Action name="upgrade" variant="primary" />]}
      >
        <Grid columns={3}>
          <Field name="plan.name" label="Plan" />
          <Field name="plan.price" label="Per month" />
          <Field name="renews_at" />
        </Grid>
        <Progress label="Seats" value="seats_used" max="plan.seats" />
      </RecordCard>
      <RecordCard
        title="Billing details"
        resource="billing_profiles"
        actions={<ModalForm mode="edit" label="Edit" title="Edit billing details" fields={['company', 'vat_number', 'address', 'email']} />}
      >
        <Grid columns={2}>
          <Field name="company" />
          <Field name="vat_number" />
          <Field name="address" />
          <Field name="email" />
        </Grid>
      </RecordCard>
    </Grid>
  </Page>,
  { icon: 'credit-card' },
)
```

`ModalForm mode="edit"` in a RecordCard's `actions` edits that card's record: a dialog with the `fields` listed, the same editors as the record page, the changed fields sent as one update with the record's ETag. Errors from the server land on their fields. Change the company name in the story and save.

### 3. How they pay

```tsx title="config/billing/page.tsx"
/** @jsxImportSource @protobase/layout */
import { Action, CardRow, Field, Grid, ModalForm, Page, Progress, RecordCard, Show, page } from '@protobase/layout'

export const billing = page(
  'billing',
  <Page title="Billing" description="Your plan, how you pay, and what you paid.">
    <Grid columns={2}>
      <RecordCard
        title="Your plan"
        resource="subscriptions"
        filter="status = 'active'"
        empty="You have no active plan."
        actions={[<Action name="cancel" variant="danger" />, <Action name="upgrade" variant="primary" />]}
      >
        <Grid columns={3}>
          <Field name="plan.name" label="Plan" />
          <Field name="plan.price" label="Per month" />
          <Field name="renews_at" />
        </Grid>
        <Progress label="Seats" value="seats_used" max="plan.seats" />
      </RecordCard>
      <RecordCard
        title="Billing details"
        resource="billing_profiles"
        actions={<ModalForm mode="edit" label="Edit" title="Edit billing details" fields={['company', 'vat_number', 'address', 'email']} />}
      >
        <Grid columns={2}>
          <Field name="company" />
          <Field name="vat_number" />
          <Field name="address" />
          <Field name="email" />
        </Grid>
      </RecordCard>
    </Grid>
    <CardRow
      title="Payment methods"
      resource="payment_methods"
      sort="is_default desc, id"
      actions={<ModalForm mode="create" label="Add card" resource="payment_methods" fields={['brand', 'last4', 'expires']} />}
    >
      <Field name="brand" label={false} />
      <Field name="last4" label="Number" />
      <Field name="expires" />
      <Show when="is_default = true">Default card</Show>
      <Show when="is_default = false">
        <Action name="makeDefault" />
        <Action name="remove" variant="danger" />
      </Show>
    </CardRow>
  </Page>,
  { icon: 'credit-card' },
)
```

The cards sit side by side in a `CardRow`, the default card first (`sort="is_default desc, id"`). Two `Show` blocks split the card on its own `is_default` field: the default card says so, the others get "Make default" and "Remove". Those are again view actions, `a.update('makeDefault', ...)` and `a.remove('remove', ...)` in the views above.

Making a card the default unsets the others in the database (a trigger or a write hook); the row reloads after every action, so the order follows. "Add card" is a `ModalForm mode="create"` in the row's header. In the story, make the Mastercard the default, remove the Visa and add a card.

### 4. What they paid, and a component of your own

```tsx title="config/billing/page.tsx"
/** @jsxImportSource @protobase/layout */
import { Action, Card, CardRow, Field, Grid, Link, ModalForm, Page, Progress, RecordCard, Show, Stat, Table, component, page } from '@protobase/layout'

/** Seats in use over the last months; the app registers the React component under this name. */
export const SeatHistory = component<{ months: number }>('SeatHistory')

export const billing = page(
  'billing',
  <Page title="Billing" description="Your plan, how you pay, and what you paid.">
    <Grid columns={2}>
      <RecordCard
        title="Your plan"
        resource="subscriptions"
        filter="status = 'active'"
        empty="You have no active plan."
        actions={[<Action name="cancel" variant="danger" />, <Action name="upgrade" variant="primary" />]}
      >
        <Grid columns={3}>
          <Field name="plan.name" label="Plan" />
          <Field name="plan.price" label="Per month" />
          <Field name="renews_at" />
        </Grid>
        <Progress label="Seats" value="seats_used" max="plan.seats" />
        <SeatHistory months={6} />
      </RecordCard>
      <RecordCard
        title="Billing details"
        resource="billing_profiles"
        actions={<ModalForm mode="edit" label="Edit" title="Edit billing details" fields={['company', 'vat_number', 'address', 'email']} />}
      >
        <Grid columns={2}>
          <Field name="company" />
          <Field name="vat_number" />
          <Field name="address" />
          <Field name="email" />
        </Grid>
      </RecordCard>
    </Grid>
    <CardRow
      title="Payment methods"
      resource="payment_methods"
      sort="is_default desc, id"
      actions={<ModalForm mode="create" label="Add card" resource="payment_methods" fields={['brand', 'last4', 'expires']} />}
    >
      <Field name="brand" label={false} />
      <Field name="last4" label="Number" />
      <Field name="expires" />
      <Show when="is_default = true">Default card</Show>
      <Show when="is_default = false">
        <Action name="makeDefault" />
        <Action name="remove" variant="danger" />
      </Show>
    </CardRow>
    <Grid columns={3}>
      <Table title="Invoices" resource="invoices" sort="issued_at desc" limit={5} span={2} />
      <Grid columns={1}>
        <Stat label="Open invoices" resource="invoices" filter="status = 'open'" />
        <RecordCard title="Latest invoice" resource="invoices" sort="issued_at desc" actions={<Action name="download" />}>
          <Grid columns={2}>
            <Field name="number" />
            <Field name="total" />
          </Grid>
        </RecordCard>
        <Card title="Questions about a bill?">
          Mail <Link href="mailto:billing@example.com">billing@example.com</Link> and we answer within a day.
        </Card>
      </Grid>
    </Grid>
  </Page>,
  { icon: 'credit-card' },
)
```

Two things here are not built-in blocks.

`SeatHistory` is a **custom component**. `component('SeatHistory')` declares it for the layout, with its props typed, and the element stays data: `{ type: 'SeatHistory', props: { months: 6 }, custom: true }`. The React code lives in the app, exported under the same name from `protobase.ui.tsx` at the project root, which the admin bundles with itself. Inside a RecordCard, `useLayoutRecord()` gives it the record.

"Download PDF" is a named action without built-in behaviour (`a.action('download', ...)`), so it runs the handler registered under its name, with the record, the API client and the router. The ERP example has one of each, its component in a file of its own:

```tsx title="protobase.ui.tsx"
import { defineUi, type ActionContext } from '@protobase/ui'

// The custom components, each in a file of its own, exported by the name their layouts declare.
export { OrdersPerDay } from './components/orders-per-day'

/** "Send reminder" on an invoice: opens a mail to the customer, and marks a draft invoice as sent. */
const send = async ({ record, recordKey, etag, client }: ActionContext) => {
  const company = await client.get('companies', String(record!.company_id))
  const subject = encodeURIComponent(`Invoice ${String(record!.number)}`)
  window.location.assign(`mailto:${String(company.record.email ?? '')}?subject=${subject}`)
  if (record!.status === 'draft') await client.update('invoices', recordKey!, { status: 'sent' }, etag ?? '*')
}

export default defineUi({ actions: { send } })
```

The rest is familiar: a paged `Table` of invoices spanning two of the three columns, a count of open invoices, and a `Link` inside the text of a card.

If the app has no component registered under a name, the page says so where the component would be, and an action without a handler is disabled:

Next: [Custom components](https://docs.protobase.net/reference/custom-components/) for the details of `protobase.ui.tsx`, and [Layouts](https://docs.protobase.net/reference/layouts/) for named actions, conditions and access.

## Your own API functions

Endpoints of your own beside the generated REST API, one file each in functions/, acting as the signed-in caller or open to a webhook.

The [REST API](https://docs.protobase.net/reference/api/) covers reading and writing records. For anything else you write a function: a webhook from a payment provider, a button that makes three writes and then texts the customer, an endpoint for another app. Each function is one file in the project's `functions/` folder, served at `/api/functions/<name>`.

If you have used Supabase Edge Functions, this will feel familiar. A function is a Web-standard handler that takes a `Request` and returns a `Response`. Only signed-in callers get in unless the function says otherwise. It has a data client that acts as the caller, and the raw database for privileged work.

The examples below come from Pip's bike shop (see [From a table to an admin](https://docs.protobase.net/guides/table-to-admin/)). The repository compiles and tests every one of them.

### A first function

```ts title="functions/hello.ts"
import { defineFunction } from '@protobase/server'

// GET /api/functions/hello: who is calling. Only a signed-in user gets here; anyone else gets a 401 problem.
export default defineFunction((_request, { session }) =>
  Response.json({ message: `Hello, ${session.user.id}`, roles: session.user.roles }),
)
```

`protobase dev` serves it at `http://localhost:5173/api/functions/hello`. A change reloads it on the next request, and a new file appears after the next change inside the project. `protobase build` bundles the functions into the deploy bundle, and `protobase serve` serves them the same way. Call it with a token:

```sh
curl -H "Authorization: Bearer $(protobase token pip@example.com)" http://localhost:5173/api/functions/hello
# {"message":"Hello, 9f3c…","roles":["admin"]}
curl -i http://localhost:5173/api/functions/hello
# HTTP/1.1 401 Unauthorized, an application/problem+json body like every other refusal
```

The app's own `authenticate` checks the token, the same one that guards the REST API, so a function needs no auth code of its own. A call without a valid token is the usual `401` problem, and the handler never runs.

### Files and names

- **One function per file:** `functions/<name>.ts`, or `functions/<name>/index.ts` for a function of several files. The name is the URL segment, so it uses letters, digits, `-` and `_`.
- **Not functions:** files and folders starting with `_` (shared code, such as `functions/_shared/`), dotfiles, `*.test.ts`, `*.spec.ts` and `*.d.ts`. A folder without an `index.ts` is an error that says so.
- **The default export** is `defineFunction(...)` from `@protobase/server`, a bare `(request, context)` handler, or a Hono app. The last two need a signed-in caller.
- **Any method** reaches the function, and so does any path below its name. The request's path is the part after `/api/functions/<name>`: `/` for the function itself, `/42/advance` for `/api/functions/workshop/42/advance`. The query string and the body are passed through unchanged.
- **In `protobase.config.ts`,** `functions: { name: fn }` replaces the folder, the same way `config` replaces `config/index.ts`. The functions of [extended configs](https://docs.protobase.net/reference/cli/#extending-a-config) are kept, and a name used by two configs is an error that names both.

### The context

The second argument of a handler (`c.env` in a [Hono app](#hono-apps)):

| | |
| --- | --- |
| `session` | The caller: `{ user: { id, roles }, tenant? }`, from the app's `authenticate`. |
| `records` | The app's resources, acting as the caller: `list`, `get`, `create`, `update`, `delete`. |
| `db` | The Kysely instance itself. It skips every access rule, row filter and tenant scope. |
| `caller()` | Resolves to `{ session, records }`. Public functions use it to ask for a caller when they need one. |
| `name` | The function's name. |

A [public function](#public-functions-and-webhooks) has only `db`, `caller()` and `name`.

### Reading and writing records as the caller

```ts title="functions/ready-for-pickup.ts"
import { z } from 'zod'
import { defineFunction, HttpProblem, readJson } from '@protobase/server'

const Input = z.object({ repairId: z.number().int().positive() })

// POST /api/functions/ready-for-pickup { "repairId": 7 }: the workshop finishes a repair. It reads and writes as the
// caller, so a mechanic touches only the repairs their access rules allow, and the repair's validation and write hooks
// run as for any other update.
export default defineFunction({ roles: ['mechanic', 'desk'] }, async (request, { records }) => {
  const { repairId } = await readJson(request, Input)
  const { record: repair, etag } = await records.get('repairs', repairId)
  if (repair.status !== 'working') {
    throw new HttpProblem(409, 'not-in-progress', 'Conflict', `Repair ${repair.number} is ${repair.status}, not in the workshop`)
  }
  // The ETag makes this fail with 412 when someone changed the repair since it was read.
  const ready = await records.update('repairs', repairId, { status: 'ready' }, { etag })
  const waiting = await records.list('repairs', { filter: `customer_id = ${repair.customer_id} AND status = "ready"`, fields: ['number', 'bike'] })
  return Response.json({ repair: ready.record, readyForCustomer: waiting.items.map(({ number, bike }) => ({ number, bike })) })
})
```

`records` takes the same path as the REST API. The caller's access rules, row filters, field access and tenant scope apply. So do the resource's validation and the write hooks, and a record the caller may not see is not there:

| | |
| --- | --- |
| `list(resource, params?)` | A page as `GET /api/v1/{resource}` gives it: `{ items, next_page_token, total_size_estimate }`. `params` are `filter`, `order_by`, `page_size`, `page_token`, `fields` (an array), `count: 'exact'` and `show_deleted`. |
| `get(resource, key)` | `{ record, etag }`. A composite key is an array of its parts, or `a,b` as in a URL. |
| `create(resource, data)` | `{ record, etag }` of the new record. |
| `update(resource, key, data, { etag }?)` | With `etag`, it fails with `412` when the record changed after it was read. |
| `delete(resource, key, { etag }?)` | `{ record, etag }` of the record as it was. It is a soft delete when the resource has one. |

A refusal or a missing record rejects with the problem the REST API would answer, such as `403`, `404` or `412`. Left uncaught, that problem becomes the function's response. Each call is its own transaction. Use the [batch endpoint](https://docs.protobase.net/reference/api/#batch-writes) when several writes must succeed or fail together.

#### Checking input

`readJson(request, schema)` reads the body and checks it against a schema. It takes any [Standard Schema](https://standardschema.dev), such as zod 4, Valibot or ArkType. The result is typed. A body that is not JSON is a `400 malformed-json` problem. A body the schema refuses is a `400 invalid-body` problem with an `errors` list of `{ field, message }`. Without a schema, `readJson(request)` only parses the JSON.

#### Roles

`defineFunction({ roles: ['mechanic', 'desk'] }, handler)` lets in callers with at least one of those roles. Anyone else gets a `403 function-forbidden` problem before the handler runs. Finer rules belong in the resources' [access rules](https://docs.protobase.net/reference/access/), which `records` already applies.

### Public functions and webhooks

```ts title="functions/payment-webhook.ts"
import { defineFunction, requireEnv, verifySignature } from '@protobase/server'

type PaymentEvent = { type: string; data: { reference: string } }

// POST /api/functions/payment-webhook: the payment provider says a repair is paid. Nobody signs in to send it, so the
// function is public and checks the provider's signature instead; with no caller, it writes through the database
// directly, past every access rule.
export default defineFunction({ public: true }, async (request, { db }) => {
  const body = await verifySignature(request, { secret: requireEnv('PAYMENTS_WEBHOOK_SECRET'), header: 'x-signature', prefix: 'sha256=' })
  const event = JSON.parse(body) as PaymentEvent
  if (event.type === 'payment.succeeded') {
    await db.updateTable('shop.repairs').set({ paid: true }).where('number', '=', event.data.reference).execute()
  }
  return new Response(null, { status: 204 })
})
```

`{ public: true }` lets anyone call the function. A sender that signs its requests is checked with one of these helpers. Each returns the body as text once the signature matches, and otherwise throws a `401 invalid-signature` problem:

- `verifySignature(request, { secret, header, prefix?, encoding?, hash? })` checks an HMAC of the body (`hex` and `SHA-256` by default), as GitHub, Shopify and many payment providers sign. GitHub's is `{ header: 'x-hub-signature-256', prefix: 'sha256=' }`, and Shopify's is `{ header: 'x-shopify-hmac-sha256', encoding: 'base64' }`.
- `verifyStandardWebhook(request, { secret })` checks [Standard Webhooks](https://www.standardwebhooks.com) signatures, as Supabase Auth hooks, Svix and Resend send them, including the timestamp (5 minutes of tolerance by default).
- Stripe ships its own check: `stripe.webhooks.constructEventAsync(await request.text(), request.headers.get('stripe-signature'), secret)`.

A public function has no caller, so it has no `records`. Its `db` skips the tenant scope too, so filter by tenant yourself where it matters. When a function should serve anyone but treat a signed-in caller differently, `await context.caller()` resolves the caller from the request, or rejects with the `401` problem.

### Secrets and other APIs

```ts title="functions/notify-customer.ts"
import { z } from 'zod'
import { defineFunction, readJson, requireEnv } from '@protobase/server'

const Input = z.object({ repairId: z.number().int().positive() })

// POST /api/functions/notify-customer { "repairId": 7 }: texts the customer that their bike is ready, through an SMS
// provider's API. The key comes from the environment: .env in development, the host's settings in production.
export default defineFunction({ roles: ['desk'] }, async (request, { records }) => {
  const { repairId } = await readJson(request, Input)
  const { record: repair } = await records.get('repairs', repairId)
  const { record: customer } = await records.get('customers', repair.customer_id as number)
  if (!customer.phone) return Response.json({ sent: false, reason: 'The customer has no phone number' })

  const sent = await fetch('https://api.sms.example/v1/messages', {
    method: 'POST',
    headers: { authorization: `Bearer ${requireEnv('SMS_API_KEY')}`, 'content-type': 'application/json' },
    body: JSON.stringify({ to: customer.phone, text: `Hi ${customer.name}, your ${repair.bike} is ready for pickup.` }),
  })
  if (!sent.ok) throw new Error(`The SMS provider answered ${sent.status}`)
  return Response.json({ sent: true })
})
```

`requireEnv(name)` returns an environment variable, or throws when it is not set. The call then fails as a `500` that the app reports, and the request goes nowhere. `protobase dev` and `protobase serve` read the nearest `.env` file. In production the serve runtime takes the host's environment as it is, so set the secret where the host keeps its settings. Keep secrets in functions, never in `protobase.ui.tsx`, which runs in the browser.

### Calling a function

From the admin UI, an [action handler](https://docs.protobase.net/reference/custom-components/#action-handlers) gets a `client` that carries the user's token. `client.invoke` calls a function the way `supabase.functions.invoke` does:

```tsx
// protobase.ui.tsx
import { defineUi } from '@protobase/ui'

export default defineUi({
  actions: {
    readyForPickup: async ({ client, record }) => {
      await client.invoke('ready-for-pickup', { body: { repairId: record?.id } })
    },
  },
})
```

`invoke(name, { method?, body?, path?, query?, headers? })` sends a `GET`, or a `POST` with a JSON `body`. It resolves to the parsed JSON, to text, or to `undefined` for a `204`. A problem rejects with an `ApiError`, as any call of the client does. Outside the admin, `createClient` from `@protobase/client` gives the same `invoke`, or you can call the URL with `fetch` and an `Authorization: Bearer` header.

### Hono apps

A function of several routes can be a [Hono](https://hono.dev) app. The context is its `c.env`:

```ts title="functions/workshop/index.ts"
import { defineFunction, functionApp } from '@protobase/server'
import { nextStatus } from './status'

// /api/functions/workshop/*: the workshop's tablet app, a Hono app on its own origin. `c.env` is the function's
// context, so every route acts as the signed-in mechanic.
const app = functionApp()

// GET /api/functions/workshop/queue
app.get('/queue', async (c) => {
  const page = await c.env.records.list('repairs', { filter: 'status = "waiting" OR status = "working"', order_by: 'booked_on', fields: ['id', 'number', 'bike', 'status'] })
  return c.json(page.items)
})

// POST /api/functions/workshop/7/advance
app.post('/:id/advance', async (c) => {
  const id = Number(c.req.param('id'))
  const { record, etag } = await c.env.records.get('repairs', id)
  const updated = await c.env.records.update('repairs', id, { status: nextStatus(record.status as string) }, { etag })
  return c.json(updated.record)
})

export default defineFunction({ roles: ['mechanic'], cors: { origin: 'https://workshop.pips-bikes.example' } }, app)
```

`functionApp()` is `new Hono()` with one difference: errors and unknown paths reach the app's own error handling, so problems are rendered as problem+json and anything else is reported. Your own Hono instance works too, typed with `FunctionEnv` (or `PublicFunctionEnv`):

```ts
import { Hono } from 'hono'
import type { FunctionEnv } from '@protobase/server'

const app = new Hono<FunctionEnv>()
// Without this, Hono answers errors itself: a plain-text 500 that the app never reports.
app.onError((error) => {
  throw error
})
export default app
```

A Hono app's routes start at `/`, because the request path no longer includes `/api/functions/<name>`. The deploy bundle supplies `hono`, `hono/cors`, `hono/http-exception` and `hono/validator` (see [the config module](https://docs.protobase.net/reference/cli/#the-config-module)). Other Hono middleware is bundled with the function, as are its other packages.

#### CORS

A function called from a page on another origin needs CORS. Use the `cors` option, which takes the options of `hono/cors`:

```ts
defineFunction({ cors: { origin: 'https://workshop.pips-bikes.example' } }, app)
```

Preflight requests are answered before authentication, because a browser sends them without a token. Every response, including a `401` problem, carries the CORS headers. In a protected function, the `hono/cors` middleware inside a Hono app would never see the preflight, because authentication runs first.

### Errors

- **Problems:** throw an `HttpProblem` (or `badRequest`, `unauthorized`, `forbidden`, `notFound` from `@protobase/server`) to answer with an [RFC 9457 problem](https://docs.protobase.net/reference/api/#errors). Hono's `HTTPException` works too.
- **Everything else:** any other error, or a handler that returns something other than a `Response`, becomes a `500 internal-error` problem without its message. The error goes to `options.onUnhandledError`, which `protobase dev` and `serve` print.

### What functions are not

- **Not under `/api/v1`,** which stays resources only. Functions live beside the system endpoints, like `/api/meta`.
- **Not in the OpenAPI document** or the `/api/docs` reference.
- **Not scheduled:** a function runs when it is called. A cron job on the host, or an external scheduler, can call one with a token, or call a public one that checks a signature.

## Your first plugin

Build a plugin, serve it, load it into an app and change it without rebuilding the app.

Sam's sales team wants the newest draft orders on their start page, each a link to the order. That is a few lines of React. Written in the app's `protobase.ui.tsx`, it would be that app's alone, shipped with each of its builds. Written as a [plugin](https://docs.protobase.net/reference/plugins/) of its own, it is built once, served on its own, and loaded by every app that lists it when it starts, whatever release the app runs.

This guide builds that plugin, serves it, loads it into an app and changes it without touching the app. It uses the orders of the ERP example (`examples/erp`); any resource of yours works the same.

### 1. A folder for the plugin

A plugin is a folder of its own, next to the app's project or anywhere else, with a `package.json` that names it:

```json
{
  "name": "sales-tools",
  "version": "1.0.0",
  "private": true,
  "type": "module"
}
```

It depends on `@protobase/ui`, the version it is built for, on `react`, and on `@protobase/cli` to build it:

```sh
bun add @protobase/ui react
bun add -d @protobase/cli @types/react
```

Its entry is a `protobase.ui.tsx`, written like a project's: each component exported under its name, from a file of its own, and the rest in `definePlugin`:

```tsx
// protobase.ui.tsx
import { definePlugin } from '@protobase/ui'

// The plugin's components, each from a file of its own, exported by the name the config declares.
export { DraftOrders } from './components/draft-orders'

export default definePlugin({ name: 'sales-tools' })
```

### 2. The component

```tsx
// components/draft-orders.tsx
import { RecordLink, useList, usePlugin } from '@protobase/ui'

/** The five newest draft orders, each a link to its page, signed with the plugin that draws them. */
export const DraftOrders = () => {
  const plugin = usePlugin()
  const drafts = useList('orders', { filter: "status = 'draft'", orderBy: 'created_at desc', pageSize: 5 })
  return (
    <div>
      <ul className="divide-y divide-border">
        {drafts.items.map((order) => (
          <li key={String(order.id)} className="flex justify-between py-1.5">
            <RecordLink resource="orders" recordKey={String(order.id)} className="font-medium text-primary-text hover:underline">
              {String(order.number)}
            </RecordLink>
            <span className="tabular-nums text-muted-foreground">{String(order.total)}</span>
          </li>
        ))}
      </ul>
      <p className="mt-2 text-xs text-faint-foreground">{`${plugin.name} ${plugin.version}`}</p>
    </div>
  )
}
```

Everything it imports from `@protobase/ui` and `react` will be the app's own at runtime: `useList` reads the API as the signed-in user, through the app's cache, and `RecordLink` goes to the order's page through the app's addresses. `usePlugin()` tells it which plugin it is part of. The Tailwind classes draw with the app's theme: `text-primary-text` is the app's link color.

### 3. Build it

```sh
bun run protobase build-plugin
```

```
built dist: plugin.json, assets/index-DDnoullN.js and assets/styles-CToS5pKa.css (sales-tools, plugin API 1, Protobase >=0.11.1)
```

`dist/assets/index-<hash>.js` is the plugin's module: its own code, with `react`, `react/jsx-runtime`, `react-dom` and `@protobase/ui` left as imports for the app to supply. `styles-<hash>.css` holds the utilities its classes use, and nothing else. `plugin.json` names both, the plugin API they are built for and the Protobase versions they run on:

```json
{
  "name": "sales-tools",
  "version": "1.0.0",
  "api": 1,
  "protobase": ">=0.11.1",
  "entry": "assets/index-DDnoullN.js",
  "css": "assets/styles-CToS5pKa.css"
}
```

### 4. Serve it

Any static host serves a plugin: a CDN, a bucket, another app. The app loads it from wherever its `plugin.json` is, with CORS when that is another origin. While you work on it, a few lines of Bun do:

```ts
// serve.ts: the plugin's dist folder with CORS, as any static host or CDN would serve it.
Bun.serve({
  port: 4000,
  async fetch(request) {
    const file = Bun.file(`dist${new URL(request.url).pathname}`)
    if (!(await file.exists())) return new Response('Not found', { status: 404 })
    return new Response(file, { headers: { 'access-control-allow-origin': '*' } })
  },
})
```

```sh
bun serve.ts
```

`http://localhost:4000/plugin.json` now answers.

### 5. Load it into the app

In the app's project, list the plugin in `.env`:

```sh
PROTOBASE_PLUGINS=http://localhost:4000/plugin.json
```

and put its component on the start page, declared with the plugin that provides it:

```tsx
// config/overview/page.tsx
/** @jsxImportSource @protobase/layout */
import { Card, component, page, Page } from '@protobase/layout'

/** The newest draft orders, drawn by the sales-tools plugin. */
export const DraftOrders = component('DraftOrders', { plugin: 'sales-tools' })

export const salesOverview = page(
  'overview',
  <Page title="Sales overview">
    <Card title="Draft orders">
      <DraftOrders />
    </Card>
  </Page>,
)
```

`{ plugin: 'sales-tools' }` tells `protobase build` that the project's own `protobase.ui.tsx` does not have the component, so it does not fail on it. Start the app with `bun run protobase dev` and sign in: the start page shows the card with the five newest drafts, and `sales-tools 1.0.0` under them. `/api/meta` named the plugin, the app read its `plugin.json`, saw that it fits, and loaded its module and stylesheet.

### 6. Change the plugin, not the app

Show three orders instead of five: set `pageSize: 3` in `components/draft-orders.tsx` and build the plugin again:

```sh
bun run protobase build-plugin
```

Reload the app. The card shows three orders, and the app was neither rebuilt nor restarted: it found the new module through the same `plugin.json`. That is how a plugin ships: a new build where its `plugin.json` is served.

### 7. Ship it

Put `dist/` on a static host, and list its `plugin.json` in the deployed app's `PROTOBASE_PLUGINS`, or in `options.plugins` of its `protobase.config.ts`, which can also give the plugin settings and limit it to some roles (see [loading](https://docs.protobase.net/reference/plugins/#loading)). Serve `plugin.json` without caching, so a new build reaches the apps on their next load, and the files in `assets/`, whose names change with their content, as long as you like.

### When the app moves on

The app updates to a new release of Protobase. Nothing happens to the plugin: within plugin API 1, every release keeps what the plugin imports, and the plugin runs on the newer `@protobase/ui` it is handed, its fixes included.

If the app were older than the plugin needs, it would not load the plugin at all. Its admins would see a notice above the app, such as "The plugin sales-tools is off: it needs Protobase >=0.12.0, and this app runs 0.11.1.", and the card would show a warning in place of the drafts, while the rest of the app works as before. [Versions](https://docs.protobase.net/reference/plugins/#versions) has the rules.

## Theming an app

Give an app its own colors, corners and font with protobase.theme.css, in light and dark, and keep them through every release.

Rijnland Industrial Supply wants its admin in its own colors: teal instead of blue, rounder corners and its own font. That is one file of values, `protobase.theme.css`, which the app reads on every page, the sign-in page included. This guide writes it for the ERP example (`examples/erp`); any project works the same. [Theme](https://docs.protobase.net/reference/theme/) has every token.

### 1. The light values

Create `protobase.theme.css` at the project root, beside `protobase.config.ts`, and set the tokens you want to change under `:root`:

```css
/* protobase.theme.css */
:root {
  --primary: #0d9488;
  --primary-hover: #0f766e;
  --primary-soft: #f0fdfa;
  --primary-border: #99f6e4;
  --primary-text: #0f766e;
  --sidebar: #f5faf9;
}
```

The primary group goes together: the color of buttons and the selected item, its hover, a soft background for selected rows, a border for those, and the color of text and links. Start `bun run protobase dev` and sign in: the buttons, links, the sidebar's selected item and the chart are teal. Every other token keeps the app's value.

Only values are allowed. A selector such as `.sidebar`, or a property such as `color`, fails the build, naming the line it is on, and `protobase dev` answers the theme with that error and draws the app without it; the [file's rules](https://docs.protobase.net/reference/theme/#the-file) say why. To change how one part looks beyond its tokens, replace it with a plugin, such as a [record card](https://docs.protobase.net/guides/record-card/).

### 2. Corners and font

```css
:root {
  /* ... */
  --radius: 10px;
  --font: Georgia, "Times New Roman", serif;
}
```

`--radius` sets every rounded corner: buttons, inputs and menus take it, cards two pixels more. `--font` is the app's text, and `--font-code` the font of identifiers and code. Reload: the app is rounder and set in Georgia.

A font the browser does not have comes with an `@font-face` rule in the same file, its files served by the app, a CDN or a font service:

```css
@font-face {
  font-family: Inter;
  src: url(https://fonts.example.com/inter-var.woff2) format("woff2");
  font-weight: 100 900;
}

:root {
  --font: Inter, ui-sans-serif, system-ui, sans-serif;
}
```

### 3. Dark

The app draws in light or dark as each user chooses in the profile menu, and follows their system until they choose ([Light, dark or the system](https://docs.protobase.net/reference/theme/#light-dark-or-the-system)). Values under `:root` hold for both, so teal on white is also teal on near-black. Give dark its own where it needs them, under `[data-theme="dark"]`:

```css
[data-theme="dark"] {
  --primary: #2dd4bf;
  --primary-hover: #5eead4;
  --primary-foreground: #042f2e;
  --primary-soft: #11302c;
  --primary-text: #5eead4;
}
```

A light teal needs dark text on it, so the button's text, `--primary-foreground`, turns near-black here. A soft background from `:root` would glare on the dark page, so `--primary-soft` gets a dark one too. Pick "Dark" in the profile menu to check each screen, the sign-in page included.

### 4. Build and ship

```sh
bun run protobase build
```

The build checks the theme as `protobase dev` does, minifies it, and writes it to `dist/public/protobase.theme.css`, which the app links after its own stylesheets. A token the app does not read is printed as a warning, since it is most likely a typo:

```
warning: protobase.theme.css:1: --primry is not a token the app reads; check its name
```

Ship the bundle as before. The theme is a file of the bundle like the others, so a static host serves it, with `no-cache` so a change shows on the next load.

### When the app moves on

The theme holds no selectors into the app, only the values of tokens, and releases only ever add tokens. So it applies the same way after every update, and a token that is added later keeps the app's value until the theme sets it. Plugins follow it too: their `bg-primary` and `rounded-md` read the same tokens, so the sales team's [plugins](https://docs.protobase.net/reference/plugins/) are teal and round in this app, and blue and square in another.

## Adding a field type

Show and edit a field your own way everywhere the app shows it, with a field type a plugin registers and a view picks.

An order's status is one of six values, and each order moves through them in turn: draft, confirmed, picking, shipped, delivered. The app shows it as a badge and edits it with a select. Rijnland's warehouse wants to see at a glance how far along an order is, and to move it with one click. That is a field type: a component that shows the value, one that edits it, and the storage types it works for. This guide adds one, `steps`, to the ERP example (`examples/erp`). [Field types](https://docs.protobase.net/reference/plugins/#field-types) has the contract.

### 1. Show the value

A field type's display gets the field's model, its value as the API gives it, and the props the view passes:

```tsx
// components/order-steps.tsx
import type { FieldWidgetProps } from '@protobase/ui'

const label = (value: string) => value.charAt(0).toUpperCase() + value.slice(1)

/** An enum's value as its place among the values: a dot per step, filled up to it, and its name. */
export const StepsDisplay = ({ field, value, props }: FieldWidgetProps<string, { last?: string }>) => {
  const steps = (field.enumValues ?? []).filter((step) => step !== 'cancelled' || value === 'cancelled')
  const at = value === null ? -1 : steps.indexOf(value)
  return (
    <span className="inline-flex items-center gap-1.5">
      {steps.map((step, index) => (
        <span key={step} className={index <= at ? 'size-2 rounded-full bg-primary' : 'size-2 rounded-full bg-border-strong'} />
      ))}
      <span className="text-xs text-muted-foreground">
        {value === null ? '—' : label(value)}
        {props.last === value ? ' ✓' : ''}
      </span>
    </span>
  )
}
```

`field.enumValues` are the steps, in the order the data config lists them, so the same field type works for any enum. `value` is `null` for an empty field. `props.last` is whatever the view says it is; here, the step that ends an order.

### 2. Edit it

An editor gets the same props, and `onChange` to set a new value, and `invalid` when the value it holds is refused:

```tsx
// components/order-steps.tsx
import type { FieldWidgetEditorProps } from '@protobase/ui'

/** The steps as buttons: the current one marked, a click moves to another. */
export const StepsEditor = ({ field, value, onChange }: FieldWidgetEditorProps<string>) => (
  <div role="radiogroup" aria-label={field.name} className="flex flex-wrap gap-1">
    {(field.enumValues ?? []).map((step) => (
      <button
        key={step}
        type="button"
        role="radio"
        aria-checked={step === value}
        onClick={() => onChange(step)}
        className={step === value ? 'rounded-md border border-primary bg-primary-soft px-2 py-1 text-xs text-primary-text' : 'rounded-md border border-border px-2 py-1 text-xs hover:bg-muted'}
      >
        {label(step)}
      </button>
    ))}
  </div>
)
```

`onChange` changes the record on the page, as typing in an input does: the page shows "Unsaved changes", and Save sends it, with the page's checks and conflict handling. A field type has no save of its own.

### 3. Register it

`protobase.ui.tsx` registers the field type under a name, with the storage types it shows:

```tsx
// protobase.ui.tsx
import { definePlugin } from '@protobase/ui'
import { StepsDisplay, StepsEditor } from './components/order-steps'

export { OrdersPerDay } from './components/orders-per-day'

export default definePlugin({
  actions: { send },
  fields: { steps: { of: ['enum'], display: StepsDisplay, editor: StepsEditor } },
})
```

`of: ['enum']` says it shows enum fields. A table cell draws `display` too, unless the field type gives a `cell` of its own, for instance a more compact one.

### 4. Pick it in the view

The view chooses the field type for a field, with props:

```ts
// config/orders/ui.ts
.fields((r) => ({
  status: r.status.help('Where the order is in its life.').widget('steps', { last: 'delivered' }),
}))
```

Run `bun run protobase dev` and open the orders. The status column shows the dots, and so does every place that shows an order's status: its page when it may not be changed, the summary in its sidebar, cards and page elements. On an order you may change, the status is a row of buttons; click one and save.

### 5. What the app checks

A field type shows the storage types it names, and nothing else. Pick it for a text field by mistake, say the order's number:

```ts
number: r.number.widget('steps'),
```

`protobase build` reads which storage types each field type of the bundle shows, and the server refuses to start:

```
error: The views do not match the resources:
  - View "orders": field "number" is text, and its field type "steps" shows only enum
```

In `protobase dev`, the app checks the same as it draws, and shows the number as it would without a field type.

A field type that throws while drawing takes only its own field with it: the field shows as it would without the field type, and the rest of the page draws. So does every field of a field type whose plugin is off.

### 6. One field type for many apps

The `steps` field type above is the project's own, built with its bundle. To give it to other apps too, move `components/order-steps.tsx` into a [plugin](https://docs.protobase.net/guides/first-plugin/) of its own and register it there:

```tsx
// workshop-tools/protobase.ui.tsx
import { definePlugin } from '@protobase/ui'
import { StepsDisplay, StepsEditor } from './components/steps'

export default definePlugin({ name: 'workshop-tools', fields: { steps: { of: ['enum'], display: StepsDisplay, editor: StepsEditor } } })
```

Each app lists the plugin and picks `.widget('steps')` in its views. The app checks the storage types when it draws; the server checks only the field types of the bundle's own plugins, since it never loads remote code.

## A custom record card

Draw each card of a cards display with a component of your own, and keep the built-in card as its fallback.

A list can show its records as [cards](https://docs.protobase.net/reference/ui-config/#list-displays) beside its table: a title, a subtitle, a badge and a few small fields. Rijnland's sales team wants order cards that lead with the total and the customer, the way they talk about orders. That is a card component: the list page still loads the orders, filters, sorts and links each card to its order; the component only draws what is inside a card. This guide adds one to the ERP example (`examples/erp`).

### 1. The card

A card component gets `row`, one record of the list:

```tsx
// components/order-card.tsx
import type { RecordCardProps } from '@protobase/ui'

/** An order as a card: its number and total, the customer, and its status. */
export const OrderCard = ({ row }: RecordCardProps) => (
  <span className="flex flex-col gap-1">
    <span className="flex items-baseline justify-between gap-2">
      <span className="font-mono text-xs text-muted-foreground">{String(row.record.number)}</span>
      <span className="font-semibold tabular-nums">€{Number(row.record.total).toFixed(2)}</span>
    </span>
    <span className="truncate font-medium">{row.labels.company_id ?? String(row.record.company_id)}</span>
    <span className="mt-1 self-start rounded-md bg-primary-soft px-1.5 py-0.5 text-xs text-primary-text">{String(row.record.status)}</span>
  </span>
)
```

`row.record` is the order as the list API gives it, and `row.labels` the name of the record each relation points at: `company_id` is the company's id, `labels.company_id` its name. `row.key` is the order's key.

The whole card is a link to the order, so the component draws no links or buttons of its own; the app draws the card's border, padding and hover, in the app's [theme](https://docs.protobase.net/reference/theme/).

### 2. Export it

```tsx
// protobase.ui.tsx
export { OrderCard } from './components/order-card'
```

### 3. Use it in the view

Declare the component in the view and give it to a cards display. The fields the display names are the fields the cards get, so name every field the card reads:

```ts
// config/orders/ui.ts
import { component } from '@protobase/layout'

export const OrderCard = component('OrderCard')

// ...
.list((r, d) => ({
  columns: [r.number, r.company_id, r.status, r.total, r.created_at],
  sort: [[r.created_at, 'desc']],
  displays: [d.cards({ title: r.number, subtitle: r.company_id, badge: r.status, meta: [r.total] }, { card: OrderCard })],
}))
```

Run `bun run protobase dev`, open the orders and switch to Cards: each order is drawn by `OrderCard`. The display keeps everything else the list has: its search, filters, sort menu, the count of orders and loading more as you scroll.

`protobase build` fails when the config declares `OrderCard` and `protobase.ui.tsx` does not export it. A card from a [plugin](https://docs.protobase.net/reference/plugins/) is declared with the plugin's name, `component('OrderCard', { plugin: 'sales-tools' })`.

### 4. When the card fails

The fields in `d.cards` do double duty: they are also the built-in card, `title` over `subtitle` with `badge` and the `meta` fields. Each card falls back to it:

- when the component throws while drawing, that card alone;
- when no component is registered under the name, for instance because its plugin is off, every card.

So a broken card still shows each order's number, customer and status, and still opens it.

## Moving onto the plugin build

What changes for a project whose protobase.ui.tsx was built into the admin app, now that protobase build makes it a plugin beside a prebuilt app.

Until 0.12, `protobase build` built the whole admin app into each bundle, with the project's `protobase.ui.tsx` and every `--extend` folder's compiled into it. Each bundle was frozen at the `@protobase/ui` it was built with: the app's fixes reached it only with a rebuild. From 0.12 the admin app is prebuilt once per release, and `protobase build` builds the project's `protobase.ui.tsx` as a [plugin](https://docs.protobase.net/reference/plugins/) beside it. The project's code is written the same way; this guide moves a project over and says what to look at.

### 1. Update and rebuild

Move the project's `@protobase` packages to 0.12 or newer, as for any release, and build:

```sh
bun add @protobase/cli@^0.12 @protobase/server@^0.12 @protobase/schema@^0.12 @protobase/layout@^0.12 @protobase/ui@^0.12
bun run protobase build
```

```
built dist: protobase.config.js, public/ with plugins app and protobase.bundle.json (api /api)
```

A bundle of an older release is refused by the 0.12 runtime, as every new minor below 1.0 is; this one rebuild is the whole migration for most projects. The new bundle holds:

```
dist/
  protobase.config.js
  public/
    index.html, assets/      the prebuilt admin app
    plugins/app/             protobase.ui.tsx, as the plugin app
    protobase.theme.css      empty until the project has a theme
    protobase.favicon.svg    Protobase's until the project has its own
  protobase.bundle.json      version 3, naming the plugins and the app's version
```

### 2. Check protobase.ui.tsx

Nothing in it has to change: its named component exports, `defineUi({ actions, shell, extends })` and the components it registers work as before. `definePlugin` is the same function under its new name, and also takes [field types](https://docs.protobase.net/reference/plugins/#field-types). Three things a plugin cannot do, which a built-in `protobase.ui.tsx` could, fail the build or show at once:

- **Modules of the shared packages.** `react`, `react/jsx-runtime`, `react-dom` and `@protobase/ui` come from the app, by those names. An import of another module of theirs, such as `react-dom/client` or a file inside `@protobase/ui`, fails the build: import from the package's main entry instead.
- **Browser work at the top level.** The build loads `protobase.ui.tsx` once in Node to read its field types. Code that touches `window`, `document` or `localStorage` when a module loads fails the build; move it into a component, a hook or a handler.
- **A data library of its own.** A component that used `@tanstack/react-query` directly found the app's query client while it was built into the app; as a plugin it carries its own copy, which does not. Read through `@protobase/ui`'s hooks (`useList`, `useRecord`, `useClient`), which share the app's cache and session.

### 3. Check its styles

The project's Tailwind classes are built into the plugin's own stylesheet, `plugins/app/assets/styles-<hash>.css`: utilities only, each reading the app's [tokens](https://docs.protobase.net/reference/theme/). `bg-primary` and `rounded-md` look as before. A class that only worked because the app's own stylesheet happened to have it is now in the plugin's too, so nothing is lost. To change the app's colors, radius or font, write a [`protobase.theme.css`](https://docs.protobase.net/guides/theme-an-app/), not rules against the app's markup.

### 4. Extensions

Each `--extend` folder's `protobase.ui.tsx` becomes a plugin of its own, `plugins/<name>/`, named by its `package.json`, else its folder, and merged before the project's, as before. Its `protobase.config.ts` is merged into the config module as before. Two extensions that would be plugins of one name fail the build: give one's `package.json` another `name`.

### 5. Hosts

A host that serves `public/` as before keeps working: `index.html` for navigations, files for the rest. Two things are new:

- The plugins' `assets/` folders, `public/plugins/<name>/assets/`, are named by their content like `public/assets/`: serve them as immutable too. `plugin.json`, `protobase.theme.css` and `protobase.favicon.svg` are served with `no-cache`.
- A host that keeps the prebuilt app of newer releases may serve it for the manifest's `shell.paths`, so the app gets that release's fixes without a rebuild (see [the shell](https://docs.protobase.net/reference/plugins/#the-shell)). `protobase serve` does so with its own when every plugin of the bundle loads in it.

### 6. protobase dev

Nothing changes: `protobase dev` builds `protobase.ui.tsx` into its page with hot reload, as before, and serves the theme too. `protobase build && protobase serve dist` runs the bundle as a host does, with the project's UI loaded as the plugin `app`.

### After the move

From now on, a release of Protobase within the same minor needs no rebuild of the project's UI: the app it runs in is the release's, and the plugin keeps working within its [plugin API](https://docs.protobase.net/reference/plugins/#versions). The server config still follows the [bundle's rule](https://docs.protobase.net/reference/versioning/#bundles-and-runtimes), so a new minor below 1.0 is still a rebuild.

## Showing another app's records

Two Protobase apps, where the shop's admin app shows the service desk's tickets with its own pages, sidebar and links.

Sam runs a web shop, and its customers' questions land in the company's service desk, a Protobase app of its own. When a customer calls about an order, Sam's people look the order up in the shop's admin app, then open the desk in another tab to find the customer's ticket. They want the tickets in the shop's admin app, next to the orders: a list, each ticket's page, the open ones in the sidebar, and a status they can change. The desk's agents keep working in the desk.

This guide makes the desk's tickets a [remote resource](https://docs.protobase.net/reference/remote-resources/) of the shop: the shop draws them with its own pages, while the desk keeps the data, the rules and the views. Nothing is copied and nothing is proxied: the shop's admin app reads the desk's API with the shop's own sign-in.

### 1. Two apps

You need two Protobase projects, each with its own database, its own [admin store](https://docs.protobase.net/reference/auth/#admin-store) and a user: the from-scratch preset (`examples/scratch` in the repository) is enough for both. Each runs on its own origin; this guide runs them side by side on one machine:

| | `BETTER_AUTH_URL` in its `.env` | Started with |
| --- | --- | --- |
| The service desk | `http://localhost:4720` | `protobase dev --port 4720` |
| The shop | `http://localhost:4710` | `protobase dev --port 4710` |

`BETTER_AUTH_URL` matters here: it is the issuer of each app's tokens, and the desk will recognise the shop's tokens by it.

The shop has its orders. The desk has tickets and their notes:

```ts
// desk: config/tickets/data.ts
import { f, resource } from '@protobase/schema'

type Caller = { user: { roles: string[] } }
const deskOnly = (ctx: unknown) => (ctx as Caller).user.roles.includes('admin')

export const tickets = resource('tickets')
  .table('tickets')
  .fields({
    id: f.integer().readOnly().filterable().sortable(),
    title: f.text().filterable().sortable(),
    customer_email: f.text().filterable(),
    status: f.enum(['open', 'waiting', 'closed']).filterable().sortable(),
    updated_at: f.timestamp().readOnly().filterable().sortable(),
  })
  .primaryKey((r) => r.id)
  .search((r) => [r.title, r.customer_email])
  .access({ delete: deskOnly })

export const ticketNotes = resource('ticket_notes')
  .table('ticket_notes')
  .fields({
    id: f.integer().readOnly().filterable().sortable(),
    ticket_id: f.relation('tickets').filterable(),
    body: f.text(),
    created_at: f.timestamp().readOnly().sortable(),
  })
  .primaryKey((r) => r.id)
  .access({ delete: deskOnly })
```

Only the desk's own admins delete: the [access rule](https://docs.protobase.net/reference/access/) holds for everyone who reaches the desk's API, the shop's people included.

```ts
// desk: config/tickets/ui.ts
import { l, view } from '@protobase/schema'
import type { ticketNotes, tickets } from './data'

const recent = { status: 'status', tones: { open: 'warning', waiting: 'neutral', closed: 'success' }, orderBy: 'updated_at desc' } as const

export const ticketsView = view<typeof tickets>('tickets')
  .title((r) => r.title)
  .names({ singular: 'Ticket', plural: 'Tickets' })
  .nav({ group: 'Support', recent })
  .fields((r) => ({ customer_email: r.customer_email.label('Customer'), status: r.status.format('badge'), updated_at: r.updated_at.label('Changed').format('relative') }))
  .list((r) => ({ columns: [r.title, r.customer_email, r.status, r.updated_at], sort: [[r.updated_at, 'desc']] }))
  .filters((r, w) => [w.facets(r.status)])
  .layout((r) => [
    l.section('Ticket', [r.title, r.customer_email, r.status]),
    l.related('Notes', { resource: 'ticket_notes', field: 'ticket_id', sort: 'created_at desc', columns: ['body', 'created_at'], empty: 'No notes yet.' }),
  ])

export const ticketNotesView = view<typeof ticketNotes>('ticket_notes').nav({ hidden: true })
```

That is the desk's admin app as its agents use it. Nothing in it knows about the shop yet.

### 2. The desk lets the shop's people in

The shop's admin app will call the desk's API from the browser, with the token the shop gave the signed-in person. The desk needs to accept two things: that token, and calls from the shop's origin.

The token: list the shop as a trusted app in the desk's authenticator, and say what the shop's roles are at the desk:

```ts
// desk: auth/auth.ts
export const authenticate = betterAuthAuthenticator({
  auth,
  tenant: 1,
  // The shop's admins sign in at the shop; the desk takes the shop's tokens and calls them `shop` here.
  apps: [{ issuer: 'http://localhost:4710', roles: { admin: 'shop' }, tenant: 1 }],
})
```

The desk now checks a token from the shop against the shop's keys, at `http://localhost:4710/api/auth/jwks`. Its holder is `shop` at the desk, never the desk's `admin`, so the `deskOnly` rule keeps tickets from being deleted from the shop; a shop user with any other role is refused. The [reference](https://docs.protobase.net/reference/remote-resources/#signing-in-at-the-source) has the details, such as the ids those users get.

The origin: the shop's admin app runs on `http://localhost:4710`, so the desk answers [CORS](https://docs.protobase.net/reference/api/#cors) for it:

```ts
// desk: protobase.config.ts
import * as config from './config'
import { auth, authenticate } from './auth/auth'

// The shop's admin app, on its own origin, calls this API from the browser.
export default { config, auth, authenticate, options: { cors: ['http://localhost:4710'] } }
```

`protobase dev` reloads the desk.

### 3. The shop draws the tickets

The shop lists the desk as a source, with the resources it draws from it:

```ts
// shop: protobase.config.ts
import * as config from './config'
import { auth, authenticate } from './auth/auth'

export default {
  config,
  auth,
  authenticate,
  options: {
    sources: [{ url: 'http://localhost:4720/api/v1', label: 'Service desk', resources: ['tickets', 'ticket_notes'] }],
  },
}
```

`url` is the desk's resources API, and `label` its name when the shop's people cannot reach it. The notes are there because a ticket's page lists them: what the shop does not draw, it does not show. A platform that runs the shop can set the same list in `PROTOBASE_SOURCES`, without touching the shop's code:

```sh
PROTOBASE_SOURCES='[{"url":"http://localhost:4720/api/v1","label":"Service desk","resources":["tickets","ticket_notes"]}]'
```

Had the shop a resource or page called `tickets` of its own, it would now refuse to start, naming both: `/tickets` can only be one thing.

### 4. The tickets in the shop

Sign in to the shop. The sidebar has a Support group with Tickets and the desk's recent tickets under it, each with its status; `/tickets` is the desk's list, with its columns, its status filter and its search, and the breadcrumbs read "Support › Tickets":

A ticket opens at `/tickets/1`, with its notes from the desk, and the trail back to the list. Change the status and Save: the shop's admin app writes it to the desk with the ticket's ETag, under the desk's rules, and the ticket moves in the sidebar. The global search in the top bar finds tickets too.

(The stories run on made-up data, with a summary section the next section describes.)

Behind it, the shop's admin app read the desk's `/api/meta` with the shop's token, kept the tickets and notes, and placed them as the desk's view says. Every list and save went straight from the browser to the desk.

### 5. A view for the shop's people

"Support" is how the desk's agents think of tickets. In the shop they are about customers, and only the open ones matter in the sidebar. The desk decides what the shop's people see, so it gives the role it gave them, `shop`, a [view of its own](https://docs.protobase.net/reference/ui-config/#actions-and-views-per-role):

```ts
// desk: config/tickets/ui.ts
// What the shop's people see: the tickets of their customers, next to the shop's orders, and the open ones first.
export const shopTicketsView = ticketsView
  .forRoles(['shop'])
  .names({ singular: 'Customer ticket', plural: 'Customer tickets' })
  .nav({ group: 'Sales', recent: { ...recent, filter: 'status != "closed"' } })
```

After a reload, "Customer tickets" sits in the shop's own Sales group, under Orders, and its recent tickets are the ones still open. The desk's agents still see Support › Tickets.

### 6. When the desk is down

Stop the desk and reload the shop. The orders work as before. The tickets stay in the sidebar, under "Service desk" and marked, and `/tickets` says what is wrong, with a way to try again:

Start the desk again and press **Try again**: the tickets are back. The same happens when the desk answers but does not accept the shop's sign-in, for instance when its `apps` does not list the shop, with the desk's own reason.

### Custom sections

The desk's views can name [custom components](https://docs.protobase.net/reference/custom-components/), such as a timeline of the ticket with `l.custom(TicketTimeline)`. The shop draws them by name from its [plugins](https://docs.protobase.net/reference/plugins/), so the desk ships them as a plugin, built with `protobase build-plugin` and served wherever the shop's browser can load it, and the shop lists it with the source, for instance:

```sh
PROTOBASE_PLUGINS='http://localhost:4720/plugins/desk/plugin.json'
```

Inside, the component reads the ticket with `useLayoutRecord()`, and `useList` and `useRecord` read the desk for the desk's resources. Without the plugin, the shop draws the component's fallback in its place and everything else of the ticket.

### Where to go from here

- [Remote resources](https://docs.protobase.net/reference/remote-resources/): how it works, the contract between an app and its sources, and the API.
- [Your first plugin](https://docs.protobase.net/guides/first-plugin/): build the plugin a source's views need.
- [Access](https://docs.protobase.net/reference/access/): what the role you give another app's users may do.

## Exporting a list

Let users save a list as an Excel or CSV file, limit how many rows a file holds, or take Export off a list.

Every list in the admin can be saved as a file: Rijnland's finance team wants this month's invoices in Excel, and the warehouse wants a CSV of the stock moves for a supplier. Nothing needs setting up for that; this guide shows what users get, and how a view limits it or takes it away. It uses the ERP example (`examples/erp`).

### 1. Export a list

Run `bun run protobase dev`, open the orders and filter them, by status for instance. **Export** beside the title opens a dialog:

- **Format**: an Excel workbook (`.xlsx`) or a CSV file.
- **Rows**: the orders the list finds, with its filters, search and sort. With orders selected in the table, **Export N** beside **Delete N** opens the same dialog with the selected ones.
- **Values**: **As shown** writes what the list shows, such as "Shipped" and the company's name; **Raw values** writes what is stored, such as `shipped` and the company's key.
- **Columns**: the list's columns, in its order. Tick other fields to add them, and move any column up or down.

The dialog says how many rows that is. **Export** asks where to save the file in Chrome and Edge, which then write it as it is built, and downloads it in other browsers. The browser builds the file while it reads the orders page by page, so a large export costs the server no more than scrolling through the list would; **Cancel** stops it.

### 2. Limit or take it off

A view can limit how many rows one file holds, or take Export off its list:

```ts
// config/orders/ui.ts
export const ordersView = view<typeof orders>('orders')
  // ...
  .export({ maxRows: 50_000 })
```

```ts
// config/stock_moves/ui.ts
export const stockMovesView = view<typeof stockMoves>('stock_moves')
  // ...
  .export(false)
```

With a limit, the dialog says when the list finds more rows than a file holds, and the file holds the first ones in the list's order.

### 3. What a file holds

A file holds what the user could read in the list, and no more: the rows come from the same list API, as that user, so their role's [row filters and field lists](https://docs.protobase.net/reference/access/#capabilities) apply, and a [sensitive field](https://docs.protobase.net/reference/data-config/#sensitive-fields) is never offered. To keep a field out of someone's exports, keep it out of what their role may read. A view's limit only shapes the page; someone who may read the orders can still read them all through the API.

The [UI config](https://docs.protobase.net/reference/ui-config/#exports) has the details: how cells are typed in a workbook, how a CSV file is quoted, and how files are named.

## Importing records from a spreadsheet

Bring a spreadsheet of companies into the admin app with the list's Import, update them from another, and turn Import off where it does not belong.

Rijnland's sales team keeps a spreadsheet of companies they met at a trade fair, and wants them in the ERP example (`examples/erp`) without typing each one. Every list offers **Import** to a user who may create its records, so there is nothing to build: this guide walks through an import, an update from a second file, and turning Import off. The [reference](https://docs.protobase.net/reference/ui-config/#importing-records) has every rule.

### 1. Import new records

Save the spreadsheet as it is (`.xlsx`), or as **CSV UTF-8**. Its first row names the columns:

| Name | City | Country | Status | Email |
| --- | --- | --- | --- | --- |
| Hoogland Fietsen | Leiden | NL | Lead | inkoop@hoogland.test |
| De Spaak | Delft | NL | lead | |

Open **Companies**, press **Import** and choose the file. Each column whose header is a field's label or name is mapped already: Country is the label the companies' view gives `country_code`, a relation whose column holds each country's key. Map other columns, or leave them at **Do not import**. Set **Numbers** and **Dates** to how the file writes them. Fields a new company needs that no column fills are named above **Check rows**, which waits for them.

**Check rows** reads the whole file and writes nothing. It shows the first rows as they will be saved and lists each row with a problem, such as a country no record has or a status that is none of Lead, Active and Dormant (`lead` is fine: case and spacing do not count). Those rows are skipped. An empty Email is saved empty; an empty cell is never filled with a default.

**Import** saves the rows in batches of 100, each batch on its own. A row the server refuses is listed with the server's reason while the rest are saved, and **Cancel import** stops after the batch it is in. The summary counts what was created, updated, skipped and failed; **Download** gives the rows not imported, with their line in the file and why, to fix and import again.

### 2. Update records from a file

A second sheet has the companies' VAT numbers, by name:

| Name | VAT number |
| --- | --- |
| Hoogland Fietsen | NL001234567B01 |

Choose **Update** under **Rows**, then **Find records by** Name and **Found in column** Name. Name is a filterable field, so the import can look the companies up; a name two companies share is a problem on its row rather than a guess. A row whose name finds no company is not imported; **Create or update** would create it instead.

### 3. Turn Import off

When a list's records should never come from a file, such as invoices that only orders should make, turn its Import off in the view:

```ts
// invoices/ui.ts
export const invoicesView = view<typeof invoices>('invoices').import(false)
```

Turning it off removes the button, not a right: the API takes the same writes from any client, and the server checks each one, so a user who may not create a record, or write one of its fields, is refused per row as on the record page.

## A create page of your own

Replace a resource's create form with a page of the app's own, built from the app's form parts, that creates records through an API function.

Pip's bike shop books repairs at the desk, and a booking is more than a row: the workshop wants to know what the bike needs, in words a customer understands, and the shop's API function plans the job. This guide gives the repairs a page of their own for that, opened by **New** on their list, and lets the API refuse plain creates. It uses the bike shop of the [first guide](https://docs.protobase.net/guides/table-to-admin/).

### 1. The page

A create page is a [composed page](https://docs.protobase.net/reference/layouts/) like any other, with a [component](https://docs.protobase.net/reference/custom-components/#components) of the project's own in it:

```tsx
// config/pages/book-repair.tsx
/** @jsxImportSource @protobase/layout */
import { Page, component, page } from '@protobase/layout'

export const BookRepair = component('BookRepair')

export const bookRepair = page(
  'book-repair',
  <Page title="Book a repair" description="What the bike needs, so the workshop can plan it.">
    <BookRepair />
  </Page>,
  { roles: ['desk', 'admin'] },
)
```

Its `roles` say who may book: they are the people who get the page, and so **New**.

### 2. The component

The component asks its questions with the [form parts](https://docs.protobase.net/reference/custom-components/#form-parts) of `@protobase/ui`, so it looks like the app's own forms, and keeps the answers in the address, so a reload keeps them:

```tsx
// components/book-repair.tsx, exported from protobase.ui.tsx: export { BookRepair } from './components/book-repair'
import { Choices, Field, Form, Input, Section, useAppAddress, useClient } from '@protobase/ui'
import { useState } from 'react'

const kinds = [
  { value: 'service', label: 'Service', description: 'The whole bike checked: brakes, gears, tyres and chain.' },
  { value: 'flat', label: 'Flat tyre', description: 'A new inner tube, and the tyre checked for what caused it.' },
  { value: 'other', label: 'Something else', description: 'Say what is wrong at the desk.' },
]

export const BookRepair = () => {
  const { address, go } = useAppAddress()
  const client = useClient()
  const [saving, setSaving] = useState(false)
  const [error, setError] = useState<unknown>()
  const values = address?.kind === 'create' ? (address.values ?? {}) : {}
  const answer = (patch: Record<string, string>) => go({ kind: 'create', resource: 'repairs', values: { ...values, ...patch } }, { replace: true })

  const book = () => {
    setSaving(true)
    client.invoke('book-repair', { body: values }).then(
      (repair) => go({ kind: 'record', resource: 'repairs', key: (repair as { id: number }).id }),
      (failure) => {
        setSaving(false)
        setError(failure)
      },
    )
  }

  return (
    <Form submit="Book repair" disabled={!values.bike || !values.kind} saving={saving} error={error} errorTitle="Not booked" onSubmit={book}>
      <Section title="The bike">
        <div className="flex flex-col gap-5">
          <Field label="Bike" required help="Make and model, as on the frame.">
            <Input value={values.bike ?? ''} onChange={(event) => answer({ bike: event.target.value })} />
          </Field>
          <Field label="What it needs" required>
            <Choices name="kind" label="What it needs" options={kinds} value={values.kind} onChange={(kind) => answer({ kind })} />
          </Field>
        </div>
      </Section>
    </Form>
  )
}
```

Without a `title`, the `Form` sits under the page's heading, with its button after the sections.

### 3. Point the view at it

```ts
// config/repairs/ui.ts
export const repairsView = view<typeof repairs>('repairs')
  // ...
  .create({ page: 'book-repair' })
```

**New** on the repairs list now opens the page at `/repairs/new`, and so does every link to a new repair, such as one on a customer's page with `values: { customer_id: '{id}' }`. Someone the page leaves out gets no **New**, and `/repairs/new` tells them they cannot create repairs; the plain create form no longer shows. The server refuses a page name it does not know when it starts.

### 4. Create through a function

With its own page, the shop can make the [API function](https://docs.protobase.net/guides/api-functions/) `book-repair` the only way in: it checks the answers, plans the job and inserts the repair with `db`, which skips the access rules. The resource's API then refuses creates:

```ts
// config/repairs/data.ts
export const repairs = resource('repairs')
  // ...
  .access({ create: () => false })
```

**New** follows the page, not this rule, so it stays for the desk. **Import** on the list follows the rule, and goes.

The [UI config](https://docs.protobase.net/reference/ui-config/#create-pages) has the details.

## Reference pages

For what the guides leave out, read a reference page: each is Markdown at its URL, so fetch the one you need instead of the HTML page.

- [Data config](https://docs.protobase.net/reference/data-config.md): Resources in data.ts, every field type and option, keys and relations, soft delete, tenants, search, validation and access.
- [UI config](https://docs.protobase.net/reference/ui-config.md): Views in ui.ts, with names, list columns, editing in the list, sorting records by hand (also an invoice's items), filters, charts (graphs of records per day or per value), exports to Excel and CSV, record layouts, related records (in a section or as dots in a list column), field hints, actions (with a dialog of inputs when they ask for some) and views per role.
- [Architecture](https://docs.protobase.net/reference/architecture.md): How the packages are laid out, which package may import which, and where tests and stories live.
- [REST API](https://docs.protobase.net/reference/api.md): The REST API that @protobase/server generates, with its routes, access, filters, writes, batches and errors.
- [Files](https://docs.protobase.net/reference/files.md): File fields, uploads with types detected from the content, downloads, derived fields, image variants and placeholders, external URLs, image referrers, and how stored files are deleted.
- [Events](https://docs.protobase.net/reference/events.md): Events a resource declares, and how the database captures every change they are raised from, through the API or not.
- [Webhooks](https://docs.protobase.net/reference/webhooks.md): Subscriptions that deliver resource events to URLs, API functions and code, signed with Standard Webhooks, retried and shaped for who receives them.
- [Record history](https://docs.protobase.net/reference/history.md): Every change of a resource's records kept with who made it, through the API or not, shown on the record page and listed for admins as Activity.
- [Record comments](https://docs.protobase.net/reference/comments.md): Comments on a resource's records by anyone who may read them, with @mentions that email the people named, shown on the record page and listed for admins.
- [AI fields and actions](https://docs.protobase.net/reference/ai.md): Fields a model fills from a prompt after each write, and buttons that have the model write a field of the record, as the user, on the assistant's model settings.
- [Public forms](https://docs.protobase.net/reference/forms.md): A form that creates a record for people without an account, such as a contact form, at its own address, behind a rate limit and a bot check.
- [Jobs](https://docs.protobase.net/reference/jobs.md): Background jobs in the app's own database, enqueued with its writes, retried with backoff, run in order per group, and checked at startup.
- [Email](https://docs.protobase.net/reference/email.md): The SMTP server and sender an app's mail goes out through, and emails of the app's own, written as React components with typed data and sent from jobs, hooks and API functions.
- [Access](https://docs.protobase.net/reference/access.md): Roles, capabilities with field lists and row filters, field rules, anonymous access, and what they default to.
- [Login with Better Auth](https://docs.protobase.net/reference/auth.md): Sign-in with Better Auth, with the admin store, sign-in methods and policy, sign-in providers, staff sign-in, roles, tokens and the public endpoints.
- [Client and live UI](https://docs.protobase.net/reference/client.md): The typed fetch client, the TanStack Query hooks and the admin app that runs on them.
- [Layouts](https://docs.protobase.net/reference/layouts.md): Composed pages from JSX layouts, the element tree they compile to, what they may hold, and how data, conditions, actions and access work in them.
- [Blocks](https://docs.protobase.net/reference/blocks.md): Every built-in layout block, its props and what it does, with examples from the ERP's sales overview.
- [Custom components](https://docs.protobase.net/reference/custom-components.md): React components and action handlers of your own in protobase.ui.tsx, used by name from layouts.
- [Plugins](https://docs.protobase.net/reference/plugins.md): React code the admin app loads when it starts instead of building it in, so it keeps working while Protobase moves on.
- [Remote resources](https://docs.protobase.net/reference/remote-resources.md): Resources another Protobase API serves, drawn in an app with its own list pages, record pages, sidebar and links, at their own addresses.
- [Theme](https://docs.protobase.net/reference/theme.md): protobase.theme.css sets the values of the app's tokens, colors, radius and fonts, for light and dark, and keeps working on every release.
- [Sidebar](https://docs.protobase.net/reference/sidebar.md): How sidebar entries are grouped, and how an entry lists recent records with status dots.
- [User menu](https://docs.protobase.net/reference/user-menu.md): The user menu at the bottom of the sidebar, with the app's own account pages and links.
- [Bottom bar](https://docs.protobase.net/reference/bottom-bar.md): Tabs along the bottom of a phone, as on iOS, for the roles that work from one.
- [Assistant](https://docs.protobase.net/reference/assistant.md): The chat beside the admin, who sees it, and its built-in backend on OpenRouter or another OpenAI-compatible endpoint.
- [CLI](https://docs.protobase.net/reference/cli.md): Every protobase command, from scaffold, doctor, dev, build, build-plugin, build-serve and serve to users, organizations and token.
- [Versioning](https://docs.protobase.net/reference/versioning.md): How Protobase is versioned and released, the commit convention behind it, and which bundles a serve runtime runs.
- [@protobase/schema API](https://docs.protobase.net/api/schema.md): Every export of @protobase/schema, generated from its doc comments.
