Freeframe

Documentation

Ledger

v1.0.1Updated Nov 11, 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 Nuxt version (1.0.0): Nuxt 4, Vue 3, TypeScript and Tailwind CSS v4, generated as a static site.

Live demo: freeframe.dev/templates/ledger

Quick start#

Requirements: Node.js 24 and pnpm (corepack enable pnpm).

sh
pnpm install
pnpm dev          # http://localhost:3000
Command What it does
pnpm dev Development server at http://localhost:3000
pnpm generate Static site in .output/public/
pnpm preview Serve the last build locally
pnpm build Node.js server build in .output/ (pages still prerendered)
pnpm lint ESLint (@nuxt/eslint flat config)
pnpm typecheck nuxt prepare, then type-check with vue-tsc
pnpm build:demo Build for /demos/nuxt/ledger into demo/ (the freeframe.dev demo; you can delete it)

Pages#

Route File Notable sections and features
/ app/pages/index.vue Featured essay, topic chips with counts, latest six essays, a quote from the archive, short notes, most read, newsletter (#newsletter)
/article app/pages/article.vue The example essay: outline, reading progress and time, chart, pull quote, code, footnotes, tags, sharing, author, keep reading
/archive app/pages/archive.vue Live search and topic filters kept in the URL, essays by year, "/" shortcut
/topics app/pages/topics.vue One card per topic with its count and latest essay, pitch call to action
/about app/pages/about.vue Mission, numbers, writers, masthead, values, "Write for The Ledger"
/contact app/pages/contact.vue Contact and pitch form (ContactForm), pitch guidelines, editor emails, newsletter
404 app/error.vue "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 app/data/articles.ts). Its header, cover, author and related essays come from the data files; the essay's text is written in app/pages/article.vue. See Giving each essay its own page to change that.

Project structure#

app/
├── app.vue                 <NuxtLayout><NuxtPage /></NuxtLayout>, with the page key (see "Links to the same page")
├── layouts/default.vue     skip link, header, reading progress (essay page), page, footer, toast, back-to-top
├── pages/                  one file per page (index.vue, article.vue, archive.vue, topics.vue, about.vue, contact.vue)
├── error.vue               404 page
├── components/
│   ├── SiteHeader.vue      top strip, masthead, menu, search link, theme switch, phone menu
│   ├── ThemeToggle.vue     light/dark switch
│   ├── SiteFooter.vue, FooterLink.vue   topic, publication and follow links, copyright year
│   ├── ArticleCard.vue     essay card ("Latest", "Keep reading")
│   ├── ReadingProgress.vue, ShareButtons.vue   article features
│   ├── NewsletterForm.vue, ContactForm.vue, FormSuccess.vue   forms
│   ├── BackToTop.vue
│   └── ArrowIcon.vue, CheckIcon.vue, SearchIcon.vue
├── composables/
│   ├── useFormCheck.ts     form validation
│   ├── usePageSeo.ts       title, description, canonical and Open Graph per page
│   ├── useCurrentPage.ts   aria-current for menu links
│   ├── useAsset.ts         prefixes files in public/ with the base path
│   └── useYear.ts          the copyright year
├── data/
│   ├── articles.ts         ARTICLES, HOME (front page picks), ARTICLE_PATH, date helpers
│   ├── authors.ts          AUTHORS, WRITERS, MASTHEAD
│   ├── topics.ts           TOPICS, topicHref(), topicCount(), topicLatest()
│   └── site.ts             name, year, menu and footer links
├── utils/                  safeStorage, theme (THEME_KEY), toast(), makeRef()
├── plugins/
│   ├── reveal.client.ts    scroll reveals
│   ├── same-page.client.ts links to the page already showing
│   └── stable-build.server.ts   reproducible builds
├── router.options.ts       query strings written like the HTML version
└── assets/
    ├── css/main.css        fonts, Tailwind import, color tokens (light and dark), component classes, article typography
    └── fonts/              Newsreader, Inter and JetBrains Mono (woff2) with their licenses
public/img/                 cover art, writer avatars, favicon.svg, og-image.svg and og-image.png

Other files: nuxt.config.ts (base path, head, theme script, prerendered routes, Tailwind), eslint.config.mjs, tsconfig.json, template.json (gallery metadata), pnpm-workspace.yaml (marks the folder as a standalone project).

Customizing#

Brand and colors#

Colors are CSS variables in app/assets/css/main.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 main.css too. The code block colors (.tok-c, .tok-k, .tok-s, .tok-f) and the theme-color meta tags (app.head in nuxt.config.ts) 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 app/assets/fonts/ and declared with @font-face at the top of main.css (the same Latin files Google Fonts serves, so text wraps exactly like the HTML version); --font-serif, --font-sans and --font-mono in @theme use them. To swap one, put another woff2 in app/assets/fonts/, change its @font-face rule and the family name in @theme. The article's typography (size, spacing, drop cap, pull quotes, code) is in the .prose-ledger rules of main.css.

Content#

Essays are listed in ARTICLES in app/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 (app/data/topics.ts) and author a key of AUTHORS (app/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 app/data/site.ts. The about page's numbers and values are STATS and VALUES at the top of app/pages/about.vue.

Essay text#

The example essay's text is plain markup inside <div class="prose-ledger" data-article> in app/pages/article.vue:

  • The first paragraph has class="lede" for the drop cap.
  • Each <h2> becomes an entry in the outline ("In this essay"), which is built from the essay's headings once the page has loaded. Give headings an id ("A practical playbook" → id="a-practical-playbook"); one without an id gets one made from its text. A heading with data-toc-skip is left out.
  • Footnotes are <sup><a href="#fn1" id="ref1" aria-describedby="notes-label">1</a></sup> in the text and <li id="fn1"> in the "Notes" list, which links back to #ref1.
  • Pull quotes are <blockquote class="pull">, notes <aside class="callout">, and code <pre tabindex="0"><code> with tok-c, tok-k, tok-s and tok-f spans for comments, keywords, strings and functions.

The tags, related essays (related) and the "More from Jonah" link are in the same file.

Giving each essay its own page#

  1. Move app/pages/article.vue to app/pages/article/[slug].vue, read the slug with useRoute().params.slug and look up the essay with article(slug). Keep each essay's text in its own component (for example app/components/essays/TheCaseForSlowerSoftware.vue) and pick it by slug.

  2. Replace ARTICLE_PATH with a function that returns each essay's address, and use it in ArticleCard.vue, archive.vue and index.vue:

    ts
    export const articleHref = (article: Article) => `/article/${article.slug}`;
  3. List every essay's route in routes in nuxt.config.ts, so nuxt generate prerenders it.

Images and artwork#

Covers (cover-*.svg, 3:2) and avatars (avatar-*.svg, square) are SVG files in public/img/; data files name them without a leading slash (img/cover-sun.svg) and pages draw them through useAsset(), which adds the base path (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 public/img/favicon.svg, linked in nuxt.config.ts.

The menu is NAV in app/data/site.ts; SiteHeader.vue renders it for desktop and phones and marks the current page with aria-current="page". The top strip (founding line, RSS and Newsletter links) is at the top of SiteHeader.vue. 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 app/pages/<route>.vue, add it to NAV and to routes in nuxt.config.ts.

Dark mode#

The theme is the dark class on <html>; Tailwind's dark: variant follows it (@custom-variant dark in main.css). A small script (themeScript in nuxt.config.ts, inlined in <head>) 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 button its pressed state and label as soon as the page is parsed. ThemeToggle.vue 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.vue, remove the .dark block from main.css and the d.classList.add('dark') part of themeScript.

Features#

Archive search and filters#

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; they're read once the page has loaded (the generated page lists every essay, so the archive reads without JavaScript too), and 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 the outline lists the essay's headings (in a collapsible "In this essay" box on phones and tablets, which closes when you pick a heading, and in the sticky sidebar on wide screens) and highlights the section you're reading (aria-current="true"). ReadingProgress.vue fills the bar at the top of the window as you scroll through the essay. The reading time is estimated from the words a reader reads (230 words a minute): each text node is counted on its own, so words in neighboring 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.vue (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") (app/utils/toast.ts, auto-imported) anywhere in the browser to show your own; the toast is a polite status region, and the back-to-top button moves above it.

Header, phone 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 (app/plugins/reveal.client.ts); they're only hidden while JavaScript runs, and everything shows at once with reduced motion. Don't put reveal on an element with a :class binding: Vue would drop the is-visible class the plugin adds.

In the HTML version every link loads a page. Here links move between pages without a reload, so app/plugins/same-page.client.ts and the page key in app/app.vue make a link to the page already showing (the Archive link while you search the archive, a footer topic link on the archive) start that page afresh at the top.

Back to top#

BackToTop.vue 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 app/layouts/default.vue, the component and the .back-to-top styles in main.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 app/error.vue Nothing: it opens /archive?q=…

Both forms use useFormCheck() from app/composables/useFormCheck.ts: 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. The contact form's panel shows a short reference (LE- and four characters, made in the browser by makeRef() in app/utils/reference.ts; show your form service's own reference instead if it returns one) and promises a reply within a week, like the intro on the contact page: change both if yours differs. The newsletter panels ask readers to look out for a confirmation email, which suits a double opt-in mailing list.

Nothing is sent. To receive submissions with a form service such as Formspree, send the form in the callback you pass to useFormCheck, for example in ContactForm.vue:

ts
const f = useFormCheck((form) => {
  reference.value = makeRef("LE");
  fetch("https://formspree.io/f/your-form-id", {
    method: "POST",
    body: new FormData(form),
    headers: { Accept: "application/json" },
  });
});

The callback runs before the form resets. To show the success panel only after the service answers, make submit in the composable async and await the request before setting sent. Connect NewsletterForm.vue to your email provider the same way. With pnpm build (a server build) you can post to a Nuxt server route instead (server/api/contact.post.ts).

Saved data (localStorage)#

Key Value Read by
ledger-theme (THEME_KEY) "dark" or "light" themeScript (before paint) and ThemeToggle.vue

Only the exact values "dark" and "light" are used; anything else is ignored and the system setting applies. The value is written through safeStorage (app/utils/safeStorage.ts) 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 blocked, the switch still works for the current page and the theme follows the system on the next one.

SEO and sharing#

  • Each page calls usePageSeo({ title, description }), which sets <title>, the meta description, og:title, og:description, og:type, og:site_name, og:image, a summary_large_image Twitter card and a canonical link. Titles and descriptions match the HTML version.

  • The canonical URL, og:url and the absolute og:image URL start with runtimeConfig.public.siteUrl in nuxt.config.ts, which reads SITE_URL (default https://theledger.example). Set it when you build:

    sh
    SITE_URL=https://www.your-magazine.com pnpm generate
  • The 404 page passes noindex: <meta name="robots" content="noindex"> and no canonical URL.

  • Consider a sitemap, an RSS feed built from ARTICLES (a server route with pnpm build, or a file written at build time; then point the RSS links at it) and Article structured data (JSON-LD with useHead()) on essay pages.

Accessibility#

Built in: a skip link and landmarks (the top strip is a labeled 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 phone menu, and reduced motion respected for reveals, transitions and scrolling. Colors 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 cover images in essays real alt text, and check contrast in both themes when you change colors.

Building and deploying#

sh
pnpm generate     # static site in .output/public/
pnpm preview      # serve it locally

nuxt generate prerenders every route listed in routes in nuxt.config.ts as <page>.html (archive.html, linked as /archive), plus 404.html from app/error.vue. Next to each page is a folder with its _payload.json, which Nuxt loads when you move between pages. Upload .output/public/ to any static host (Netlify, Cloudflare Pages, GitHub Pages, S3, Vercel). Hosts that don't map /archive to archive.html on their own need that rewrite (Vercel: "cleanUrls": true); most serve 404.html for unknown URLs.

To serve the site from a sub-folder, set DEMO_BASE_PATH, which becomes Nuxt's app.baseURL:

sh
DEMO_BASE_PATH=/magazine pnpm generate

pnpm build makes a Node.js server build instead (run it with node .output/server/index.mjs); the pages are still prerendered, and you can add server routes. demo/ is the freeframe.dev demo build and can be deleted.

TypeScript setup#

pnpm typecheck runs vue-tsc against the configs Nuxt writes to .nuxt/ (tsconfig.json references them). typescript is aliased to @typescript/typescript6, the TypeScript 6 API that vue-tsc and typescript-eslint load; replace it with typescript@7 once they support it.

Troubleshooting#

  • The build fails with Unknown article "…". A slug in HOME or the article page'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 <h2> headings inside data-article are listed, without data-toc-skip.
  • The theme flashes or ignores the switch. Keep themeScript in app.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 draw files in public/ through useAsset().
  • A new page shows the 404 page after pnpm generate. Add its route to routes in nuxt.config.ts.
  • /archive shows a 404 on my host. The host doesn't map extensionless URLs to .html files; add that rewrite (for example Vercel's "cleanUrls": true).
  • A form shows its thank-you message but nothing arrives. It sends nothing until you connect it; see Forms.
  • pnpm lint fails right after cloning. It needs .nuxt/, which pnpm install creates (nuxt prepare).

License#

Freeframe Commercial License: use it in unlimited websites and apps for yourself and your clients; it may not be resold or redistributed as a template. The full terms, with your licensee details, are in LICENSE.txt and at https://freeframe.dev/license#commercial. Third-party components keep their own licenses. The fonts are licensed under the SIL Open Font License (see app/assets/fonts/). The Ledger and everyone in this template are fictional.

Search documentation

Search the guides and every template's documentation