Overview#
Ledger is an editorial magazine and blog template for long-form writing, shown as The Ledger, a fictional independent magazine of essays about software, design, craft and attention. It has a featured front page, a long-form article layout (outline that follows your reading, reading progress, footnotes, pull quotes, code and an accessible chart), a searchable archive with topic filters, topic pages, an about page with the masthead, and a contact page, with a light and dark theme. This is the Next.js version (1.0.0): Next.js 16 (App Router), React 19, TypeScript and Tailwind CSS v4, exported as a static site.
Live demo: freeframe.dev/templates/ledger
Quick start#
Requirements: Node.js 24 and pnpm (corepack enable pnpm) or npm.
pnpm install
pnpm dev # http://localhost:3000| Command | What it does |
|---|---|
pnpm dev |
Development server at http://localhost:3000 |
pnpm build |
Static site in out/ |
pnpm start |
Serve out/ locally (npx serve out) |
pnpm lint |
ESLint (Next.js core-web-vitals and TypeScript rules) |
pnpm typecheck |
Generate route types (next typegen), then type-check with tsc (TypeScript 7) |
pnpm build:demo |
Build for /demos/nextjs/ledger into demo/ (the freeframe.dev demo; you can delete it) |
Pages#
| Route | File | Notable sections and features |
|---|---|---|
/ |
src/app/page.tsx |
Featured essay, topic chips with counts, latest six essays, a quote from the archive, short notes, most read, newsletter (#newsletter) |
/article |
src/app/article/page.tsx |
The example essay (ArticleView): outline, reading progress and time, chart, pull quote, code, footnotes, tags, sharing, author, keep reading |
/archive |
src/app/archive/page.tsx |
Live search and topic filters kept in the URL (ArchiveBrowser), essays by year, "/" shortcut |
/topics |
src/app/topics/page.tsx |
One card per topic with its count and latest essay, pitch call to action |
/about |
src/app/about/page.tsx |
Mission, numbers, writers, masthead, values, "Write for The Ledger" |
/contact |
src/app/contact/page.tsx |
Contact and pitch form (ContactForm), pitch guidelines, editor emails, newsletter |
| 404 | src/app/not-found.tsx |
"This page went to press without us" with an archive search |
One article page#
Like the HTML version, the template has one example article page, /article, which shows the featured essay; every
essay card links to it (ARTICLE_PATH in src/data/articles.ts). The page is built from data rather than written by
hand: src/app/article/page.tsx looks up the essay's listing in ARTICLES and its text in ESSAYS
(src/content/index.ts) and renders ArticleView. We kept a single route instead of generating one per essay with
generateStaticParams, because only one essay has a full text and the demo's addresses match the HTML version. See
Giving each essay its own page to switch.
Project structure#
src/
├── app/
│ ├── layout.tsx fonts, metadata, theme script, top strip, header, footer, toast, back-to-top
│ ├── page.tsx home
│ ├── <route>/page.tsx one folder per page
│ ├── not-found.tsx 404 page
│ ├── globals.css Tailwind import, colour tokens (light and dark), component classes, article typography
│ └── icon.svg favicon
├── components/
│ ├── SiteHeader.tsx masthead, menu, search link, theme switch, mobile menu (client)
│ ├── ThemeToggle.tsx light/dark switch (client)
│ ├── SiteFooter.tsx topic, publication and follow links
│ ├── ArticleCard.tsx essay card and grid
│ ├── ArticleView.tsx the article layout
│ ├── ArchiveBrowser.tsx archive search and filters (client)
│ ├── TableOfContents.tsx, ReadingProgress.tsx, ReadingTime.tsx, ShareButtons.tsx article features (client)
│ ├── Prose.tsx Footnote, PullQuote, Callout, CodeBlock for essay text
│ ├── InterruptionsChart.tsx the example essay's SVG chart
│ ├── NewsletterForm.tsx, ContactForm.tsx, FormSuccess.tsx forms (client)
│ ├── Toast.tsx toast() and the toast element (client)
│ ├── RevealObserver.tsx, BackToTop.tsx, Year.tsx, Icons.tsx
├── content/
│ ├── types.ts the Essay type
│ ├── index.ts ESSAYS: essays with a full text, by slug
│ └── the-case-for-slower-software.tsx the example essay
├── data/
│ ├── articles.ts ARTICLES, HOME (front page picks), ARTICLE_PATH, date helpers
│ ├── authors.ts AUTHORS, WRITERS, MASTHEAD
│ ├── topics.ts TOPICS, topicHref()
│ └── site.ts name, tagline, menu and footer links
├── lib/
│ ├── asset.ts prefixes public files with the base path
│ ├── metadata.ts pageMetadata(): title, description and Open Graph per page
│ ├── theme.ts THEME_KEY, theme labels and the boot script
│ ├── use-form-check.tsx form validation hook
│ └── style.ts cx() for class names
└── fonts/ Newsreader, Inter and JetBrains Mono (woff2) with their licences
public/img/ cover art, writer avatars, og-image.svg and og-image.pngOther files: next.config.ts (static export and base path), eslint.config.mjs, postcss.config.mjs,
tsconfig.json, template.json (gallery metadata), pnpm-workspace.yaml (marks the folder as a standalone project).
Customising#
Brand and colours#
Colours are CSS variables at the top of src/app/globals.css, once for light (:root) and once for dark (.dark).
@theme inline turns them into Tailwind utilities (bg-paper, text-muted, border-line, text-accent...), so every
utility follows the theme automatically:
| Variable | Light | Dark | Used for |
|---|---|---|---|
--paper |
#faf7f2 |
#14130f |
Page background |
--surface |
#ffffff |
#1c1b17 |
Cards, inputs |
--ink |
#1c1a17 |
#ece6da |
Text, dark bands, buttons |
--muted |
#69635a |
#a49d90 |
Secondary text |
--line |
#e6dfd3 |
#2d2b26 |
Hairlines |
--accent |
#b5442c |
#e57a56 |
Kickers, links, highlights |
--accent-soft |
#f6e4dc |
#3a2219 |
Callouts, success panels, inline code |
To change the accent, set both versions and check the contrast of accent text on --paper:
:root {
--accent: #2c5fb5;
--accent-soft: #e0e9f8;
}
.dark {
--accent: #7fa7ef;
--accent-soft: #1d2a40;
}Accent text on the inverted bands (.kicker.on-ink) uses the other theme's accent so it stays readable; update those
two values in globals.css too. The code block colours (.tok-c, .tok-k, .tok-s, .tok-f) and theme-color
(viewport in src/app/layout.tsx) are set separately.
Typography#
Headlines and essays use Newsreader (with italics), the interface uses Inter and code uses JetBrains Mono.
They're self-hosted from src/fonts/ with next/font/local in src/app/layout.tsx (the same Latin files Google Fonts
serves, declared the same way, so text wraps exactly like the HTML version) and exposed as --font-newsreader,
--font-inter and --font-jetbrains-mono, which --font-serif, --font-sans and --font-mono use in globals.css.
To swap one, put another woff2 in src/fonts/ and point localFont at it, or use next/font/google:
import { Source_Serif_4 } from "next/font/google";
const serif = Source_Serif_4({ subsets: ["latin"], variable: "--font-newsreader", display: "swap" });The article's typography (size, spacing, drop cap, pull quotes, code) is in the .prose-ledger rules of globals.css.
Content#
Essays are listed in ARTICLES in src/data/articles.ts, newest first:
{
slug: "the-long-way-round",
title: "The long way round",
dek: "Why the slowest route through a codebase is often the one worth documenting.",
topic: "Engineering",
author: "priya",
date: "2026-10-01",
minutes: 7,
cover: "/img/cover-arcs.svg",
},topic must be one of TOPICS (src/data/topics.ts) and author a key of AUTHORS (src/data/authors.ts); both
are type-checked. The home page, archive, topic counts and "latest" essays all update from this list. The front page's
picks are HOME in the same file: featured (the lead essay, also shown on /article), shortNotes, mostRead and
the archive quote. "Latest" is the six newest essays after the featured one.
Writers and editors are AUTHORS (name, role, avatar and an optional bio); WRITERS and MASTHEAD choose who appears
on the about page. Topics are TOPICS (name and description), in the order they appear on the home page, the topics
page and the footer. Menu and footer links are in src/data/site.ts.
Essay text#
An essay's full text is an Essay object (src/content/types.ts) in its own file under src/content/, registered in
ESSAYS in src/content/index.ts:
intro: the paragraphs before the first heading (give the first<p className="lede">for the drop cap).sections:{ heading, body }pairs. Each heading becomes an<h2>with an id ("A practical playbook"→#a-practical-playbook) and an entry in the outline.notes: footnotes, numbered from 1. Mark them in the text with<Footnote n={1} />; they link both ways.tags,related(slugs for "Keep reading"),coverAltandcoverCaption.
src/components/Prose.tsx has the building blocks: PullQuote, Callout, Footnote and CodeBlock (one array of
tokens per line, ["k", "const"] for keywords, ["s", "'text'"] for strings, ["c", "// note"] for comments and
["f", "name"] for functions).
Giving each essay its own page#
-
Move
src/app/article/page.tsxtosrc/app/article/[slug]/page.tsx, take the slug fromparamsand list every essay with a text:tsxexport function generateStaticParams() { return Object.keys(ESSAYS).map((slug) => ({ slug })); } -
Replace
ARTICLE_PATHwith a function that returns each essay's address, and use it in the cards (ArticleCard.tsx,ArchiveBrowser.tsx,src/app/page.tsx):tsexport const articleHref = (article: Article) => `/article/${article.slug}`; -
Only link cards to essays that have a text, or give every essay one: static export can only build listed slugs.
Images and artwork#
Covers (cover-*.svg, 3:2) and avatars (avatar-*.svg, square) are SVG files in public/img/; always reference them
through asset() (see Building and deploying). Replace them with photos of the same
proportions; the article page crops the cover to 16:9 on phones and 2:1 on wider screens. The social image is
public/img/og-image.png (1200×630), exported from og-image.svg. The favicon is src/app/icon.svg; Next.js links it
automatically.
Navigation, header and footer#
The menu is NAV in src/data/site.ts; SiteHeader renders it for desktop and mobile and marks the current page with
aria-current="page". The top strip (founding line, RSS and Newsletter links) is in src/app/layout.tsx. The footer's
topic links come from TOPICS, the others from FOOTER_PUBLICATION and FOOTER_FOLLOW; replace the "#"
placeholders with your feed and social profiles. To add a page, create src/app/<route>/page.tsx and add it to NAV.
Dark mode#
The theme is the dark class on <html>; Tailwind's dark: variant follows it (@custom-variant dark in
globals.css). A small script (themeScript in src/lib/theme.ts, inlined in <head> by the layout) applies it before
the page paints, so there's no flash: a stored "dark" or "light" wins, and anything else (no choice yet, or an
unexpected value) follows the system setting. It also gives the theme buttons their pressed state and label as soon as
the page is parsed. ThemeToggle switches the class and saves the choice.
- Start in dark mode regardless of the system: in
themeScript, change the condition befored.classList.add('dark')tot!=='light'. - Remove dark mode: delete
<ThemeToggle />fromSiteHeader.tsx, remove the.darkblock fromglobals.cssand thed.classList.add('dark')part ofthemeScript.
Features#
Archive search and filters#
ArchiveBrowser (client) on /archive: typing searches titles, summaries, writers and topics; the chips ("All" and
every topic that has essays, alphabetically) filter by topic (aria-pressed). The count ("4 essays") is a polite live
region, and an empty state offers "Clear search & filters", which also focuses the search box. The filters live in the
URL (/archive?topic=Design&q=calm), so filtered views can be shared and the home page's topic chips, the article's
tags and the 404 search link straight into them; on arrival the address is tidied (?topic=design becomes
?topic=Design, unknown topics are dropped). Press / anywhere on the page (outside a field) to jump to the
search box.
Article outline, progress and reading time#
On the article page, TableOfContents lists the essay's headings (in a collapsible "In this essay" box on phones and
tablets, and in the sticky sidebar on wide screens) and highlights the section you're reading (aria-current="true").
ReadingProgress fills the bar at the top of the window as you scroll through the essay. ReadingTime estimates the
reading time from the words a reader reads (230 words a minute): each text node is counted on its own, so words in
neighbouring tags never run together, and SVG text (the chart's title, description and labels), scripts, styles,
hidden elements and the notes are left out; captions and code count. The listing's minutes is shown until it's
measured.
Sharing and the toast#
ShareButtons (twice on the article page) builds X, LinkedIn and email links from the page's address and title, and
"Copy link" copies the address and shows "Link copied to clipboard" in the toast. Call toast("Your message") from
src/components/Toast.tsx anywhere in a client component to show your own; the toast is a polite status region, and
the back-to-top button moves above it.
Header, mobile menu and scroll reveals#
Below 1024px the menu button opens #mobile-menu (aria-expanded, label "Open menu"/"Close menu", icons swap);
Escape closes it and returns focus to the button, and it closes after choosing a link or when the window gets wider.
Elements with the reveal class fade in as they scroll into view (RevealObserver); they're only hidden while
JavaScript runs, and everything shows at once with reduced motion.
Back to top#
BackToTop appears after you scroll about one screen, scrolls back to the top (instantly with reduced motion) and
moves focus to <main>. Remove it by deleting <BackToTop /> in src/app/layout.tsx and the .back-to-top styles in
globals.css.
Forms#
| Form | Component | Validates |
|---|---|---|
| Newsletter (home, contact sidebar) | NewsletterForm (id="nl-email" / id="side-email") |
|
| Contact and pitches | ContactForm (on /contact) |
Name (2+ characters), email, subject, a message of at least 30 characters, the consent checkbox |
| 404 search | src/app/not-found.tsx |
Nothing: it opens /archive?q=… |
Both forms use useFormCheck() from src/lib/use-form-check.tsx: the browser's own constraints with friendly messages
that name the field (its data-label, e.g. "Please enter your email address."). A field is checked when it loses
focus (once it has a value) and on every change while it's invalid; each has an error line (<field id>-error)
linked with aria-describedby, and aria-invalid once checked. Submitting checks every field and focuses the first
problem; when everything is valid the success panel appears and is focused, and the form resets.
Nothing is sent. To receive submissions with a form service such as Formspree, pass an
onValid callback to useFormCheck, for example in ContactForm.tsx:
const { formProps, field, error, success } = useFormCheck((form) => {
fetch("https://formspree.io/f/your-form-id", {
method: "POST",
body: new FormData(form),
headers: { Accept: "application/json" },
});
});onValid runs before the form resets. To show the success panel only after the service answers, make onSubmit in
the hook async and await the request before setSent. Update the success texts, which say the form is a demo. Connect
NewsletterForm to your email provider the same way. If you remove the static export, you can post to a Route
Handler (e.g. src/app/api/contact/route.ts) or call a Server Action instead.
Saved data (localStorage)#
| Key | Value | Read by |
|---|---|---|
ledger-theme (THEME_KEY) |
"dark" or "light" |
themeScript (before paint) and ThemeToggle |
Only the exact values "dark" and "light" are used; anything else is ignored and the system setting applies. The
value is written when the reader uses the theme switch. To reset it, run localStorage.removeItem("ledger-theme") in
the browser console or clear the site's data. If storage is unavailable, the theme still follows the system.
SEO and sharing#
- Defaults are in
metadatainsrc/app/layout.tsx: a title template (%s — The Ledger), description and Open Graph image. - Each page exports
metadata = pageMetadata(title, description)fromsrc/lib/metadata.ts; the article page passes"article"as the Open Graph type. The home page sets its full title. metadataBaseisprocess.env.NEXT_PUBLIC_SITE_URL(falling back tosite.urlinsrc/data/site.ts). Set it when you build so the social image URL is absolute:
NEXT_PUBLIC_SITE_URL=https://www.your-magazine.com pnpm buildThe 404 page is marked noindex by Next.js. Consider adding src/app/sitemap.ts and src/app/robots.ts (both work
with static export), an RSS feed (a Route Handler such as src/app/feed.xml/route.ts with
export const dynamic = "force-static", built from ARTICLES, then point the RSS links at it), and
Article structured data (JSON-LD) on essay pages.
Accessibility#
Built in: a skip link and landmarks (the top strip is a labelled region), one h1 per page, visible focus rings,
aria-current in the navigation and the outline, a theme switch with aria-pressed and a label that says what it will
do, filter chips with aria-pressed, a live archive count, two-way footnote links, a titled and described SVG chart, a
focusable (scrollable) code block, form errors linked with aria-describedby and focus moved to the first error or to
the success panel, an accessible mobile menu, and reduced motion respected for reveals, transitions and scrolling.
Colours meet WCAG AA in both themes.
When editing, keep one h1 per page, label every input (and give it a data-label for the error messages), give
covers in essays a real coverAlt, and check contrast in both themes when you change colours.
Building and deploying#
pnpm build writes a static site to out/ (output: "export" in next.config.ts):
- Vercel: import the repository; no settings needed.
- Netlify / Cloudflare Pages: build command
pnpm build, publish directoryout. - Any static host: upload the contents of
out/.
To serve from a sub-folder, build with DEMO_BASE_PATH=/magazine pnpm build; next.config.ts passes it to basePath
and NEXT_PUBLIC_BASE_PATH, which asset() uses for files in public/. pnpm build:demo does this for the
freeframe.dev demo (demo/, which you can delete).
To use server features (Route Handlers, Server Actions), remove output: "export" and images.unoptimized from
next.config.ts and deploy to a Node-capable host such as Vercel.
TypeScript setup#
package.json installs @typescript/native (TypeScript 7, which provides tsc) and typescript as an alias of
@typescript/typescript6, because typescript-eslint and parts of the Next.js toolchain still need the TypeScript 6
API. When typescript-eslint supports TypeScript 7, replace both with typescript@7. eslint.config.mjs pins
settings.react.version, because eslint-plugin-react can't detect it under ESLint 10.
Troubleshooting#
- The build fails with
Unknown article "…". A slug inHOMEor an essay'srelatedlist doesn't match anyARTICLESentry; fix the typo. - A new essay fails to type-check.
topicmust be one ofTOPICSandauthora key ofAUTHORS. - A new topic has no archive chip. Chips are built from the topics essays use; add an essay in that topic.
- The outline is missing a heading. Only
sectionsheadings are listed; put the heading in its own section. - The theme flashes or ignores the switch. Keep
themeScriptin<head>and use the same key everywhere (THEME_KEY); clear an old value withlocalStorage.removeItem("ledger-theme"). - Images break under a sub-folder. Build with
DEMO_BASE_PATH, and always wrap public paths inasset(). generateStaticParamserrors after adding[slug]routes. Static export needs every slug listed (see Giving each essay its own page).- A form says it worked but nothing arrived. It's a demo until connected (see Forms).
License#
MIT: the full text is in LICENSE.txt, included in the download. You can use, change and sell sites built with this
template, including the SVG cover art and avatars. The fonts are licensed under the SIL Open Font License (see
src/fonts/). The Ledger and everyone in this template are fictional.