Connecting
How the CMS resolves its API base URL and authenticates against a Quickback API.
Connecting
The CMS always talks to a real Quickback API. There is no mock client, no seed-data mode, and no role switcher — the signed-in user's role comes from their Better Auth organization membership. What varies is only which API it points at.
Resolving the API URL
Every per-project value resolves in this order:
window.__QUICKBACK_RUNTIME— the runtime blob a Quickback-generated worker injects into the HTML shell- The baked
VITE_*env from the Vite build - A built-in default
For the API base URL specifically, the default chain ends at
window.location.origin, so a CMS served from the same origin as the API needs no
configuration at all.
Set VITE_QUICKBACK_API_URL when the API lives somewhere else:
VITE_QUICKBACK_API_URL=https://api.example.comRuntime config takes precedence
When a Quickback-generated worker serves the CMS (unified /cms, custom domain, or dev hostnames), it injects the project's values into the HTML shell as window.__QUICKBACK_RUNTIME — apiUrl, accountUrl, cmsAccess, cmsSysadmin, pinnedOrganizationId, enableRealtime. Every per-project field resolves as runtime blob → baked VITE_* env → default, so a single generic CMS build (no .env) served by the worker behaves identically to a per-project baked build. Baked builds keep working unchanged.
Once connected, the CMS:
- Reads the Better Auth session from cookies
- Fetches the user's organization membership and derives their role (
owner,admin, ormember) from it - Makes 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 single interface, implemented by the HTTP client:
type Record_ = Record<string, unknown>;
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_,
action?: { standalone?: boolean; path?: string; method?: string }
): Promise<Record_>;
listView(
table: string,
viewName: string,
params?: ListParams
): Promise<ListResult>;
}List Parameters
interface ColumnFilter {
op: "eq" | "ne" | "like" | "in" | "gt" | "gte" | "lt" | "lte";
value: string;
}
interface ListParams {
page?: number;
pageSize?: number;
sort?: string;
order?: "asc" | "desc";
search?: string;
/** Per-column filters keyed by column name. */
filters?: Record<string, ColumnFilter>;
}Each ColumnFilter maps 1:1 to the API's flat query-param form — { op: 'like', value: 'engineer' } becomes ?title.like=engineer.
List Result
interface ListResult {
data: Record_[];
total: number;
page: number;
pageSize: number;
}Server-Side Security
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 client is constructed once from the resolved apiUrl and provided via React
context (ApiClientContext), available throughout the component tree. It reads
the Better Auth session cookie and calls the standard REST endpoints
(/api/v1/{table}, or /api/v2/… when contract.routes is "v2").
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.
CMS access defaults to access: "sysadmin" — only user.role === "sysadmin" (the cross-tenant data-plane tier) can use /cms/*. Set access: "member" to also admit anyone holding an organization membership. user.role === "appmanager" opens the CMS in neither mode: it is the control-plane tier and belongs at /account/admin. The gate is enforced at multiple layers:
- Account UI hides the "Go to CMS" button for callers outside those roles.
- CMS SPA shell — the Worker returns
403(or redirects unauthenticated requests to login) before servingindex.htmlto a non-operator. A non-operator who types the CMS URL directly cannot load the app. /api/v1/schemareturns403 Platform operator access requiredto callers outside those roles, so schema metadata can't be scraped by hitting the API directly.custom_viewroutes are gated onuserRole: ["appmanager"]rather than org membership, so saved views can't be listed or modified without platform-control-plane rights.- In-SPA screen — a signed-in caller who isn't admitted is served the shell and shown a screen naming what they'd need. There is no wrong-role redirect.
Your resource API endpoints (the ones generated for your features) are unaffected — they continue to use the per-resource read, create, update, delete, and upsert access rules you defined.
To let organization members in as well as sysadmins:
cms: { access: "member" }See Access gate for the full model — including why appmanager opens the CMS in neither mode.
See Multi-Domain Architecture for details on multi-domain routing.
Default Org Mode vs Pinned Organization Mode
The CMS supports two organization flows depending on your project's config.
Default Org Mode
By default, 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 Quickback projects with no pinned org.
Pinned Organization Mode
When your project sets features.pinnedOrganizationId, the CMS keeps org-backed auth but fixes the active org at runtime. In this mode:
- No org selector is shown
- The org gate is bypassed because the active org is known up front
- The user's
rolesstill come from membership in the pinned org - Org-scoped data remains scoped by
organizationId
Pinned organization mode is useful for internal tools, blogs, and one-customer deployments that still want org-backed roles and schema conventions without runtime org switching.
File uploads need valid R2 credentials
The CMS does not upload profile pictures or organization logos — those are
Account UI, gated on account.fileUploads. They only work with
managed R2 and the R2_ACCOUNT_ID /
R2_ACCESS_KEY_ID / R2_SECRET_ACCESS_KEY secrets. Start apps leave that
flag off: a browser deploy can create the bucket, but it cannot mint those
credentials. A q.url() field in the CMS stores a string; it does not
upload to R2.
Next Steps
- Table Views — Browse and Data Table view modes
- Security — How roles, guards, and masking work in the CMS