On this page
  1. The route files plus the composer
  2. What the catch-all serves
  3. The actions vocabulary
  4. The guard and the ambient type
  5. The root layout must be chrome-free
  6. Why /healthz lives at the site root
  7. Per-route mounting (advanced)

The canonical admin mount

A cairn site mounts the whole /admin surface with one catch-all route pair plus one server composer. The engine’s createCairnAdmin facade serves every admin view through a single load and a single actions record, so the site restates no route table and wires no action names by hand. The showcase at examples/showcase is the working model of this shape; copy its files, not a guess at them.

This wiring assumes the site sets csrf: { checkOrigin: false } in svelte.config.js, since cairn’s guard owns CSRF for the admin through a double-submit token. See the deploy guide for that step.

The route files plus the composer

The catch-all route pair, reproduced from the showcase:

// src/routes/admin/[...path]/+page.server.ts
// The single-mount admin route: one catch-all serves every /admin view through the engine's
// load and actions. The composition (runtime, deps) lives in $lib/cairn.server.
import { admin } from '$lib/cairn.server.js';

// The admin must never be prerendered; a site that defaults to prerender=true would bake a
// build-time snapshot of a session-gated page.
export const prerender = false;

export const load = admin.load;
export const actions = admin.actions;
<!-- src/routes/admin/[...path]/+page.svelte -->
<script lang="ts">
  import { CairnAdmin } from '@glw907/cairn-cms/components';
  import type { AdminData } from '@glw907/cairn-cms/sveltekit';
  import { cairn } from '$lib/cairn.config.js';
  import type { ActionData } from './$types';

  let { data, form }: { data: AdminData; form: ActionData } = $props();
</script>

<CairnAdmin {data} {form} render={cairn.rendering.render} registry={cairn.rendering.components} icons={cairn.rendering.icons} />

The shared shell layout pair wraps the whole /admin subtree in cairn’s chrome. The catch-all +page.svelte shown earlier renders bare inside it: the chrome (the sidebar, the top bar, the command palette, the theme) moved out of CairnAdmin into this /admin/+layout, so every /admin/** route, the engine’s views and any custom screen a site adds, renders inside one shell. Reproduced from the showcase:

// src/routes/admin/+layout.server.ts
// The shared admin shell's load: the chrome (nav, user, theme, streamed pending count) for every
// /admin/** route, including a developer's own custom screens.
import { admin } from '$lib/cairn.server.js';

export const load = admin.shellLoad;
<!-- src/routes/admin/+layout.svelte -->
<script lang="ts">
  import { CairnAdminShell } from '@glw907/cairn-cms/components';
  import type { AdminShellData } from '@glw907/cairn-cms/sveltekit';
  import type { Snippet } from 'svelte';

  let { data, children }: { data: { shell: AdminShellData }; children: Snippet } = $props();
</script>

<CairnAdminShell data={data.shell}>{@render children()}</CairnAdminShell>

A site adds its own admin screen by dropping a concrete route under /admin/ (for example /admin/signups), which wins over the catch-all and renders inside this same shell. See Add a custom admin screen for the worked route.

The composer builds the runtime once, and every server route that needs it (the admin mount, /healthz) imports it rather than re-running composeRuntime per route:

// 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';

export const runtime = composeRuntime({ adapter: cairn, siteConfig });
export const admin = createCairnAdmin(runtime);

createCairnAdmin defaults the magic-link branding from the runtime’s siteName and sender, so most sites pass no deps at all. A site that does override something passes the grouped CairnAdminDeps bag: { auth: { branding?, send? }, tidy: { client?, timeoutMs? } }. The showcase reads markdown through a fake GitHub backend in development, which rides event.locals.backend from a fenced dev handle rather than through a dep. A deployed site connects the real backend and mints installation tokens on demand, so it passes no backend dep. See CairnAdminDeps for the full shape.

Keep the prerender = false line. The admin is session-gated, and a site that prerenders by default would otherwise try to bake a build-time snapshot of it; the explicit opt-out keeps the whole subtree request-time.

What the catch-all serves

The one route answers every admin URL. createCairnAdmin’s load parses event.url.pathname (never the rest param, so an encoded segment cannot confuse the split) and dispatches:

URLViewNotes
/adminindexRedirects to the first concept’s list.
/admin/loginloginThe magic-link request form. Public.
/admin/auth/confirmconfirmThe magic-link landing. Public.
/admin/<concept>listOne concept’s entries, with create, delete, and publish-all.
/admin/<concept>/<id>editThe entry editor.
/admin/editorseditorsThe owner-gated editor management.
/admin/navnavThe nav tree editor. A 404 unless the adapter configures editor.nav.
/admin/mediamediaThe media library.
/admin/settingssettingsThe tidy settings screen.
/admin/vocabularyvocabularyThe tag-vocabulary editor: add, rename, delete an unused tag, and seed from tags already in use.
/admin/helphelpThe Help home: getting started, the formatting reference, and the support hand-off.

Any other shape is a 404, and one trailing slash is tolerated. Logout has no URL of its own; the admin shell posts it as the named ?/logout action on whatever page the editor is on.

The actions vocabulary

actions covers the full admin vocabulary in one static record. Each named action parses the pathname the same way the load does and throws a 404 when the parsed view does not support it, so a save posted to a list URL refuses rather than misfiring:

ActionValid viewsDelegates to
requestloginthe magic-link request
confirmconfirmthe token confirm
logoutany parsed viewthe session logout
createlistthe entry create
saveedit, navthe entry save, or the nav save (404 without editor.nav)
publisheditthe entry publish
discardeditthe pending-edit discard
renameeditthe entry rename
deleteedit, listthe entry delete (id from the path, or from the form body on a list)
publishAlllist, edit, editors, navthe site-wide publish
saveVocabularyvocabularythe tag-vocabulary commit, with the cross-branch delete gate failing closed
addEditor, removeEditor, setRoleeditorsthe owner-gated editor management

The engine’s components post these names, so an action-adding release reaches a site through the version bump alone; there is no per-site action table to keep in sync.

The guard and the ambient type

The engine’s auth guard (createAuthGuard(), wired in hooks.server.ts) gates the whole /admin/* subtree before any load runs. The mount itself does no access control; the guard owns it. The guard sets event.locals.editor, and one line in src/app.d.ts types it: import '@glw907/cairn-cms/ambient'; (see the ambient types reference). The guard and the mount also read a set of Cloudflare bindings (the auth store, the email sender, the GitHub App credentials); type App.Platform.env by intersecting CairnPlatformBindings rather than restating each one by hand, so a binding a site forgets to wire fails at compile time:

// src/app.d.ts
import type { CairnPlatformBindings } from '@glw907/cairn-cms/sveltekit';
import '@glw907/cairn-cms/ambient';

declare global {
  namespace App {
    interface Platform {
      env: CairnPlatformBindings & { /* the site's own bindings */ };
    }
  }
}

A media-enabled site also intersects CairnMediaBindings, since MEDIA_BUCKET exists only when the adapter turns media on.

A site that already has a handle hook (for example, injecting a saved theme into the SSR’d <html data-theme>) keeps it by sequencing the engine guard after its own:

// src/hooks.server.ts
import { sequence } from '@sveltejs/kit/hooks';
import { createAuthGuard } from '@glw907/cairn-cms/sveltekit';
import { theme } from './theme-handle.js';

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

The guard owns /admin gating and runs last; the site’s hook runs first and sees every request.

The root layout must be chrome-free

The host root layout wraps every route, including /admin. If it renders site chrome (a nav, a footer, a width-constraining container) or imports the host’s app.css, that chrome wraps the admin, and the admin shell cannot fill the viewport. The admin self-styles and does not need the host’s CSS, so the fix is to keep the root layout bare and move all public chrome plus app.css into a URL-transparent (site) group.

src/routes/
  +layout.svelte        bare: {@render children()} and nothing else
  (site)/               URL-transparent group; the public URLs do not change
    +layout.svelte      imports app.css, renders the nav, <main>, and footer
    +page.svelte ...    the home page and the public pages, moved in
  admin/+layout.{server.ts,svelte}   the shared shell that wraps every /admin route
  admin/[...path]/      the catch-all mount; outside the chrome
  feed.xml/ sitemap.xml/ robots.txt/ healthz/   endpoints; no layout, stay at the root

Group folders are invisible in the URL, so moving the public pages into (site)/ changes no paths. Endpoints render no layout, so they stay at the root. The admin sits outside the group, so the host chrome never wraps it.

A dev-only guard in the admin backs this rule. In development, the admin shell and the login page walk their ancestor chain on mount, and when a width-constraining ancestor sits between the admin root and <body> they log one console.error that names the ancestor and points here. The guard compiles out of production and changes no rendering.

A known limitation this rule contains. The compiled admin stylesheet carries DaisyUI @keyframes and Tailwind @property rules that are document-global by CSS spec; a selector scope cannot bound them. They cause no collision because the sheet is code-split to the routes that import it, and only the admin roots import it, so it loads only on /admin, where this rule keeps the host’s CSS away. Two things preserve that boundary: keep app.css in the (site) group so it never loads on /admin, and do not import the engine’s admin components onto a host page.

Why /healthz lives at the site root

The auth guard gates every /admin/* path. A deploy health check has to be reachable without a session, so it cannot live under /admin. Mount it at the site root and call the engine’s healthLoad(event, runtime) (event first):

// src/routes/healthz/+server.ts
import { json } from '@sveltejs/kit';
import { healthLoad } from '@glw907/cairn-cms/sveltekit';
import { runtime } from '$lib/cairn.server.js';

export const prerender = false;  // see below
export const GET = async (event) => json(await healthLoad(event, runtime));

On a site that prerenders by default, the explicit prerender = false is required. Without it the endpoint prerenders at build time, when the GitHub App key is absent, freezing a permanent ok:false that also 404 responses at runtime. The admin mount carries its own prerender = false; this root endpoint needs its own opt-out.

Per-route mounting (advanced)

The per-surface factories behind the facade (createAuthRoutes, createContentRoutes, createEditorRoutes, createNavRoutes) remain public, so a site that needs to interpose on one surface, or to mount a single admin view inside its own shell, can wire routes by hand against them. That path trades the single mount’s stability for control: the site then owns its route tree and must track the action vocabulary across releases itself. The factory signatures, the view components’ named-action contracts, and worked per-route examples live in the SvelteKit reference.

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