← All posts

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.

·3 min read

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.