← All writing

A static site that checks itself

The build is only finished when the output has been read back.

Web performanceNext.jsStatic sites
The satyajiit.dev homepage: the headline “It started with a what if.” above the OpenAlly chapter, with app screens on a laptop and two phones.
The site you are reading, built once and checked before it ships.

This site has no application server. Every page is an HTML file produced at build time by a Next.js static export, then served from Firebase Hosting. That makes hosting simple, and it moves all the interesting failures to one place: the build.

A static site fails quietly. A broken image path, a page with two H1s, a feed that lost an article or a service worker that caches the wrong thing all deploy perfectly well. Nobody sees an error. So I treat the build as two halves: one that produces the export, and one that reads the export back and refuses to finish if something is wrong.

Markdown in, typed documents out

All the writing, case studies and page copy live as Markdown files in a content/ folder, each with YAML frontmatter. The frontmatter is parsed with gray-matter and validated against a Zod schema before anything is rendered. A slug must be lowercase and hyphenated. A description has to be between 30 and 200 characters. Dates are plain YYYY-MM-DD strings. A cover must point at a file under /images/ or /media/.

The schema also encodes a few editorial rules that are easy to forget. Product figures are allowed only with a source URL, or with an explicit note that the figure is reported by the owner. An image needs alt text. The body has to start at a level-two heading, because the page template already owns the H1. Anything that is not a plain page must have at least 160 words, so an empty draft cannot slip into a collection.

Markdown is compiled with unified, remark and rehype, with sanitisation turned on. Level-two headings get stable ids, and those ids become the “In this story” navigation beside each article. Drafts are filtered out, and two documents claiming the same route stop the build.

In development, the repository rereads the files on every request, so an edited paragraph appears on refresh. In a production build it is read once and cached.

One build, many outputs

Before Next.js runs, a preparation script turns the same documents into everything else the site needs.

Images

Source artwork is kept at full size in the repository, and sharp produces WebP derivatives at fixed widths, usually 640, 960 and 1440 pixels. Components pick from those with srcset, keyed on the 960 pixel file. Two writing covers also have 4K delivery files. Their source images are smaller than 3840 pixels, so the enlargement is done deliberately with a Lanczos kernel, and the code comment says so. I would rather record that than let a filename imply a native 4K render.

The installable app icons are rasterised from one SVG: plain icons at 192 and 512 pixels, maskable tiles with the glyph inside the safe zone, and a 180 pixel Apple touch icon.

Social cards

Every page gets three social images: 1200 by 630 for Open Graph, 1600 by 800 for X, and a 1080 square for chat previews. They are rendered by headless Chromium through Playwright, using the page’s own cover art, its title and the site typeface. A product page also carries its logo. Because they are generated from the content, a renamed article cannot keep an old card.

Discovery files

The same pass writes a sitemap with lastmod taken from each document’s updated or date field, an RSS feed of the writing, a Markdown export of every page, and llms.txt with llms-full.txt for readers that prefer plain text. A route manifest lists every published path, and later steps read it.

The output folders the script owns are deleted and regenerated each time. A draft that was published once and then withdrawn cannot survive in an old export file.

A page in more than one format

Each HTML page links to its Markdown version through an alternate link with the text/markdown type. The Markdown is not a summary. It includes the article body, product feature walkthroughs, screenshot captions, product figures with their source and the date they were checked, and the list of sources.

That only works if the two versions agree, so the build checks it. That brings me to the second half.

Reading the export back

After next build has written the static files, a verification script opens every route in the manifest and inspects the HTML it finds on disk. A simplified version of the main loop:

for (const route of routes) {
  const html = await readFile(`out${route}index.html`, "utf8");
  assert.equal((html.match(/<h1[ >]/g) ?? []).length, 1, `One H1: ${route}`);
  assert.ok(html.includes(`${site.url}${route}`), `Canonical: ${route}`);
  const title = html.match(/<title>(.*?)<\/title>/)?.[1];
  assert.ok(title && !titles.has(title), `Unique title: ${route}`);
  titles.add(title!);
  for (const [, href] of html.matchAll(/(?:href|src)="(\/[^"?#]*)/g)) {
    const file = path.join("out", decodeURIComponent(href));
    await access(href.endsWith("/") ? `${file}/index.html` : file);
  }
}

Each page must have exactly one H1, a canonical URL, a title no other page uses, structured data, an Open Graph image, all three social card files, one manifest link and the right theme colour. Every local href and src has to resolve to a real file in the export. A link to a page that does not exist fails the build with the route and the broken path in the message.

Then the script compares formats. Each document’s Markdown body must appear in its exported Markdown file, and that file must appear in llms-full.txt. Screenshot captions, feature steps, product figures and their check dates are compared one by one. The 4K cover files are opened and measured to confirm 3840 by 2160 pixels. The archived old site must be absent from the output.

The service worker comes from the same build

The site works offline once it has been visited, through a service worker. That worker is generated after next build, from the export itself, and it carries the build id and a precache list.

The list is decided by a small classification function. Hashed files under /_next/static/ are immutable and cached without a revision. Every page’s index.html, the icons, the manifest and the brand marks form the app shell. Films, posters, captions, feeds and Markdown exports are never handled by the worker at all. A single precached file may not exceed 512 KB, and the whole shell has a 4 MB limit, so a large image added by accident fails the build.

Both caches are named after the build id. When a new worker installs, it fetches the shell, deletes every cache that is not its own, takes control of open tabs and reloads each one once. A first install never reloads, and a tab will not reload twice within 30 seconds, so a host serving two versions at once cannot cause a loop.

The worker only behaves if the host does its part, so I wrote that down as a contract: /sw.js served with no-cache, no catch-all rewrite to index.html, directory URLs that redirect to a trailing slash, static assets cached for a year, and range requests on media. The Firebase configuration in the repository encodes those rules.

What the checks do not tell me

These checks prove the export is internally consistent. They do not prove that the site feels good on an old phone. A separate Playwright suite runs axe accessibility rules on every route and checks for horizontal overflow at six widths from 320 to 1440 pixels. Lighthouse runs against a local production preview. Those are lab measurements. They are useful, and they are still different from what real visitors experience.

Physical-device testing and real screen reader output sit outside these checks, and the verification notes in the repository say so. I would rather keep that list of limits next to the results than let a passing build suggest it measured more than it did.

Sources & further reading

Another question worth following?

Explore the workGet in touch

A good project often starts here.

Leave me
a what if.

All the places to find me ↗
To Satyajit

There’s an app I wish existed.

Tell me who would use it and what they are trying to do. A rough sketch is plenty to start with.

Talk to me on LinkedInLet’s follow the question.