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. "All Fields" plus custom views in the toolbar.
- Auto-form generation — Create and edit forms built from guards (createable/updatable fields).
- Display column auto-detection — FK labels resolved automatically from
name,title,label,code, and other common patterns.
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 compileThe 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 "Go to CMS" button is hidden for callers who wouldn't get in.
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.
Breaking in v0.62. cms.access was renamed from "admin" | "user", and both old values meant something different: "admin" admitted appmanager, and "user" admitted any authenticated caller — including one belonging to no organization, who then reached a CMS with nothing in it. The old spellings are rejected at compile time rather than aliased, because aliasing would preserve the old behaviour under a familiar name. Migrate "admin" → "sysadmin", "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.
Sysadmin scope lives in the browser and rides every request as ?organizationId= — it is not part of the session, because a sysadmin holds no membership row for Better Auth to make active. Change it from the sidebar.
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