How I Built This Portfolio
I've rebuilt this portfolio three times.
The first time was because I wanted to build a portfolio and had absolutely no clue what I was doing.
The second came after I looked back at the first one and questioned every decision I'd ever made.
The third? It's the first version I'm genuinely proud to share, and I can confidently send it to someone and say, "Hey, check out my amazing portfolio" and actually mean it.
This post is the writeup I wish I'd had before rebuilding my portfolio for the third time. I'll walk through what's running under the hood, why I made the choices I did, and which ones I'd defend to the death versus the ones I'll probably quietly undo in six months while muttering to myself.
Fair warning, this is a long one. Go get a coffee. Or a tea.
This exact post is itself a live demo of everything I'm about to describe. Every component you'll see rendered below, this callout, the code blocks, the file tree, the Mermaid diagram later on, is real MDX compiling at build time. Nothing here is not an MDX driven component. Trust me, I checked. Twice.
#The stack, so you know what you're dealing with
Next.js App Router, Tailwind, Shadcn for primitives, Motion for anything that moves, and MDX for basically every scrap of long-form content on the site: blog posts, project case studies, lab writeups, all of it.
No CMS. No headless anything. No database. Everything is a file sitting in a folder, being extremely chill about its own existence.
Here's the question I get most: why does a personal portfolio need this much machinery? Honestly, it doesn't. You could ship this exact site as a folder of static HTML and nobody browsing it would notice from the outside. But I wanted the site itself to be proof of how I like to build things, and a portfolio held together with duct tape is a weird advertisement for someone claiming to be good at building software. So the plumbing matters here more than it would for, say, a landing page selling a productivity app with a cartoon mascot.
#Three content types, one pattern, zero manual registries
This is the part of the codebase I'm most smug about, mostly because it kills a bug that used to eat twenty minutes of my life on a weekly basis: forgetting to register new content somewhere and then wondering why my brand new blog post refuses to exist.
Blog posts, project case studies, and lab experiments all follow the same three-step recipe.
Drop a file. A new post is a new MDX file in the right content folder. That's the whole authoring step. No ceremony, no sacrificing a rubber duck to the build gods.
Add frontmatter. Title at minimum, plus whatever fields the content type expects. Date and tags for blog posts, tech stack and links for projects, a category and a demo reference for lab entries.
Let the scanner find it. A small module per content type walks its folder the moment the server starts, reads each file's frontmatter with gray-matter, and builds a typed list in memory. Routes generate their static params straight off that list.
Here's roughly what that scan looks like, trimmed and generalized:
function buildRegistry(): MDXPageMeta[] {
const dir = join(process.cwd(), "content/blog");
const files = readdirSync(dir).filter((f) => f.endsWith(".mdx"));
return files.map((file) => {
const slug = file.replace(/\.mdx$/, "");
const raw = readFileSync(join(dir, file), "utf-8");
const { data } = matter(raw);
if (!data.title) return null; // silently skipped, no title, no page
return {
slug,
title: data.title,
date: data.date ?? "1970-01-01",
tags: data.tags ?? [],
path: `/blog/${slug}`,
import: () => import(`../content/blog/${slug}.mdx`),
absPath: join(dir, file),
};
}).filter(Boolean);
}That import field is doing the real work, it's a lazy loader, not the compiled component itself. Whichever page renders a single post awaits it inside the page component, so every post still gets its own code-split chunk, instead of the whole blog shipping as one bundle whether you're reading one post or none.
The lab content type has one extra layer bolted on. Alongside the lazy MDX import, every entry also carries a lazy loader for a real, running React component. The listing page renders a small, scaled-down instance of every single demo directly on its card. Not a screenshot standing in for one, an actual live component quietly doing its thing at 70% scale like it's being punished for something.
#Why not a real CMS
I looked at a couple of headless CMS platforms and some content-layer tools first. They're all genuinely good. I passed for a boring reason: I didn't want to run or pay for another service to publish a handful of posts a year, and I liked that with plain files, my version history doubles as my content history.
If I'm ever publishing daily I'll revisit this. For now a folder and a text editor is the entire CMS, and I've made peace with the fact that fixing a typo means shipping a whole new build. It's fine. It's genuinely fine. Stop looking at me like that.
#Frontmatter, and what each content type actually needs
Since I'm trying to cover everything, here's what a real frontmatter block looks like, since that's genuinely the entire authoring interface:
---
title: "Your post title"
description: "One sentence, shows up in the card and the meta tags"
date: "2026-07-16"
tags: ["nextjs", "react"]
featured: false
readTime: 8
---A project adds a cover image, a gallery array for the case study's carousel, categories for the filter pills, a tech stack list, and optional live/repo links. A lab entry drops the date and tags entirely and points at a demo component reference instead, since lab entries sort alphabetically rather than by publish date, there's no real concept of "when" for a UI experiment you might keep tweaking forever out of pure stubbornness.
#The client/server boundary problem
Since content scanning lives entirely in server land, none of it can be handed directly to a client component. The fix is a pair of small adapter functions per content type:
export type MDXPageClientMeta = Omit<MDXPageMeta, "import" | "absPath">;
export function toClientMeta(pages: MDXPageMeta[]): MDXPageClientMeta[] {
return pages.map(({ import: _drop, absPath: _dropToo, ...rest }) => rest);
}Every page that needs to hand this data to something interactive runs it through one of these first. A bit of ceremony, but it means "functions aren't serializable" only has to teach me its lesson once per content type instead of once per page I happen to be touching that week.
#Syntax highlighting
If you've glanced at any code block above, it was colored and formatted by Shiki at build time, not by a client-side library running after the page loads. This is the opposite of what most tutorials tell you to do, and it's worth explaining, because it sounds like overkill right up until you've seen the alternative in the wild.
The usual approach ships a highlighter library and a grammar file down the wire, runs it after hydration, and lets the code block render as plain unstyled text for a frame before it snaps into color. That flash has an actual name, FOUC (flash of unstyled content), and it's invisible right up until someone points it out. After that you'll see it everywhere, forever, like a word you just learned and now can't unhear.
Shiki does something different. It uses the same TextMate grammars VS Code itself uses, so highlighting is genuinely accurate instead of "close enough with a pile of regex." And because it can run entirely inside a build step, I generate the final highlighted HTML once, for every theme I care about, and ship plain static markup. Zero client-side JavaScript required to make code look correct.
let highlighterPromise: Promise<Highlighter> | null = null;
async function getHighlighter() {
if (!highlighterPromise) {
highlighterPromise = createHighlighter({
themes: ["github-light", "github-dark"],
langs: Object.keys(bundledLanguages),
});
}
return highlighterPromise;
}That highlighter instance is memoized at module scope on purpose. Skip the memoization and every code block spins up its own Shiki instance, which is slow, and also a great way to run a build out of memory on a page with a lot of snippets. Ask me how I know.
#Live diagrams: Mermaid, rendered fully in the browser
Here's a part of the content system I've skipped talking about before, and it's one of my favorites, mostly because it's the exact opposite philosophy from the code blocks above.
Where code highlighting deliberately happens at build time to avoid client work, diagrams go the other way entirely and render live in the browser, on purpose, like a plot twist I planned all along.
Here's an actual one, showing roughly how a request for a blog post flows through the content system I just described:
Why go client-side here, right after three sections convincing you build-time rendering is objectively correct and morally superior? Because Mermaid's renderer is fundamentally a browser-oriented library, it wants a real DOM to draw into. Getting it to behave inside a Node build step without one is a much bigger lift than it's worth for how often I actually reach for a diagram, which is "occasionally, when words alone start feeling insufficient."
So the diagram component takes raw diagram source as its content, and on mount asks Mermaid to render it into SVG, which gets dropped onto the page.
A few things went into making this feel less like a bolted-on widget:
- It respects the theme. Mermaid bakes its colors in at render time instead of reading CSS variables live, so flipping between light and dark mode doesn't automatically restyle a diagram that's already drawn. A small MutationObserver watches for the theme change and, when it happens, throws the old diagram away and redraws the whole thing from scratch with a fresh palette. Dramatic, but effective.
- It has real loading and error states. While Mermaid chews on the source, you get a small spinner and a "rendering" message instead of a mysterious blank gap. Typo in the diagram syntax? You get an actual readable error box, not a silent failure or a raw crash that makes you question your life choices.
- Each render gets its own random id, since Mermaid needs a unique id per instance to avoid collisions if more than one diagram shows up on the same page.
Because this renders entirely client-side, a diagram-heavy post shows a brief loading flash before the SVG appears, especially on a slow connection. I've decided that's a fair price for how rarely I reach for a diagram compared to how often I write a code block. If I ever wrote something mostly-diagrams, I'd revisit build-time Mermaid rendering. Today is not that day.
#The table of contents, and why it was more work than it should have been
Any article with more than one heading gets a sidebar table of contents. If you've been scrolling as you read this, you've probably noticed the current section staying highlighted as you move through the page. Both of these turned out to be way more interesting to build than they had any right to be.
#Extracting headings with a regex, on purpose
The heading list comes from a plain regular expression, not a full markdown AST walk:
const headingRegex = /^(#{2,4})\s+(.+)$/gm;I know how that sounds, I had the exact same reaction when I wrote it. A proper AST is the right tool if you actually need to understand a document's structure. But the requirement here is small and specific: find lines starting with two to four hash marks, grab the text, generate an anchor id.
A full parsing pipeline earns its keep when I need to reason about nested markdown. For "list the headings in this file," a regex is faster to write, faster to run, and has zero extra dependency to keep in sync. It's not bulletproof, if I ever wrote something heading-shaped inside a code fence it would get picked up incorrectly. Across everything I've written so far that hasn't happened once, so I'm choosing not to worry about it until it does.
#The Writing section
This one exists because I like explaining how things work almost as much as I like building them, and for a long time those explanations just lived nowhere, scattered across gists and half-written tweets and long messages to friends that had way more effort in them than a chat message really deserves.
Featured posts get pulled into the same cover-flow treatment you'd see on the home page, so the thing I most want someone to read is the thing they actually see first instead of buried in a list. Everything else sorts by date and can be filtered by tag, which sounds like a small feature until you've got more than a handful of posts and suddenly can't remember which one had the thing about Shiki.
This exact post is a pretty good example of why the page exists in the first place. I sat down meaning to write a few paragraphs about a nav bug and ended up here, several thousand words later, because apparently that's just what happens once I give myself a proper place to do it instead of a DM someone will never search for again.
#The Projects section
This one came from a much more specific annoyance: I kept explaining the same project to different people in different DMs, and every time I'd forget to mention the one detail that actually made it interesting. Someone asks "what have you built" and I send a bare link, and the link does absolutely none of the work of explaining why the thing mattered.
Featured work opens in an auto-advancing carousel up top, the same one I talked about earlier with the eight-second cycle and the six-hundred-millisecond lock. Everything else sits in a filterable grid by category, so if someone only cares about, say, interactive experiments and not design systems, they don't have to scroll past everything to find what's relevant to them.
What actually matters here is that each project gets to be a story instead of a screenshot. What the problem was, what I tried first and why it didn't work, what I'd do differently now. That's the difference between "here's a link" and "here's why you should care about this link," and I was tired of being on the wrong side of that difference every time someone asked.
#The lab section
The lab is a small gallery of interactive experiments. A button that follows your cursor magnetically. A liquid-feeling hover button with a soft gradient blur. A shape that endlessly morphs through a loop of border radius, scale, and rotation. A card that flips on click with a real spring transition. A list with a shared layout animation between its toggle and its items. A scroll-linked reveal tied directly to how far something has traveled through the viewport.
Each entry pairs an MDX writeup with a genuinely live, running component. Not a screenshot standing in for one, not a gif pretending to be interactive.
This section exists mostly because I wanted a low-stakes place to try small interaction ideas without needing to justify them as part of a "real" project. Some turned into patterns I've reused elsewhere on the site, the drag physics on the work section cards actually started life as a lab experiment first. Some are just fun to fidget with, which I've decided is a perfectly fine reason for a section to exist on a personal site.
#Why the site looks the way it looks
Before I get into any of the fun technical stuff, I want to talk about the look of the site itself, because I spent a genuinely embarrassing number of hours just staring at it before I ever felt okay writing code for it.
I'm an Apple fan, and it shows. Not in a "I downloaded their font and called it a day" way, more in a "I've watched way too many of their product pages scroll by and started asking myself why they feel the way they feel" way. There's a specific kind of calm those pages have. Nothing is shouting at you. There's room to breathe between things. Whatever you're supposed to be looking at is obvious, because everything else quietly gets out of the way.
So that's what I chased here. I gave things room instead of cramming the page edge to edge, because a page that's out of breath is exhausting to look at even if you can't say why. I kept color mostly out of it, the site is mostly neutral tones, so when claret or lemon actually show up, they mean something instead of being background noise competing with twelve other colors for your attention.
Type does a lot of the emotional labor too. Headings are big and tight and confident, because I want them to feel like a statement, not a suggestion. Body text gets to relax, generous line height, nothing packed in, because I want it comfortable to actually sit and read, not just skim past. You're several thousand words into this post right now, if it felt like a wall of text the whole way down, I'd have failed at this part specifically.
And motion. I'm honestly a little precious about this one. I don't want things moving just because moving was easy to add, I want one thing moving at a time, doing a job, while everything else holds still around it. That's the exact instinct behind the written animation rulebook I get into further down, I noticed myself reaching for motion out of habit instead of intent, and I didn't like that, so I made myself write down when I was actually allowed to reach for it.
None of this is stuff a visitor consciously notices. Nobody's going to land on the homepage and think "ah, generous whitespace, tight heading tracking, single-focus motion." But I think they feel it anyway, the same way you can walk into a room that's been thought about and just feel more at ease, without being able to point at exactly why.
#Dark mode, and why it's the default
Theming runs on next-themes, which handles the genuinely annoying parts by hand: reading the saved preference before React hydrates so there's no flash of the wrong theme, and respecting system preference automatically if nobody's picked one yet.
The site defaults to dark. Partly aesthetic preference, but mostly because that's how I actually look at my own site the overwhelming majority of the time, and defaulting to whatever I personally use most meant dark mode contrast issues got caught first, as a primary concern, instead of as an afterthought bolted on once the light theme already looked finished and I'd lost the will to double check the other one.
#Accessibility notes, the honest version
I'd love to say accessibility was baked in from the first commit. That wouldn't be true, so I won't pretend it was.
What is true: interactive elements use real semantic elements, proper aria labels on icon-only controls like the carousel arrows and the mobile menu toggle, color contrast checked in both themes rather than just the one I personally look at more, and visible focus states rather than suppressed ones. I mention that last one specifically because I've seen far too many portfolio sites strip outline styles for aesthetic reasons, and I didn't want to be one of them just to shave off a pixel of visual noise.
The active nav link, after the fix described earlier, also sets an explicit "current page" attribute now, so the current section gets communicated to assistive tech, not just conveyed with an underline that a screen reader has no opinion about.
What I haven't done a full pass on yet: keyboard navigation through the draggable project windows specifically, since dragging is inherently a mouse and touch interaction and I haven't built a keyboard-accessible equivalent for repositioning them. I also haven't run a full screen reader pass across every page. Both are on the list. I'd rather say that plainly than imply this is further along than it actually is.
#Wrapping up
If you made it all the way down here, genuinely, thank you. This turned into a much longer post than I planned when I sat down to write it, which is apparently just what happens when I start explaining my own decisions to myself in writing.
The short version: read files off disk instead of a database, highlight code before the browser ever sees it but render diagrams live because the tradeoff runs the other way, animate things that deserve animating and write the rules down so you stop guessing, and be honest in public, bugs included, not just the parts that turned out well.
That's more or less the whole philosophy behind this site. Now you actually know exactly how it's put together, warts, jokes about traffic light dots, and all.