Building a Modern Portfolio with Next.js 16: Server Components, Performance, and Best Practices
Building a Modern Portfolio with Next.js 16
After working with Next.js on various projects, I decided it was time to rebuild my portfolio from the ground up using the latest and greatest: Next.js 16 with the App Router, React Server Components, and all the modern best practices I've learned.
This post breaks down the architecture decisions, performance optimizations, and lessons learned from building a production-ready portfolio that's fast, maintainable, and future-proof.
Why Next.js 16?
I could have gone with any framework (or no framework at all), but Next.js 16 offered everything I needed:
✅ React Server Components - Less JavaScript shipped to the client
✅ Static Generation - Blazing-fast page loads
✅ Built-in Optimization - Image optimization, font loading, etc.
✅ TypeScript - Type safety throughout
✅ Great DX - Fast refresh, great error messages
Architecture Decisions
1. Server Components First
The biggest shift in Next.js 13+ is React Server Components (RSC). My rule: everything is a Server Component by default unless it needs interactivity.
// This is a Server Component (default)
export default function HeroSection() {
return (
<section>
<h1>Hi, I'm Brent 👋</h1>
<p>A full-stack developer...</p>
</section>
);
}
Only when I need client-side interactivity (like animations or state) do I add 'use client':
// Client Component for interactive avatar
'use client';
import { motion } from 'framer-motion';
export function AnimatedAvatar() {
return (
<motion.div whileHover={{ scale: 1.1 }}>
<Avatar src="/brent.webp" />
</motion.div>
);
}
Benefits:
- Smaller JavaScript bundles
- Faster hydration
- Better SEO (more content server-rendered)
- Lower Time to Interactive (TTI)
2. Data Organization
I separated all content into a clean data layer:
src/
├── data/
│ ├── projects/
│ │ └── projects.ts # All project data
│ ├── skills.tsx # Skills with icons
│ └── experience.ts # Work & education
├── lib/
│ ├── blog.ts # Blog utilities
│ └── animations.ts # Reusable animation configs
└── content/
└── blog/ # MDX blog posts
Why this matters:
- Content updates don't require touching component code
- Single source of truth
- Easy to migrate to a CMS later
- Better for team collaboration
3. Component Strategy
I follow a clear component hierarchy:
Components
├── Server Components (default)
│ ├── HeroSection
│ ├── ProjectCard
│ └── Footer
├── Client Components ('use client')
│ ├── AnimatedSection (animation wrapper)
│ ├── WaveEmoji (interactive)
│ └── AnimatedAvatar (hover effects)
└── UI Components (shadcn/ui)
├── Button
├── Card
└── Dialog
The pattern: Server Components render the structure and data, Client Components handle interactions.
Performance Optimizations
1. Image Optimization
Next.js Image component is incredible. Here's how I use it:
import Image from 'next/image';
<Image
src="/project-screenshot.webp"
alt="Project screenshot"
width={1200}
height={630}
sizes="(max-width: 768px) 100vw, (max-width: 1200px) 50vw, 33vw"
priority={false} // Only true for above-the-fold images
loading="lazy"
/>
Automatic benefits:
- WebP/AVIF format conversion
- Responsive image srcsets
- Lazy loading
- Blur placeholder
- Cumulative Layout Shift (CLS) prevention
Result: Images are typically 60-80% smaller than the originals.
2. Font Optimization
Using next/font for automatic font optimization:
import { Roboto, Fira_Code } from 'next/font/google';
const roboto = Roboto({
subsets: ['latin'],
weight: ['400', '500', '700'],
display: 'swap',
variable: '--font-roboto',
});
const firaCode = Fira_Code({
subsets: ['latin'],
display: 'swap',
variable: '--font-fira-code',
});
Benefits:
- Zero layout shift (fonts load with correct metrics)
- Self-hosted (no external requests)
- Automatic subsetting
font-display: swapfor instant text rendering
3. Bundle Size
Initial bundle size was concerning, so I analyzed it:
pnpm build:analyze
Findings:
- Framer Motion was 40KB (worth it for animations)
- Lucide Icons added 5KB (replaced heavier alternatives)
- Removed unused dependencies (saved 120KB)
Optimizations:
// Before: Importing entire library
import * as Icons from 'lucide-react';
// After: Tree-shakeable imports
import { Calendar, Clock, Github } from 'lucide-react';
Configured in next.config.ts:
experimental: {
optimizePackageImports: ['framer-motion', 'lucide-react'],
}
4. Code Splitting
Used dynamic imports for heavy components:
import dynamic from 'next/dynamic';
// Lazy load the Projects component (heavy with images)
const Projects = dynamic(() => import('@/components/projects/Projects'), {
loading: () => <LoadingSpinner />,
ssr: false, // Skip SSR for this component
});
SEO & Metadata
Next.js 16 has a great Metadata API:
// app/layout.tsx
export const metadata: Metadata = {
title: {
default: 'Brent Vervaet | Full-Stack Developer',
template: '%s | Brent Vervaet',
},
description: 'Full-stack developer specializing in...',
keywords: ['Brent Vervaet', 'Full-Stack Developer', ...],
openGraph: {
type: 'website',
locale: 'en_US',
url: 'https://brentvervaet.dev',
title: 'Brent Vervaet | Full-Stack Developer',
images: ['/og-image.webp'],
},
twitter: {
card: 'summary_large_image',
creator: '@brentieV',
},
robots: {
index: true,
follow: true,
},
};
For dynamic pages:
// app/blog/[slug]/page.tsx
export async function generateMetadata({ params }): Promise<Metadata> {
const post = await getPost(params.slug);
return {
title: post.title,
description: post.description,
openGraph: {
type: 'article',
publishedTime: post.date,
authors: ['Brent Vervaet'],
},
};
}
Added structured data:
<script type="application/ld+json">
{
"@context": "https://schema.org",
"@type": "Person",
"name": "Brent Vervaet",
"jobTitle": "Full-Stack Developer",
"url": "https://brentvervaet.dev",
"sameAs": [
"https://github.com/brentvervaet",
"https://linkedin.com/in/brentvervaet"
]
}
</script>
Error Handling
Proper error boundaries make a huge difference:
// app/error.tsx
'use client';
export default function Error({ error, reset }) {
return (
<div className="error-container">
<h2>Something went wrong</h2>
<p>{error.message}</p>
<button onClick={reset}>Try again</button>
</div>
);
}
// app/not-found.tsx
export default function NotFound() {
return (
<div>
<h1>404 - Page Not Found</h1>
<Link href="/">Go home</Link>
</div>
);
}
Result: Users get helpful, branded error pages instead of blank screens.
Blog Implementation
Added an MDX-based blog for technical writing:
// lib/blog.ts
import fs from 'fs';
import path from 'path';
import matter from 'gray-matter';
import readingTime from 'reading-time';
export function getBlogPosts() {
const postsDirectory = path.join(process.cwd(), 'src/content/blog');
const fileNames = fs.readdirSync(postsDirectory);
return fileNames
.filter(fileName => fileName.endsWith('.mdx'))
.map(fileName => {
const fullPath = path.join(postsDirectory, fileName);
const fileContents = fs.readFileSync(fullPath, 'utf8');
const { data, content } = matter(fileContents);
return {
slug: fileName.replace(/\.mdx$/, ''),
title: data.title,
date: data.date,
description: data.description,
tags: data.tags,
readingTime: readingTime(content).text,
content,
};
})
.sort((a, b) => (a.date < b.date ? 1 : -1));
}
Blog posts are just MDX files with frontmatter:
---
title: "My Blog Post"
date: "2026-04-23"
description: "Description here"
tags: ["nextjs", "react"]
---
# Content here
```typescript
const hello = "world";
**Features:**
- Syntax highlighting (rehype-pretty-code)
- Reading time calculation
- Tag filtering
- SEO optimization
- Static generation
## Analytics
Integrated Vercel Analytics for tracking:
```tsx
// app/layout.tsx
import { Analytics } from '@vercel/analytics/react';
import { SpeedInsights } from '@vercel/speed-insights/next';
export default function RootLayout({ children }) {
return (
<html>
<body>
{children}
<Analytics />
<SpeedInsights />
</body>
</html>
);
}
Zero configuration on Vercel. Just add the components and deploy.
Build Results
Final production build:
Route (app) Size
┌ ○ / 142 kB
├ ○ /about 89 kB
├ ○ /blog 95 kB
├ ● /blog/[slug] 112 kB
├ ● /projects/[slug] 156 kB
└ ○ /sitemap.xml 1.2 kB
○ Static
● SSG (Static Site Generation)
Build time: 2.8s
Performance scores (Lighthouse):
- Performance: 98
- Accessibility: 100
- Best Practices: 100
- SEO: 100
Lessons Learned
1. Server Components Are a Game Changer
Reducing JavaScript on the client makes a massive difference. My initial page load dropped from 180KB to 45KB of JavaScript.
2. TypeScript Strict Mode is Worth It
I enabled strict mode from day one:
{
"compilerOptions": {
"strict": true,
"noUncheckedIndexedAccess": true,
"noImplicitReturns": true
}
}
Caught dozens of potential bugs during development.
3. Data Separation is Key
Extracting all content into /src/data/ made updates trivial. I can now update projects or skills without touching any component code.
4. Framer Motion is Powerful (But Heavy)
Animations are great for polish, but be strategic. I only use Framer Motion for complex animations—simple hover effects use CSS.
5. Build Times Matter
Next.js 16 with Turbopack is fast. Cold builds in under 3 seconds. Fast Refresh is instant.
What's Next?
Future improvements I'm considering:
- View Transitions API instead of Framer Motion
- Progressive Web App (PWA) support
- RSS feed for the blog
- Newsletter integration
- Dark mode toggle (currently auto-detects)
Tech Stack Summary
- Framework: Next.js 16 (App Router)
- Language: TypeScript (strict mode)
- Styling: Tailwind CSS 4
- UI Components: shadcn/ui + Radix UI
- Animations: Framer Motion
- Icons: Lucide React
- Blog: MDX with next-mdx-remote
- Analytics: Vercel Analytics + Speed Insights
- Deployment: Vercel
- Package Manager: pnpm
Key Takeaways
Building a modern portfolio taught me:
- Server Components > Client Components for most use cases
- Performance budgets matter - measure everything
- Proper data organization makes maintenance easy
- Error boundaries are non-negotiable
- Analytics early help you iterate
If you're building a portfolio, focus on:
- Fast load times (< 3s)
- Great mobile experience
- Clear CTAs (hire me, view projects, etc.)
- Showing your work, not just listing it
View the source: GitHub Repository
Live site: brentvervaet.dev
Questions or feedback? Connect with me on LinkedIn or GitHub!