On this page
  1. Add the route
  2. Gate it with requireSession, requireEditor, or requireOwner
  3. A custom section with its own role
  4. Wire the auditSink
  5. Link it from the sidebar with adminNav
  6. Reach your own data
  7. Verify it
  8. 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 type { PageData } from './$types';

  let { data }: { data: PageData } = $props();
</script>

<h1 class="text-2xl font-semibold">Signups</h1>
<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>
<table class="table">
  <thead>
    <tr><th>Name</th><th>Email</th><th><span class="sr-only">Actions</span></th></tr>
  </thead>
  <tbody>
    {#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}
  </tbody>
</table>

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. The preceding DaisyUI classes (input, btn, table) are the same ones cairn builds the shell with, so a screen that reuses them needs no stylesheet of its own. OfficeList wraps this same header-plus-card shell for you, 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.editor 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 minimal shape, { locals: { editor } }, so they read straight off your route’s own load or action event. 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.editor 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.editor.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 with its own role

requireOwner answers one question: is this editor cairn’s owner? A section with its own authorization axis, a club committee seat, a paid membership tier, anything that is not cairn’s content role, needs a role model of its own, stored in the site’s own database and checked on the site’s own terms. The pattern below, worked from a production club-admin section, is the shape that scales past one screen to a whole /admin/club/** tree.

Start with a +layout.server.ts on the section’s root that resolves the site’s own role and refuses a signed-in editor who holds none:

// src/routes/admin/club/+layout.server.ts
import { error } from '@sveltejs/kit';
import type { LayoutServerLoad } from './$types';
import { requireSession } from '@glw907/cairn-cms/sveltekit';
import { getClubRole, resolveClubDb } from '$lib/club/roles.js';

export const load: LayoutServerLoad = async (event) => {
  const editor = requireSession(event);
  const db = resolveClubDb(event.platform?.env);
  const role = db ? await getClubRole(db, editor.email) : null;
  if (!role) error(403, 'Your account has no club role. Ask a club owner to grant one.');
  return { role };
};

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 role check inline, or an editor with no club role at all can still submit a POST directly to a URL they were never shown a link to. Writing that check by hand at the top of every action is exactly the boilerplate that produces a missed screen; the fix is a site-local wrapper that composes the engine’s adminAction with the section’s own role precondition once, so a new screen cannot forget it:

// src/lib/club/action.ts
import { fail } from '@sveltejs/kit';
import { adminAction } from '@glw907/cairn-cms/sveltekit';
import type { AdminActionContext, AdminActionEvent } from '@glw907/cairn-cms/sveltekit';
import { getClubRole, resolveClubDb, type ClubRole } from './roles.js';

interface ClubActionContext extends AdminActionContext {
  role: ClubRole;
}

/** Compose the engine's adminAction with the Club section's own role precondition. adminAction
 *  itself resolves the editor, verifies CSRF, and reads the form once; this wrapper adds the
 *  section's own role check on top, fail-closed, before the handler ever runs. */
export function clubAction<T>(
  handler: (args: { event: AdminActionEvent; form: FormData; ctx: ClubActionContext }) => Promise<T>,
  opts: { action: string; entity: string },
) {
  return adminAction(async ({ event, form, ctx }) => {
    const db = resolveClubDb(event.platform?.env);
    const role = db ? await getClubRole(db, ctx.editor.email) : null;
    if (!role) {
      ctx.audit({ action: opts.action, entity: opts.entity, detail: 'rejected: no club role' });
      return fail(403, { error: 'A club role is required.' });
    }
    return handler({ event, form, ctx: { ...ctx, role } });
  });
}

Every action under /admin/club/** wraps with clubAction instead of calling adminAction directly, the same way every load calls requireSession. A validation reject or a role refusal that returns fail() from inside a clubAction-wrapped handler owes adminAction no audit record for the rejection itself: a request that mutated nothing needs no audit trail of its own non-mutation. Record only the handler’s own domain-meaningful rejects, like the role check above. The exemption cuts one way: reject before you write. A handler that mutates and then returns fail() 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.auditSink 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). 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 { 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 waitUntil = event.platform?.context?.waitUntil?.bind(event.platform.context);
    if (db) event.locals.auditSink = createClubAuditSink(db, waitUntil);
  }
  return resolve(event);
};

export const handle = sequence(wireClubAuditSink, createAuthGuard());

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.editor, 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 adminNav, which adds an entry beside cairn’s own built-in group; a site that wants to arrange the whole sidebar, cairn’s own screens included, declares navLayout instead, covered in Organize your admin nav (the two are mutually exclusive). Either way the entry shape below is the same:

import type { AdminNavEntry } from '@glw907/cairn-cms/sveltekit';

const adminNav: AdminNavEntry[] = [{ label: 'Signups', icon: 'inbox', href: '/admin/signups' }];

That array is the value of adminNav on your adapter’s editor group, the same group nav and supportContact live under.

icon has to be one of the nine bundled Lucide names (AdminNavIcon: anchor, calendar, clipboard-list, list, users, package, inbox, table, wrench), 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:

adminNav icon "mail" is not one of anchor, calendar, clipboard-list, list, users, package, inbox, table, wrench
adminNav 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 custom admin-nav seam in the SvelteKit reference.

ownerOnly, and navLayout’s own declarative roles, only reach cairn’s own declared role vocabulary. A whole section gated by a site-owned role, the Club section above, say, needs its sidebar entry hidden from an editor who has no club role at all, a question neither can answer. navFilter, a per-request hook on createCairnAdmin and createContentRoutes, is that seam: it 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 { getClubRole, resolveClubDb } from './club/roles.js';
import type { ResolvedLayoutNode } from '@glw907/cairn-cms/sveltekit';
import type { Editor } from '@glw907/cairn-cms';
import type { ContentEvent } from '@glw907/cairn-cms/sveltekit';

async function filterClubNav(
  items: ResolvedLayoutNode[],
  ctx: { editor: Editor; event: ContentEvent },
): Promise<ResolvedLayoutNode[]> {
  const db = resolveClubDb(ctx.event.platform?.env);
  const role = db ? await getClubRole(db, ctx.editor.email) : null;
  return role ? items : items.filter((item) => item.label !== 'Club');
}

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: an editor without the role never sees “Club” in the sidebar, and never lands on a URL the section’s own +layout.server.ts guard would then refuse anyway. See ContentRoutesDeps 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 a role-gated section, sign in as an editor with no club role and confirm the sidebar hides the section entirely, then confirm typing the URL directly still returns a 403 from the layout guard. 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 is not enough.

requireSession and requireOwner document the two guard calls this guide used. adminAction documents the admin-scoped action wrapper the custom section composes. The custom admin-nav seam covers AdminNavEntry, AdminNavIcon, and the validated ResolvedNavEntry shape in full, and ContentRoutesDeps documents navFilter. Organize your admin nav covers navLayout, the seam for arranging the whole sidebar rather than adding one entry to it. CairnAdminShell, OfficeList, and CsrfField document the shell your screen renders inside, the header-plus-card frame a triage screen composes, and the field every one of its 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.editor typing in full.

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