Colophon
This site is a Next.js app on the App Router, React 19, Tailwind CSS v4, TypeScript, deployed on Vercel. That sentence describes about forty thousand other sites, so it is not the interesting part. The interesting part is the handful of places where I chose the harder thing on purpose.
§Posts are React components
There is no MDX here, and no content pipeline. Every post is a hand-written page at app/thoughts/<slug>/page.tsx that imports the typography components and composes them. This one included — the paragraph you are reading is a <p> inside a server component.
Markdown is a lossy format for what I want to do. The moment a piece needs a chart, an animation, a diagram that responds to hover, or a sidenote that sits in the margin at wide viewports and folds inline on a phone, markdown starts needing escape hatches, and MDX is mostly a machine for providing them. If the escape hatch is where the work happens, the format is not earning its place. So I removed the format.[1]
Metadata lives separately, in a hardcoded array in lib/posts.ts, and that array is the single source of truth. Add an entry and the post appears on the index, in the sitemap, and in all three feeds at once. Forget to add it and the page throws at build time, because getPostBySlug does not tolerate an unknown slug. Both of those are deliberate: I wanted the failure to be loud and early rather than a page quietly missing from the feed for six months.
§The design system is one colour ramp
There is no tailwind.config file. Tailwind v4 lets the design system live in CSS, so all of it is an @theme block in app/globals.css that overrides the default neutral palette with warm off-white tones, sets three font variables, and adds one extra breakpoint.
The whole site is expressed in that single ramp: 50 is the page background, 900 is the strongest text, and every border, surface and muted label is a step in between. Nothing on this site is blue because it is a link or grey because it is secondary — it is a number on one scale, and the scale is semantic.
Which buys something specific. Dark mode is not a second theme. It is the same ramp, reversed:
@media (prefers-color-scheme: dark) {
:root:not([data-theme="light"]) {
--color-neutral-50: #13120f; /* was #faf9f5 */
--color-neutral-900: #f5f2ec; /* was #1f1e1d */
/* ...nine more */
}
}Eleven lines, and every surface, border and piece of text on the site inverts at once. There is not one dark: variant in the codebase. No component knows what theme it is in, and none of them should have to. The constraint that makes this work is that the ramp has to stay honest — the day I reach for a colour because it looks nice in light mode is the day the whole thing needs auditing.
§Where the toggle gets awkward
Following the system preference is free. Letting a reader override it is where the cost shows up, and it shows up in three places at once.
- The choice lives in
localStorage, which the server cannot see. So a blocking inline script applies it before first paint, and<html>carriessuppressHydrationWarning— the server-client mismatch is real and intended. - The dark ramp is now written twice: once in the media query, once under
[data-theme="dark"]. CSS cannot share a declaration list across a media query and a selector, so the duplication is structural rather than sloppy. - The 1s crossfade only applies while switching. A global
* { transition: color 1s }would also animate colours on first paint and stretch every hover to a full second.
That last one has a trap in it worth writing down. CSS resolves a transition against the style in effect before the change. Add the transition class and flip the theme in the same tick and the browser sees no-transition-then-new-colour, and snaps. So:
root.classList.add("theme-transition");
void root.offsetWidth; // force a style flush — deleting this kills the fade
root.dataset.theme = chosen;A line that looks exactly like dead code and is holding up the entire effect.[2]
§Everything else
Code blocks run through rehype-pretty-code at render time, inside async server components — the highlighting is done before the HTML leaves the server, so there is no syntax highlighter in the bundle. Shiki emits both themes and the dark one is swapped in by CSS custom property, which is the one place the reversed-ramp trick does not reach on its own.
Footnotes are context-based, so a note can be a React node rather than a string, and they render as sidenotes in the margin above xl. Math is KaTeX rendered to HTML at build. Headings generate their own anchors from their text. Page transitions use React’s unstable_ViewTransition, which is exactly as unstable as the name promises. Open Graph images are generated per-post at the edge. There are three feeds — RSS, Atom and JSON — because it costs one file each and I would rather someone read this in whatever they already use.
The fonts are Inter for text, Lora for anything italic or quoted, and JetBrains Mono for code. The italic serif is doing more work than it looks like: em, i and q are globally restyled, which is why the navigation reads the way it does.
§What it does not have
No comments, no analytics beyond page counts, no cookie banner because there are no cookies to consent to, no newsletter box asking for your email before you have read a sentence, no share buttons. Everything on this site loads because it is part of the writing.
The whole thing is a few hundred kilobytes and one person’s opinions. If something here is broken or wrong, I would like to know.
- ^The cost is real: there is no draft folder full of .md files I could move to another engine in an afternoon. I decided I would rather have the ceiling than the exit.
- ^Reading offsetWidth forces the browser to flush style, which is normally the thing you are told never to do. Here it is the entire point.