Why Next.js Performance Matters
The Business Impact of Performance
- 53% of mobile users abandon sites that take longer than 3 seconds to load
- 100ms improvement in load time can increase conversion rates by 1%
- 1 second delay decreases customer satisfaction by 16%
- Sites loading in 2 seconds have an average bounce rate of 9% vs 38% for 5-second loads
- Amazon: Every 100ms of latency costs 1% in sales
- Walmart: Every 1 second improvement resulted in 2% increase in conversions
Performance as a Feature
- User retention and engagement
- Conversion and revenue
- SEO rankings (Google's Page Experience)
- Brand perception
- Operating costs (CDN, compute)
Core Web Vitals Optimization
Understanding the Metrics
-
LCP (Largest Contentful Paint): Loading performance
- Target: < 2.5 seconds
- Measures: When main content becomes visible
-
FID (First Input Delay): Interactivity
- Target: < 100 milliseconds
- Measures: Time until page responds to first interaction
-
CLS (Cumulative Layout Shift): Visual stability
- Target: < 0.1
- Measures: Unexpected layout shifts
Measuring Core Web Vitals
Javascript
// pages/_app.js - Track real user metrics
export function reportWebVitals(metric) {
const { id, name, label, value } = metric;
// Log to console in development
if (process.env.NODE_ENV === 'development') {
console.log(name, value);
}
// Send to analytics in production
if (label === 'web-vital') {
// Google Analytics 4
window.gtag?.('event', name, {
event_category: label === 'web-vital' ? 'Web Vitals' : 'Next.js Metric',
value: Math.round(name === 'CLS' ? value * 1000 : value),
event_label: id,
non_interaction: true,
});
// Custom analytics endpoint
fetch('/api/analytics', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
metric: name,
value: value,
id: id,
path: window.location.pathname,
timestamp: Date.now()
})
});
}
}
Lighthouse CI Integration
Yaml
# .github/workflows/lighthouse.yml
name: Lighthouse CI
on: [pull_request]
jobs:
lighthouse:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v3
- name: Setup Node
uses: actions/setup-node@v3
with:
node-version: '18'
- name: Install dependencies
run: npm ci
- name: Build
run: npm run build
- name: Run Lighthouse CI
uses: treosh/lighthouse-ci-action@v9
with:
urls: |
http://localhost:3000/
http://localhost:3000/blog
http://localhost:3000/products
uploadArtifacts: true
temporaryPublicStorage: true
runs: 3
Javascript
// lighthouserc.js
module.exports = {
ci: {
collect: {
startServerCommand: 'npm run start',
url: ['http://localhost:3000/'],
numberOfRuns: 3
},
assert: {
preset: 'lighthouse:recommended',
assertions: {
'categories:performance': ['error', { minScore: 0.9 }],
'categories:accessibility': ['error', { minScore: 0.9 }],
'categories:best-practices': ['error', { minScore: 0.9 }],
'categories:seo': ['error', { minScore: 0.9 }],
'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'
}
}
};
Server-Side Rendering vs Static Generation vs App Router
Rendering Strategy Decision Matrix
Pages Router: SSR Implementation
Javascript
// pages/user/[id].js
export async function getServerSideProps({ params, req, res }) {
// Set cache headers for edge caching
res.setHeader(
'Cache-Control',
'public, s-maxage=10, stale-while-revalidate=59'
);
try {
const userData = await fetchUser(params.id);
if (!userData) {
return {
notFound: true,
};
}
return {
props: {
user: userData,
fetchedAt: new Date().toISOString()
}
};
} catch (error) {
return {
redirect: {
destination: '/error',
permanent: false
}
};
}
}
export default function UserPage({ user, fetchedAt }) {
return (
<div>
<h1>{user.name}</h1>
<p>Data fetched at: {fetchedAt}</p>
</div>
);
}
Pages Router: ISR (Incremental Static Regeneration)
Javascript
// pages/blog/[slug].js
export async function getStaticProps({ params }) {
const post = await getPostBySlug(params.slug);
return {
props: { post },
// Regenerate page every 60 seconds if there's a request
revalidate: 60
};
}
export async function getStaticPaths() {
// Generate top 100 most popular posts at build time
const popularPosts = await getPopularPosts(100);
return {
paths: popularPosts.map(post => ({
params: { slug: post.slug }
})),
// Generate other pages on-demand
fallback: 'blocking' // or 'true' for instant loading state
};
}
App Router: Server Components by Default
Jsx
// app/products/[id]/page.jsx
import { Suspense } from 'react';
import ProductDetails from './ProductDetails';
import Reviews from './Reviews';
import RelatedProducts from './RelatedProducts';
// This is a Server Component by default
export default async function ProductPage({ params }) {
// Fetch in parallel
const [product, reviews] = await Promise.all([
fetchProduct(params.id),
fetchReviews(params.id)
]);
return (
<div>
{/* Immediate render with fetched data */}
<ProductDetails product={product} />
{/* Show loading state while fetching related products */}
<Suspense fallback={<ReviewsSkeleton />}>
<Reviews initialReviews={reviews} />
</Suspense>
<Suspense fallback={<ProductsSkeleton />}>
<RelatedProducts productId={params.id} />
</Suspense>
</div>
);
}
// Related products as separate async Server Component
async function RelatedProducts({ productId }) {
const products = await fetchRelatedProducts(productId);
return <ProductGrid products={products} />;
}
App Router: Streaming with Suspense
Jsx
// app/dashboard/page.jsx
import { Suspense } from 'react';
export default function Dashboard() {
return (
<div className="dashboard">
{/* Critical content renders first */}
<Header />
{/* Non-critical content streams in */}
<Suspense fallback={<Skeleton />}>
<Analytics />
</Suspense>
<Suspense fallback={<Skeleton />}>
<RecentActivity />
</Suspense>
<Suspense fallback={<Skeleton />}>
<Chart />
</Suspense>
</div>
);
}
// Each component fetches independently
async function Analytics() {
const data = await fetchAnalytics(); // Slow query
return <AnalyticsWidget data={data} />;
}
- Time to First Byte (TTFB): Same
- First Contentful Paint (FCP): 40% faster (renders shell immediately)
- Largest Contentful Paint (LCP): 25% faster (progressive loading)
Advanced Image Optimization
Using Next.js Image Component
Jsx
import Image from 'next/image';
function Hero() {
return (
<div className="hero">
{/* Automatically optimized */}
<Image
src="/hero.jpg"
alt="Hero image"
width={1920}
height={1080}
priority // Load with high priority (above fold)
placeholder="blur" // Show blur while loading
blurDataURL="data:image/jpeg;base64,/9j/4AAQSkZJRg..." // Base64 blur
sizes="(max-width: 768px) 100vw, (max-width: 1200px) 50vw, 33vw"
quality={85} // Default is 75
/>
</div>
);
}
// Below-the-fold images
function ProductGrid({ products }) {
return (
<div className="grid">
{products.map(product => (
<Image
key={product.id}
src={product.image}
alt={product.name}
width={400}
height={400}
loading="lazy" // Lazy load (default for non-priority images)
placeholder="blur"
blurDataURL={product.blurHash}
/>
))}
</div>
);
}
Generating Blur Placeholders
Javascript
// scripts/generate-blur.js
import { getPlaiceholder } from 'plaiceholder';
import fs from 'fs';
async function generateBlurData() {
const images = [
'public/hero.jpg',
'public/product1.jpg',
// ... more images
];
const blurData = {};
for (const imagePath of images) {
const { base64 } = await getPlaiceholder(imagePath);
blurData[imagePath] = base64;
}
fs.writeFileSync(
'blur-data.json',
JSON.stringify(blurData, null, 2)
);
}
generateBlurData();
Responsive Images with Art Direction
Jsx
import Image from 'next/image';
function ResponsiveHero() {
return (
<picture>
{/* Mobile: Portrait */}
<source
media="(max-width: 768px)"
srcSet="/hero-mobile.jpg"
/>
{/* Tablet: Square */}
<source
media="(max-width: 1200px)"
srcSet="/hero-tablet.jpg"
/>
{/* Desktop: Landscape */}
<Image
src="/hero-desktop.jpg"
alt="Hero"
width={1920}
height={1080}
priority
/>
</picture>
);
}
Image CDN Configuration
Javascript
// next.config.js
module.exports = {
images: {
domains: ['cdn.example.com', 'images.unsplash.com'],
loader: 'cloudinary', // or 'imgix', 'akamai', 'custom'
path: 'https://res.cloudinary.com/demo/',
deviceSizes: [640, 750, 828, 1080, 1200, 1920, 2048, 3840],
imageSizes: [16, 32, 48, 64, 96, 128, 256, 384],
formats: ['image/avif', 'image/webp'], // Modern formats
minimumCacheTTL: 60,
dangerouslyAllowSVG: true,
contentSecurityPolicy: "default-src 'self'; script-src 'none'; sandbox;",
}
};
- 40-60% smaller file sizes with AVIF/WebP
- Automatic responsive images
- Lazy loading by default
- Prevents layout shift with aspect ratio
Code Splitting Strategies
Dynamic Imports for Heavy Components
Javascript
import dynamic from 'next/dynamic';
import { Suspense } from 'react';
// Heavy charting library - load only when needed
const Chart = dynamic(
() => import('../components/HeavyChart'),
{
loading: () => <ChartSkeleton />,
ssr: false // Client-only component
}
);
// Code-split by route
const AdminPanel = dynamic(
() => import('../components/AdminPanel'),
{
loading: () => <div>Loading admin panel...</div>,
ssr: true // Can be server-rendered
}
);
// Conditional loading
function Dashboard({ isAdmin }) {
return (
<div>
<h1>Dashboard</h1>
{isAdmin && <AdminPanel />}
</div>
);
}
Route-Based Code Splitting
Javascript
// Automatic code splitting by route in App Router
app/
├── (marketing)/
│ ├── page.jsx // Bundle: marketing
│ ├── about/page.jsx // Bundle: marketing-about
│ └── contact/page.jsx // Bundle: marketing-contact
├── (shop)/
│ ├── product/[id]/ // Bundle: shop-product
│ └── cart/ // Bundle: shop-cart
└── (dashboard)/
├── analytics/ // Bundle: dashboard-analytics
└── settings/ // Bundle: dashboard-settings
// Each route only loads its required JavaScript
Component-Level Splitting with Lazy Loading
Jsx
'use client';
import { lazy, Suspense, useState } from 'react';
// Lazy load modals
const ShareModal = lazy(() => import('./ShareModal'));
const EditModal = lazy(() => import('./EditModal'));
export default function Article() {
const [showShare, setShowShare] = useState(false);
const [showEdit, setShowEdit] = useState(false);
return (
<div>
<article>Content here...</article>
<button onClick={() => setShowShare(true)}>
Share
</button>
{showShare && (
<Suspense fallback={<ModalSkeleton />}>
<ShareModal onClose={() => setShowShare(false)} />
</Suspense>
)}
{showEdit && (
<Suspense fallback={<ModalSkeleton />}>
<EditModal onClose={() => setShowEdit(false)} />
</Suspense>
)}
</div>
);
}
Bundle Size Optimization
Analyzing Your Bundle
Bash
# Install bundle analyzer
npm install @next/bundle-analyzer
# next.config.js
const withBundleAnalyzer = require('@next/bundle-analyzer')({
enabled: process.env.ANALYZE === 'true',
});
module.exports = withBundleAnalyzer({
// Your Next.js config
});
# Run analysis
ANALYZE=true npm run build
Tree Shaking and Dead Code Elimination
Javascript
// ❌ Bad - imports entire library (100KB+)
import _ from 'lodash';
const debounced = _.debounce(fn, 300);
// ✅ Good - imports only needed function (5KB)
import debounce from 'lodash-es/debounce';
const debounced = debounce(fn, 300);
// ❌ Bad - imports all icons
import { FaHome, FaUser, FaSearch } from 'react-icons/fa';
// ✅ Good - specific imports
import FaHome from 'react-icons/fa/FaHome';
import FaUser from 'react-icons/fa/FaUser';
import FaSearch from 'react-icons/fa/FaSearch';
Remove Unused Dependencies
Bash
# Analyze unused dependencies
npx depcheck
# Remove unused packages
npm uninstall <unused-package>
# Check bundle impact
npm run build
Modern JavaScript for Modern Browsers
Javascript
// next.config.js
module.exports = {
swcMinify: true, // Use SWC minifier (faster than Terser)
compiler: {
removeConsole: process.env.NODE_ENV === 'production',
},
experimental: {
modern: true, // Ship modern JavaScript to modern browsers
}
};
API Route Optimization
Implementing Edge Functions
Javascript
// pages/api/edge-function.js
export const config = {
runtime: 'edge', // Use Edge Runtime for better performance
};
export default async function handler(req) {
const { searchParams } = new URL(req.url);
const id = searchParams.get('id');
// Fast response from edge location
const data = await fetch(`https://api.example.com/data/${id}`);
return new Response(
JSON.stringify({ data: await data.json() }),
{
status: 200,
headers: {
'content-type': 'application/json',
'cache-control': 'public, s-maxage=60, stale-while-revalidate=120'
},
}
);
}
App Router: Route Handlers with Caching
Javascript
// app/api/products/route.js
import { NextResponse } from 'next/server';
export async function GET(request) {
const products = await fetchProducts();
return NextResponse.json(products, {
headers: {
'Cache-Control': 'public, s-maxage=3600, stale-while-revalidate=86400'
}
});
}
// Enable edge runtime
export const runtime = 'edge';
// Revalidate every hour
export const revalidate = 3600;
Database Query Optimization
Connection Pooling with Prisma
Javascript
// lib/prisma.js
import { PrismaClient } from '@prisma/client';
const globalForPrisma = global;
export const prisma =
globalForPrisma.prisma ||
new PrismaClient({
log: process.env.NODE_ENV === 'development' ? ['query', 'error', 'warn'] : ['error'],
datasources: {
db: {
url: process.env.DATABASE_URL,
},
},
});
if (process.env.NODE_ENV !== 'production') {
globalForPrisma.prisma = prisma;
}
// Configure connection pool
// In DATABASE_URL: ?connection_limit=10&pool_timeout=20
Preventing N+1 Queries
Javascript
// ❌ Bad - N+1 queries
async function getPostsWithAuthors() {
const posts = await prisma.post.findMany();
// Makes a separate query for EACH post
const postsWithAuthors = await Promise.all(
posts.map(async post => ({
...post,
author: await prisma.user.findUnique({
where: { id: post.authorId }
})
}))
);
return postsWithAuthors;
}
// ✅ Good - Single query with join
async function getPostsWithAuthors() {
const posts = await prisma.post.findMany({
include: {
author: true, // Join in single query
comments: {
take: 5,
orderBy: { createdAt: 'desc' }
}
}
});
return posts;
}
Query Result Caching
Javascript
import { LRUCache } from 'lru-cache';
const cache = new LRUCache({
max: 500,
ttl: 1000 * 60 * 5, // 5 minutes
});
async function getCachedProducts() {
const cacheKey = 'products:all';
// Check cache first
const cached = cache.get(cacheKey);
if (cached) return cached;
// Fetch from database
const products = await prisma.product.findMany();
// Store in cache
cache.set(cacheKey, products);
return products;
}
Caching Strategies
CDN Configuration
Javascript
// next.config.js
module.exports = {
async headers() {
return [
{
source: '/static/:path*',
headers: [
{
key: 'Cache-Control',
value: 'public, max-age=31536000, immutable',
},
],
},
{
source: '/api/data',
headers: [
{
key: 'Cache-Control',
value: 'public, s-maxage=60, stale-while-revalidate=120',
},
],
},
];
},
};
SWR for Client-Side Data Fetching
Jsx
'use client';
import useSWR from 'swr';
const fetcher = (url) => fetch(url).then(res => res.json());
export default function Profile() {
const { data, error, isLoading } = useSWR('/api/user', fetcher, {
revalidateOnFocus: false,
revalidateOnReconnect: false,
refreshInterval: 60000, // Refresh every minute
dedupingInterval: 2000, // Dedupe requests within 2 seconds
});
if (error) return <div>Failed to load</div>;
if (isLoading) return <div>Loading...</div>;
return <div>Hello {data.name}!</div>;
}
React Query for Advanced Caching
Jsx
'use client';
import { useQuery, QueryClient, QueryClientProvider } from '@tanstack/react-query';
const queryClient = new QueryClient({
defaultOptions: {
queries: {
staleTime: 60 * 1000, // Data fresh for 1 minute
cacheTime: 5 * 60 * 1000, // Keep in cache for 5 minutes
retry: 3,
refetchOnWindowFocus: false,
},
},
});
function ProductList() {
const { data, isLoading } = useQuery({
queryKey: ['products'],
queryFn: () => fetch('/api/products').then(res => res.json()),
staleTime: 5 * 60 * 1000, // 5 minutes
});
if (isLoading) return <Skeleton />;
return <ProductGrid products={data} />;
}
export default function App({ children }) {
return (
<QueryClientProvider client={queryClient}>
{children}
</QueryClientProvider>
);
}
Font Optimization
Using next/font for Zero Layout Shift
Javascript
// app/layout.jsx
import { Inter, Roboto_Mono } from 'next/font/google';
const inter = Inter({
subsets: ['latin'],
display: 'swap',
variable: '--font-inter',
preload: true,
});
const robotoMono = Roboto_Mono({
subsets: ['latin'],
display: 'swap',
variable: '--font-roboto-mono',
weight: ['400', '700'],
});
export default function RootLayout({ children }) {
return (
<html className={`${inter.variable} ${robotoMono.variable}`}>
<body className="font-sans">{children}</body>
</html>
);
}
Css
/* globals.css */
:root {
--font-inter: 'Inter', sans-serif;
--font-roboto-mono: 'Roboto Mono', monospace;
}
body {
font-family: var(--font-inter);
}
code {
font-family: var(--font-roboto-mono);
}
Self-Hosted Fonts
Javascript
// app/layout.jsx
import localFont from 'next/font/local';
const customFont = localFont({
src: [
{
path: '../public/fonts/custom-regular.woff2',
weight: '400',
style: 'normal',
},
{
path: '../public/fonts/custom-bold.woff2',
weight: '700',
style: 'normal',
},
],
variable: '--font-custom',
display: 'swap',
preload: true,
});
Monitoring and Analytics
Setting Up Real User Monitoring (RUM)
Javascript
// lib/analytics.js
export function initAnalytics() {
// Vercel Analytics
if (process.env.NEXT_PUBLIC_VERCEL_ENV === 'production') {
import('@vercel/analytics').then(({ inject }) => inject());
}
// DataDog RUM
if (typeof window !== 'undefined') {
import('@datadog/browser-rum').then(({ datadogRum }) => {
datadogRum.init({
applicationId: process.env.NEXT_PUBLIC_DD_APP_ID,
clientToken: process.env.NEXT_PUBLIC_DD_CLIENT_TOKEN,
site: 'datadoghq.com',
service: 'nextjs-app',
env: process.env.NEXT_PUBLIC_VERCEL_ENV,
sampleRate: 100,
trackInteractions: true,
defaultPrivacyLevel: 'mask-user-input'
});
});
}
}
Custom Performance Monitoring
Javascript
// lib/performance.js
export class PerformanceMonitor {
static measureNavigation() {
if (typeof window === 'undefined') return;
window.addEventListener('load', () => {
const navigation = performance.getEntriesByType('navigation')[0];
const metrics = {
dns: navigation.domainLookupEnd - navigation.domainLookupStart,
tcp: navigation.connectEnd - navigation.connectStart,
ttfb: navigation.responseStart - navigation.requestStart,
download: navigation.responseEnd - navigation.responseStart,
domParse: navigation.domContentLoadedEventEnd - navigation.responseEnd,
total: navigation.loadEventEnd - navigation.fetchStart
};
// Send to analytics
fetch('/api/performance', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(metrics)
});
});
}
static measureResource(resourceUrl) {
const resources = performance.getEntriesByType('resource');
const resource = resources.find(r => r.name.includes(resourceUrl));
if (resource) {
console.log(`${resourceUrl} took ${resource.duration}ms`);
}
}
}
Performance Budgets
Javascript
// next.config.js
module.exports = {
webpack: (config, { isServer }) => {
if (!isServer) {
// Fail build if bundle exceeds limits
config.performance = {
maxAssetSize: 244000, // 244 KB
maxEntrypointSize: 244000,
hints: 'error'
};
}
return config;
}
};
Deployment Optimization
Vercel Configuration
Json
// vercel.json
{
"functions": {
"pages/api/heavy-computation.js": {
"maxDuration": 10,
"memory": 1024
}
},
"rewrites": [
{
"source": "/api/:path*",
"destination": "https://api.example.com/:path*"
}
],
"headers": [
{
"source": "/(.*)",
"headers": [
{
"key": "X-Content-Type-Options",
"value": "nosniff"
},
{
"key": "X-Frame-Options",
"value": "DENY"
},
{
"key": "X-XSS-Protection",
"value": "1; mode=block"
}
]
}
],
"regions": ["iad1", "sfo1"]
}
Environment-Specific Optimization
Javascript
// next.config.js
const config = {
reactStrictMode: true,
poweredByHeader: false,
compress: true,
};
if (process.env.NODE_ENV === 'production') {
config.compiler = {
removeConsole: {
exclude: ['error', 'warn'],
},
};
config.experimental = {
optimizeCss: true,
modern: true,
};
}
module.exports = config;
Performance Checklist
Before Launch
- Lighthouse score > 90 for all key pages
- All images optimized with next/image
- Fonts loaded with next/font
- Code splitting implemented for heavy components
- Bundle size < 250KB (initial load)
- LCP < 2.5s on 3G connection
- CLS < 0.1
- FID < 100ms
- API routes cached appropriately
- Database queries optimized (no N+1)
- Performance monitoring configured
Continuous Optimization
Javascript
// Auto-generated performance regression alerts
// .github/workflows/performance.yml
name: Performance Regression Check
on: [pull_request]
jobs:
check:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v3
- run: npm ci
- run: npm run build
- name: Check bundle size
run: |
npm install -g bundlesize
bundlesize
- name: Lighthouse CI
run: npm run lighthouse:ci
Conclusion
- Measure first: Use RUM and synthetic monitoring
- Optimize assets: Images, fonts, and JavaScript bundles
- Choose the right rendering: SSG, ISR, SSR, or App Router based on use case
- Cache aggressively: At CDN, server, and client levels
- Monitor continuously: Set up alerts for performance regressions
- Set budgets: Enforce performance budgets in CI/CD