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.


Settings
Content the whole site shares lives in a singleton, a collection with exactly one record. See Site data and Singletons.
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:
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.mdThe seed uploads it, and keeps the home page so the menu can link to it:
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:
// 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:
rm -rf apps/backend/.data apps/backend/.uploads
pnpm devSettings in the dashboard now holds the header and the footer, each on its own tab:


Reading the settings
The site's layout reads the settings once, and hands each part the fields it shows. See Frameworks.
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:
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:
'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:
'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:
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:
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>
);
}/**
* 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>:
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:
'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:
/* 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:


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.
git clone --branch lesson-03-header-and-footer https://github.com/ohnejs/rayleigh-next rayleigh