RatchetRatchet

Console

The generated admin console — branding, custom forms, and custom field inputs.

ratchet serve mounts a generated console SPA — a sidebar of models, list/form views generated from each model's field metadata, and session-based login, with no per-model UI code to write.

Where it's mounted

By default the console mounts at /console. Set consolePath in ratchet.config.ts to change that:

export default defineConfig({
  // ...
  consolePath: '/dashboard', // any path starting with '/', no trailing slash
});

consolePath can also be '/', which mounts the console as the app's catch-all fallback — the whole app is the console, with /api and /api/auth still reachable at their usual paths (they're registered first, so they keep precedence over the console's own catch-all route). It can't be /api, /api/auth, or anything starting with /api/ — those are the framework's own routers.

consolePath is baked into the console client bundle at build time (it's used as the client-side router's basename), so changing it requires re-running ratchet build/ratchet dev.

Branding

The sidebar/header heading defaults to "Ratchet console". Override it with brand in ratchet.config.ts:

export default defineConfig({
  // ...
  brand: {
    name: 'Acme Admin',
    logoUrl: 'https://acme.example/logo.svg', // an absolute URL — the console is a static bundle
  },
});

Both fields are optional. Like consolePath, brand is baked into the console client bundle at build time, so changing it requires a rebuild.

How it's built

ratchet build (or ratchet dev, for local iteration) bundles the framework's own console client entry with Bun.build + Tailwind into hashed assets plus a manifest.json, written under <generatedDir>/console/. This always runs — the console client is framework-owned, with no per-app entry file to author. createConsoleRouter serves that shell and its assets at ${consolePath}/*, and falls back to a 503 with an instructive message if the console hasn't been built yet.

Because the SPA uses client-side routing, every path under consolePath (not just consolePath itself) serves the same HTML shell — a hard refresh on /console/customers/:id needs to resolve to the shell too, since routing happens in the browser after it loads.

Metadata API

The console SPA discovers models and renders CRUD views from a small metadata API, backed by the same registry /api/:model uses. It's namespaced under /meta (not /api) precisely so it never collides with the top-level /api router, even when consolePath is '/':

MethodPath
GET${consolePath}/meta/modelsmetadata for every non-hidden model
GET${consolePath}/meta/models/:namemetadata for one model
GET${consolePath}/meta/domainsmetadata for every Domain that has Domain Settings
GET${consolePath}/meta/domains/:name/settingsone Domain's current settings values
PATCH${consolePath}/meta/domains/:name/settingsupdate one Domain's settings values

All require an authenticated session (see Auth) and are driven by each model's fields/console options or a Domain's defineDomain() — there's no separate console schema to maintain.

Controlling what shows up

Set these under console in defineModel:

export const Session = defineModel('sessions', {
  fields: { /* ... */ },
  console: { hidden: true }, // managed only through /api/auth/*, not the console CRUD views
});

export const Customer = defineModel('customers', {
  fields: { /* ... */ },
  console: {
    label: 'Customers',      // sidebar/heading text; defaults to a capitalized model name
    displayField: 'name',    // shown in reference dropdowns and list-view titles; defaults to the first string field, or 'id'
  },
});

A field.reference(...) on a model automatically renders as a dropdown in the generated form, populated from the target model's rows and labeled with its displayField. A field with sensitive: true (e.g. passwordHash) never round-trips to the client; one with writeAs submits under its declared input key instead of its column name; one with displayText uses that as its list/form label instead of a humanized field key (see Common options).

List view

Every model's list view has a small toolbar above the table:

  • Search — a single free-text box, shown whenever the model has at least one field declared indexed: true with kind string or text. It debounces as you type and matches like the REST API's ilike filter operator would: case-insensitive, substring, OR'd across every eligible field. A field that isn't indexed: true (or isn't string/text) is invisible to search, the same gate that already applies to ?filter=/?sort= (see Common options and REST API) — a common field like a title or description won't be found unless it's indexed.
  • Filter — the manual clause builder (field/operator/value, with AND/OR groups), unchanged; also gated on indexed: true.
  • Sort — click a column header for an instant single-key sort (shift-click to add a secondary key), or open the Sort panel to build a multi-level sort and click Apply. Only header clicks apply immediately; the panel stages its edits until Apply is clicked.
  • Columns — show/hide individual columns. id is always shown.
  • Export CSV — downloads every row matching the current filter/search/sort (not just the visible page), respecting the current column show/hide selection, capped at 5,000 rows (a banner says so if the export was truncated).

On a plain model list page, Search/Filter/Sort/Columns are ad-hoc and live in the URL (?q=, ?filter=, ?sort=, ?cols=) so they survive a reload and can be shared as a link. On a saved Workspace View tab, Filter and Sort are persisted to the tab instead (so they're still there next time the tab is opened); Search and Columns stay per-session there and reset when the tab is reopened.

Domains

A Domain groups related models — declare a model inside a top-level subdirectory of modelsDir and it belongs to that Domain:

models/
  auth/
    user.model.ts       # domain: 'auth'
    settings.domain.ts  # this Domain's settings, below
  billing/
    invoice.model.ts    # domain: 'billing'
  customer.model.ts     # no domain — declared at modelsDir's root

The console sidebar groups a Domain's models under one labeled section, instead of listing every model flat. A model declared directly under modelsDir, with no subdirectory, has no Domain and stays outside any section.

Domains

A Domain can also declare a display label, typed, DB-backed, console-editable settings, and extra console sidebar links, with defineDomain(). Add a *.domain.ts file under the same subdirectory as the Domain's models:

// models/auth/settings.domain.ts
import { defineDomain, field } from '@egig/ratchet/core';

export const AuthSettings = defineDomain('auth', {
  label: 'Authentication',           // settings-page heading; defaults to a capitalized domain name
  settings: {
    sessionTtlDays: field.integer({ default: 7 }),
    requireMfa: field.boolean({ default: false }),
  },
  consoleMenu: [
    // extra sidebar links rendered above this Domain's models — for a page with no model of its
    // own to derive a link from.
    { label: 'Audit log', to: '/auth/audit-log' },
  ],
});

The name argument ('auth' above) must match the folder it's declared in (ratchet generate rejects a mismatch). settings and consoleMenu are both optional — declare either, both, or (rarely) neither.

The sidebar shows one "Settings" link (only once at least one Domain declares settings) opening ${consolePath}/settings, a single page that tabs across every Domain that has settings — ${consolePath}/settings/:domain selects a tab directly, and /settings itself redirects to the first one. Each tab is a form generated from that Domain's settings the same way a model's form is.

Read a Domain's current settings from a pipeline function:

import { getDomainSettings } from '@egig/ratchet/core';
import { AuthSettings } from '../models/auth/settings.domain.js';

const settings = await getDomainSettings(ctx.db, AuthSettings); // { sessionTtlDays, requireMfa }

Custom forms

Every model gets a create/edit form generated from its fields — good enough for most models, but sometimes not (a layout the generated one-field-per-row form can't express, extra client-side logic, a field that isn't really editable in a plain input). Drop in a full replacement by adding a <name>.form.tsx under modelsDir, where <name> is the model's own name (the string passed to defineModel(), e.g. 'customers' — not the .model.ts file's basename):

models/
  customer.model.ts       # defineModel('customers', { ... })
  customers.form.tsx       # replaces customers' generated create/edit form

It can live anywhere under modelsDir (colocated with the model, under a Domain folder, wherever) — only the filename matters. ratchet generate rejects a <name>.form.tsx that doesn't match a real model's name, and rejects two forms declared for the same model.

The file's default export takes over the whole create/edit form for that model — the dialog ModelListPage opens for new/:id renders it in place of the generated one, with the same onDone contract (call it after a successful save, or when the user cancels, to close the dialog):

// models/customers.form.tsx
import { useState } from 'react';
import { createRow, updateRow, type ModelFormProps } from '@egig/ratchet/console/client';

export default function CustomersForm({ model, mode, id, fields, onDone }: ModelFormProps) {
  const [values, setValues] = useState<Record<string, unknown>>({});
  const onChange = (key: string, value: unknown) => setValues((v) => ({ ...v, [key]: value }));

  async function handleSubmit() {
    if (mode === 'create') await createRow(model.name, values);
    else await updateRow(model.name, id!, values);
    onDone();
  }

  return (
    <div>
      <label>
        {fields.name.meta.label}
        {fields.name.render({ value: values.name, onChange })}
      </label>
      <label>
        {fields.email.meta.label}
        {fields.email.render({ value: values.email, onChange })}
      </label>
      <button onClick={handleSubmit}>Save</button>
      <button onClick={onDone}>Cancel</button>
    </div>
  );
}

A custom form owns its data fetching and mutations entirely — there's no partial hand-off of just the field list. @egig/ratchet/console/client exports what the generated form itself is built from, so a custom one doesn't have to reinvent it: getRow/createRow/updateRow/listRows/callOperation/ApiRequestError/hasPermission (api.ts), useModels/useAuth. listRows and callOperation matter beyond the form's own model too — a form that also manages a related model's rows (see Role's builtin form below) uses them to query that related model and invoke a custom operation, the same way the generated list/detail views do.

fields (ModelFormProps.fields) is the model's own fields, each already bound to its built-in editor — fields[name].render({ value, onChange, error? }) renders that field's real control (a reference's dropdown, file's upload button, manyToMany's multiselect, a field.custom() renderer, a plain input, whichever kind it is) without the custom form needing to know or switch on what kind of field it is. fields[name].meta is that field's metadata (label, required, kind, ...), for building a label/layout around .render()'s output. A field that's sensitive with no writeAs (never round-tripped to the client at all) has no entry. For a page that isn't a model form but still wants these — a custom bulk-edit dialog, say — createModelFieldRenderers(model, mode) builds the same map standalone. FieldInput (the lower-level component .render() itself calls) is also exported directly, for a form that wants to build the field/inputKey/modelName binding itself instead.

Tailwind classes used in a *.form.tsx are picked up by the console build the same way the framework's own components are — no extra config needed.

A custom form isn't limited to its own model's fields — it owns its data fetching/mutations entirely, so it can just as well read and write a related model alongside the one it was opened for. Role's own console form (src/auth/models/role.form.tsx, shipped with the framework — see Builtin forms below) is a real, worked example: it edits name/description the normal way and manages the role's entire permissions grant list — a tree of resource → action → field checkboxes, '*' collapsing a fully-granted subtree into one wildcard entry — persisted via the Role model's own createRow/updateRow (the whole permissions array in one Save), instead of a separate CRUD screen. See Auth for how the grant list is shaped.

Builtin forms

A handful of the framework's own built-in models (currently just Role) ship their own console form the same way a consuming app's <name>.form.tsx does — generate() (src/codegen/generate.ts) merges a fixed BUILTIN_FORMS list (src/codegen/builtins.ts) into the app's customForms map by default, so every app gets Role's combined edit-role-and-permissions form with nothing to author. A builtin form is exposed through its own subpath (@egig/ratchet/auth/console-forms, not the package's main @egig/ratchet/auth entry) so a plain backend deploy that never touches the console isn't forced to resolve react just because it imported createAuthRouter/the model definitions.

A builtin is only ever a default — an app's own <name>.form.tsx for the same model always takes precedence (dropping the builtin entirely, not conflicting with it the way two of the app's own forms for one model would). Nothing else about custom forms changes: same ModelFormProps, same modelsDir scan, same override mechanism, whether the model being replaced is one the app declared itself or one the framework did.

Custom field inputs

Replacing a whole form is sometimes more than a single field needs. Add a <model>.<field>.input.tsx under modelsDir and every place that field would normally render its kind-based input — the generated form, and fields[name].render(...) in a custom form (above) — renders this instead:

models/
  customer.model.ts         # defineModel('customers', { fields: { email: field.string(...), ... } })
  customers.email.input.tsx  # replaces the `email` field's input everywhere on `customers`

No change to the model definition is needed — this is purely a filename, unlike field.custom(name, base) (a separate, model-declared mechanism keyed by a custom type name, core/field.ts), which this takes priority over if both somehow apply to the same field. ratchet generate rejects a <model>.<field>.input.tsx that doesn't match a real model and one of its declared fields, a name with no . separating them, and two inputs declared for the same model+field.

The file's default export receives the same props the generated switch itself uses (FieldInputProps, also what a field.custom() renderer receives) — value/onChange/error/mode, plus field (this field's own metadata), inputKey (the key to onChange/the save payload — usually field.key, but a sensitive+writeAs field like a password submits under its writeAs instead), and modelName:

// models/customers.email.input.tsx
import type { FieldInputProps } from '@egig/ratchet/console/client';

export default function CustomersEmailInput({ value, onChange, inputKey, error }: FieldInputProps) {
  return (
    <div>
      <input
        type="email"
        value={(value as string) ?? ''}
        onChange={(e) => onChange(inputKey, e.target.value.toLowerCase())}
      />
      {error && <p className="text-red-600 text-xs">{error}</p>}
    </div>
  );
}

Tailwind classes used in a *.input.tsx are picked up the same way *.form.tsx's are — no extra config needed.

UI primitives (experimental)

The console is being migrated onto its own small primitive layer — Button, Input, Label, and Dialog, built on unstyled Radix Primitives and restyled onto the console's token-based light/dark theme. They're exported from @egig/ratchet/console/client alongside everything above, so a *.form.tsx/*.input.tsx can match the console's own look instead of reinventing a button:

import { Button, Input, Label } from '@egig/ratchet/console/client';

<Label htmlFor="email">Email</Label>
<Input id="email" type="email" value={email} onChange={(e) => onChange('email', e.target.value)} />
<Button type="submit">Save</Button>

This is still an early, small slice — only these four primitives exist so far, and their props may still change shape as the rest of the console (model list/form pages, the comboboxes, RowTable) migrates onto them. Pin your @egig/ratchet version if you depend on this today.

Dark mode

The console has a per-browser light/dark toggle in the top-right of its header, persisted to localStorage and defaulting to the OS/browser preference on first visit. Only the UI primitives above and the console's own shell (sidebar, header, login/setup pages) follow it so far — the rest of the console doesn't re-theme yet.

Aside from custom forms, custom field inputs, and the branding config, every app gets the same console shell (model list/table views, settings tabs) — there's still no custom pages, and no console/client/main.tsx to author; the entry point that mounts <ConsoleApp /> lives in the framework.

On this page