On this page
  1. Declare the tree
  2. Start a section collapsed
  3. Override an engine screen’s icon
  4. Badge a nav entry with pending work
  5. The principles
  6. Scale it to your site
  7. Omission falls back; hiding is explicit
  8. Verify it
  9. Related reference

Organize your admin nav

Cairn’s default sidebar is flat: its own screens and whatever flat entries your site added, one unsectioned list, no group header. That’s a deliberate choice, not an unfinished one. A label costs a reader a category decision on every visit, and a zero-config sidebar is small enough that the decision never pays for itself. It stops being enough once your site adds several screens of its own, because at that point the flat list needs a shape, and the shape that serves the person signing in is what they do, not which screens shipped with cairn and which ones you built. navLayout lets you declare the whole sidebar, cairn’s own screens and yours interleaved, arranged around what your editors actually do. This guide covers the contract and the evidence behind the shape cairn recommends. It assumes Define an adapter and schema and Add a custom admin screen are already done, since the worked example below arranges a custom section alongside cairn’s own.

Declare the tree

navLayout is a plain-data array on the adapter’s editor group, the one seam for your sidebar. Each node is one of three kinds. An engine reference places one of cairn’s own screens by id. A site entry is a labeled, iconed link to one of your own /admin/ routes. A section groups a mix of both under one label:

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

const navLayout: NavLayout = [
  {
    label: 'Club',
    roles: ['owner'],
    children: [
      { label: 'Events', icon: 'calendar', href: '/admin/club/events' },
      { label: 'Members', icon: 'users', href: '/admin/club/members' },
    ],
  },
  {
    label: 'Content',
    children: [{ screen: 'posts' }, { screen: 'pages' }],
  },
  {
    label: 'Site',
    children: [
      { screen: 'media' },
      { screen: 'vocabulary' },
      { screen: 'settings', label: 'Site settings' },
      { screen: 'editors' },
    ],
  },
];

{ screen: 'posts' } places the posts concept’s own list and editor, its icon and href staying engine-owned; { screen: 'settings', label: 'Site settings' } places the built-in Settings screen under a relabel, so it never collides with a same-named screen of your own. roles: ['owner'] renders the Club section only for that role; swap it for whatever your own vocabulary names a club-admin audience once you’ve declared one with defineRoles (see Give a role its own admin area). roles takes any role name your vocabulary declares, since role names type as plain strings. Wire the tree onto the adapter next to your other editor-experience knobs:

// src/lib/cairn.config.ts
import { defineAdapter } from '@glw907/cairn-cms';

export const cairn = defineAdapter({
  // ...content, backend, email, rendering...
  editor: { navLayout },
});

Every node validates when the runtime composes: an unknown screen id, a screen referenced twice, a nested section, an empty section, an empty relabel, or a roles name outside your vocabulary all throw an actionable error naming the bad node, so a typo fails at server start instead of rendering a broken sidebar. A site entry inside the tree validates the same way a top-level entry does (the bundled icon allowlist, the built-in href collision); see the navLayout seam for the full contract.

roles is a visibility hint, not enforcement: it decides what renders in the sidebar, the same courtesy hidden: true is, and says nothing about what a direct URL visit is allowed to do. For a role that should actually be refused at the route, declare the access map instead (or alongside it): a site with a real multi-role authorization story reaches for the map, and roles stays for the lighter case of just steering what a role notices.

Start a section collapsed

A section renders open by default, the same as before navLayout existed. Declare collapsed: true to start it closed instead, for a section most editors open rarely:

{
  label: 'Site',
  collapsed: true,
  children: [{ screen: 'media' }, { screen: 'vocabulary' }, { screen: 'settings' }],
},

This only sets the starting state for a visitor who has never touched a section header. The cairn-admin-nav-collapsed cookie remembers each visitor’s own toggles once they touch any header, and from then on the cookie wins entirely, in both directions, even over a declared default. A section you add later, after a returning visitor’s cookie already exists, renders open regardless of its own collapsed declaration, since the cookie is authoritative from the moment it exists. There’s no way to force a section open or closed for a visitor who has already made their own choice, by design: a declared default is a first impression, not a standing override.

Override an engine screen’s icon

Every concept shares one document icon by default, so two dated concepts (Posts and, say, Bulletins) look identical in the sidebar at a glance. Give one an icon override, any name from the bundled NavIcon allowlist:

{ screen: 'bulletins', icon: 'megaphone' },

The override replaces only the icon; the door’s label and href stay engine-owned unless you also set label. An unknown name throws the same allowlist error a site entry’s own icon does.

Badge a nav entry with pending work

A site computes its own pending-work counts, such as an unreviewed signups queue, and the shell renders them as quiet pills on the matching nav entry. Declare an attention function beside your other content-routes dependencies:

// 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 { db } from './club/db.js';

export const runtime = composeRuntime({ adapter: cairn, siteConfig });
export const admin = createCairnAdmin(runtime, {
  attention: async ({ editor }) => {
    const pending = await db.assetRequests.countPendingFor(editor.role);
    return [{ href: '/admin/club/assets', count: pending, label: 'pending requests' }];
  },
});

The engine calls this once per request, after the sidebar is already resolved and filtered, and drops any item whose href doesn’t match a nav entry the current session can see, so a count never leaks to a role that can’t act on it. A zero or negative count renders no pill at all. A collapsed section’s header shows the sum of its visible children’s counts, computed live from the same items, and that header sum disappears once the section opens, since each child’s own pill is already visible. “Once per request” means every admin load and every client-side navigation between admin screens, so keep this function fast. A thrown error fails the whole shell load rather than degrading quietly, so catch a transient failure inside your own callback and return an empty array if you want the sidebar to render without its counts instead. See the attention seam for the full contract, including the accessibility shape (the count lives in the entry’s accessible name, not on the pill itself).

The principles

navLayout enforces structure. It has no opinion of its own about what a good sidebar looks like. These are cairn’s, each grounded in published usability research or a survey of comparable administrator tools rather than house taste:

  • Nouns, not verbs. Name a section after the objects it manages, such as Posts or Members, not the action of managing them, such as Write or Manage people. No study measures verb labels against noun labels for administrator tools specifically, but nothing contradicts the near-universal convention, and a noun-based sidebar is one less thing for a new editor to learn.
  • Flat until it hurts. Skip a section header below roughly eight to ten items; a flat list under that size reads fine unsectioned. Splitting one list into two costs a real, measured category decision on every visit (Omanson, Miller & Joseph 2014), and no study sets a clean threshold for a sidebar this short. Treat “eight to ten” as where practitioner convergence and that measured cost start to outweigh a flat scan, not as a studied number.
  • Group by editor workflow, when you do group. A section named after what your editors do, the Club section in the worked example, reads better to the person using it than one that mirrors your codebase or cairn’s own built-in versus custom split.
  • Content first; settings, roster, and help sink last. What an editor uses daily belongs before what they need only occasionally. Configuration and who-has-access are infrequent, cross-cutting concerns, and every comparable administrator tool trails them the same way.
  • Stable arrangement. One tree, filtered by roles, never rearranged per role beyond subtraction. Reordering a sidebar by usage measurably slows a returning user down; a fixed layout that only drops items for a role performs about as well as one that never varies at all.
  • Don’t over-hide. hidden: true retires a door for good; it isn’t a decluttering tool, and the command palette is an escape valve, never a substitute for visible nav. One controlled study of hidden and collapsed navigation found discoverability down more than 20% and desktop task completion at least 39% slower (NN/g hidden-navigation study).

Scale it to your site

Three sizes cover most sites:

  • Default scale, roughly eight items or fewer. Stay flat. This is cairn’s own zero-config sidebar, and it’s the guide’s own advice against grouping before grouping pays for itself.
  • One growing domain. Once your site adds a real section of its own, lead the sidebar with it and let cairn’s own screens trail behind in a second section, the shape the worked example earlier in this guide follows.
  • Several roles, many screens, 15 or more. Reach for the full arrangement: sections by editor workflow, roles gating each one, settings and roster sunk to the last section, and every colliding name relabeled. This is the shape a large, multi-role site needs and a small one doesn’t.

No aesthetic linting enforces any of this. validateNavLayout catches a structural mistake. The preceding principles are yours to apply.

Omission falls back; hiding is explicit

Adding one link to the sidebar doesn’t require declaring the rest of it. Say your whole reason for reaching for navLayout is one extra route:

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

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

That’s the whole declaration. Every one of cairn’s own screens it never references, every content concept plus Library, Tags, the nav-menu editor, Settings, Editors, and Help, still renders, in a trailing group after a divider, in cairn’s own screen order. A navLayout you declare doesn’t have to reference every one of cairn’s own screens: anything the tree never mentions falls back automatically, so a one-link site never enumerates the screens it isn’t touching.

The multi-section worked example earlier in this guide never references Help either, so Help lands there too, in the same foot slot the zero-config sidebar already reserves for it. This means an engine update that ships a new built-in screen surfaces on your sidebar instead of silently vanishing because your tree predates it.

To remove a door on purpose instead, mark it hidden: true:

{ screen: 'vocabulary', hidden: true }

The route stays live; only the sidebar link disappears. That’s deliberate: nav placement is never authorization. Hiding a screen from the sidebar doesn’t stop a signed-in editor from typing its URL directly. Gate the route itself with requireAccess (the recommended path once you’ve declared an access map), requireSession, requireEditor, requireOwner, or your own check, the way Add a custom admin screen covers. The roles visibility on a navLayout node is the same kind of courtesy hidden is: it decides what renders, not what’s allowed.

Verify it

Sign in as each role your tree gates and confirm the sidebar shows exactly the sections and items that role should see, in the order you declared. Confirm an unreferenced screen (Help, in the worked example) still renders in the trailing fallback group, and that a screen you mark hidden disappears from the sidebar but its route still answers when you type its URL directly as an editor who should see it.

The navLayout seam documents every node type, NavLayoutEntry and its icon allowlist, validateNavLayout, and resolveNavLayout in full, including collapsed and the icon override. ContentRoutesOptions navFilter is the per-request seam for a grant that depends on state outside cairn’s own role vocabulary, composed after every gate navLayout already applies. The attention seam documents the attention dep and AttentionItem in full. Restrict admin access by role covers defineAccess and requireAccess, the recommended path for enforcement rather than the roles visibility hint. Give a role its own admin area covers declaring a role vocabulary with defineRoles in full, the prerequisite for a roles list naming anything beyond owner and editor.

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