Why Rebuild at All
My old portfolio was a forked Next.js 12 template with placeholder blog posts written by the original author, Three.js device models rendering MacBook GLBs in WebGL, and nav links commented out in the source. It had my name on it but none of my work.
More importantly, it was the wrong format. I don't need a designer portfolio. I need an engineering knowledge base — somewhere to put case studies, architecture notes, and the experiments I'm actually running. Something that grows over time and demonstrates depth, not just a list of job titles.
Starting With the Right Stack
Next.js 15 (App Router) was the obvious choice. The decision that took longer was the content pipeline.
The old portfolio used mdx-bundler — a Pages Router tool that bundles ESM per file. The App Router equivalent is next-mdx-remote/rsc, but that means manually writing TypeScript interfaces for frontmatter, building your own getAllArticles() functions, and handling slug validation yourself.
I looked at Contentlayer, which is what most engineering blogs recommend. It's dead — last release June 2022, Stackbit's acquisition by Netlify killed the sponsorship. There's a community fork (contentlayer2) but it's incompatible with Turbopack and maintenance is thin.
Velite was the answer. It's Zod-native, generates TypeScript types automatically, handles slug uniqueness validation at build time, compiles MDX to function-body strings for zero client-side rendering cost, and works with Turbopack. The trade-off: it's ESM-only, which means the obvious approach of importing it in next.config.ts fails because Next.js compiles the config to CommonJS.
The fix is simple — run Velite as a separate step in the build script:
"build": "velite build && tsx scripts/build-search-index.ts && next build"Three sequential steps, each only runs if the previous succeeds. Velite generates .velite/, the search index script reads from it, Next.js picks up everything via the #velite path alias.
The Content Pipeline
Velite reads content/**/*.mdx, validates frontmatter against Zod schemas, and outputs:
.velite/index.js— typed named exports for each collection.velite/index.d.ts— auto-generated TypeScript typespublic/static/— hashed content assets
Importing content in a page looks like:
import { articles, caseStudies, experiments, notes } from "#velite";
const published = articles.filter((a) => !a.draft);Everything is typed. The frontmatter schema is the source of truth. If you add a field to the schema without updating the MDX, Velite fails the build and tells you exactly which file is missing it.
The Slug Gotcha
Velite's s.slug() reads the slug from frontmatter, not the filename. Every MDX file needs:
slug: your-slug-hereI learned this when Velite threw Required: slug on the first build attempt.
The MDX JSX Gotcha
MDX parses bare < followed by a number as a JSX tag. <50ms is valid JSX syntax for an unclosed component named 50ms — which isn't a valid identifier, so the parser throws. Always write "under 50ms" in MDX prose.
Three Features That Differentiated It
1. Build-Time Search
The search index is a static JSON file generated at build time. scripts/build-search-index.ts imports from .velite, aggregates all four content types, and writes public/search-index.json.
On the client, MiniSearch loads the index on first Cmd+K press, caches it in memory, and queries locally. Sub-5ms responses, works offline, zero API cost.
The type system for search covers all four content types:
export type ContentType = "article" | "experiment" | "case-study" | "note";Each type gets a distinct color in the search modal: emerald for case studies, blue for articles, amber for notes, purple for experiments. That color system ended up applied globally across listing pages, featured cards, and the tag index.
2. Content as a Knowledge Graph
Every content piece has two relationship fields:
tags: ["python", "fastapi"] # automatic — shared tags = related content
related: ["modular-rule-engine"] # explicit — cross-reference by slugBacklinks are computed automatically at build time. If a case study lists an experiment in its related field, the experiment detail page shows the case study under "Referenced By" without any manual configuration.
This is the Obsidian wiki-link model applied to an engineering portfolio. A visitor reading the FastAPI performance case study can follow a tag to see all Python content, or follow a backlink to the article that references it.
3. Content from Actual Notes
The most valuable content in this portfolio came from notes I wrote while working — Apple FM SDK explorations, PII detection benchmarks, debugging sessions. Not hypothetical tutorials, but real findings.
The pipeline was: write a note → strip company-specific references → reshape for a public audience → publish as article or case study. The PII detection benchmark in notes/apple-fm-sdk/07-pii-detection-benchmark.md became a full article with 25 test cases and quantitative results. The rule engine work became a case study with architectural diagrams.
Gotchas Worth Documenting
Velite ESM/CJS conflict. Velite is ESM-only. Trying to import it inside next.config.ts via import("velite") fails because Next.js compiles the config to CJS. Solution: run Velite as a separate && step, never import it in next.config.ts.
Code blocks need always-dark backgrounds. rehype-pretty-code uses Shiki's github-dark theme and outputs tokens with inline colors designed for dark backgrounds. If the code block background follows the site's light/dark theme, you get near-invisible light text on a light background in light mode. Fix: hardcode background: rgb(var(--color-code-bg)) to a dark slate color in both modes.
Mermaid's Math.random() causes React Strict Mode flicker. Mermaid needs a unique DOM ID for each render. Using Math.random() in the render function causes a double-render flash in Strict Mode because the ID changes between renders. Fix: useId() from React 18 gives a stable, SSR-safe unique ID per component instance.
dangerouslySetInnerHTML and children can't coexist. The initial Mermaid component tried dangerouslySetInnerHTML={svg ? { __html: svg } : undefined} with children as the loading state. React throws if both are present on the same element, even when one is undefined. Fix: split into two separate return paths — loading state returns an element with children, rendered state returns an element with dangerouslySetInnerHTML.
Navbar refactored into NavLinks and MobileMenu. As the navbar grew — desktop links, search trigger, social icons, theme toggle, mobile hamburger — it became a single 160-line client component. Splitting into NavLinks.tsx and MobileMenu.tsx reduced complexity and allowed the mobile menu to manage its own open/close state independently.
Architecture at a Glance
content/**/*.mdx
│
▼ velite build
.velite/index.js (typed exports)
│
├──▶ tsx scripts/build-search-index.ts
│ └──▶ public/search-index.json
│
└──▶ next build
├── Static pages (SSG)
├── /api/github (serverless)
├── /sitemap.xml
├── /robots.txt
└── /opengraph-image
What I'd Do Differently
Start with the content, not the tech. I spent too long on the pipeline before writing a single real article. The Velite setup, the theme system, the search index — none of it matters until there's content worth finding.
Add the TOC earlier. The sticky table of contents on article pages was one of the last things built. It's one of the highest-value features for long technical content, and it would have shaped how I structured articles from the start.
Content-type colors from day one. The emerald/blue/amber/purple color identity started in the search modal and worked backwards to the listing pages. If I'd defined it in a shared constant at the start, the cards, badges, and page headers would have been consistent from the first commit.