Next.js App Router for a Content-Heavy Portfolio: Trade-offs and Lessons
Back to Log
Next.jsReactPerformanceMDX

Next.js App Router for a Content-Heavy Portfolio: Trade-offs and Lessons

5 min read
Next.js

Problem Context

I needed a portfolio site that could handle:

  • Blog posts written in MDX with code syntax highlighting
  • Static generation for fast load times
  • Dark mode support
  • Deployable to Vercel's free tier

Constraints:

  • Build time must stay under 10 minutes (Vercel limit)
  • First Contentful Paint < 1.5 seconds
  • SEO-friendly (static HTML, not client-side rendered)
  • Easy content authoring (no database setup)

Initial Approach: Pages Router

The first version used Next.js Pages Router with MDX. This worked but had friction:

What didn't work:

  • Mixing server and client components was unclear
  • Layout sharing across routes required _app.tsx gymnastics
  • MDX file imports required custom webpack config
  • Route groups weren't available (wanted /blog and /projects with shared layouts)

The App Router promised better solutions for these problems.

Design Decisions & Trade-offs

App Router vs Pages Router

Why I switched to App Router:

  • Native MDX support via @next/mdx
  • Better layout hierarchy (nested layouts without hacks)
  • React Server Components by default (smaller bundle)
  • Clearer mental model for data fetching

Trade-offs:

  • Bleeding edge at the time (documentation gaps)
  • Some libraries didn't support RSC yet
  • Learning curve for server vs client components

The migration took a weekend but simplified the codebase significantly.

Content Strategy: File-Based MDX

Options considered:

  1. CMS (Contentful, Sanity): Overkill for a personal blog
  2. Markdown in database: Requires backend, complicates deployment
  3. MDX files in /content: Simple, version-controlled, no backend

I chose Option 3 because:

  • Blog posts are code (commits show history)
  • No external dependencies
  • Local development doesn't need API keys

Trade-off: Adding a new post requires a rebuild. For a personal blog, that's acceptable.

Build Performance with MDX

MDX compilation is slow. With 20 blog posts, build time went from 45 seconds to 3 minutes.

Problem: Each MDX file is compiled individually at build time.

Optimization attempts:

  1. Caching MDX output: Didn't help (Next.js already caches)
  2. Lazy loading MDX: Broke static generation
  3. Simpler MDX plugins: Removing rehype-prism saved 20 seconds

Final solution: Accepted the build time. 3 minutes is below Vercel's limit and only happens on deploy, not in development.

What I would change: If the blog grows to 100+ posts, consider:

  • Pagination to reduce builds (only rebuild changed posts)
  • Moving syntax highlighting to client-side (highlight.js on demand)

Styling: Tailwind with Typography Plugin

Why Tailwind:

  • Utility-first reduces custom CSS
  • @tailwindcss/typography handles blog post styling automatically
  • Dark mode support via dark: prefix

Trade-off: The final CSS bundle is ~50KB (gzipped). For a blog, that's acceptable. For a landing page, it's heavy.

Static Generation for Blog Posts

All blog posts use generateStaticParams() to pre-render at build time.

export async function generateStaticParams() {
  const posts = getAllPosts();
  return posts.map((post) => ({ slug: post.slug }));
}

Why static over SSR:

  • Content doesn't change between deploys
  • Faster load times (no server rendering delay)
  • Works on Vercel's Edge Network

Trade-off: Updated posts require a redeploy. This is fine for a personal blog.

Implementation Notes

Image Handling

Blog post images are stored in /public/images/blog-post/.

Initial mistake: Used raw <img> tags. This meant:

  • No lazy loading
  • No responsive sizing
  • No format optimization (WebP)

Solution: Use Next.js <Image> component where possible.

Limitation: Inside MDX, <Image> requires imports. For simplicity, I kept<img> in MDX but added loading="lazy" manually.

Dark Mode Implementation

Dark mode uses Tailwind's dark: class strategy with a custom theme switcher.

Challenge: Preventing flash of wrong theme on page load.

Solution:

  • Store theme preference in localStorage
  • Inject a blocking script in <head> that sets the class before render
  • Use next-themes package to handle the logic

This added ~2KB to the bundle but eliminated the flash.

Deployment: Vercel

Why Vercel:

  • Native Next.js support
  • Free tier includes HTTPS and custom domains
  • Automatic deploys from GitHub

Constraint: 10-minute build limit on free tier.

Risk: If blog posts exceed ~100, build time might hit the limit. Mitigation plan: upgrade or switch to incremental static regeneration.

Results & Impact

Current performance (Lighthouse):

  • Performance: 98
  • Accessibility: 100
  • Best Practices: 100
  • SEO: 100

Build time: 3 minutes for 5 blog posts

Bundle size:

  • Initial JS: ~85KB (gzipped)
  • CSS: ~50KB (gzipped)

What stayed hard:

  • MDX syntax checking (no linter caught errors until build)
  • Code highlighting in dark mode (needed custom CSS tweaks)
  • RSS feed generation (had to write custom script)

Takeaways

Choosing App Router early paid off. While it had rough edges, the layout system and RSC model simplified the codebase.

MDX build performance is acceptable for personal blogs but would need optimization for larger content sites. The trade-off is worth it for the authoring experience.

For anyone building a similar portfolio: start with static generation, add complexity only when you hit real constraints. Don't optimize build time until it's actually a problem.

Share this article