Next.js Performance Optimization (2026)
Claude Code Next.js Performance Optimization
Next.js performance optimization requires a systematic approach covering bundle analysis, runtime efficiency, and runtime performance. Claude Code combined with specialized skills can accelerate this process significantly.
Getting a Next.js application to feel fast involves multiple distinct layers. A page can have a perfect Lighthouse score in isolation and still feel sluggish in production if caching is misconfigured, if too much JavaScript ships to the browser, or if images load without proper prioritization. This guide walks through each layer methodically, with the kind of code-level detail that lets you take action immediately.
Understanding the Performance Layers
Before diving into specific techniques, it helps to have a mental model of where performance is lost. Next.js applications typically suffer in one or more of these areas:
| Layer | Primary metric | Common culprit |
|---|---|---|
| Bundle size | Time to Interactive (TTI) | Unoptimized imports, no code splitting |
| Image delivery | Largest Contentful Paint (LCP) | Missing priority, wrong sizes |
| Data fetching | Time to First Byte (TTFB) | No caching, waterfall requests |
| Runtime rendering | Total Blocking Time (TBT) | Too many Client Components, missing memoization |
| Client navigation | Perceived speed | No prefetching, excessive client-side state |
Claude Code is useful at every layer because it can read your entire codebase at once and identify patterns that human reviewers often miss, like a 'use client' directive added unnecessarily three levels up a component tree, causing an entire subtree to ship as client JavaScript.
Analyzing Bundle Size
Large JavaScript bundles directly impact initial load times and Time to Interactive (TTI). The first step is understanding what’s actually in your bundle.
Use the Bash tool to run bundle analysis:
npx next build && npx @next/bundle-analyzer
This generates a visual report showing which modules contribute most to bundle size. Claude Code can then help you interpret these results and identify optimization targets.
The frontend-design skill is particularly useful here, it understands component patterns and can suggest code splitting strategies specific to Next.js App Router. When you describe your page structure, it can recommend which components should use dynamic imports versus static imports.
To enable the bundle analyzer permanently in your project, configure it in next.config.js:
const withBundleAnalyzer = require('@next/bundle-analyzer')({
enabled: process.env.ANALYZE === 'true',
})
module.exports = withBundleAnalyzer({
// your existing Next.js config
})
Then run ANALYZE=true npm run build whenever you want a fresh bundle report. Looking at the output, focus on:
- Chunks over 100 kB. these are candidates for dynamic imports
- Duplicated packages. multiple versions of the same library inflate bundles silently
- Packages you don’t recognize. often transitive dependencies that is replaced with lighter alternatives
A common discovery is that a single component importing a full charting library (like Recharts or Chart.js) causes every page in the app to download that library. Dynamic imports solve this directly.
Implementing Dynamic Imports
Code splitting at the component level reduces initial JavaScript payload. Next.js supports dynamic imports natively:
import dynamic from 'next/dynamic'
const HeavyChart = dynamic(
() => import('../components/HeavyChart'),
{
loading: () => <ChartSkeleton />,
ssr: false
}
)
The key decisions are ssr: false for client-only components and the loading fallback for perceived performance. Claude Code can audit your page components and suggest where dynamic imports would have the most impact.
Beyond single components, you can dynamically import entire feature modules. Suppose your dashboard has an advanced analytics panel that only power users access. Lazy-load the entire panel:
import dynamic from 'next/dynamic'
import { useState } from 'react'
const AdvancedAnalytics = dynamic(
() => import('../features/AdvancedAnalytics'),
{ loading: () => <div>Loading analytics...</div> }
)
export function Dashboard() {
const [showAnalytics, setShowAnalytics] = useState(false)
return (
<div>
<button onClick={() => setShowAnalytics(true)}>
Show Advanced Analytics
</button>
{showAnalytics && <AdvancedAnalytics />}
</div>
)
}
The analytics bundle only downloads when the user clicks the button. For a 200 kB analytics module, this can cut initial page weight in half.
When to use dynamic imports vs. static imports:
| Scenario | Use dynamic import? |
|---|---|
| Component only visible after user action | Yes |
| Component below the fold on most screen sizes | Yes |
| Component requires browser-only APIs (canvas, WebGL) | Yes with ssr: false |
| Component visible immediately on page load | No |
| Small utility component (< 5 kB) | No |
| Shared layout component | No |
Image Optimization Strategies
Images typically account for the largest portion of page weight. Next.js provides the next/image component, but proper configuration matters:
import Image from 'next/image'
export function HeroSection() {
return (
<Image
src="/hero.png"
alt="Product screenshot"
width={1200}
height={600}
priority
sizes="(max-width: 768px) 100vw, 50vw"
/>
)
}
The priority prop preloads above-the-fold images, improving Largest Contentful Paint (LCP). The sizes attribute helps the browser select the appropriate image variant.
Only use priority on the single image that is the LCP element, typically the hero image. Marking multiple images as priority defeats the purpose; the browser downloads them all simultaneously and gains nothing.
For images below the fold, Next.js applies lazy loading automatically. You can confirm this is working by checking the Network tab in DevTools, below-fold images should not appear in the initial request waterfall.
Common image mistakes and fixes:
// Wrong: no sizes attribute, browser guesses
<Image src="/thumbnail.jpg" width={400} height={300} alt="..." />
// Correct: sizes tells browser the rendered width at each breakpoint
<Image
src="/thumbnail.jpg"
width={400}
height={300}
sizes="(max-width: 640px) 100vw, (max-width: 1024px) 50vw, 400px"
alt="..."
/>
For dynamic images from a CMS or CDN, configure remote patterns in next.config.js:
module.exports = {
images: {
remotePatterns: [
{
protocol: 'https',
hostname: 'images.yourdomain.com',
pathname: '/uploads/',
},
],
formats: ['image/avif', 'image/webp'],
},
}
Enabling AVIF format can reduce image sizes by 20-50% compared to WebP, at the cost of slightly longer encoding time during the build. For most production sites the tradeoff is worthwhile.
Route-Based Code Splitting
Next.js automatically splits code by route, but you can optimize further with Route Groups:
app/
(marketing)/
page.tsx
layout.tsx
(app)/
dashboard/
page.tsx
layout.tsx
Route Groups let you share layouts without bundling marketing code with dashboard code. This is especially valuable when the two sections have different dependency needs.
The tdd skill can help you write tests that verify route-based splitting works correctly, ensuring that code intended for one route doesn’t leak into another.
A practical way to verify your route splitting is working: after running next build, check the .next/static/chunks directory. Each route should have its own chunk file. If you see a single large chunk shared across routes that have nothing in common, there is a dependency being inadvertently shared through a module that should be split.
You can also use Next.js’s built-in instrumentation to trace which modules load for which routes:
NEXT_PRIVATE_DEBUG_CACHE=1 npm run build 2>&1 | grep "shared"
This surfaces modules being marked as shared across routes, letting you decide whether that sharing is intentional.
Reducing Client-Side JavaScript
Server Components in Next.js App Router reduce client-side JavaScript by default. The key is understanding which components truly need interactivity:
- Server Components (default): Data fetching, rendering, no interactivity
- Client Components (
'use client'): Event handlers, hooks, browser APIs
Audit your components with this pattern:
grep -r "'use client'" app/
Aim for Server Components as the default. Move interactivity to leaf components marked with 'use client'.
A common architectural mistake is placing 'use client' high in the tree when only a small piece of that subtree needs it. Consider this pattern:
// Before: entire card is a Client Component because of one button
'use client'
export function ProductCard({ product }) {
const [saved, setSaved] = useState(false)
return (
<div>
<h2>{product.name}</h2> {/* Server-renderable */}
<p>{product.description}</p> {/* Server-renderable */}
<img src={product.image} alt="" /> {/* Server-renderable */}
<button onClick={() => setSaved(!saved)}>
{saved ? 'Saved' : 'Save'}
</button>
</div>
)
}
// After: only the interactive button is a Client Component
// ProductCard.tsx (Server Component, no 'use client')
import { SaveButton } from './SaveButton'
export function ProductCard({ product }) {
return (
<div>
<h2>{product.name}</h2>
<p>{product.description}</p>
<img src={product.image} alt="" />
<SaveButton />
</div>
)
}
// SaveButton.tsx
'use client'
import { useState } from 'react'
export function SaveButton() {
const [saved, setSaved] = useState(false)
return (
<button onClick={() => setSaved(!saved)}>
{saved ? 'Saved' : 'Save'}
</button>
)
}
The second pattern ships significantly less JavaScript because the product name, description, and image are rendered on the server and sent as HTML, no JavaScript required.
Runtime Performance
Bundle size affects load time, but runtime performance affects perceived responsiveness. Key areas include:
Memoization Strategy
React.memo, useMemo, and useCallback prevent unnecessary re-renders, but overusing them adds complexity without benefit:
'use client'
import { memo, useMemo } from 'react'
// Only memoize expensive computations
const ExpensiveList = memo(function ExpensiveList({ items }) {
const sorted = useMemo(
() => items.sort((a, b) => b.score - a.score),
[items]
)
return (
<ul>
{sorted.map(item => (
<ListItem key={item.id} item={item} />
))}
</ul>
)
})
A practical rule: only add useMemo when you can measure a render time over 1ms for the computation. Memoizing a .filter() over a 10-item array adds overhead without benefit. Memoizing a sort-and-group operation over 10,000 items can cut a 50ms computation to near zero on subsequent renders.
Use React DevTools Profiler to identify components that re-render unexpectedly. The “highlight updates” feature in React DevTools makes wasteful re-renders visible at a glance, components flash when they render, so you can spot when a parent re-render is cascading into children unnecessarily.
Virtualization for Large Lists
Rendering thousands of items tanks performance. Use windowing libraries:
import { useVirtualizer } from '@tanstack/react-virtual'
function VirtualList({ items }) {
const parentRef = useRef(null)
const virtualizer = useVirtualizer({
count: items.length,
getScrollElement: () => parentRef.current,
estimateSize: () => 48,
})
return (
<div ref={parentRef} style={{ height: '400px', overflow: 'auto' }}>
<div style={{ height: virtualizer.getTotalSize() }}>
{virtualizer.getVirtualItems().map(virtual => (
<div
key={virtual.key}
style={{
position: 'absolute',
transform: `translateY(${virtual.start}px)`,
}}
>
{items[virtual.index].name}
</div>
))}
</div>
</div>
)
}
Virtualization is non-negotiable for lists exceeding a few hundred items. A list of 5,000 items rendered without virtualization can freeze the browser for several seconds on page load. The same list virtualized renders instantly because only the ~15 visible items exist in the DOM at any time.
For variable-height items (like comment threads), use measureElement to measure actual rendered heights:
const virtualizer = useVirtualizer({
count: items.length,
getScrollElement: () => parentRef.current,
estimateSize: () => 80, // estimate, will be measured
measureElement: (el) => el.getBoundingClientRect().height,
})
Caching Strategies
Next.js provides multiple caching layers:
- Request Memoization: Automatic within React
- Data Cache: Persisted across builds
- Full Route Cache: Prerendered pages
- Router Cache: Client-side navigation
For dynamic data, use revalidate:
async function getData() {
const res = await fetch('https://api.example.com/data', {
next: { revalidate: 60 }
})
return res.json()
}
This caches the fetch result for 60 seconds, reducing database and API load.
For data that changes unpredictably, use on-demand revalidation via tags:
// Fetch with a cache tag
async function getProduct(id: string) {
const res = await fetch(`https://api.example.com/products/${id}`, {
next: { tags: [`product-${id}`] }
})
return res.json()
}
// Route handler to invalidate on demand
// app/api/revalidate/route.ts
import { revalidateTag } from 'next/cache'
import { NextRequest } from 'next/server'
export async function POST(request: NextRequest) {
const { productId } = await request.json()
revalidateTag(`product-${productId}`)
return Response.json({ revalidated: true })
}
Your backend can call this endpoint (with appropriate authentication) whenever a product is updated, invalidating only the affected cache entries rather than the entire data cache.
Caching decision matrix:
| Data type | Recommended strategy | Revalidate interval |
|---|---|---|
| Static content (legal, docs) | force-cache |
Never or long-lived |
| Product catalog | Tag-based revalidation | On change |
| Blog posts | Time-based revalidation | 1 hour |
| User-specific data | no-store |
N/A |
| Real-time data (prices, inventory) | no-store |
N/A |
Server-Side Data Fetching Patterns
One of the most impactful optimizations available in Next.js App Router is moving data fetching out of Client Components entirely. Fetching data in Server Components eliminates client-server waterfalls, the pattern where the browser downloads JavaScript, executes it, discovers it needs data, makes an API request, then renders.
// Server Component. no waterfall, data arrives with the HTML
async function ProductPage({ params }) {
// These two fetches happen in parallel on the server
const [product, reviews] = await Promise.all([
getProduct(params.id),
getReviews(params.id),
])
return (
<main>
<ProductDetails product={product} />
<ReviewList reviews={reviews} />
</main>
)
}
By using Promise.all, both fetches happen simultaneously on the server. The client receives a fully rendered page without making any additional API requests after hydration.
Measuring Performance
Use the supermemory skill to track performance metrics over time. The skill can organize your benchmarks and help you correlate changes with performance improvements:
- Lighthouse: Overall scores
- Web Vitals: Core Web Vitals metrics
- Bundle Analyzer: Size trends
- React DevTools: Component render counts
Run Lighthouse in CI to catch regressions:
npm install -D @lhci/cli
Configure .lighthouserc.js to establish performance budgets:
module.exports = {
ci: {
collect: {
url: ['http://localhost:3000/', 'http://localhost:3000/products'],
numberOfRuns: 3,
},
assert: {
assertions: {
'categories:performance': ['error', { minScore: 0.85 }],
'first-contentful-paint': ['error', { maxNumericValue: 2000 }],
'largest-contentful-paint': ['error', { maxNumericValue: 2500 }],
'cumulative-layout-shift': ['error', { maxNumericValue: 0.1 }],
'total-blocking-time': ['error', { maxNumericValue: 300 }],
},
},
upload: {
target: 'temporary-public-storage',
},
},
}
These assertions fail the CI pipeline if any page regresses below your defined thresholds, preventing performance regressions from shipping unnoticed.
Automating Optimization Workflows
Combine Claude Code with your existing tooling. Create a skill that runs a performance audit:
Performance audit script
npm run build
npx next build --analyze
lighthouse https://localhost:3000 --output json --output-path lighthouse-results.json
Claude Code can interpret these results and prioritize fixes based on impact.
A useful pattern is to add performance audit as a pre-deployment step in your CI pipeline. Using GitHub Actions:
.github/workflows/performance.yml
name: Performance Audit
on: [pull_request]
jobs:
lighthouse:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: '20'
- run: npm ci
- run: npm run build
- run: npm run start &
- run: npx wait-on http://localhost:3000
- run: npx lhci autorun
env:
LHCI_GITHUB_APP_TOKEN: ${{ secrets.LHCI_GITHUB_APP_TOKEN }}
This posts Lighthouse results directly to each pull request, making performance impact visible before merging.
A Prioritized Optimization Checklist
Not all optimizations are equal. Start with the ones that move metrics the most:
- Run bundle analyzer. identify and eliminate large unused dependencies
- Audit
'use client'directives. push them down to leaf components - Add
priorityto LCP images. immediate LCP improvement - Add
sizesattribute to all images. prevents oversized image downloads - Implement dynamic imports for below-fold features. reduces TTI
- Enable Route Groups. prevents marketing code from loading in app routes
- Add
revalidateto frequently-fetched data. reduces API and database load - Add virtualization to lists over 200 items. eliminates render blocking
- Set up Lighthouse CI. prevents future regressions
- Profile with React DevTools. find and fix unexpected re-renders
Summary
Next.js performance optimization involves multiple layers: bundle size through code splitting, runtime performance through proper component architecture, and caching strategies for data fetching. Claude Code accelerates this process by analyzing your codebase, suggesting targeted optimizations, and helping you implement patterns like dynamic imports and virtualization.
The specialized skills like frontend-design, tdd, and supermemory each contribute to a comprehensive performance workflow, from design patterns that prevent performance issues, to tests that catch regressions, to memory systems that track improvements over time.
Start with bundle analysis, implement route-based and component-based code splitting, add image optimization, and layer on runtime optimizations as needed. Measure continuously to ensure your optimizations actually move the metrics that matter. The checklist above gives you a prioritized starting point that works for most Next.js applications, work through it top-to-bottom, measure after each change, and stop when your Core Web Vitals reach acceptable thresholds.
Try it: Estimate your monthly spend with our Cost Calculator.
Related Reading
- Claude Code Next.js Deployment Optimization. CI/CD, Docker, environment config, and production release workflows (complements this performance guide)
- Claude Code for Beginners: Complete Getting Started Guide
- Best Claude Skills for Developers in 2026
- Claude Skills Guides Hub
Built by theluckystrike. More at zovo.one
Get started → Generate your project setup with our Project Starter.