Admin UI
Schema-driven admin interface that renders your entire backend from Quickback definitions — zero UI code per table.
Available in Early Access
The Admin UI is available today for any Quickback-compiled API that emits a schema registry. Core schema-driven CRUD, views, actions, access, and masking workflows are ready to use. The CMS is evolving quickly, so review release notes when upgrading projects that depend on customized admin workflows.
Admin UI
A schema-driven admin interface that reads schema-registry.json generated by the Quickback compiler. Every table, column, action, view, and security rule is rendered automatically. Zero UI code per table.
Overview
The CMS generates its entire UI from your Quickback definitions. Define a table with columns, guards, masking, views, and actions in your feature files. Run the compiler. The CMS reads the resulting schema registry and renders a complete admin interface — data tables, inline editing, action dialogs, role-based access, and field masking — all without writing a single line of UI code.
Key Features
- Schema-driven — Zero UI code per table. Add a table, recompile, and it appears in the CMS.
- Dashboard — Stats grid and feature cards showing tables, columns, actions, views, and masked fields at a glance.
- Custom pages — Split-panel layouts with drag-and-drop, matching engine, and page-level actions for workflows like reconciliation.
- Embedded in your Worker — Set
cms: truein config. The CMS is served as static assets from the same Cloudflare Worker as your API — same origin, no CORS, auth cookies work naturally. - Dual view modes — Table browse mode for navigation and Data Table mode for spreadsheet-style editing.
- Role-based access — Owner, admin, and member roles with live switching. CRUD buttons hidden when unauthorized.
- Multi-tenant & fixed-org — Org-scoped access by default, with pinned-organization mode for simpler deployments.
- Inline spreadsheet editing — Excel/Google Sheets-like editing with keyboard navigation (arrows, Tab, Enter, Escape).
- FK typeahead — Server-side search for foreign key fields with debounced queries and keyboard navigation.
- Field masking — Email, phone, SSN, and redaction patterns applied per role. Masked fields show a lock icon.
- Custom actions — Action dialogs with auto-generated input forms, access filtering, CMS metadata (icons, categories, confirmations), and side effects warnings.
- Views — Named column-level projections per role, with an explicit default projection for CMS table entry.
- Auto-form generation — Create and edit forms built from guards (createable/updatable fields). Inputs follow the column's constraints —
q.enumrenders a select,q.urla URL input,q.text({ maxLength })a length-capped field — required columns are marked and constraints shown as helper text, untouched optional fields are omitted from the request (cleared ones are sent asnull), and aVALIDATION_ERRORfrom the API is shown inline under the offending fields. - Human-readable relationships — FK labels and selectors use the target table's
displayColumnand an authored schema reference.
CMS Readiness Review
Enabling the CMS makes the definitions visible; it does not invent a safe UI workflow. Before compiling a CMS-backed project, review every visible table against this checklist:
- Collection entry — A table with no named views can use its ordinary
collection projection. Once
read.viewscontains a named view, there is no implicit full-row fallback. Setread.defaultViewto an accessible named view when the CMS must open the table directly. Without it, bareGET /api/v1/<table>correctly returns400 VIEW_REQUIRED; callers must useGET /api/v1/<table>/views/<name>. The registry marks that intentional state withrequiresExplicitView: true, so the CMS shows Choose a view and makes no data request until one is selected. It never silently chooses the first accessible projection.defaultSortonly chooses ordering and cannot satisfy view selection or fixVIEW_REQUIRED. - Labels — Set
displayColumnon tables used as FK targets or record titles. Auto-detection is a convenience, but an explicit value makes the intended human label stable. - Relationships — For feature-table foreign keys, declare the relationship
in the schema with
.references(() => target.id). The CMS needs the emittedfkTargetfor selector/typeahead controls. Treat a matching*Idsibling table name as a compiler diagnostic suggestion, not as proof of a relationship. Better Auth ids are the exception: they live inAUTH_DBand remain plain text, not cross-database FKs. When a target has named views, its role-accessibleread.defaultViewmust include its primary key anddisplayColumn; the CMS lookup uses that named route only. Add the label to the view's effectivequery.searchablefor typeahead, or it becomes a first-50 chooser with no search request. - CRUD controls — New requires generated
createCRUD, admittedcreate.access, and all required non-default inputs inguards.createable. Edit controls require generatedupdateCRUD, admittedupdate.access, and fields inguards.updatable. A protected workflow field belongs in neither generic write list. Give it a schema default for creation; only its named transitions/actions may write it. - Business actions — A record-bound action needs an existing row. A
workflow that creates the first row should be a standalone action with
path:and explicit table-toolbar placement, such ascms: { placement: "tables", tables: ["reservations"] }. Check action roles and record conditions, and usecms.hiddenonly when the action is intentionally API-only. Action input names are humanized (sessionIdbecomes Session). The CMS resolves a matchingfkTargetthrough the parent-table column metadata, then reads the FK target through its safe collection or named-default-view route. It renders search only when that route supports it. Without an explicit schema relationship, the action dialog falls back to plain text.
Read Schema Registry, Table Views, and Actions before authoring or reviewing a CMS-backed schema.
Architecture
The CMS sits at the end of the Quickback compilation pipeline:
Quickback Definitions (feature files)
|
v
Compiler
|
v
schema-registry.json
|
v
CMS reads it
|
v
Renders admin UIYour feature definitions are the single source of truth. The compiler extracts all metadata — columns, types, guards, masking rules, views, actions, validation, and firewall config — into a static JSON file. The CMS consumes that file and renders the appropriate UI for each table.
How Embedded Serving Works
When cms: true is set, the compiler builds the CMS SPA from source at compile time (with your project-specific env vars baked in) and outputs the assets to src/apps/cms/. It also configures wrangler.toml:
[assets]
binding = "ASSETS"
directory = "src/apps"
not_found_handling = "none"
run_worker_first = trueAll requests go through the Worker first. The Worker handles SPA routing — serving CMS at /cms/ on the unified domain and at root (/) on a custom CMS domain. API paths (/api/*, /auth/*, etc.) are handled by Hono as normal.
See Multi-Domain Architecture for details on hostname-based routing.
Zero UI Code
The CMS generates its entire UI from your Quickback definitions. Add a table, recompile, and it appears in the CMS. No UI code to write.
Quick Start
1. Enable CMS in config
export default defineConfig({
name: "my-app",
cms: true,
// ...providers
});2. Compile
quickback buildThe compiler generates schema-registry.json and copies CMS static assets to src/apps/. It also adds an [assets] section to wrangler.toml so Cloudflare serves the CMS SPA automatically.
3. Run
npm run devOpen your Worker URL in a browser — the CMS is served at the root. API routes (/api/*, /auth/*, etc.) pass through to your Hono app as normal. Everything runs on the same origin — no CORS configuration needed, auth cookies work naturally.
Optional: Custom CMS domain
cms: { domain: "cms.example.com" }This adds a custom domain route to wrangler.toml. Both domains serve the same Worker — api.example.com for the API, cms.example.com for the CMS. The compiler also auto-infers a unified quickback.example.com domain where everything is available. See Multi-Domain Architecture.
Access gate
cms.access answers one question: can anyone in the organization hierarchy use the CMS, or only a platform sysadmin?
| Value | Who gets in |
|---|---|
"sysadmin" (default) | Only user.role === "sysadmin" — the cross-tenant data-plane tier. They choose a tenant from the org picker. |
"member" | The above, plus any caller holding a membership in an organization, scoped to that organization. |
cms: { access: "member" } // let organization members inStart apps ship that setting. A sysadmin who also holds an organization membership uses the seat (owner / admin / member) rather than the cross-tenant picker — the picker is only for a sysadmin who belongs to nothing.
File uploads need valid R2 credentials
Profile pictures and organization logos are Account UI, not the CMS — they
live at /account/profile and /account/<slug>/settings. They only work with
account.fileUploads: true, managed R2
(defineFileStorage("cloudflare-r2", { managed: true })), and the
R2_ACCOUNT_ID / R2_ACCESS_KEY_ID / R2_SECRET_ACCESS_KEY secrets. A
Start workers.dev deploy can create the bucket; it cannot mint those
credentials, so Start leaves uploads off. Pasting a URL into a q.url()
column in the CMS is just a string — it does not upload bytes.
user.role === "appmanager" does not open the CMS in either mode. appmanager is the control plane — users, organizations, and subscriptions at /account/admin — and deliberately holds no tenant-data powers. Keeping those separable is the entire point of the appmanager/sysadmin split. An appmanager who is also a member of an organization gets in as that member, on the membership, not on the platform role.
Where the gate actually lives
The CMS shell is served to any signed-in caller. Authorization is decided in the SPA, which renders a screen naming the caller's state and what to do about it — a signed-in user who can't get in is told why, instead of being silently bounced.
That is a deliberate trade: the old gate answered a wrong-role caller with a 302 so it couldn't be used to probe roles. The shell is a generic prebuilt bundle with no project data in it, and being unable to distinguish a permissions problem from a broken deploy cost more than the deniability was worth.
The gates that carry real data:
/api/v1/schema— the metadata endpoint. Admitssysadmin, plus any caller with a membership whenaccess: "member". Otherwise403withCMS_ACCESS_DENIEDand a hint naming the requirement. Without the registry there is no CMS.custom_viewroutes — saved views follow whoever is admitted: thesysadmintier in"sysadmin"mode, org membership or sysadmin in"member"mode.- Account UI link — the profile CMS action is hidden for callers who wouldn't get in. For admitted members it activates the selected organization before opening the CMS.
Your resource API endpoints (/api/v1/<resource>) are not affected — they continue to use the per-resource read, create, update, delete, and upsert access rules you defined.
cms.access accepts "sysadmin" or "member". The retired "admin" and "user" spellings are rejected at compile time; migrate "admin" → "sysadmin" and "user" → "member".
What a blocked caller actually sees
The shell is served to any signed-in caller, and the SPA resolves one of six states — each its own screen naming what is true and what to do next. Nothing redirects: a signed-in user bounced elsewhere cannot tell a permissions problem from a broken deploy.
| State | Screen |
|---|---|
| Not signed in | Redirect to sign-in (the one redirect that remains — it is where you were going anyway) |
sysadmin, no tenant chosen | Tenant picker, filterable, with All organizations as a first-class choice |
sysadmin, tenant chosen | The CMS, scoped to that tenant |
appmanager, access: "sysadmin" | "The CMS shows organization data" → Go to admin |
Anyone else, access: "sysadmin" | "This CMS is restricted" — names the config value that would admit them |
access: "member", no membership | "You're not in any organization yet" → View invitations (an appmanager gets Go to admin instead — telling them to ask an admin for an invite when they are the admin is a dead end) |
access: "member", one membership | Auto-selected; straight in |
access: "member", several | Membership picker |
The decision is a pure function (resolveCmsAccess), so the table above is tested rather than described.
For a zero-membership sysadmin in explicit cross-tenant mode, scope lives in the browser and rides every request as ?organizationId= because Better Auth has no membership row to make active. Change it from the sidebar. A sysadmin who also holds memberships follows the ordinary membership flow instead; selecting a membership clears any older cross-tenant override. The sidebar shows the sole organization as static text and becomes a dropdown only when the user belongs to multiple organizations.
access: "sysadmin" without cms.sysadmin
A sysadmin can still pin a single tenant — ?organizationId= cross-tenant addressing is admitted for sysadmins unconditionally. The "All organizations" unfiltered view is what needs the firewall escape that cms.sysadmin: true emits.
Optional: Skip SPA rebuild
After the first compile, you can skip rebuilding the CMS SPA on subsequent compiles:
cms: { build: false }This is useful when you're iterating on API features and don't need to rebuild the CMS UI each time — it saves significant compile time. Set build: true (or omit it) when you need to update the CMS assets.
Optional: Custom output directory
cms: { outputDir: "my-custom-path/cms" }This changes where the compiled CMS assets are placed (relative to project root), instead of the default src/apps/cms/.
Next Steps
- Schema Registry — Understand the JSON format the compiler generates
- Connecting — Demo mode, live mode, CLI command, and auth modes
- Dashboard — Stats grid and feature navigation
- Table Views — Browse and Data Table view modes
- Custom Pages — Split-panel layouts, matching engine, and drag-drop
- Inline Editing — Spreadsheet-style editing and FK typeahead
- Security — How the CMS enforces all four security layers
- Actions — Custom actions with input forms, access filtering, and CMS metadata
- Schema Format Reference — Full TypeScript types for schema-registry.json
- Components Reference — All CMS components and their props