Search pages, headings and code across the docs, examples and tutorials.

to navigateto open

Lesson 03

Header and footer

Move Rayleigh's logo, menu and footer links into a settings record in the CMS, read it in a Next layout, and add a dark mode that never flashes.

Start from the previous lesson's code

The Rayleigh home page with the logo, the Portfolio menu item and the theme switch in the header

Settings

Content the whole site shares lives in a singleton, a collection with exactly one record. See Site data and Singletons.

apps/backend/collections/Settings.ts
import { defineCollection, field } from 'ohnejs';

// The site-wide settings your header and footer read: the logo, the menu and the footer links.
// A singleton holds exactly one record, so the dashboard opens it straight away.
// See https://ohne.dev/docs/cms/website#site-data
export default defineCollection({
  singleton: true,
  // Anyone may read the settings, and only an editor may change them.
  api: { read: 'public', update: true },
  fields: {
    logo: field('image', {
      description: 'Drawn in one dark color. The dark theme shows it inverted.',
    }),
    menu: field('repeater', {
      fields: {
        label: field('text'),
        // A link to a page follows it when its slug changes.
        link: field('link', { collections: ['Pages'] }),
      },
      layout: [{ row: ['label', 'link'] }],
    }),
    copyright: field('text', { nullable: true }),
    social: field('repeater', {
      fields: {
        icon: field('select', {
          choices: [
            { value: 'github', label: 'GitHub' },
            { value: 'x', label: 'X' },
            { value: 'discord', label: 'Discord' },
            { value: 'website', label: 'Website' },
          ],
        }),
        // Without `collections`, a link holds an address only.
        link: field('link'),
        title: field('text', {
          nullable: true,
          description: 'Shown when a visitor hovers the icon.',
        }),
      },
      layout: [{ row: ['icon | 10rem', 'link', 'title'] }],
    }),
  },
  dashboard: {
    icon: 'settings',
    layout: [
      {
        tabs: [
          { label: 'Header', fields: ['logo', 'menu'] },
          { label: 'Footer', fields: ['copyright', 'social'] },
        ],
      },
    ],
  },
});

The content

Copy the logo and its credits from this lesson's tag:

Terminal
curl -sL https://github.com/ohnejs/rayleigh-next/archive/refs/tags/lesson-03-header-and-footer.tar.gz \
  | tar -xzf - --strip-components=1 \
    rayleigh-next-lesson-03-header-and-footer/apps/backend/seed/images/logo.svg \
    rayleigh-next-lesson-03-header-and-footer/apps/backend/seed/CREDITS.md

The seed uploads it, and keeps the home page so the menu can link to it:

apps/backend/boot/seed.tslines 26-37
  const logo = await upload('logo.svg', 'Rayleigh');

  const portfolio = [];
  for (const { title, directory, items } of galleries) {
    const gallery = [];
    for (const [name, caption] of items) {
      gallery.push({ image: await upload(`${directory}/${name}.webp`, caption), caption });
    }
    portfolio.push({ block: 'Portfolio' as const, fields: { title, gallery } });
  }

  const home = await query('Pages').createOrThrow({

Then it fills Settings:

apps/backend/boot/seed.tslines 59-76
  // A singleton always has its one record, so the seed updates it instead of creating it.
  await query('Settings').update({
    logo,
    menu: [{ label: 'Portfolio', link: { collection: 'Pages', record: home.UUID } }],
    copyright: '© 2026 Rayleigh. Built with ohne.',
    social: [
      {
        icon: 'github',
        link: { url: 'https://github.com/ohnejs', newTab: true },
        title: 'ohne on GitHub',
      },
      {
        icon: 'website',
        link: { url: 'https://ohne.dev/examples/rayleigh', newTab: true },
        title: 'Rayleigh on ohne.dev',
      },
    ],
  });

Stop pnpm dev, delete the data and start again, then create your admin account once more:

Terminal
rm -rf apps/backend/.data apps/backend/.uploads
pnpm dev

Settings in the dashboard now holds the header and the footer, each on its own tab:

The Settings record in the dashboard, with the logo upload and the Portfolio menu item on its Header tab

Reading the settings

The site's layout reads the settings once, and hands each part the fields it shows. See Frameworks.

apps/web/app/[[...slug]]/layout.tsx
import { Footer } from '../../components/Footer';
import { Header } from '../../components/Header';
import { ohne } from '../../lib/ohne';

// Every page of the site shares this header and footer, the not-found page too.
// `ohne.read` reads the `Settings` singleton once, and the read is cached like the page's.
export default async function SiteLayout({ children }: LayoutProps<'/[[...slug]]'>) {
  const settings = await ohne.read('Settings');
  return (
    <>
      <Header logo={settings.logo} menu={settings.menu} />
      {children}
      <Footer copyright={settings.copyright} social={settings.social} />
    </>
  );
}

The logo is now the upload:

apps/web/components/Logo.tsx
import type { Upload } from '../ohne';

// The logo is an upload in `Settings`, so an editor can replace it in the dashboard.
export function Logo({ logo }: { logo: Upload | null }) {
  if (!logo) return null;
  return (
    <img
      className="logo"
      src={logo.url}
      width={logo.width ?? undefined}
      height={logo.height ?? undefined}
      alt={logo.description ?? 'Rayleigh'}
      decoding="async"
    />
  );
}

The header opens a full-screen menu on small screens, so it becomes a client component:

apps/web/components/Header.tsx
'use client';

import Link from 'next/link';
import { usePathname } from 'next/navigation';
import { useEffect, useState } from 'react';

import type { Records } from '../ohne';

import { DarkModeSwitch } from './DarkModeSwitch';
import { Logo } from './Logo';
import { Menu } from './Menu';

/**
 * Props for `Header`.
 */
export interface HeaderProps {
  /**
   * The logo, from `Settings`.
   */
  logo: Records['Settings']['logo'];

  /**
   * The menu items, from `Settings`.
   */
  menu: Records['Settings']['menu'];
}

export function Header({ logo, menu }: HeaderProps) {
  const [open, setOpen] = useState(false);
  const path = usePathname();

  useEffect(() => setOpen(false), [path]);

  useEffect(() => {
    // The open menu covers the page, so the page behind it stops scrolling.
    document.documentElement.classList.toggle('scroll-lock', open);
    if (!open) return;
    window.scrollTo({ top: 0, behavior: 'smooth' });
    const close = (event: KeyboardEvent) => event.key === 'Escape' && setOpen(false);
    addEventListener('keydown', close);
    return () => removeEventListener('keydown', close);
  }, [open]);

  return (
    <header className="container header">
      <Link href="/" className="header__home" aria-label="Rayleigh">
        <Logo logo={logo} />
      </Link>
      <Menu menu={menu} open={open} onNavigate={() => setOpen(false)} />
      <DarkModeSwitch />
      <button
        type="button"
        className="header__burger"
        aria-controls="menu"
        aria-expanded={open}
        title={open ? 'Close menu' : 'Open menu'}
        onClick={() => setOpen(!open)}
      >
        <span />
        <span />
      </button>
    </header>
  );
}

The menu marks the current page, and a dot follows the pointer:

apps/web/components/Menu.tsx
'use client';

import { linkHref } from '@ohnejs/client';
import { OhneLink } from '@ohnejs/next/client';
import { usePathname } from 'next/navigation';
import { type CSSProperties, useEffect, useRef, useState } from 'react';

import type { Records } from '../ohne';

/**
 * Props for `Menu`.
 */
export interface MenuProps {
  /**
   * The menu items, from `Settings`.
   */
  menu: Records['Settings']['menu'];

  /**
   * Whether the mobile menu is open.
   */
  open: boolean;

  /**
   * Called when a visitor follows a link, so the mobile menu closes.
   */
  onNavigate: () => void;
}

export function Menu({ menu, open, onNavigate }: MenuProps) {
  const path = usePathname();
  const items = useRef<(HTMLLIElement | null)[]>([]);
  const [dot, setDot] = useState<number | null>(null);

  // `linkHref` turns a link field into its address, so a post at `/blog/<slug>` still marks Blog.
  const active = menu.findIndex(({ link }) => {
    const href = linkHref(link);
    return href === path || (href !== undefined && href !== '/' && path.startsWith(`${href}/`));
  });

  // The dot sits under the active item, and follows the pointer while it is over the menu.
  // It needs the item's position, which only the browser knows, so the server renders none.
  function moveDot(index = active) {
    setDot(active < 0 ? null : (items.current[index]?.offsetLeft ?? null));
  }

  useEffect(() => {
    moveDot();
    const reset = () => moveDot();
    addEventListener('resize', reset);
    return () => removeEventListener('resize', reset);
  }, [active]);

  if (!menu.length) return null;
  return (
    <nav id="menu" className={open ? 'menu menu--open' : 'menu'}>
      <ul className="menu__list" onMouseLeave={() => moveDot()}>
        {menu.map(({ UUID, label, link }, i) => (
          <li
            key={UUID}
            ref={(item) => {
              items.current[i] = item;
            }}
          >
            <OhneLink
              link={link}
              className={i === active ? 'menu__link menu__link--active' : 'menu__link'}
              aria-current={i === active ? 'page' : undefined}
              onMouseEnter={() => moveDot(i)}
              onClick={onNavigate}
            >
              {label}
            </OhneLink>
          </li>
        ))}
      </ul>
      {dot !== null && (
        <span className="menu__dot" style={{ '--dot-x': `${dot}px` } as CSSProperties} />
      )}
    </nav>
  );
}

The footer shows the copyright line and the social links:

apps/web/components/Footer.tsx
import { OhneLink } from '@ohnejs/next/client';

import type { Records } from '../ohne';

import { IconBrandDiscord } from './icon/BrandDiscord';
import { IconBrandGithub } from './icon/BrandGithub';
import { IconBrandX } from './icon/BrandX';
import { IconWorld } from './icon/World';

// The `icon` select in `Settings` names one of these.
const icons = {
  github: IconBrandGithub,
  x: IconBrandX,
  discord: IconBrandDiscord,
  website: IconWorld,
};

// The names of the icons, for a link without a title.
const names = { github: 'GitHub', x: 'X', discord: 'Discord', website: 'Website' };

/**
 * Props for `Footer`.
 */
export interface FooterProps {
  /**
   * The copyright line, from `Settings`.
   */
  copyright: Records['Settings']['copyright'];

  /**
   * The social links, from `Settings`.
   */
  social: Records['Settings']['social'];
}

export function Footer({ copyright, social }: FooterProps) {
  return (
    <footer className="container footer">
      {copyright && <p className="footer__copyright">{copyright}</p>}
      {social.length > 0 && (
        <ul className="footer__social">
          {social.map(({ UUID, icon, link, title }) => {
            const Icon = icons[icon];
            return (
              <li key={UUID}>
                <OhneLink
                  link={link}
                  className="footer__social-link"
                  title={title ?? undefined}
                  aria-label={title ?? names[icon]}
                >
                  <Icon />
                </OhneLink>
              </li>
            );
          })}
        </ul>
      )}
    </footer>
  );
}

The website link gets a globe icon:

apps/web/components/icon/World.tsx
import type { SVGProps } from 'react';

export function IconWorld(props: SVGProps<SVGSVGElement>) {
  return (
    <svg aria-hidden="true" fill="none" height="12" viewBox="0 0 12 12" width="12" {...props}>
      <path
        d="M10.5 6C10.5 8.48528 8.48528 10.5 6 10.5C3.51472 10.5 1.5 8.48528 1.5 6C1.5 3.51472 3.51472 1.5 6 1.5C8.48528 1.5 10.5 3.51472 10.5 6Z"
        stroke="currentColor"
        strokeWidth="0.75"
      />
      <path
        d="M1.75 4.5H10.25M1.75 7.5H10.25M6 1.5C4.75 2.75 4.125 4.25 4.125 6C4.125 7.75 4.75 9.25 6 10.5C7.25 9.25 7.875 7.75 7.875 6C7.875 4.25 7.25 2.75 6 1.5Z"
        stroke="currentColor"
        strokeLinecap="round"
        strokeLinejoin="round"
        strokeWidth="0.75"
      />
    </svg>
  );
}
apps/web/lib/theme.ts
/**
 * The two themes a visitor can pick.
 */
export type Theme = 'light' | 'dark';

const KEY = 'theme';

// Runs in the page's `<head>`, before the first paint, so the page never flashes the wrong theme.
// It follows the system setting until the visitor picks a theme, then keeps their pick.
export const themeScript = `(() => {
  const media = matchMedia('(prefers-color-scheme: dark)');
  const apply = () => {
    let theme = null;
    try { theme = localStorage.getItem('${KEY}'); } catch {}
    document.documentElement.dataset.theme =
      theme === 'light' || theme === 'dark' ? theme : media.matches ? 'dark' : 'light';
  };
  apply();
  media.addEventListener('change', apply);
})();`;

/**
 * The theme the page shows right now.
 */
export function currentTheme(): Theme {
  return document.documentElement.dataset.theme === 'dark' ? 'dark' : 'light';
}

/**
 * Shows `theme` and remembers it for the next visit.
 * A browser that blocks storage still switches, and forgets the pick on reload.
 */
export function setTheme(theme: Theme): void {
  document.documentElement.dataset.theme = theme;
  try {
    localStorage.setItem(KEY, theme);
  } catch {}
}

The root layout runs that script in <head>:

apps/web/app/layout.tsxlines 5-16
import { themeScript } from '../lib/theme';
import './styles/site.css';

// The title of a page that has none of its own, like the not-found page.
export const metadata: Metadata = { title: 'Rayleigh' };

export default function Layout({ children }: LayoutProps<'/'>) {
  return (
    // The theme script sets `data-theme` before React hydrates, so React must not warn about it.
    <html lang="en" suppressHydrationWarning>
      <head>
        <script dangerouslySetInnerHTML={{ __html: themeScript }} />

The switch replaces the placeholder button:

apps/web/components/DarkModeSwitch.tsx
'use client';

import { useLayoutEffect, useRef } from 'react';

import { currentTheme, setTheme, themeScript } from '../lib/theme';
import { IconMoon } from './icon/Moon';
import { IconSun } from './icon/Sun';

// The server cannot know a visitor's theme, so it renders the same button for both.
// CSS picks the icon from the `data-theme` the theme script set, so the page never flashes.
export function DarkModeSwitch() {
  const turns = useRef<number | null>(null);

  // Next renders a missing page in the browser, so the theme script in `<head>` never ran: run it here.
  useLayoutEffect(() => {
    if (document.documentElement.dataset.theme) return;
    document.head.append(
      Object.assign(document.createElement('script'), { textContent: themeScript }),
    );
  }, []);

  return (
    <button
      type="button"
      className="theme-switch"
      aria-label="Toggle dark mode"
      title="Toggle dark mode"
      onClick={(event) => {
        const theme = currentTheme();
        // Each click turns the sun and the moon half a circle further, starting from the current theme.
        turns.current = (turns.current ?? (theme === 'dark' ? 1 : 0)) + 1;
        event.currentTarget.style.setProperty('--turns', String(turns.current));
        setTheme(theme === 'dark' ? 'light' : 'dark');
      }}
    >
      <span className="theme-switch__orbit">
        <IconSun className="theme-switch__sun" />
        <IconMoon className="theme-switch__moon" />
      </span>
    </button>
  );
}

Styles

The tokens gain a dark theme, and the layout the menu and the footer links:

apps/web/app/styles/tokens.css
/* Every color, font and size the other stylesheets use. The dark theme swaps the colors only. */
/* Breakpoints, widest first: 1199px (small laptop), 1023px (tablet), 767px (phone). */
:root {
  --scheme: light;
  --heading: #010203;
  --copy: #234345;
  --accent: #268baa;
  --border: #e2e4e8;
  --dim: #59797b;
  --page: #fff;
  --ink: #010203;
  --overlay: rgb(1 2 3 / 0.7);
  --backdrop: rgb(1 2 3 / 0.8);
  --menu-backdrop: rgb(255 255 255 / 0.75);
  --badge: #234345;
  --badge-icon: #fff;

  --font-copy: Lato, ui-sans-serif, system-ui, sans-serif;
  --font-heading: Poppins, ui-sans-serif, system-ui, sans-serif;

  --container: 73.75rem;
  --content: 48.75rem;
  --gutter: 3rem;
  --section: 5.75rem;
  --radius: 0.375rem;
  --ease: cubic-bezier(0.4, 0, 0.2, 1);
  --fast: 150ms;
  --slow: 300ms;
}

/* A visitor who asks for less motion gets every change at once, a theme switch included. */
@media (prefers-reduced-motion: reduce) {
  :root {
    --fast: 0ms;
    --slow: 0ms;
  }
}

/* The theme script in the page's `<head>` sets `data-theme` before the first paint. */
:root[data-theme='dark'] {
  --scheme: dark;
  --heading: #fff;
  --copy: rgb(255 255 255 / 0.8);
  --border: rgb(255 255 255 / 0.1);
  --page: #010203;
  --menu-backdrop: rgb(1 2 3 / 0.8);
  --badge: #fff;
  --badge-icon: #010203;
}

@media (max-width: 1199px) {
  :root {
    --gutter: 2rem;
  }
}

@media (max-width: 767px) {
  :root {
    --gutter: 1.5rem;
  }
}

Narrow the window below 1024 pixels, and the menu moves behind a button:

Rayleigh on a phone with the full-screen menu open over the page

Edit the menu under Settings in the dashboard, and the header follows.

Next, About page adds a page with new blocks.

Checkpoint

The code at this lesson

Every lesson ends at a tag. Clone it to start from here, or compare it with yours.

Terminal
git clone --branch lesson-03-header-and-footer https://github.com/ohnejs/rayleigh-next rayleigh
Browse on GitHub