Freeframe

Documentation

Ledger

v1.0.1Updated Oct 14, 2026An editorial magazine for long-form writing

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.

sh
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.png

Other 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:

css
: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:

tsx
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:

ts
{
  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"), coverAlt and coverCaption.

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#

  1. Move src/app/article/page.tsx to src/app/article/[slug]/page.tsx, take the slug from params and list every essay with a text:

    tsx
    export function generateStaticParams() {
      return Object.keys(ESSAYS).map((slug) => ({ slug }));
    }
  2. Replace ARTICLE_PATH with a function that returns each essay's address, and use it in the cards (ArticleCard.tsx, ArchiveBrowser.tsx, src/app/page.tsx):

    ts
    export const articleHref = (article: Article) => `/article/${article.slug}`;
  3. 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.

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 before d.classList.add('dark') to t!=='light'.
  • Remove dark mode: delete <ThemeToggle /> from SiteHeader.tsx, remove the .dark block from globals.css and the d.classList.add('dark') part of themeScript.

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") 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:

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 metadata in src/app/layout.tsx: a title template (%s — The Ledger), description and Open Graph image.
  • Each page exports metadata = pageMetadata(title, description) from src/lib/metadata.ts; the article page passes "article" as the Open Graph type. The home page sets its full title.
  • metadataBase is process.env.NEXT_PUBLIC_SITE_URL (falling back to site.url in src/data/site.ts). Set it when you build so the social image URL is absolute:
sh
NEXT_PUBLIC_SITE_URL=https://www.your-magazine.com pnpm build

The 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 directory out.
  • 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 in HOME or an essay's related list doesn't match any ARTICLES entry; fix the typo.
  • A new essay fails to type-check. topic must be one of TOPICS and author a key of AUTHORS.
  • 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 sections headings are listed; put the heading in its own section.
  • The theme flashes or ignores the switch. Keep themeScript in <head> and use the same key everywhere (THEME_KEY); clear an old value with localStorage.removeItem("ledger-theme").
  • Images break under a sub-folder. Build with DEMO_BASE_PATH, and always wrap public paths in asset().
  • generateStaticParams errors 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.

Search documentation

Search the guides and every template's documentation