Add a custom admin screen
A custom admin screen is an ordinary SvelteKit route dropped under /admin/. There’s no plugin
API to register against and no components folder cairn scans for you: the route is a plain
+page.server.ts and +page.svelte, and because it names a concrete path, SvelteKit’s own router
picks it over the engine’s [...path] catch-all whenever both could match the same URL. The
example below is the showcase’s own /admin/signups screen, a small list of newsletter signups
that lives entirely in the developer’s own D1 table and that cairn never reads. It assumes the
canonical single mount from The canonical admin
mount is already wired in. Keep examples/showcase/src/routes/admin/signups open alongside, since every snippet below is
that route, close to verbatim.
Two commitments frame everything in this guide. An extended cairn should still feel like one visually coherent system: your screen inherits the admin’s design grammar through the shell, the theme tokens, and the shared component recipes, so it reads as built in from the start rather than bolted on, and the design conventions in the components reference name the treatments to reach for. And your screen inherits the responsive craft the same way: the shell already composes its chrome at every width, and building from the documented recipes keeps a custom screen composed at phone and ultrawide widths.
The rest of this guide grows that one screen into a whole custom section, the shape a real site
proves once it has more than a screen or two of its own admin surface. The worked pattern below is
drawn from a production build for a membership club: a /admin/club/** section (events, classes,
members, assets) gated by a club-specific role, member-scale and outside cairn’s own capability
model. The code here describes that pattern; it is not the site’s own files, since a site’s role
model, database, and screens are always its own. A staff-scale role that only ever needs cairn’s
own three capability levels belongs on the declared role vocabulary
instead; a custom D1-backed role model like this one is for a role cairn was never meant to know
about (a large, dynamic membership, say, not a handful of staff).
Add the route
Drop a directory next to the engine’s own admin routes and give it a +page.server.ts:
// src/routes/admin/signups/+page.server.ts
import type { PageServerLoad, Actions } from './$types';
import { requireOwner } from '@glw907/cairn-cms/sveltekit';
import { fail } from '@sveltejs/kit';
interface SignupRow {
id: number;
name: string;
email: string;
}
export const load: PageServerLoad = async (event) => {
requireOwner(event);
const { results } = await event.platform!.env.APP_DB.prepare(
'SELECT id, name, email FROM signups ORDER BY id DESC',
).all<SignupRow>();
return { signups: results };
};
export const actions: Actions = {
create: async (event) => {
requireOwner(event);
const form = await event.request.formData();
const name = String(form.get('name') ?? '').trim();
const email = String(form.get('email') ?? '').trim();
if (!name || !email) return fail(400, { error: 'missing' });
await event.platform!.env.APP_DB.prepare(
'INSERT INTO signups (name, email) VALUES (?, ?)',
).bind(name, email).run();
return { created: true };
},
remove: async (event) => {
requireOwner(event);
const id = Number((await event.request.formData()).get('id'));
await event.platform!.env.APP_DB.prepare('DELETE FROM signups WHERE id = ?').bind(id).run();
return { removed: true };
},
};
and a matching +page.svelte:
<!-- src/routes/admin/signups/+page.svelte -->
<script lang="ts">
import { CsrfField } from '@glw907/cairn-cms/components';
import { PageHeader, AdminTable } from '@glw907/cairn-cms/admin-toolkit';
import type { PageData } from './$types';
let { data }: { data: PageData } = $props();
</script>
<PageHeader title="Signups" />
<form method="POST" action="?/create" class="my-4 flex gap-2">
<CsrfField />
<label class="sr-only" for="signup-name">Name</label>
<input id="signup-name" name="name" placeholder="Name" class="input input-bordered" />
<label class="sr-only" for="signup-email">Email</label>
<input id="signup-email" name="email" placeholder="Email" class="input input-bordered" />
<button class="btn btn-primary">Add</button>
</form>
<AdminTable density="sm" rowCount={data.signups.length}>
{#snippet header()}
<th scope="col">Name</th>
<th scope="col">Email</th>
<th scope="col"><span class="sr-only">Actions</span></th>
{/snippet}
{#snippet children()}
{#each data.signups as s (s.id)}
<tr>
<td>{s.name}</td>
<td>{s.email}</td>
<td>
<form method="POST" action="?/remove">
<CsrfField />
<input type="hidden" name="id" value={s.id} />
<button class="btn btn-ghost btn-xs">Delete</button>
</form>
</td>
</tr>
{/each}
{/snippet}
</AdminTable>
Nothing here mounts a layout of its own. The site’s shared /admin/+layout.svelte already wraps
the whole /admin/** subtree in
CairnAdminShell, so this page renders inside the
same sidebar, top bar, and theme as every built-in view.
PageHeader and
AdminTable, from the packaged
@glw907/cairn-cms/admin-toolkit subpath, are the same header and table shell cairn’s own admin
screens build with, so a custom screen reaches for them instead of hand-rolling a parallel header
or table chrome. The remaining DaisyUI classes (input, btn) are the same ones cairn builds the
shell with, so a form needs no stylesheet of its own.
OfficeList is the alternative header-plus-card shell
for a triage screen that wants both in one wrap, with an optional eyebrow naming the section a
screen belongs to; the Club section’s own screens below all pass eyebrow="Club".
Gate it with requireSession, requireEditor, or requireOwner
The engine’s auth guard already ran before this route’s load does, and it set
event.locals.cairnEditor for the whole /admin/* subtree, typed with no work on your part by the
one import '@glw907/cairn-cms/ambient'; line every site’s src/app.d.ts carries (see the ambient
types reference). Reading that identity, and refusing the request when it
isn’t good enough, is
requireSession,
requireEditor, and
requireOwner. All three take the same
CairnEvent shape, so a real SvelteKit load or
action event satisfies them structurally with no extra work on your part.
requireSession returns the signed-in editor, of any capability, or
redirects to /admin/login. requireEditor does the same, then also answers a none-capability
session with a 403. requireOwner goes further still, answering anything short of owner with a
403. The signups list is owner-only management, so every preceding load and action calls
requireOwner; a screen every editor should be able to use would call requireSession or
requireEditor instead, depending on whether a none-capability role should reach it.
A none-capability session, the third rung of cairn’s declared role
vocabulary, still authenticates like any other editor: it carries the
same populated, typed locals.cairnEditor and passes through this custom-route seam untouched.
Only cairn’s own content and roster surfaces refuse it, by calling requireEditor or
requireOwner themselves. Nothing here blocks a none-capability role from reaching your own
screen. You decide with whichever of the three preceding calls matches the screen, or your own
check on event.locals.cairnEditor.capability. Give a role its own admin
area walks that exact case end to end, and the
requireEditor reference states the none contract in
full.
The requireOwner(event) call is the server-side gate. A sidebar entry can hide a link from an editor (the
next section shows how), but hiding a link isn’t access control, and nothing stops an editor from
typing the URL directly. The requireOwner(event) call at the top of every load and action is what
actually turns that request away.
A custom section gated by the access map
requireOwner answers one question: is this editor cairn’s owner? A section with its own
authorization axis, a club committee seat, a named subset of staff cairn’s default owner/editor
pair doesn’t distinguish, needs its own declared role and its own access rule. The pattern
below, worked from a production club-admin section, is the shape that scales past one screen to
a whole /admin/club/** tree.
Declare the role and the access rule
Add the section’s role to the site’s vocabulary with
defineRoles, mapped to whichever capability fits (editor
here, since a club-admin edits but doesn’t manage the roster), then declare an access
map rule covering the section’s whole path and wire it to
createAuthGuard, the same two-places pattern
Restrict admin access by role walks through in full:
// src/lib/cairn.config.ts
import { defineAdapter, defineRoles } from '@glw907/cairn-cms';
export const roles = defineRoles({
owner: 'owner',
'club-admin': 'editor',
});
export const cairn = defineAdapter({
// ...content, backend, email, rendering...
roles,
});
// src/lib/cairn.access.ts
import { defineAccess } from '@glw907/cairn-cms';
import { roles } from './cairn.config.js';
export const access = defineAccess(roles, {
'/admin/club': ['club-admin'],
});
// src/hooks.server.ts
import { sequence } from '@sveltejs/kit/hooks';
import { createAuthGuard } from '@glw907/cairn-cms/sveltekit';
import { roles } from './lib/cairn.config.js';
import { access } from './lib/cairn.access.js';
export const handle = sequence(createAuthGuard({ roles, access }));
/admin/club matches every path underneath it by prefix (canReach’s deepest-prefix rule), so
one rule covers the section’s events, classes, members, and assets screens without repeating it
per route.
Gate the layout load
Every screen under the section calls
requireAccess at the top of its load, the same
predicate the section’s actions gate on below, so a page and its own form never disagree about
who’s admitted:
// src/routes/admin/club/+layout.server.ts
import type { LayoutServerLoad } from './$types';
import { requireAccess } from '@glw907/cairn-cms/sveltekit';
export const load: LayoutServerLoad = (event) => {
const editor = requireAccess(event); // denies every role the map doesn't name for /admin/club
return { editor };
};
A layout guard like this one protects loads only. SvelteKit dispatches a matched form action
directly, with no ancestor load run first, so this guard never runs before a POST to
/admin/club/events?/update. Every mutating action under the section needs the same check
inline, or a session the layout would refuse can still submit a POST directly to a URL it was
never shown a link to.
This is also where the guide’s two patterns start refusing differently, and the difference
matters once your own screen adds error handling. The requireOwner(event) call in the
preceding signups example throws
SvelteKit’s own error(); the framework renders the correct status through your +error.svelte
with nothing further to wire. adminAction’s own guards, the ones createSectionAction composes
below, throw the same SvelteKit-native shapes, a redirect() for a missing session, an error()
for a CSRF mismatch, so they need no handleError of your own either.
createSectionAction’s own authorization and database-binding checks return fail(...) instead,
which never throws at all and renders as inline form state on the page. See
Refusal channels for the full model.
Writing the access-map check by hand at the top of every action is exactly the boilerplate
createSectionAction exists to close: it
composes adminAction’s editor resolution, CSRF, and
audit contract with the same access-map check requireAccess runs, plus the section’s own
database binding, in one call:
// src/lib/club/action.ts
import { createSectionAction } from '@glw907/cairn-cms/sveltekit';
import type { D1Database } from '@cloudflare/workers-types';
import { resolveClubDb, type ClubEnv } from './roles.js';
export const clubAction = createSectionAction<ClubEnv, D1Database>({
resolveDb: (env: ClubEnv | undefined) => resolveClubDb(env),
});
// src/routes/admin/club/events/[id]/+page.server.ts
import { clubAction } from '$lib/club/action.js';
import type { Actions } from './$types';
export const actions: Actions = {
approve: clubAction(async ({ form, ctx }) => {
const id = String(form.get('id'));
await ctx.db.prepare('update event set approved = 1 where id = ?').bind(id).run();
ctx.audit({ action: 'approve', entity: 'event', entityId: id });
return { ok: true };
}, { action: 'approve', entity: 'event' }),
};
Every action under /admin/club/** wraps with clubAction instead of calling adminAction
directly, the same way every load calls requireAccess. A refused request never reaches the
handler: a missing binding, an unmapped path, or a role the map doesn’t admit each returns an
already-audited fail(), so the handler above owes an audit record only for the mutation it
actually performs. A handler that mutates and then returns fail() for its own domain reason
must still emit its own audit, because nothing rolls its writes back and the wrapper can’t see
them.
Wire the auditSink
ctx.audit (available on every adminAction-wrapped handler, clubAction included) always logs
one structured admin.action.audited record through the engine’s own logger. That is enough to
read in Workers Logs, but nothing persists it to a queryable table until the site wires
event.locals.cairnAuditSink itself: cairn ships the seam, not the storage. The
import '@glw907/cairn-cms/ambient' line already in your src/app.d.ts types the assignment
(see the ambient types reference).
cairn packages one implementation of that seam,
createD1AuditSink on
@glw907/cairn-cms/sveltekit: copy its bundled migration
(cp node_modules/@glw907/cairn-cms/migrations/0002_audit.sql migrations/, then wrangler d1 migrations apply) and wire the factory in hooks.server.ts, no hand-rolled sink module of your
own to write. The reference page carries the full contract: the required waitUntil, the
truncation maxima, and why a section using createSectionAction should also configure its rate
limit, since an authorization denial audits before the section’s own database binding is ever
read. Reach for a hand-rolled sink, like the Club section’s below, only when a section’s own
audit_log schema needs to differ from the packaged one.
Set it in hooks.server.ts, scoped to the section so the rest of /admin never resolves a binding
it has no use for:
// src/hooks.server.ts
import { sequence } from '@sveltejs/kit/hooks';
import type { Handle } from '@sveltejs/kit';
import { createAuthGuard } from '@glw907/cairn-cms/sveltekit';
import { roles } from '$lib/cairn.config.js';
import { access } from '$lib/cairn.access.js';
import { resolveClubDb } from '$lib/club/roles.js';
import { createClubAuditSink } from '$lib/club/audit-sink.js';
const wireClubAuditSink: Handle = ({ event, resolve }) => {
if (event.url.pathname.startsWith('/admin/club')) {
const db = resolveClubDb(event.platform?.env);
const ctx = event.platform?.ctx;
// The bind is required: an unbound `ctx.waitUntil` throws "Illegal invocation" in workerd.
const waitUntil = ctx ? ctx.waitUntil.bind(ctx) : undefined;
if (db) event.locals.cairnAuditSink = createClubAuditSink(db, waitUntil);
}
return resolve(event);
};
export const handle = sequence(wireClubAuditSink, createAuthGuard({ roles, access }));
AdminActionAuditSink is synchronous ((record) => void); adminAction calls it without
awaiting. On Cloudflare Workers, a fire-and-forget write started inside a handler is not
guaranteed to finish before the response returns and the Worker’s execution context is torn down,
so an un-awaited insert can silently drop the row. Thread the write through
waitUntil so the
platform keeps it alive past the response, the way the sink below does; a persist failure should
never fail the user’s action that triggered it, so this only logs loudly on error rather than
throwing:
// src/lib/club/audit-sink.ts
import type { D1Database } from '@cloudflare/workers-types';
import type { AdminActionAuditRecord, AdminActionAuditSink } from '@glw907/cairn-cms/sveltekit';
export function createClubAuditSink(
db: D1Database,
waitUntil?: (promise: Promise<unknown>) => void,
): AdminActionAuditSink {
return (record: AdminActionAuditRecord) => {
const write = db
.prepare('INSERT INTO audit_log (actor, action, entity, entity_id, detail) VALUES (?1, ?2, ?3, ?4, ?5)')
.bind(record.actor, record.action, record.entity, record.entityId ?? null, record.detail ?? null)
.run()
.catch((err: unknown) => console.error('admin/club: audit_log insert failed', err));
waitUntil?.(write);
};
}
Outside a real Cloudflare runtime (a bare unit test, say), waitUntil is undefined, and the sink
still runs, just without that extension; a test asserting on the sink’s own call does not need one.
Link it from the sidebar with navLayout
A sidebar entry is validated data on your adapter’s editor group. It does not register the
route; the file already did that. This section covers navLayout, the one seam for your sidebar,
covered in full in Organize your admin nav. A site entry inside the
tree is a plain, labeled, iconed link:
import type { NavLayout } from '@glw907/cairn-cms/sveltekit';
const navLayout: NavLayout = [{ label: 'Signups', icon: 'inbox', href: '/admin/signups' }];
That array is the value of navLayout on your adapter’s editor group, the same group nav and
supportContact live under. Every one of cairn’s own screens the tree never mentions still renders,
in a trailing fallback group; see Omission falls back; hiding is
explicit for the mechanism in
full, including the same single-entry case as the array above.
icon has to be one of the bundled Lucide names
(NavIcon lists the full allowlist), and href has to be a
path no built-in view already owns. Cairn validates both when it builds the admin routes at server
start, so a typo fails loudly at boot instead of rendering a broken or shadowing link:
navLayout: icon "envelope" is not one of anchor, banknote, bell, calendar, clipboard-list, file-pen, files, graduation-cap, image, inbox, key-round, life-buoy, list, list-ordered, mail, megaphone, menu, package, puzzle, send, settings, shield-check, table, tags, users, users-round, wrench
navLayout: href "/admin/media" collides with cairn's built-in "media" view; choose an unclaimed /admin/<segment>
Set ownerOnly: true on an entry to hide it from a signed-in editor whose resolved capability isn’t
owner, whatever their role name. That flag only decides what the sidebar renders. It changes
nothing about what the route itself allows. The
full seam, including the validated ResolvedNavEntry shape the shell
actually renders, is the navLayout seam
in the SvelteKit reference.
The Club section built so far needs no separate navFilter call to hide itself from a
non-club-admin editor: since it’s gated by the access map, resolveNavLayout
reads the same map and drops it from the sidebar for any role the map doesn’t name, and
navLayout’s own declarative roles (see Organize your admin nav)
reaches the same declared vocabulary. navFilter earns its keep for a criterion cairn’s role
vocabulary can’t express at all, a feature your own database turns on per site rather than a
role name. Say the club only sometimes tracks boats: a per-request hook on
createCairnAdmin and createContentRoutes receives the
already-arranged, already-gated top-level nodes (sections and loose entries, cairn’s own screens
included when the site declares navLayout) plus the signed-in editor, and returns the nodes to
render:
// src/lib/cairn.server.ts
import { composeRuntime } from '@glw907/cairn-cms';
import { createCairnAdmin } from '@glw907/cairn-cms/sveltekit';
import { cairn, siteConfig } from './cairn.config.js';
import { clubFeatureEnabled, resolveClubDb } from './club/roles.js';
import type { ResolvedLayoutNode } from '@glw907/cairn-cms/sveltekit';
import type { Editor } from '@glw907/cairn-cms';
import type { CairnEvent } from '@glw907/cairn-cms/sveltekit';
async function filterClubNav(
items: ResolvedLayoutNode[],
ctx: { editor: Editor; event: CairnEvent },
): Promise<ResolvedLayoutNode[]> {
const db = resolveClubDb(ctx.event.platform?.env);
const boatsEnabled = db ? await clubFeatureEnabled(db, 'boats') : false;
return boatsEnabled ? items : items.filter((item) => item.label !== 'Boats');
}
export const runtime = composeRuntime({ adapter: cairn, siteConfig });
export const admin = createCairnAdmin(runtime, { navFilter: filterClubNav });
Hiding the link this way is a courtesy, not a gate: pair it with a guard inside the Boats screen’s
own load (requireAccess, plus your own feature check) so a direct URL still refuses when the
feature is off. See ContentRoutesOptions for the full
navFilter signature, and Organize your admin nav for arranging
cairn’s own screens alongside a section like this one.
Reach your own data
event.platform.env carries whatever bindings your wrangler.jsonc declares. Add your own D1
database next to the engine’s, the same way the showcase adds
APP_DB next to AUTH_DB:
// wrangler.jsonc
"d1_databases": [
{ "binding": "AUTH_DB", "database_name": "your-site-auth", "database_id": "…" },
{ "binding": "APP_DB", "database_name": "your-site-app", "database_id": "…" }
]
Intersect its type onto App.Platform.env in src/app.d.ts, next to the engine’s own
CairnPlatformBindings intersection (see the
guard and the ambient type for the
full shape). From there, event.platform!.env.APP_DB is exactly the binding the preceding load
and actions already used. Cairn’s engine never reads, migrates, or validates this table. It’s
yours the same way any other Cloudflare binding on your Worker is yours,
and a custom screen is the ordinary way to give editors a form in front of it instead of a raw D1
console.
A migration that adds a foreign key must land its referenced table first, and remote D1 enforces
that where a local test double often does not. A migration that adds REFERENCES some_table on a
column, before some_table exists on the real database, passes every unit test written against a
fake D1 (nothing in a fake enforces referential integrity) and then fails every write on the actual
deployed database. Sequence your own migrations so a REFERENCES target always lands before the
edge that points at it, and run at least one real write against a scratch D1 database before
trusting a schema change, not only the doubles your unit tests use.
Verify it
Sign in to /admin as an owner and open /admin/signups directly (or click the sidebar entry, if
you added one). Add a row, then delete it. Sign in as a non-owner editor and try the same URL: the
requireOwner call returns a 403 instead of rendering the list.
For the Club section, sign in as an editor without the club-admin role and confirm the sidebar
hides the section entirely, then confirm typing /admin/club directly still returns a 403 from
the layout load. Submit a form action directly (curl, or a saved request) as that same editor,
bypassing the sidebar and the page entirely, and confirm clubAction refuses it too: the layout
guard alone isn’t enough. Sign in as a club-admin and confirm both the load and the action admit
it.
Related reference
requireOwner and
requireAccess document the two guard calls this guide
used. adminAction documents the admin-scoped action
wrapper createSectionAction, documented in full,
composes. defineRoles and Access map
document the declarations the Club section builds on, and Restrict admin access by
role walks through wiring them in full. The navLayout
seam covers
NavLayoutEntry, NavIcon, and the validated ResolvedNavEntry shape in full, and
ContentRoutesOptions documents navFilter.
Organize your admin nav covers arranging the whole sidebar, from
one added link to a full multi-section tree.
CairnAdminShell documents the shell your screen
renders inside. The admin toolkit documents PageHeader and
AdminTable, the packaged header and table components this guide’s screen builds with, and
OfficeList documents the alternative header-plus-card
frame a triage screen composes in one wrap. CsrfField
documents the field every one of this guide’s forms needs.
The canonical admin mount
covers the route pair and layout this guide assumed were already in place, and the ambient types
reference covers the locals.cairnEditor typing in full.