Overview#
Notebook is a personal developer blog. The demo belongs to Imke Vos, a fictional backend engineer in Paducah who writes long posts about databases, build tools and debugging. It has a home page with the latest posts and featured series, a post layout made for technical writing (highlighted code with copy buttons, heading links, an outline that follows your reading, a progress bar and series navigation), an archive by year, a tag browser, instant search, a series page, projects, an about and now page and a subscribe page. The design is a sheet of graph paper with a red margin line and a yellow highlighter, in light and dark mode. This is the Nuxt version: Nuxt 4, Vue 3 single-file components, TypeScript and Tailwind CSS v4, generated as a static site. This guide covers version 1.0.0.
Live demo: freeframe.dev/templates/notebook
Quick start#
Requirements: Node.js 24 or newer and pnpm (corepack enable pnpm).
pnpm install
pnpm dev # http://localhost:3000| Command | What it does |
|---|---|
pnpm dev |
Development server with hot reload |
pnpm generate |
Static site in .output/public/ |
pnpm build |
Node.js server build in .output/ (pages still prerendered) |
pnpm preview |
Serve the last build locally |
pnpm typecheck |
nuxt prepare, then type-check with vue-tsc |
pnpm lint |
ESLint (flat config from @nuxt/eslint) |
pnpm build:demo |
Build for /demos/nuxt/notebook into demo/ (only used by freeframe.dev; you can delete it) |
pnpm install runs nuxt prepare, which writes the .nuxt/ folder that TypeScript and ESLint read.
Pages#
| Route | File | Purpose and notable sections |
|---|---|---|
/ |
app/pages/index.vue |
Introduction with the "now" line, newest post, five more latest posts, series, tags, newsletter |
/post |
app/pages/post.vue |
The example post: series box, outline, reading time and progress, code with copy buttons, callouts, heading links, older/newer posts |
/archive |
app/pages/archive.vue |
Every post grouped by year, with links to each year |
/tags |
app/pages/tags.vue |
Tag buttons with counts and the posts of the chosen tag (/tags?tag=postgres) |
/series |
app/pages/series.vue |
Each series with its description and parts, in order |
/search |
app/pages/search.vue |
Instant search with highlighted matches (/search?q=vacuum) |
/about |
app/pages/about.vue |
Introduction and portrait, now, uses, contact |
/projects |
app/pages/projects.vue |
Project cards with status, language and links, talks |
/subscribe |
app/pages/subscribe.vue |
Email sign-up form, RSS address with a copy button, recent emails |
| 404 | app/error.vue |
Search box and links back (generated as 404.html) |
One example post#
The demo has one full post at /post, and every post in lists links there (each post's href in
app/data/posts.ts is "/post"). See Adding a post to give each post its own page.
Project structure#
notebook/
app/
app.vue <NuxtLayout><NuxtPage /></NuxtLayout>, with the page key (see same-page.client.ts)
layouts/default.vue skip link, header, reading progress (post page), footer, toast, back-to-top
pages/ one file per page (index.vue, post.vue, archive.vue...)
error.vue 404 page
components/
SiteHeader.vue logo, menu, search link, theme switch, mobile menu
SiteFooter.vue footer columns, copyright year
PostRow.vue a post in a list (MarkedText.vue highlights search matches)
TagLink.vue a tag as a link to the tag browser
ReadingABTreePageByHand.vue the example post's text
PostHeading.vue post heading with id and "#" link
CodeBlock.vue code block with language label and copy button
PostOutline.vue "On this page" outline with scroll spy
ReadingProgress.vue progress bar at the top of the post page
SeriesNav.vue series box of a post
PostNav.vue older/newer posts
NewsletterForm.vue the home page's sign-up form
BackToTop.vue back-to-top button
composables/
useFormCheck.ts form validation
usePageSeo.ts title, description, canonical and Open Graph tags
useCurrentPage.ts aria-current for menu links
useAsset.ts files in public/ with the base path
data/
posts.ts the post index and series, plus helpers
projects.ts projects and talks
site.ts name, email, feed address, menu and footer links
utils/
highlight.ts SQL and shell highlighting for code blocks
marks.ts splits text around search matches
clipboard.ts, toast.ts, theme.ts, safeStorage.ts
plugins/
reveal.client.ts scroll reveals
search-shortcut.client.ts "/" jumps to search
same-page.client.ts links to the page already showing start it afresh, at the top
keep-url.client.ts keeps the address exactly as opened (?tag=…, #section)
stable-build.server.ts byte-identical builds
router.options.ts keeps query strings as written (?q=build%20graph)
assets/css/main.css color tokens, fonts, component classes (Tailwind CSS v4)
assets/fonts/ self-hosted woff2 files and their licenses
public/img/ logo, favicon, illustrations, portrait, social image
nuxt.config.ts base path, head defaults, theme script, SITE_URL, prerendered routes
template.json, README.md, DOCS.md, CHANGELOG.mdCustomizing#
Brand and colors#
All colors are CSS custom properties near the top of app/assets/css/main.css, once for light mode (:root)
and once for dark mode (.dark). Tailwind reads them through @theme inline, so classes such as bg-paper,
text-muted, border-line, text-pen and bg-marker follow the tokens.
| Token | Used for |
|---|---|
--paper |
Page background |
--surface |
Cards ("sheets"), inputs |
--ink / --muted |
Text and secondary text |
--line |
Borders and dashed rules |
--grid |
The graph-paper lines behind the page |
--pen / --pen-soft |
Red pen: margin lines, // labels, series badges, active outline entry |
--marker |
Highlighter: link underlines, <mark>, the current menu item |
--shadow |
Hard offset shadow under cards and buttons |
--code-bg, --code-ink, --code-line, --tok-* |
Code blocks and the syntax colors |
To make the pen blue instead of red:
:root { --pen: #1f4fb8; --pen-soft: #e2e9f8; }
.dark { --pen: #8fb3ff; --pen-soft: #1c2740; }To remove the graph paper, delete the background-image and background-size lines from body in
main.css. If you change --paper, also update the two theme-color entries in nuxt.config.ts.
Typography#
Three fonts are self-hosted with @font-face rules at the top of main.css (files and SIL Open Font
Licenses in app/assets/fonts/): Martian Mono for headings and labels (slightly condensed with
font-stretch: 87.5% in the .display, .kicker and .post-title classes), Atkinson Hyperlegible Next
for text and Atkinson Hyperlegible Mono for code. They are the files Google Fonts serves, so nothing is
loaded from Google. @theme maps them to --font-display, --font-sans and --font-mono. To swap a font,
replace its woff2 file and @font-face rule, and the family name in @theme.
Content#
The post index. app/data/posts.ts lists every post once, newest first, in POSTS, and the series in
SERIES. The home page, archive, tag browser, search, series page, the subscribe page's recent emails and
each post's series box and older/newer links are all built from it:
{
slug: "my-new-post",
title: "My new post",
excerpt: "One sentence about it.",
date: "2026-11-02",
tags: ["postgres"],
series: "",
part: 0,
minutes: 6,
href: "/post",
},| Field | Meaning |
|---|---|
slug |
Unique id; the post page passes it to <SeriesNav> and <PostNav> |
title, excerpt |
Shown in lists and searched |
date |
YYYY-MM-DD, shown exactly as written (never converted to a time zone) |
tags |
Lower-case tag names |
series, part |
A series slug from SERIES and the part number, or "" and 0 |
minutes |
Reading time shown in lists |
href |
The post's route |
A series is { slug, name, description }; its parts, part count and total reading time are worked out from
POSTS (partsOf(), minutesOf()). The home page shows the newest post large and the next five as a list;
the tag list counts tags with tagCounts().
app/data/site.ts holds the name, email address, feed address, menu (NAV) and footer columns (FOOTER).
app/data/projects.ts holds PROJECTS (name, status, language with its dot color class, stars, description,
links), STATUS_CLASS and TALKS. The about page's text, now section and uses list are in
app/pages/about.vue.
Writing a post#
A post's text is a component (the example is app/components/ReadingABTreePageByHand.vue), made of plain
elements plus two helpers:
<p class="lede">The first paragraph, a little larger.</p>
<PostHeading text="Setting the table" />
<PostHeading text="What the high key is for" :level="3" />
<CodeBlock lang="sql" :code="CODE[1]" />
<aside class="callout">A note in red pen.</aside>
<aside class="callout callout-note">A note in highlighter.</aside>The component has several root elements on purpose: .prose-nb spaces its direct children, so don't wrap
them in a <div>. PostHeading gives the heading an id (from its text) and a # link; <h2>s and <h3>s
become the outline. CodeBlock adds the language label and the copy button; the code itself is a string
(the example keeps them in a CODE list in the component's script). SQL and shell (bash, sh) are
highlighted by app/utils/highlight.ts with five classes (tok-k keywords, tok-s strings, tok-n
numbers, tok-c comments, tok-f functions and commands) colored by the --tok-* tokens; other languages
(text for output) are shown as they are. Add keywords to SQL_KEYWORDS, or a function for another language.
Adding a post#
- Write its text in a component, for example
app/components/posts/MyNewPost.vue. - Add it at the top of
POSTSwithhref: "/posts/my-new-post". - Turn
app/pages/post.vueintoapp/pages/posts/[slug].vue: find the post withPOSTS.find((p) => p.slug === useRoute().params.slug), and render its text component (for example from a{ [slug]: Component }map). The header,<SeriesNav :slug>,<PostNav :slug>and the outline work for any post. List every post's route inroutesinnuxt.config.tssopnpm generatewrites them, and changeroute.path === '/post'inapp/layouts/default.vueso the progress bar shows on posts.
Images and artwork#
Everything in public/img/ is an original SVG: logo.svg, favicon.svg, notebook-page.svg (home
illustration, 560 × 440), imke.svg (portrait, square) and og-image.svg (social image source,
1200 × 630). Replace the portrait with a square photo of any size and keep width/height. og-image.png
is the social image that sites actually display; export a new one at 1200 × 630 when you change the SVG.
Files in public/ are referenced with asset("img/…") (app/composables/useAsset.ts) so they work under a
base path.
Navigation, header and footer#
The menu is NAV in app/data/site.ts, used by both the desktop and mobile menus in
app/components/SiteHeader.vue, which marks the current page with aria-current="page"
(useCurrentPage()). Footer columns are FOOTER in the same file, rendered by SiteFooter.vue; links that
start with / become NuxtLinks, anything else (#, mailto:) a plain link.
Dark mode#
The theme button in SiteHeader.vue toggles the dark class on <html> and stores "dark" or "light"
under notebook-theme (THEME_KEY in app/utils/theme.ts). The themeScript in nuxt.config.ts applies
it before the page paints; without a stored choice the page follows the system setting. To default to dark,
change the script's condition to t!=='light'. To remove dark mode, delete the button and the script's
try { … } part.
Features#
Code blocks and copy buttons#
CodeBlock.vue renders a .code-block frame with the language (LANGS maps sql → SQL, bash → Shell,
text → Output; other names are shown as written) and a Copy button. Copying uses the Clipboard API
with a fallback (app/utils/clipboard.ts); the button reads "Copied" for two seconds and a toast announces
it. The code is focusable (tabindex="0") so keyboard users can scroll wide lines, and named ("SQL code")
for screen readers. The syntax colors switch with the site theme.
Heading links, outline and progress#
PostHeading.vue links each section with #; following the link also copies the section's address.
PostOutline.vue (used twice: a sidebar on wide screens, a collapsible "On this page" on phones) reads the
post's <h2>s and <h3>s from [data-post-body] once the page has mounted, and highlights the section you
are reading, marked aria-current="location": the last heading above the top quarter of the screen.
Following a link in the phone version closes it. ReadingProgress.vue fills the bar at the top as you read
the text, and app/pages/post.vue counts the visible words at 230 a minute for the reading time.
Series box and older/newer posts#
SeriesNav.vue shows the post's series with every part (the current one highlighted and marked
aria-current="page"); posts without a series show nothing. PostNav.vue links to the next older and newer
posts in POSTS; the newest post has only "Older post".
Archive by year#
/archive groups POSTS by the year of their date, with jump links to each year (#year-2025).
Tag browser#
app/pages/tags.vue shows "All" and one toggle button (aria-pressed) per tag, most used first. The chosen
tag is read from ?tag= once the page has mounted, and choosing one rewrites the address with
history.replaceState, so the view can be shared. ?tag= is matched in any case; a value that isn't a tag
shows every post and a short notice. A tag link to the page you are on starts it afresh with that tag
(app/plugins/same-page.client.ts and the page key in app/app.vue).
Search#
app/pages/search.vue searches as you type: every word must appear in a post's title, summary, tags or series
name. Matches are wrapped in <mark> and the count is announced politely. The query is kept in ?q= (read up
to 100 characters), Enter works too, and "Clear the search" returns focus to the box.
app/plugins/search-shortcut.client.ts makes <kbd>/</kbd> jump to search from any page, or into the box on
the search page; it is ignored while you type in a field.
Copy the feed address#
The Copy button on /subscribe copies the value of the #feed-url input (the feed address in
app/data/site.ts).
Scroll reveals, mobile menu and back to top#
app/plugins/reveal.client.ts fades in .reveal elements as they scroll into view (and everything above
them), straight away with reduced motion. Don't put .reveal on an element with a :class binding: Vue would
remove the is-visible class the plugin adds. Below 1024 px the menu button opens the mobile menu; Escape
closes it and returns focus, and it closes after following a link. BackToTop.vue appears after about one
screen, scrolls up (instantly with reduced motion) and focuses the main content; remove it from
app/layouts/default.vue to drop it.
Forms#
| Form | File | Validates |
|---|---|---|
| Newsletter (home) | app/components/NewsletterForm.vue |
|
| Email subscription | app/pages/subscribe.vue |
Email; name is optional, frequency and series announcements are choices |
| 404 search | app/error.vue |
Nothing: it opens /search?q=… |
Both forms use useFormCheck() from app/composables/useFormCheck.ts: the browser's own constraints with
friendly messages ("Please enter your email address."). A field is checked when it loses focus (once it has a
value) and again as you type while it's invalid; each has an error line (<field id>-error) linked with
aria-describedby, and aria-invalid once checked. Submitting focuses the first problem, or resets the form
and shows and focuses the success panel. The success texts tell the reader to look out for a confirmation
email, as most newsletter services send one to confirm a sign-up; change them if yours doesn't.
Nothing is sent until you connect the forms, even though the success texts read as if it had been. To
send sign-ups to a service such as Buttondown or Formspree,
fill in the onValid function next to the form, for example in app/pages/subscribe.vue:
function onValid(form: HTMLFormElement) {
fetch("https://formspree.io/f/your-form-id", {
method: "POST",
body: new FormData(form),
headers: { Accept: "application/json" },
});
}onValid runs before the form resets. With pnpm build (a Node.js server) you can instead post to a Nitro
server route such as server/api/subscribe.post.ts.
Saved data (localStorage)#
| Key | Value | Validation |
|---|---|---|
notebook-theme |
"dark" or "light" |
Any other value is ignored and the system setting is used |
Nothing else is stored. Storage goes through app/utils/safeStorage.ts, which keeps the choice in memory for
the page view when the browser blocks storage. Clear it in the browser's site data, or run
localStorage.removeItem("notebook-theme").
SEO and sharing#
- Each page calls
usePageSeo({ title, description })(app/composables/usePageSeo.ts) with its title and description; the post passestype: "article"and the 404 pagenoindex: true. It sets Open Graph and Twitter tags and a canonical link. - Canonical URLs,
og:urlandog:imageare absolute:SITE_URLplus the base path. Set it when you build:
SITE_URL=https://www.example.com pnpm generateWithout it, runtimeConfig.public.siteUrl in nuxt.config.ts falls back to https://imkevos.example.
Consider adding a sitemap, a robots.txt in public/ and an RSS feed at the address in site.feed, built
from POSTS (for example a Nitro server route that is prerendered).
Accessibility#
- A skip link, landmarks (
header, labelednavs,main,aside,footer) and one<h1>per page. - Visible focus outlines in the pen color; the current page is marked with
aria-current="page". - Toggle buttons (
aria-pressed) for theme and tags, live regions for counts, copy and form messages. - Focusable, labeled code blocks; heading links named after their section.
- Colors meet WCAG AA in both themes, including the syntax colors.
- Reduced motion turns off reveals and smooth scrolling.
When editing, keep one <h1> per page, give every new input a <label>, and keep headings in order.
Building and deploying#
pnpm generate # static site in .output/public/
pnpm preview # preview it locallyEvery page is prerendered to its own file (archive.html, not archive/index.html) and linked as /archive.
- Netlify or Cloudflare Pages: build command
pnpm generate, publish directory.output/public. - Vercel: import the repository; Nuxt is detected automatically.
- Any static host: upload
.output/public/. If the host doesn't serve/archivefromarchive.html, setautoSubfolderIndex: trueinnitro.prerenderinnuxt.config.ts. - Sub-folder hosting: build with
DEMO_BASE_PATH=/notes pnpm generate; it becomesapp.baseURL, which everyNuxtLink, script and stylesheet uses.
demo/ is the build used for the freeframe.dev demo; you can delete it.
TypeScript setup: typescript is an alias of @typescript/typescript6, the TypeScript 6 API that vue-tsc and
typescript-eslint need.
Troubleshooting#
| Problem | Fix |
|---|---|
| A new post doesn't appear in lists, tags or search | Add it to POSTS in app/data/posts.ts; check the date is YYYY-MM-DD |
| The series box or older/newer links are missing | The slug passed to <SeriesNav>/<PostNav> must match the post's slug |
| Post paragraphs lose their spacing | Keep the post's elements as direct children of [data-post-body]; don't wrap them in a <div> |
| A heading is missing from the outline | Only <h2> and <h3> inside [data-post-body] are listed; use <PostHeading> |
| A Tailwind class built from data has no effect | Write complete class names in the data (before:bg-[#dea584]), not pieces |
pnpm lint can't find .nuxt/eslint.config.mjs |
Run pnpm install (or pnpm typecheck) first; it runs nuxt prepare |
| A hydration warning after editing a page | Read window, Date and localStorage only in onMounted() or event handlers |
| The newsletter form shows its confirmation but nothing arrives | It sends nothing until you connect it; see Forms |
Copy buttons don't copy on plain http:// |
The Clipboard API needs https or localhost; the fallback covers most browsers |
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. Martian Mono,
Atkinson Hyperlegible Next and Atkinson Hyperlegible Mono are under the SIL Open Font License
(app/assets/fonts/*-OFL.txt). Imke Vos, Havenlogic and the posts' examples are fictional.