I wanted the new site to feel modern but stay boring where it counts: fast, static, and easy to write for. Here's the stack and a few of the decisions.
The stack
- Next.js 15 (App Router, React Server Components)
- Tailwind CSS v4 with its new CSS-first config
- Shiki via
rehype-pretty-codefor syntax highlighting - Framer Motion for tasteful entrance animations
Tailwind v4 is CSS-first
No more tailwind.config.js. Tokens live in your stylesheet now:
@import "tailwindcss";
@plugin '@tailwindcss/typography';
@custom-variant dark (&:where(.dark, .dark *));
@theme {
--font-heading: var(--font-space-grotesk), sans-serif;
--color-brand: #8b7cff;
}Server-side highlighting with Shiki
The markdown pipeline runs entirely on the server, so the browser only ever receives ready-to-paint HTML — no highlighting library shipped to the client:
import rehypePrettyCode from "rehype-pretty-code";
import rehypeStringify from "rehype-stringify";
import remarkParse from "remark-parse";
import remarkRehype from "remark-rehype";
import { unified } from "unified";
export async function transformMarkdown(markdown: string) {
const file = await unified()
.use(remarkParse)
.use(remarkRehype)
.use(rehypePrettyCode, {
theme: { light: "github-light", dark: "github-dark" },
})
.use(rehypeStringify)
.process(markdown);
return file.toString();
}Because the themes are defined as a { light, dark } pair, a couple of CSS
variables swap the colours when you toggle the theme — no re-render required.
A tiny bit of Python, for variety
def fib(n: int) -> list[int]:
a, b = 0, 1
out = []
for _ in range(n):
out.append(a)
a, b = b, a + b
return out
print(fib(10)) # [0, 1, 1, 2, 3, 5, 8, 13, 21, 34]And a shell snippet, because every blog needs one:
npx create-next-app@latest my-site --ts
cd my-site && npm run devThat's the whole trick. Static HTML, highlighted on the server, with a design system that lives in CSS. Fast to load, fast to write for.