
Performance Is a Feature
I have audited dozens of Next.js applications, and the pattern is consistent: teams launch with excellent Core Web Vitals, then watch performance degrade as features accumulate. Performance is not a one-time optimization—it requires ongoing attention and measurement.
In this guide, I will share the techniques that consistently improve Next.js performance, from quick wins to advanced optimizations.
Understanding Core Web Vitals
The Three Metrics That Matter
- LCP (Largest Contentful Paint): Time for the main content to load. Target: under 2.5s.
- FID (First Input Delay): Time until the page responds to interaction. Target: under 100ms.
- CLS (Cumulative Layout Shift): Visual stability—content should not jump around. Target: under 0.1.
Why They Matter
- Google uses Core Web Vitals as ranking signals
- Slow sites lose users: 53% abandon if load takes over 3 seconds
- Performance directly impacts conversion rates
Image Optimization
The next/image Component
Images are typically the largest elements on a page. Next.js provides automatic optimization:
// Always use next/image for automatic optimization
import Image from 'next/image';
export function Hero() {
return (
);
}
// For dynamic images (e.g., from CMS)
export function ProductImage({ src, alt }) {
return (
);
}
Image Best Practices
- Use priority: For above-the-fold images that impact LCP.
- Specify sizes: Helps browser choose the right image size.
- Use blur placeholders: Improves perceived performance.
- Avoid layout shift: Always provide width and height or use fill.
Code Splitting
Dynamic Imports
Split large components to reduce initial bundle size:
import dynamic from 'next/dynamic';
// Heavy component loaded only when needed
const HeavyChart = dynamic(
() => import('@/components/HeavyChart'),
{
loading: () => ,
ssr: false // Client-only if needed
}
);
// Modal loaded on user interaction
const [showModal, setShowModal] = useState(false);
const Modal = dynamic(() => import('@/components/Modal'));
// Usage: Modal only loads when showModal is true
{showModal && setShowModal(false)} />}
Route-Based Splitting
Next.js automatically splits by route. Help it by:
- Keeping page components focused
- Moving shared code to layout.tsx
- Using barrel exports carefully (they can bloat bundles)
Server Components and Client Components
Default to Server Components
In Next.js 13+, components are Server Components by default. This means:
- Zero JavaScript sent to client for static rendering
- Direct database/API access without client exposure
- Faster initial page load
When to Use Client Components
Add 'use client' only when you need:
- useState, useEffect, or other hooks
- Event handlers (onClick, onChange)
- Browser-only APIs (localStorage, window)
- Third-party libraries requiring browser environment
// Server Component (default) - no JavaScript to client
export default async function ProductPage({ params }) {
const product = await getProduct(params.id);
return (
{product.name}
{product.description}
{/* Client boundary */}
);
}
// Client Component - only interactive part
'use client'
export function AddToCartButton({ productId }) {
const [added, setAdded] = useState(false);
return (
);
}
Caching and Data Fetching
Next.js Fetch Caching
// Cached by default (can be served from CDN)
const data = await fetch('https://api.example.com/data');
// Revalidate every hour
const data = await fetch('https://api.example.com/data', {
next: { revalidate: 3600 }
});
// Fresh on every request
const data = await fetch('https://api.example.com/data', {
cache: 'no-store'
});
// On-demand revalidation
import { revalidateTag } from 'next/cache';
// In API route after mutation:
revalidateTag('products');
Static Generation (SSG) vs Server Rendering (SSR)
- SSG: Build-time rendering. Best for content that rarely changes.
- ISR: Static with background revalidation. Best for frequent but not real-time updates.
- SSR: Request-time rendering. For personalized or highly dynamic content.
Font Optimization
Using next/font
Fonts can block rendering. Next.js provides automatic optimization:
// app/layout.tsx
import { Inter, Roboto_Mono } from 'next/font/google';
const inter = Inter({
subsets: ['latin'],
display: 'swap', // Prevents FOIT
variable: '--font-inter',
});
const robotoMono = Roboto_Mono({
subsets: ['latin'],
display: 'swap',
variable: '--font-mono',
});
export default function RootLayout({ children }) {
return (
{children}
);
}
Font Best Practices
- Use variable fonts to reduce file count
- Limit font weights and subsets
- Use font-display: swap to prevent invisible text
- Self-host fonts via next/font for best performance
Script Optimization
Using next/script
import Script from 'next/script';
// Analytics - loads after page is interactive
// Heavy third-party - loads when browser is idle
// Critical path - loads immediately (use sparingly)
Bundle Analysis
Finding What's Bloating Your Bundle
# Install bundle analyzer
npm install @next/bundle-analyzer
# Add to next.config.js
const withBundleAnalyzer = require('@next/bundle-analyzer')({
enabled: process.env.ANALYZE === 'true',
});
module.exports = withBundleAnalyzer({
// your config
});
# Run analysis
ANALYZE=true npm run build
Common Bundle Bloat Sources
- Moment.js: Replace with date-fns or dayjs.
- Lodash: Import specific functions, not entire library.
- Heavy UI libraries: Tree-shake or use lightweight alternatives.
- Barrel exports: Can accidentally import entire packages.
Rendering Patterns
Streaming with Suspense
import { Suspense } from 'react';
export default function Dashboard() {
return (
}>
{/* Server component that fetches data */}
}>
);
}
Streaming sends content progressively—users see the shell immediately while data loads.
Parallel Data Fetching
// BAD: Sequential (slow)
async function Dashboard() {
const user = await getUser();
const posts = await getPosts();
const stats = await getStats();
// ...
}
// GOOD: Parallel (fast)
async function Dashboard() {
const [user, posts, stats] = await Promise.all([
getUser(),
getPosts(),
getStats()
]);
// ...
}
Monitoring Performance
Setting Up Analytics
// app/layout.tsx
import { Analytics } from '@vercel/analytics/react';
import { SpeedInsights } from '@vercel/speed-insights/next';
export default function RootLayout({ children }) {
return (
{children}
);
}
Tools for Measurement
- Vercel Analytics: Real user metrics from production
- Chrome Lighthouse: Lab testing in DevTools
- PageSpeed Insights: Field data from Chrome UX Report
- Web Vitals library: Custom monitoring
Frequently Asked Questions
Q: My LCP is slow—where do I start?
A: Check your largest above-fold element. If it's an image, add priority prop. If it's text, check font loading strategy. Use Chrome DevTools Performance tab to identify the exact element.
Q: How do I reduce JavaScript bundle size?
A: Run bundle analyzer first. Then: use Server Components, dynamic imports for heavy libraries, replace large dependencies with lighter alternatives.
Q: My CLS score is bad. How do I fix layout shift?
A: Always specify image dimensions, reserve space for dynamic content, avoid inserting content above existing content, use skeleton loaders.
Key Takeaways
- Use Server Components by default—only add 'use client' when needed.
- Always optimize images with next/image and appropriate sizing.
- Dynamic import heavy components to reduce initial bundle.
- Choose the right rendering strategy: SSG > ISR > SSR.
- Use next/font for optimal font loading.
- Monitor real user metrics, not just lab scores.
- Analyze bundles regularly to catch regressions.
Conclusion
Next.js provides powerful performance primitives out of the box. The teams that achieve excellent Core Web Vitals are the ones who treat performance as a feature—measuring continuously, optimizing proactively, and making performance part of the development culture.
Resources
Written by XQA Team
Our team of experts delivers insights on technology, business, and design. We are dedicated to helping you build better products and scale your business.