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/… on contract-v2 projects).
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: "admin" — only user.role === "appmanager" (the Better Auth platform control-plane tier) can reach /cms/*. When cms.sysadmin: true, user.role === "sysadmin" is also admitted. Other authenticated users are redirected to /account/profile. 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 gate — if any earlier layer is bypassed, the SPA still redirects the user to
/account/profile.
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 allow any authenticated user to access the CMS, opt out explicitly:
cms: { access: "user" }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.
Next Steps
- Table Views — Browse and Data Table view modes
- Security — How roles, guards, and masking work in the CMS