On this page
  1. Add the route
  2. Gate it with requireSession, requireEditor, or requireOwner
  3. A custom section gated by the access map
  4. Declare the role and the access rule
  5. Gate the layout load
  6. Wire the auditSink
  7. Link it from the sidebar with navLayout
  8. Reach your own data
  9. Verify it
  10. Related reference

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.

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.

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.

Edit this page on GitHub(opens in a new tab)