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.tsxgymnastics - MDX file imports required custom webpack config
- Route groups weren't available (wanted
/blogand/projectswith 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:
- CMS (Contentful, Sanity): Overkill for a personal blog
- Markdown in database: Requires backend, complicates deployment
- 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:
- Caching MDX output: Didn't help (Next.js already caches)
- Lazy loading MDX: Broke static generation
- Simpler MDX plugins: Removing
rehype-prismsaved 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.json demand)
Styling: Tailwind with Typography Plugin
Why Tailwind:
- Utility-first reduces custom CSS
@tailwindcss/typographyhandles 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-themespackage 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.
