How I Built This Site
The stack, the tradeoffs, and the decisions behind a statically exported Next.js site — content pipeline, syntax highlighting, Mermaid diagrams, CI on a homelab runner, and deploying to Cloudflare Pages.
I'd never built a website before this one. I wanted somewhere to put things that a resume or LinkedIn profile doesn't have room for — the actual how and why behind the work, not just a bullet point summary of it. Building the site itself turned out to be part of that: I had to learn the stack from scratch, and this post documents the decisions I made along the way.
Framework
Next.js 15 with the App Router. The one non-default thing is output: "export"
in next.config.mjs, which tells Next to produce plain HTML, CSS, and JS in
./out instead of running a Node server. No serverless functions, no edge
runtime — everything builds at deploy time.
I chose this mostly because the site doesn't need a server. It's just content.
A static export also means the Cloudflare Pages deploy is dead simple: Wrangler
pushes the ./out directory and that's it.
The downside is no runtime data — no server-side search, nothing dynamic that can't be fetched from the client. For a personal site I don't care.
Content pipeline — Velite
Content lives in src/content/ as MDX files, processed by Velite.
I looked at a few options before landing on it. @next/mdx is the built-in
Next approach but you don't get structured collections out of it — every file is
just a page, there's no schema. next-mdx-remote looked promising but it's
designed to compile MDX at request time, which doesn't work with a static
export. Contentlayer was the one most people recommended but it's been
unmaintained for a while and I didn't want to build on that.
Velite lets you define typed collections in a config file and point them at
directories of MDX. At build time you get fully-typed TypeScript objects back.
The posts, projects, pages, and books collections each have a schema
in velite.config.ts:
const posts = defineCollection({
name: "Post",
pattern: "writing/**/*.mdx",
schema: s.object({
slug: s.path(),
title: s.string().max(120),
description: s.string().max(300),
date: s.isodate(),
tags: s.array(s.string()).default([]),
draft: s.boolean().default(false),
body: s.mdx(),
}).transform(computedFields),
});Velite compiles each MDX file down to a pre-built function body and writes it
to .velite/. The renderer in src/components/mdx-content.tsx just executes
that function body — no MDX compilation happening at runtime, on the server or
the client.
Code highlighting
Code blocks are highlighted by rehype-pretty-code with Shiki, wired in as a
rehype plugin in the Velite config. It all happens at build time — by the time
a page loads in the browser, the highlighted HTML is already baked in.
mdx: {
rehypePlugins: [
[rehypePrettyCode, { theme: { dark: "github-dark", light: "github-light" } }],
],
},I picked github-light and github-dark because they match the site's own
theme toggle. The toggle uses next-themes with a class on <html>, so Shiki
and the UI stay in sync without any extra wiring.
Mermaid diagrams
Mermaid was trickier. There are rehype plugins for it but they all need a
headless browser to render the SVG at build time, which felt like too much to
add. Instead I wrote a small client component at src/components/mermaid.tsx
that runs mermaid.render() on mount, reads the current theme from
next-themes, and drops the SVG into a div. It re-renders when the theme
changes.
<Mermaid chart={`
flowchart LR
A[MDX source] --> B[Velite compile]
B --> C[Pre-built function body]
C --> D[MDXContent renders it]
`} />It's registered in mdx-content.tsx so you can use <Mermaid> anywhere in
MDX without an import.
Resume
The resume is stored as a JSON Resume file at
src/content/resume.json. Before every build, a small script
(scripts/sync-resume.mjs) copies it to public/resume.json with the phone
number removed. The rendered page and the downloadable JSON are both public —
I just didn't want my phone number sitting in a file that anyone can pull.
Deployment
Deploys go to Cloudflare Pages via GitHub Actions. npm run build runs
Velite then Next's static export, Wrangler pushes ./out to Cloudflare. Pushes
to main go to production, PRs get a preview URL.
The runner isn't GitHub-hosted. It's arc-runner-website, running on the
homelab cluster via Actions Runner Controller. Builds happen inside the cluster,
secrets come from Vault, and I'm not using GitHub Actions minutes. There's also
a side effect I like: if the site deploy breaks, it usually means something in
the cluster is broken, which is useful signal.
What I'd do differently
The ARC runner is the main fragility. If the cluster is down, deploys just sit
in the queue until it comes back. I could add ubuntu-latest as a fallback in
the workflow in about five minutes — I just haven't bothered because it's only
been an issue once.
Velite's docs are thin in places. The error messages when frontmatter doesn't match the schema aren't great — you get a build failure but tracing it back to the specific file takes some digging. Worth knowing before you commit to it.