A Portfolio Should Be a Graph, Not a Landing Page
I wanted this portfolio to be an engineering knowledge base — where every article, case study, experiment, and note is cross-linked. Where a visitor reading about FastAPI performance can follow a backlink to the Kubernetes deployment article that references it, or jump to the related case study through shared tags. Where content compounds over time instead of sitting in isolation.
The result is a growing collection of articles, case studies, experiments, and notes — all cross-referenced, searchable in under 5ms, and statically generated with zero runtime cost.
Here's how I built it.
The Architecture
Three sequential build steps, each only runs if the previous succeeds:
"build": "velite build && tsx scripts/build-search-index.ts && next build"Velite compiles MDX into typed JavaScript, the search script indexes it, and Next.js statically generates all routes from the output. At runtime, two things need a persistent layer: reader reactions (shared across all visitors) and reading progress (local to the browser).
Five Features That Make It Work
1. 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 at build time. If a case study lists an experiment in its related field, the experiment page shows the case study under "Referenced By" — no manual wiring needed.
This is the Obsidian wiki-link model applied to a portfolio. A visitor reading the FastAPI performance case study can follow a tag to all Python content, or follow a backlink to the article that references it. Content compounds instead of sitting in silos.
2. Build-Time Search (Under 5ms, Works Offline)
No search API. No Algolia. The search index is a static JSON file generated at build time from all 4 content types.
On the client, MiniSearch loads the index on first Cmd+K press, caches it in memory, and queries locally. Each content type gets a distinct color in the results: emerald for case studies, blue for articles, amber for notes, purple for experiments.
export type ContentType = "article" | "experiment" | "case-study" | "note";That color system ended up applied globally — listing pages, featured cards, tag index, search modal — all from a single type definition.
3. Content From Real Work, Not Tutorials
The most valuable content here came from notes I wrote while working — PII detection benchmarks, rule engine architectures, debugging sessions. Not hypothetical tutorials, but real findings.
The pipeline: write a note while solving a problem, strip company-specific references, reshape for a public audience, publish. A PII detection benchmark became a full article with 25 test cases. Rule engine work became a case study with architecture diagrams.
If you're building a portfolio and don't know what to write about, start with what you debugged last week.
4. Reader Reactions (Upstash Redis, Zero Infrastructure)
A static site can still have dynamic features — as long as you're deliberate about what needs a server and what doesn't.
Reactions need to be shared across all visitors (one reader's "useful" vote should be visible to the next). That rules out localStorage. The solution is a serverless Redis store via Upstash — REST-based, no connection pooling, no cold start penalty, free tier covers a portfolio comfortably.
// /api/reactions — two operations
GET ?slug=x → MGET reactions:x:useful reactions:x:explained reactions:x:more
POST ?slug=x&type=useful → INCR reactions:x:usefulArticles get three reactions (👍 Useful / 💡 Well explained / 🔖 Want more like this). Notes get a binary yes/no with a live percentage shown after voting. One vote per browser session via localStorage — no login required.
The rate limiter mirrors the /api/github route: per-IP, per-slug, per-hour, in-memory. Not bulletproof across serverless cold starts, but sufficient at portfolio scale.
5. Continue Reading (localStorage Only)
Reading progress is the opposite case from reactions — it's personal to the reader, not shared. localStorage is the right tool. No Redis commands spent.
A useReadingProgress hook attaches a debounced scroll listener (500ms) and saves:
// localStorage key: reading:{slug}
{ slug, title, permalink, type, progress: 72, updatedAt: 1741234567 }A ReadingProgressBar component renders a 2px accent bar fixed to the top of the viewport — live scroll progress while reading, and the same hook call saves position as a side effect.
On listing pages, ContinueReading reads all in-progress entries and surfaces the most recently read piece above the list. It disappears once progress hits 90% — assumed complete.
The key decision: use localStorage for anything personal and ephemeral, Redis for anything that needs to be visible across visitors. The two never overlap.
The Stack Decision That Mattered
Next.js 15 (App Router) was the obvious choice. The decision that actually mattered was the content pipeline.
The old portfolio used mdx-bundler — a Pages Router tool. The App Router alternatives: next-mdx-remote/rsc requires manually writing TypeScript interfaces for frontmatter and building your own getAllArticles(). Contentlayer is dead — last release June 2022, killed by Stackbit's acquisition.
Velite solved everything. Zod-native schemas, auto-generated TypeScript types, build-time slug validation, MDX compiled to function-body strings for zero client-side cost, and Turbopack compatibility.
Importing content is one line:
import { articles, caseStudies, experiments, notes } from "#velite";Everything is typed. The frontmatter schema is the source of truth. Add a field to the schema without updating the MDX, and Velite fails the build telling you exactly which file is broken.
Gotchas That Cost Me Hours
Velite is ESM-only. Importing it inside next.config.ts fails because Next.js compiles the config to CJS. Solution: run Velite as a separate && step in the build script.
MDX parses <50ms as a JSX tag. A bare < followed by a number is valid JSX syntax for an unclosed component named 50ms. The parser throws. Always write "under 50ms" in prose.
Code blocks need always-dark backgrounds. rehype-pretty-code outputs tokens with inline colors for dark backgrounds. If the code block follows the site theme, you get invisible light text on light backgrounds in light mode. Fix: hardcode the code background to dark slate in both modes.
Math.random() in Mermaid causes Strict Mode flicker. Mermaid needs a unique DOM ID. Math.random() changes between Strict Mode's double renders, causing a flash. Fix: React 18's useId() gives a stable, SSR-safe unique ID.
dangerouslySetInnerHTML and children can't coexist. Even when one is undefined, React throws. Fix: separate return paths for loading vs. rendered states.
What I'd Do Differently
Start with the content, not the tech. This is the biggest lesson. I spent too long perfecting the pipeline — the Velite config, the theme system, the search index — before writing a single real article. None of it matters until there's content worth finding. The pipeline should serve the content, not the other way around. If I started over, I'd write 5 articles in plain Markdown before touching a single build tool.
Add the table of contents earlier. The sticky TOC 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.
Define the color system on day one. The emerald/blue/amber/purple identity started in the search modal and worked backwards to listing pages. If I'd defined it in a shared constant from the first commit, cards, badges, and headers would have been consistent from the start instead of retrofitted.
Add engagement features earlier. Reactions and reading progress were afterthoughts, but they're cheap to build and immediately useful — reactions tell you which content resonates, reading progress tells you where people drop off. Both should have been in the first deploy.