Back to Blogs
Why Most Next.js Portfolios Are Poorly Optimized (And How to Fix Yours)
nextjsperformanceoptimizationportfolioweb-vitals

Why Most Next.js Portfolios Are Poorly Optimized (And How to Fix Yours)

Your Next.js 16 portfolio might score 100 on Lighthouse and still be slow. Here is how to actually optimize for the metrics that matter in 2026.

0 views
0

Your Next.js 16 portfolio might score 100 on Lighthouse and still be slow. Here is how to actually optimize for the metrics that matter in 2026.

I have audited dozens of developer portfolios over the past year, many built with Next.js, and the same optimization mistakes keep appearing. The painful part? Most developers think their site is fast because Lighthouse gives them a green score.

Lighthouse is lying to you. Or more accurately, it is telling you a very incomplete story.

The Lighthouse Trap

Here is the dirty secret about Lighthouse scores: a 100/100 Lighthouse score in a controlled lab environment tells you almost nothing about how your site performs for real users on real devices.

Common Mistake: Optimizing for Lighthouse instead of optimizing for actual user experience. These are not the same thing. A site can score 100 on Lighthouse while feeling sluggish on a mid-range Android phone over 4G.

The metrics that actually matter in 2026 are:

  • INP (Interaction to Next Paint): How fast does the page respond when someone clicks something?
  • LCP (Largest Contentful Paint): How quickly does the main content appear?
  • CLS (Cumulative Layout Shift): Does the page jump around while loading?
  • TTFB (Time to First Byte): How fast does the server respond?

Most Next.js portfolios nail LCP (server-rendered content loads fast) but completely fail on INP because they ship too much client-side JavaScript.

The 5 Most Common Next.js Portfolio Sins

Sin 1: Client Components Everywhere

This is the biggest offender. Next.js 16 defaults to Server Components, but most developers immediately slap "use client" on everything because their favorite animation library or state management tool requires it.

typescripttypescript
// Bad: The entire page is a client component
"use client";
import { motion } from "framer-motion";
import { useState, useEffect } from "react";
 
export default function ProjectsPage() {
  const [projects, setProjects] = useState([]);
  
  useEffect(() => {
    fetch("/api/projects").then(r => r.json()).then(setProjects);
  }, []);
  
  return (
    <motion.div animate={{ opacity: 1 }}>
      {projects.map(p => <ProjectCard key={p.id} {...p} />)}
    </motion.div>
  );
}

This code has three problems:

  1. The entire page is a client component, so no server-side rendering benefit
  2. Data fetching happens on the client with useEffect (waterfall request)
  3. framer-motion ships ~40KB of JS for a simple fade animation

The Fix: Push client boundaries as deep as possible. The page itself should be a Server Component. Only the specific interactive elements need to be Client Components.

typescripttypescript
// Good: Server Component page with isolated client boundaries
import { getProjects } from "@/lib/projects";
import { ProjectCard } from "./project-card"; // Server Component
import { AnimatedContainer } from "./animated-container"; // Client Component
 
export default async function ProjectsPage() {
  const projects = await getProjects();
  
  return (
    <AnimatedContainer>
      {projects.map(p => <ProjectCard key={p.id} {...p} />)}
    </AnimatedContainer>
  );
}

The data fetches on the server. The HTML renders on the server. Only the animation wrapper is a Client Component, and it receives server-rendered children.

Sin 2: Unoptimized Images

I see this constantly: developers using the Next.js <Image> component but failing to actually optimize their images.

Common mistakes:

  • Using PNG screenshots instead of WebP/AVIF
  • Not setting sizes prop, causing the browser to download full-size images
  • Missing priority on the hero/banner image (hurts LCP)
  • Using massive original images (3000x2000+) when the displayed size is 800x400
typescripttypescript
// Bad
<Image src="/images/project.png" width={800} height={400} alt="Project" />
 
// Good
<Image 
  src="/images/project.webp" 
  width={800} 
  height={400} 
  alt="Project showcase: real-time dashboard with analytics"
  sizes="(max-width: 768px) 100vw, (max-width: 1200px) 50vw, 800px"
  priority // Only for above-the-fold images
  quality={85}
/>

Convert Images to WebP or AVIF

Use tools like Sharp, Squoosh, or even a simple build script to convert all PNG/JPG images to WebP. AVIF offers even better compression but has slightly less browser support.

Set Proper Sizes

The sizes prop tells the browser which image size to download based on viewport width. Without it, Next.js generates multiple sizes but the browser might download a larger one than needed.

Prioritize Above-the-Fold Images

Add priority to your hero image, profile photo, or any image visible without scrolling. This tells Next.js to preload it, which directly improves LCP.

Audit Image Dimensions

If your image displays at 800x400, there is no reason to serve a 3000x2000 original. Resize at the source level. This reduces storage cost and speeds up the optimization pipeline.

Sin 3: Font Loading Gone Wrong

Custom fonts are the silent killer of portfolio performance. Google Fonts, self-hosted fonts, icon fonts, they all cause layout shifts and slow down rendering if loaded incorrectly.

The most common mistake I see: loading 4-5 font weights when only 2-3 are actually used.

typescripttypescript
// Bad: Loading too many weights
import { Inter } from "next/font/google";
 
const inter = Inter({ 
  subsets: ["latin"],
  weight: ["100", "200", "300", "400", "500", "600", "700", "800", "900"]
});
typescripttypescript
// Good: Only load what you use
import { Inter } from "next/font/google";
 
const inter = Inter({ 
  subsets: ["latin"],
  weight: ["400", "600", "700"],
  display: "swap",
  preload: true,
});

Each additional font weight can add 15-30KB. If you are loading 9 weights, that is potentially 200KB+ of font files. On a 3G connection, that is seconds of delay.

Sin 4: Route-Level Code Splitting Ignored

Next.js 16 handles page-level code splitting automatically, but it cannot save you from importing heavy libraries in your layout or shared components.

A pattern I see constantly:

typescripttypescript
// app/layout.tsx
import { Analytics } from "@vercel/analytics/react";
import { SpeedInsights } from "@vercel/speed-insights/next";
import { CommandMenu } from "@/components/CommandMenu"; // Heavy component
import { MusicWidget } from "@/components/MusicWidget"; // Another heavy one
import { Notifications } from "@/components/Notifications"; // And another

Every component imported in layout.tsx ships JavaScript to every single page. If your CommandMenu uses a library like cmdk (10KB+), that JavaScript loads even on pages where nobody will ever open the command menu.

Performance Killer: A common pattern is to import a heavy animation library, a markdown renderer, or a rich text editor in the root layout. This JS ships to every page, even if 90% of your visitors only see the homepage.

The Fix: Lazy load heavy components that are not immediately visible.

typescripttypescript
import dynamic from "next/dynamic";
 
const CommandMenu = dynamic(() => import("@/components/CommandMenu"), {
  ssr: false, // No need to SSR a keyboard-triggered overlay
});
 
const MusicWidget = dynamic(() => import("@/components/MusicWidget"), {
  ssr: false,
  loading: () => <MusicWidgetSkeleton />,
});

Sin 5: No Caching Strategy

This blows my mind every time I see it. A portfolio site, where the content changes maybe once a week, making fresh API calls on every single request.

typescripttypescript
// Bad: No caching, fetches fresh data on every request
export default async function BlogPage() {
  const posts = await fetch("https://api.example.com/posts").then(r => r.json());
  return <BlogList posts={posts} />;
}
typescripttypescript
// Good: Revalidate every hour
export default async function BlogPage() {
  const posts = await fetch("https://api.example.com/posts", {
    next: { revalidate: 3600 }
  }).then(r => r.json());
  return <BlogList posts={posts} />;
}

Even better, for truly static content like blog posts, use generateStaticParams and let Next.js generate static pages at build time:

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

Static pages are served from the CDN edge. No server computation, no database queries, no cold starts. Just HTML, instantly.

A Real-World Audit: What I Found on a Client Portfolio

I recently audited a senior developer's Next.js 16 portfolio. Lighthouse score: 97. Real-world performance? Terrible on mobile.

Here is what I found:

IssueImpact
3 unused font weights loaded+90KB
framer-motion imported in layout+42KB on every page
8 Client Components that could be Server Components+120KB total JS
Blog images served as unoptimized PNGLCP of 4.2s on mobile
No revalidation strategy on API routesFresh DB queries on every visit

After applying the fixes outlined above:

  • Total JS shipped: Reduced from 380KB to 145KB (62% reduction)
  • LCP (mobile): Dropped from 4.2s to 1.1s
  • INP: Dropped from 280ms to 65ms
  • TTFB: Improved by 800ms by adding proper caching

The Lighthouse score was barely affected (went from 97 to 99). But the actual user experience went from sluggish to snappy. That is the difference between optimizing for a score and optimizing for people.

The Quick Optimization Checklist

If you want to audit your own Next.js portfolio right now, here is a practical checklist:

Run this command to see exactly how much JS your pages ship:

bashbash
npx next build
# Check the "First Load JS" column in the output
# Anything over 150KB for a portfolio page is a red flag

Then go through this list:

  • Are you using Server Components by default? (Check for unnecessary "use client")
  • Is your hero/banner image set to priority?
  • Are all images in WebP or AVIF format?
  • Are you loading only the font weights you actually use?
  • Are heavy components (command menu, modals, widgets) lazy loaded?
  • Does your build output show reasonable "First Load JS" numbers?
  • Are you using revalidate or generateStaticParams where appropriate?
  • Have you tested on a real mobile device, not just Chrome DevTools?

The Bottom Line

A fast portfolio is not about chasing Lighthouse scores. It is about making intentional decisions: Server Components by default, images properly optimized, fonts trimmed, heavy libraries lazy loaded, and caching in place.

Your portfolio is often the first thing a potential employer or client sees. If it takes 4 seconds to load on their phone, it does not matter how beautiful your animations are. They have already left.

Optimize for real users on real devices. Your future self will thank you.


 

Have you audited your portfolio recently? What performance wins did you find? Share your before/after numbers in the comments.


Parth Sharma

Author Parth Sharma

Full-Stack Developer, Freelancer, & Founder. Obsessed with crafting pixel-perfect, high-performance web experiences that feel alive.

Discussion0

Join the conversation

Sign in to leave a comment, like, or reply.

No comments yet. Start the discussion!