Skip to main content
Back to Blog

Building This Portfolio Site

5 min read
Next.jsMDXTailwind CSSTypeScript

Why Another Portfolio Site

Most portfolio sites are cookie-cutter templates. A hero banner, some project cards, a contact form. Done. The problem is that they all look the same, and none of them tell you anything meaningful about the person behind them.

I wanted something different. A site that demonstrates the same engineering thinking I bring to production systems: intentional architecture, clean abstractions, and performance that doesn't require apologies. Something a hiring manager or collaborator could poke at and think, "This person actually builds things."

The site you're reading right now is that attempt. Here's how it came together.

The Stack

The foundation is Next.js 16 with the App Router, TypeScript, and Tailwind CSS v4. Content lives in MDX files, which means I get the flexibility of Markdown with the power of React components when I need them.

// The blog data layer follows the same pattern as projects
export interface BlogPost {
  slug: string;
  title: string;
  description: string;
  date: string;
  tags: string[];
  readingTime: string;
  published: boolean;
}

export function getPublishedPosts(): BlogPost[] {
  return posts
    .filter((p) => p.published)
    .sort((a, b) => new Date(b.date).getTime() - new Date(a.date).getTime());
}

Why this stack? Next.js gives me static generation for performance, the App Router for clean routing, and built-in SEO primitives (generateMetadata, sitemap.ts, opengraph-image.tsx). Tailwind v4 uses CSS custom properties natively, which makes the dark mode system clean. And TypeScript catches the bugs that would otherwise show up in production.

Design Decisions

Three decisions shaped everything else.

Dark Mode That Works

The most common dark mode bug is the flash of wrong theme on first load. The user has dark mode set, but for a split second they see a white page before JavaScript hydrates and applies the correct theme.

The fix is an inline script in the <head> that runs before anything renders:

<script>
  (function() {
    const stored = localStorage.getItem('theme');
    const preferred = stored ||
      (window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light');
    document.documentElement.classList.toggle('dark', preferred === 'dark');
  })();
</script>

This runs synchronously before the first paint. No flash. The next-themes library handles toggling after hydration, but the initial state is set by raw JavaScript.

Terminal Aesthetic for Code

Code blocks on this site always use a dark theme, regardless of whether you're viewing the site in light or dark mode. This was a deliberate choice. Code belongs on a dark background. It's the terminal aesthetic: code is where the real work happens.

The syntax highlighting uses Shiki with the one-dark-pro theme. Both the light and dark theme configurations point to the same dark theme, so code blocks never flip when you toggle the site's color mode.

Content as Data, Not CMS

With only a handful of blog posts and five project pages, a CMS would be overhead. Instead, content lives in typed TypeScript arrays and MDX files. The data layer is just a file:

export const projects: Project[] = [
  {
    slug: "cash-flow-dashboard",
    title: "Cash Flow Dashboard",
    category: "restaurant-tech",
    techStack: ["TypeScript", "Drizzle ORM", "PostgreSQL"],
    featured: true,
    hasNDA: true,
    status: "completed",
  },
  // ...
];

When the site grows past 10-15 posts, a headless CMS becomes worth it. Until then, this is simpler and faster.

MDX Pipeline

MDX is Markdown with JSX. It lets me write blog posts as Markdown but drop in React components when I need something beyond text. For project case studies, that means architecture diagrams in lightbox overlays, impact blocks with structured data, and NDA disclaimers that render conditionally.

The pipeline uses @next/mdx with remark-gfm for GitHub Flavored Markdown (tables, strikethrough, task lists) and @shikijs/rehype for syntax highlighting. Headings get auto-generated IDs via rehype-slug, which powers the table of contents you see at the top of each post.

The tricky part was making this work with Next.js 16's default Turbopack bundler. Turbopack is written in Rust and can't serialize JavaScript function options across the Rust/JS boundary. Shiki needs function options for themes and transformers. The solution: use the --webpack flag for builds, which brings back full webpack compatibility. It's a minor trade-off in build speed for full plugin support.

What I'd Do Differently

RSS feed content. Right now the RSS feed includes post descriptions but not full rendered content. Rendering MDX to HTML for RSS requires a separate compilation step that adds complexity. For a two-post blog, the description is enough. As the post count grows, full-content RSS becomes worth the effort.

Testing. This site has zero automated tests. For a personal portfolio with no dynamic user input beyond the contact form, manual testing has been sufficient. But if I were building this for a client, I'd have Playwright tests for the critical paths.

Build-time TOC. The table of contents is generated client-side by scanning the DOM for headings. It works, but it means a brief moment after page load where the TOC isn't visible. A remark plugin that extracts headings at build time would be cleaner.

The site is open source. Poke around. Break things. Tell me what you'd do differently.