Connecting
Run the CMS in demo mode with mock data or connect it to a live Quickback API.
Connecting
The CMS supports two modes: demo mode for development and testing, and live mode for connecting to a real Quickback API.
Demo Mode
When no VITE_QUICKBACK_API_URL is set, the CMS runs in demo mode:
- Uses a mock API client backed by
localStorage - Loads seed data from JSON files in
data/mock/ - Provides a role switcher in the header to test owner/admin/member access
- Simulates authentication sessions without a real backend
Demo mode is useful for prototyping your schema, testing guard behavior across roles, and verifying masking rules before deploying.
# No VITE_QUICKBACK_API_URL — CMS runs in demo modeMock Data
Place JSON files in data/mock/ matching your table names:
data/mock/
contact.json # Array of contact records
project.json # Array of project records
invoice.json # Array of invoice records
_meta.json # Mock session and org metadataEach file contains an array of records. The mock client loads them on startup and persists changes to localStorage.
Role Switcher
In demo mode, the header displays a role switcher dropdown allowing you to switch between owner, admin, and member roles in real time. This lets you verify:
- Which CRUD buttons appear per role
- Which form fields are editable
- Which masking rules apply
- Which actions are available
- Which views are accessible
Live Mode
Set VITE_QUICKBACK_API_URL to connect to a real Quickback API:
VITE_QUICKBACK_API_URL=https://api.example.comIn live mode, the CMS:
- Reads the Better Auth session from cookies
- Fetches the user's organization membership and role
- Makes real API calls to the Quickback backend for all CRUD operations
- Applies server-side security (firewall, guards, masking) in addition to client-side UI filtering
API Client Interface
The CMS communicates with the backend through a standard interface:
interface IApiClient {
list(table: string, params?: ListParams): Promise<ListResult>;
get(table: string, id: string): Promise<Record | null>;
create(table: string, data: Record): Promise<Record>;
update(table: string, id: string, data: Partial<Record>): Promise<Record>;
delete(table: string, id: string): Promise<void>;
executeAction(
table: string,
actionName: string,
recordId: string | null,
input: Record
): Promise<Record>;
listView(
table: string,
viewName: string,
params?: ListParams
): Promise<ListResult>;
}List Parameters
interface ListParams {
page?: number;
pageSize?: number;
sort?: string;
order?: "asc" | "desc";
search?: string;
filters?: Record<string, unknown>;
}List Result
interface ListResult {
data: Record[];
total: number;
page: number;
pageSize: number;
}Both the mock client and the live client implement this same interface. The CMS code is identical in both modes — only the underlying transport changes.
Server-Side Security
In live mode, all four Quickback security layers (firewall, CRUD access, guards, masking) are enforced server-side. The CMS hides unauthorized UI elements as a convenience, but the API rejects unauthorized requests regardless.
How the Client is Created
The CMS auto-creates the API client based on configuration:
- No
VITE_QUICKBACK_API_URL— Instantiates the mock client, loads seed data fromdata/mock/, and useslocalStoragefor persistence. VITE_QUICKBACK_API_URLis set — Instantiates the live client, reads the auth session cookie, and makes fetch requests to the Quickback API using the standard REST endpoints (/api/v1/{table}).
The client is provided via React context (ApiClientContext) and available throughout the component tree.
Embedded Mode (Recommended)
Set cms: true in your config to embed the CMS directly in your compiled Worker:
export default defineConfig({
name: "my-app",
cms: true,
// ...providers
});When you run quickback compile, the CLI:
- Builds the CMS SPA from source and places assets in
src/apps/cms/ - Adds
[assets]config towrangler.tomlwithrun_worker_first = true - Generates Worker routing to serve the CMS SPA
Run wrangler dev and the CMS is served at /cms/. API routes (/api/*, /auth/*, etc.) pass through to your Hono app. Same origin — no CORS, auth cookies work naturally. On a custom domain, the CMS is served at root (/).
Custom CMS Domain
Optionally serve the CMS on a separate subdomain:
cms: { domain: "cms.example.com" }This adds a custom domain route to wrangler.toml. Both api.example.com and cms.example.com point to the same Worker. The compiler auto-configures hostname-based routing and cross-subdomain cookies.
You can also restrict CMS access to admin users only:
cms: { domain: "cms.example.com", access: "admin" }When access: "admin" is set, the CMS is gated on user.role === "admin" at multiple layers:
- Account UI hides the "Go to CMS" button for non-admins.
- CMS SPA shell — the Worker returns
403(or redirects unauthenticated requests to login) before servingindex.htmlto a non-admin. A non-admin who types the CMS URL directly cannot load the app. /api/v1/schemareturns403 Admin access requiredto non-admin callers, so schema metadata can't be scraped by hitting the API directly.custom_viewCRUD is gated onuserRole: ["admin"]rather than org membership, so saved views can't be listed or modified without admin rights.- In-SPA gate — if any earlier layer is bypassed, the SPA still renders an "Access Denied" screen.
Your resource CRUD endpoints (the ones generated for your features) are unaffected — they continue to use the per-resource crud.access.roles you defined.
See Multi-Domain Architecture for details on multi-domain routing.
Multi-Tenant vs Single-Tenant
The CMS supports two authentication modes depending on your project's organization configuration.
Multi-Tenant (Default)
In multi-tenant mode, the CMS gates access behind organization membership:
- User logs in via Better Auth session
- CMS fetches the user's organization memberships
- If the user belongs to multiple organizations, an org selector is displayed
- Once an org is selected, the user's role within that org determines their CMS permissions
This is the default behavior for all Quickback projects.
Single-Tenant Mode
When your project disables organizations (features.organizations: false in config), the compiler sets singleTenant: true in the schema registry. In this mode:
- No org gate or org selector is shown
- The user's role is read directly from
user.roleon the session - All data is unscoped (no
organizationIdfiltering)
Single-tenant mode is useful for internal tools, personal projects, or apps that don't need multi-org isolation.
Next Steps
- Table Views — Browse and Data Table view modes
- Security — How roles, guards, and masking work in the CMS