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).
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.pngOther 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:
: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:
{
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 anid("A practical playbook"→id="a-practical-playbook"); one without an id gets one made from its text. A heading withdata-toc-skipis 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>withtok-c,tok-k,tok-sandtok-fspans 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#
-
Move
app/pages/article.vuetoapp/pages/article/[slug].vue, read the slug withuseRoute().params.slugand look up the essay witharticle(slug). Keep each essay's text in its own component (for exampleapp/components/essays/TheCaseForSlowerSoftware.vue) and pick it by slug. -
Replace
ARTICLE_PATHwith a function that returns each essay's address, and use it inArticleCard.vue,archive.vueandindex.vue:tsexport const articleHref = (article: Article) => `/article/${article.slug}`; -
List every essay's route in
routesinnuxt.config.ts, sonuxt generateprerenders 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.
Navigation, header and footer#
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 befored.classList.add('dark')tot!=='light'. - Remove dark mode: delete
<ThemeToggle />fromSiteHeader.vue, remove the.darkblock frommain.cssand thed.classList.add('dark')part ofthemeScript.
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.
Links to the same page#
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") |
|
| 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:
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, asummary_large_imageTwitter card and a canonical link. Titles and descriptions match the HTML version. -
The canonical URL,
og:urland the absoluteog:imageURL start withruntimeConfig.public.siteUrlinnuxt.config.ts, which readsSITE_URL(defaulthttps://theledger.example). Set it when you build:shSITE_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 withpnpm build, or a file written at build time; then point the RSS links at it) andArticlestructured data (JSON-LD withuseHead()) 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#
pnpm generate # static site in .output/public/
pnpm preview # serve it locallynuxt 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:
DEMO_BASE_PATH=/magazine pnpm generatepnpm 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 inHOMEor the article page'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
<h2>headings insidedata-articleare listed, withoutdata-toc-skip. - The theme flashes or ignores the switch. Keep
themeScriptinapp.headand 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 draw files inpublic/throughuseAsset(). - A new page shows the 404 page after
pnpm generate. Add its route toroutesinnuxt.config.ts. /archiveshows a 404 on my host. The host doesn't map extensionless URLs to.htmlfiles; 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 lintfails right after cloning. It needs.nuxt/, whichpnpm installcreates (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.