02 — Editorial site + headless CMS
Fieldnote
Fieldnote is the build most studios actually subcontract: a content-led site with a real editing experience behind it. The Studio is embedded at /studio rather than hosted separately, so there is one deploy, one domain, and one thing for an editor to bookmark. If you look at one thing here, make it the 45-second capture of the editing experience — it answers the question a screenshot cannot.
- Next.js
- Sanity
- Portable Text
- Tailwind
- Vercel
Measured on the finished build
- Lighthouse, homepage
- 97 / 100 / 100 / 100 / 100, mobile, LH 13.4.1
- Lighthouse, article
- 95 / 100 / 100 / 100 / 100
- Homepage timings
- FCP 1.1s, LCP 2.6s, TBT 20ms, CLS 0
- Article timings
- FCP 0.9s, LCP 2.9s, TBT 20ms, CLS 0
- Revalidation
- Tag-based, webhook-driven, no timer
- Editing experience
- 45-second capture, no narration, linked from the README
The hard part
The CMS is the deliverable, and nobody demos it
A headless build is judged twice: once by the visitor, on speed, and once by the editor, every week for years. The second audience is the one that renews the retainer, and it is the one a portfolio screenshot never shows.
The technical half of that is cache invalidation. An editor who hits publish, reloads, and sees the old version reasonably concludes the integration is broken — and then rings the studio.
What I did
Model it for the editor, invalidate on publish
The content model carries field descriptions, validation and custom preview titles, so the Studio reads as a tool rather than a form. Draft mode reads the drafts perspective, caches nothing, and turns on stega — invisible characters encoding document and field ids into the text, which is what makes a click on the rendered page jump to the right field in the Studio. Stega is enabled per request rather than on the client, because those characters are corruption in published output: they travel into copied text and search results.
Nothing revalidates on a timer. An article is not stale sixty seconds after it was fetched; it is stale when an editor changes it. Responses are cached indefinitely and tagged by document type and identity, and the publish webhook clears the matching tags when Sanity says something changed.
Decisions, with the price attached
What was chosen, and what it cost
Tags expire outright, against Next's recommended profile
A cache-life profile serves the stale copy while fetching fresh data behind it. That is the right default for most sites and the wrong one here: it is precisely the behaviour that makes an editor think publish is broken.
The cost — One slower request for the next visitor after every publish. Paid deliberately, to keep the editor's mental model true.
A CLS of 0 that was once a lie
An earlier version of this build also reported CLS 0 — because the typefaces were not rendering at all, so there was no font swap to shift anything. With Zodiak, Newsreader and IBM Plex Mono actually applied, holding 0 means the metric-matched fallbacks and the fixed-row header are doing the work.
The cost — Nothing, except the habit of asking what a good number is actually measuring. It is in the README because a zero from a broken measurement is worse than a bad number.
The dataset is public and the drafts are not
Every article on the site is queryable over the public API without an account, so the content model can be inspected without asking me for anything. Drafts still require a token — Sanity will not serve an id containing a dot to an anonymous caller — which is what the preview flow uses.
The cost — Read-only Studio access is on request rather than published as a shared login. A public write-capable login on a demo is a liability, not a convenience.
Stated rather than left to be found
Known limits
- Performance is the only category that moves between runs — 97, 97 and 96 on the homepage across three measurements, 95 to 97 on the article. The figures above are from the captured run, and the variance is stated in the README rather than smoothed away.
- The display face is self-hosted under a licence that permits self-hosting rather than redistribution, so the repo fetches it at build time instead of committing it.