Table of Contents
- Built-in Component Optimization
- Script Loading Strategies
- Dependency Management
- Caching and ISR
- Font Optimization
- Lazy Loading & Code Splitting
- Data Fetching Optimization
- Client-Side Data Fetching with SWR
- Component Optimization
- List Virtualization
- React Compiler Integration
- JavaScript Bundle Optimization
- CSS Optimization
- API Route Caching
- Edge Runtime
- Core Web Vitals
- SEO Optimization
- Security Optimization
1. Built-in Component Optimization
Purpose
Next.js provides optimized built-in components that automatically handle performance best practices, eliminating the need for manual optimization and reducing common pitfalls.
Key Components
Image Component (next/image)
The Image component is one of the most powerful performance tools in Next.js. It provides:
- Automatic lazy loading
- Image resizing based on device
- Format optimization (WebP, AVIF)
- Prevention of Cumulative Layout Shift (CLS)
- Reduced bandwidth usage
Basic Implementation:
import Image from 'next/image';
export default function ProductCard() {
return (
<div>
<Image
src="/product.jpg"
alt="Product description"
width={500}
height={300}
priority={false} // Set to true for above-the-fold images
placeholder="blur" // Shows blur while loading
blurDataURL="data:image/jpeg;base64,..." // Optional blur placeholder
/>
</div>
);
}
Advanced Configuration (next.config.js):
module.exports = {
images: {
deviceSizes: [640, 750, 828, 1080, 1200, 1920, 2048, 3840],
imageSizes: [16, 32, 48, 64, 96, 128, 256, 384],
domains: ['example.com', 'cdn.example.com'], // Allowed external domains
remotePatterns: [
{
protocol: 'https',
hostname: '**.example.com',
port: '',
pathname: '/images/**',
},
],
formats: ['image/webp', 'image/avif'],
minimumCacheTTL: 60,
dangerouslyAllowSVG: true,
contentSecurityPolicy: "default-src 'self'; script-src 'none'; sandbox;",
},
}
Link Component (next/link)
Enables client-side navigation without full page reloads.
import Link from 'next/link';
export default function Navigation() {
return (
<nav>
<Link href="/about" prefetch={true}>
About
</Link>
<Link href="/blog" prefetch={false}>
Blog
</Link>
</nav>
);
}
Points to Remember:
- Use
priority={true}for above-the-fold images - Always specify width and height to prevent layout shift
- Use
fillprop for responsive containers - External images require domain whitelisting
- WebP format provides ~30% smaller file sizes than JPEG
- Set
prefetch={false}on links to content that may not be visited
2. Script Loading Strategies
Purpose
Optimize third-party script loading to prevent blocking the main thread and improve page load performance. The Script component gives you fine-grained control over when and how scripts are loaded.
Loading Strategies
beforeInteractive
Loads before any Next.js code and before page hydration. Use for critical scripts that must run before page interaction.
import Script from 'next/script';
export default function RootLayout({ children }) {
return (
<html>
<body>
<Script
src="https://polyfill.io/v3/polyfill.min.js"
strategy="beforeInteractive"
/>
{children}
</body>
</html>
);
}
afterInteractive (Default)
Loads after page becomes interactive. Ideal for analytics, ads, and non-critical scripts.
import Script from 'next/script';
export default function Analytics() {
return (
<Script
src="https://www.googletagmanager.com/gtag/js?id=GA_MEASUREMENT_ID"
strategy="afterInteractive"
onLoad={() => {
console.log('Analytics loaded');
}}
/>
);
}
lazyOnload
Loads during browser idle time. Perfect for chat widgets, social media buttons, or any non-essential scripts.
import Script from 'next/script';
export default function ChatWidget() {
return (
<Script
src="https://cdn.chatwidget.com/widget.js"
strategy="lazyOnload"
onReady={() => {
console.log('Chat widget ready');
}}
/>
);
}
worker (Experimental)
Loads scripts in a web worker for better performance.
import Script from 'next/script';
export default function Page() {
return (
<Script
src="https://example.com/heavy-script.js"
strategy="worker"
/>
);
}
Points to Remember:
- Only use
beforeInteractivefor truly critical scripts - Most analytics should use
afterInteractive - Social media widgets and chat should use
lazyOnload - Use
onLoadandonReadycallbacks for script-dependent code - Avoid inline scripts when possible; use external files with Script component
3. Dependency Management
Purpose
Remove unused dependencies to reduce bundle size, improve build times, and minimize security vulnerabilities.
Method 1: Manual Audit
Step-by-step process:
- Review
package.jsondependencies - Search codebase for import statements
- Remove unused packages
- Test thoroughly
# Search for package usage
grep -r "import.*from 'package-name'" ./src
grep -r "require('package-name')" ./src
Method 2: Using Depcheck
Installation:
npm install -g depcheck
# or
yarn global add depcheck
Usage:
# Run in project root
depcheck
# With options
depcheck --ignores="eslint-*,@types/*"
# Specify directories
depcheck ./src --ignore-dirs=build,dist
Example Output:
Unused dependencies
* lodash
* moment
* axios
Missing dependencies
* react-icons
Unused devDependencies
* @testing-library/jest-dom
Automated Cleanup Script (package.json):
{
"scripts": {
"deps:check": "depcheck",
"deps:clean": "depcheck --json | jq -r '.dependencies[]' | xargs npm uninstall"
}
}
Points to Remember:
- Run depcheck regularly (monthly or before major releases)
- Some packages are used only in config files (check carefully)
- Types packages (@types/*) may not show up in code search
- Test after removing dependencies
- Keep devDependencies separate from dependencies
- Document why certain packages are needed if they appear unused
4. Caching and ISR
Purpose
Implement smart caching strategies to serve content faster and reduce server load while keeping data fresh.
Incremental Static Regeneration (ISR)
ISR allows you to create or update static pages after build time, combining the benefits of static generation with dynamic data.
Basic ISR Implementation:
// app/blog/[slug]/page.js
export const revalidate = 3600; // Revalidate every hour
export async function generateStaticParams() {
const posts = await getPosts();
return posts.map((post) => ({
slug: post.slug,
}));
}
export default async function BlogPost({ params }) {
const post = await getPost(params.slug);
return (
<article>
<h1>{post.title}</h1>
<p>{post.content}</p>
<time>{post.publishedAt}</time>
</article>
);
}
On-Demand Revalidation:
// app/api/revalidate/route.js
import { revalidatePath, revalidateTag } from 'next/cache';
import { NextResponse } from 'next/server';
export async function POST(request) {
const secret = request.nextUrl.searchParams.get('secret');
if (secret !== process.env.REVALIDATION_SECRET) {
return NextResponse.json({ message: 'Invalid secret' }, { status: 401 });
}
const path = request.nextUrl.searchParams.get('path');
if (path) {
revalidatePath(path);
return NextResponse.json({ revalidated: true, now: Date.now() });
}
return NextResponse.json({ message: 'Missing path' }, { status: 400 });
}
Tag-based Revalidation:
// Fetch with cache tags
export default async function ProductList() {
const products = await fetch('https://api.example.com/products', {
next: {
tags: ['products'],
revalidate: 3600
}
});
return <div>{/* Render products */}</div>;
}
// Revalidate by tag
// app/api/revalidate-products/route.js
export async function POST() {
revalidateTag('products');
return NextResponse.json({ revalidated: true });
}
Cache Control Headers:
// app/api/data/route.js
export async function GET() {
const data = await fetchData();
return NextResponse.json(data, {
headers: {
'Cache-Control': 'public, s-maxage=60, stale-while-revalidate=300'
}
});
}
Points to Remember:
- ISR is perfect for content that updates periodically (blogs, products)
- Use shorter revalidation times (60s) for frequently changing data
- Use longer times (3600s+) for stable content
- On-demand revalidation is ideal for CMS webhooks
- Cache tags enable granular cache invalidation
-
stale-while-revalidateserves stale content while fetching fresh data
5. Font Optimization
Purpose
Eliminate layout shift caused by font loading and improve font loading performance automatically.
Using next/font
Google Fonts:
// app/layout.js
import { Inter, Roboto_Mono } from 'next/font/google';
const inter = Inter({
subsets: ['latin'],
display: 'swap',
variable: '--font-inter',
weight: ['400', '500', '600', '700'],
});
const robotoMono = Roboto_Mono({
subsets: ['latin'],
display: 'swap',
variable: '--font-roboto-mono',
});
export default function RootLayout({ children }) {
return (
<html lang="en" className={`${inter.variable} ${robotoMono.variable}`}>
<body className={inter.className}>{children}</body>
</html>
);
}
CSS Usage:
/* globals.css */
body {
font-family: var(--font-inter), sans-serif;
}
code {
font-family: var(--font-roboto-mono), monospace;
}
Local Fonts:
// app/layout.js
import localFont from 'next/font/local';
const myFont = localFont({
src: [
{
path: './fonts/MyFont-Regular.woff2',
weight: '400',
style: 'normal',
},
{
path: './fonts/MyFont-Bold.woff2',
weight: '700',
style: 'normal',
},
],
variable: '--font-my-font',
display: 'swap',
});
export default function RootLayout({ children }) {
return (
<html lang="en" className={myFont.variable}>
<body>{children}</body>
</html>
);
}
Preloading Fonts:
import { Inter } from 'next/font/google';
const inter = Inter({
subsets: ['latin'],
preload: true, // Preload font
adjustFontFallback: true, // Automatically adjust fallback font
});
Points to Remember:
-
next/fontautomatically hosts Google Fonts locally - Fonts are downloaded at build time, not runtime
- Use
display: 'swap'to prevent invisible text - Only include font weights you actually use
- Use variable fonts when possible for smaller file sizes
- Subset fonts to only include needed characters
-
adjustFontFallbackreduces CLS by matching fallback font metrics
6. Lazy Loading & Code Splitting
Purpose
Load code only when needed to reduce initial bundle size and improve Time to Interactive (TTI).
Dynamic Imports
Component Lazy Loading:
import dynamic from 'next/dynamic';
// Basic dynamic import
const HeavyComponent = dynamic(() => import('../components/HeavyComponent'));
// With loading state
const DynamicComponent = dynamic(
() => import('../components/ExpensiveComponent'),
{
loading: () => <div>Loading...</div>,
ssr: false, // Disable server-side rendering
}
);
export default function Page() {
return (
<div>
<h1>My Page</h1>
<HeavyComponent />
<DynamicComponent />
</div>
);
}
Named Exports:
import dynamic from 'next/dynamic';
const DynamicChart = dynamic(
() => import('../components/Charts').then((mod) => mod.LineChart),
{ ssr: false }
);
Conditional Loading:
'use client';
import { useState } from 'react';
import dynamic from 'next/dynamic';
const VideoPlayer = dynamic(() => import('../components/VideoPlayer'), {
ssr: false,
});
export default function MediaSection() {
const [showVideo, setShowVideo] = useState(false);
return (
<div>
<button onClick={() => setShowVideo(true)}>
Load Video Player
</button>
{showVideo && <VideoPlayer />}
</div>
);
}
Route-based Code Splitting:
// Automatically handled by Next.js
// Each page in app/ or pages/ is a separate bundle
// app/dashboard/page.js - Bundle 1
// app/profile/page.js - Bundle 2
// app/settings/page.js - Bundle 3
Library Code Splitting:
// Instead of:
import { format } from 'date-fns';
// Use:
import format from 'date-fns/format';
// Or with dynamic import:
const formatDate = async (date) => {
const { format } = await import('date-fns');
return format(date, 'yyyy-MM-dd');
};
Points to Remember:
- Use dynamic imports for components below the fold
- Modal dialogs are perfect candidates for lazy loading
- Charts, maps, and rich text editors should be lazy loaded
- Set
ssr: falsefor browser-only components - Each dynamic import creates a separate chunk
- Don't over-split; find the right balance
- Use React Suspense boundaries for better UX
7. Data Fetching Optimization
Purpose
Fetch data efficiently using the right method for each use case, minimizing server load and improving user experience.
Server Components (Default)
Basic Data Fetching:
// app/products/page.js
async function getProducts() {
const res = await fetch('https://api.example.com/products', {
next: { revalidate: 3600 } // ISR with 1-hour revalidation
});
if (!res.ok) throw new Error('Failed to fetch products');
return res.json();
}
export default async function ProductsPage() {
const products = await getProducts();
return (
<div>
{products.map(product => (
<ProductCard key={product.id} product={product} />
))}
</div>
);
}
Parallel Data Fetching:
async function getUser(id) {
const res = await fetch(`https://api.example.com/users/${id}`);
return res.json();
}
async function getUserPosts(id) {
const res = await fetch(`https://api.example.com/users/${id}/posts`);
return res.json();
}
export default async function UserProfile({ params }) {
// Fetch in parallel
const [user, posts] = await Promise.all([
getUser(params.id),
getUserPosts(params.id)
]);
return (
<div>
<UserInfo user={user} />
<PostsList posts={posts} />
</div>
);
}
Sequential Data Fetching (when needed):
export default async function Page() {
const user = await getUser();
// Wait for user before fetching preferences
const preferences = await getUserPreferences(user.id);
return <UserDashboard user={user} preferences={preferences} />;
}
Streaming with Suspense:
// app/dashboard/page.js
import { Suspense } from 'react';
async function SlowComponent() {
await new Promise(resolve => setTimeout(resolve, 3000));
const data = await fetchSlowData();
return <div>{data}</div>;
}
export default function Dashboard() {
return (
<div>
<h1>Dashboard</h1>
<FastComponent />
<Suspense fallback={<LoadingSpinner />}>
<SlowComponent />
</Suspense>
</div>
);
}
Request Deduplication:
// Next.js automatically deduplicates identical requests
// Both calls fetch only once
async function Header() {
const user = await fetch('https://api.example.com/user').then(r => r.json());
return <div>{user.name}</div>;
}
async function Sidebar() {
const user = await fetch('https://api.example.com/user').then(r => r.json());
return <div>{user.email}</div>;
}
Points to Remember:
- Server Components are fetched on the server (zero client JS)
- Use parallel fetching when data isn't interdependent
- Streaming improves perceived performance
- Request deduplication happens automatically
- Use
cache: 'no-store'for always-fresh data - Use
next: { revalidate: 0 }for dynamic data
8. Client-Side Data Fetching with SWR
Purpose
Implement smart client-side caching with automatic revalidation, providing a fast and reactive user experience.
SWR Setup
Installation:
npm install swr
# or
yarn add swr
Basic Usage:
'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
);
if (error) return <div>Failed to load</div>;
if (isLoading) return <div>Loading...</div>;
return <div>Hello {data.name}!</div>;
}
Global Configuration:
// app/layout.js
'use client';
import { SWRConfig } from 'swr';
const fetcher = (url) => fetch(url).then((res) => res.json());
export default function RootLayout({ children }) {
return (
<SWRConfig
value={{
refreshInterval: 3000,
fetcher: fetcher,
revalidateOnFocus: true,
revalidateOnReconnect: true,
shouldRetryOnError: true,
errorRetryCount: 3,
dedupingInterval: 2000,
}}
>
{children}
</SWRConfig>
);
}
Optimistic Updates:
'use client';
import useSWR, { mutate } from 'swr';
export default function TodoList() {
const { data: todos } = useSWR('/api/todos', fetcher);
const addTodo = async (text) => {
const newTodo = { id: Date.now(), text, completed: false };
// Optimistically update UI
mutate(
'/api/todos',
[...todos, newTodo],
false // Don't revalidate immediately
);
// Send request to server
await fetch('/api/todos', {
method: 'POST',
body: JSON.stringify(newTodo),
});
// Revalidate to sync with server
mutate('/api/todos');
};
return (
<div>
{todos?.map(todo => (
<TodoItem key={todo.id} todo={todo} />
))}
<AddTodoButton onAdd={addTodo} />
</div>
);
}
Conditional Fetching:
'use client';
import useSWR from 'swr';
export default function UserData({ userId }) {
// Only fetch when userId exists
const { data } = useSWR(
userId ? `/api/user/${userId}` : null,
fetcher
);
return data ? <div>{data.name}</div> : null;
}
Pagination:
'use client';
import useSWR from 'swr';
import { useState } from 'react';
export default function PaginatedList() {
const [page, setPage] = useState(1);
const { data, error, isLoading } = useSWR(
`/api/items?page=${page}`,
fetcher,
{ keepPreviousData: true }
);
return (
<div>
{data?.items.map(item => <Item key={item.id} item={item} />)}
<button onClick={() => setPage(page - 1)} disabled={page === 1}>
Previous
</button>
<button onClick={() => setPage(page + 1)}>
Next
</button>
</div>
);
}
Points to Remember:
- SWR automatically revalidates on focus, reconnect, and interval
- Use optimistic updates for instant UI feedback
-
mutate()can update multiple SWR keys at once - Set
revalidateIfStale: falsefor truly static data - Use
useSWRInfinitefor infinite scroll patterns - SWR handles race conditions automatically
- Combine with Server Components for optimal performance
9. Component Optimization
Purpose
Prevent unnecessary re-renders and optimize component performance using React's memoization techniques.
React.memo
Basic Memoization:
import { memo } from 'react';
const ExpensiveComponent = memo(function ExpensiveComponent({ data, onClick }) {
console.log('Rendering ExpensiveComponent');
return (
<div onClick={onClick}>
{data.map(item => (
<div key={item.id}>{item.name}</div>
))}
</div>
);
});
export default ExpensiveComponent;
Custom Comparison Function:
import { memo } from 'react';
const ProductCard = memo(
function ProductCard({ product, onAddToCart }) {
return (
<div>
<h3>{product.name}</h3>
<p>${product.price}</p>
<button onClick={() => onAddToCart(product.id)}>
Add to Cart
</button>
</div>
);
},
(prevProps, nextProps) => {
// Only re-render if product data changed
return (
prevProps.product.id === nextProps.product.id &&
prevProps.product.price === nextProps.product.price &&
prevProps.product.name === nextProps.product.name
);
}
);
useMemo
Expensive Calculations:
'use client';
import { useMemo } from 'react';
export default function DataTable({ data, filter }) {
const filteredData = useMemo(() => {
console.log('Filtering data...');
return data.filter(item =>
item.category === filter
).sort((a, b) => b.price - a.price);
}, [data, filter]); // Only recalculate when data or filter changes
return (
<table>
{filteredData.map(item => (
<tr key={item.id}>
<td>{item.name}</td>
<td>${item.price}</td>
</tr>
))}
</table>
);
}
useCallback
Stable Function References:
'use client';
import { useState, useCallback } from 'react';
export default function ParentComponent() {
const [count, setCount] = useState(0);
const [items, setItems] = useState([]);
// Without useCallback, this function is recreated on every render
const handleAddItem = useCallback((item) => {
setItems(prev => [...prev, item]);
}, []); // Empty deps = function never changes
const handleItemClick = useCallback((id) => {
console.log('Clicked item:', id);
// Uses latest count value
}, [count]);
return (
<div>
<button onClick={() => setCount(count + 1)}>
Count: {count}
</button>
<ChildComponent onAddItem={handleAddItem} />
</div>
);
}
const ChildComponent = memo(function ChildComponent({ onAddItem }) {
console.log('Child rendered');
return <button onClick={() => onAddItem({ id: Date.now() })}>Add</button>;
});
Points to Remember:
- Don't memoize everything; it adds overhead
- Use memo for components that render often with same props
- useMemo for expensive calculations, not simple operations
- useCallback for functions passed to memoized children
- Profile before and after to verify improvements
- React 19+ compiler handles much of this automatically
- Server Components don't need memoization
10. List Virtualization
Purpose
Render only visible items in long lists to dramatically reduce DOM nodes and improve performance.
Using react-window
Installation:
npm install react-window
# or
yarn add react-window
Fixed Size List:
'use client';
import { FixedSizeList } from 'react-window';
const Row = ({ index, style }) => (
<div style={style}>
Row {index}
</div>
);
export default function VirtualList({ items }) {
return (
<FixedSizeList
height={600}
itemCount={items.length}
itemSize={50}
width="100%"
>
{Row}
</FixedSizeList>
);
}
Variable Size List:
'use client';
import { VariableSizeList } from 'react-window';
import { useRef } from 'react';
export default function VariableList({ items }) {
const listRef = useRef();
const getItemSize = (index) => {
// Return different heights based on content
return items[index].isExpanded ? 200 : 50;
};
const Row = ({ index, style }) => (
<div style={style}>
<h3>{items[index].title}</h3>
<p>{items[index].description}</p>
</div>
);
return (
<VariableSizeList
ref={listRef}
height={600}
itemCount={items.length}
itemSize={getItemSize}
width="100%"
>
{Row}
</VariableSizeList>
);
}
Grid Virtualization:
'use client';
import { FixedSizeGrid } from 'react-window';
const Cell = ({ columnIndex, rowIndex, style }) => (
<div style={style}>
Item {rowIndex},{columnIndex}
</div>
);
export default function VirtualGrid() {
return (
<FixedSizeGrid
columnCount={100}
columnWidth={100}
height={600}
rowCount={100}
rowHeight={100}
width={800}
>
{Cell}
</FixedSizeGrid>
);
}
With Data:
'use client';
import { FixedSizeList } from 'react-window';
export default function ProductList({ products }) {
const Row = ({ index, style }) => {
const product = products[index];
return (
<div style={style} className="product-row">
<img src={product.image} alt={product.name} />
<h3>{product.name}</h3>
<p>${product.price}</p>
<button>Add to Cart</button>
</div>
);
};
return (
<FixedSizeList
height={800}
itemCount={products.length}
itemSize={120}
width="100%"
overscanCount={5} // Render extra items outside viewport
>
{Row}
</FixedSizeList>
);
}
Points to Remember:
- Use for lists with 100+ items
- Fixed size is more performant than variable size
- Set
overscanCountto render items just outside viewport - Great for feeds, product catalogs, log viewers
- Combine with SWR for infinite scroll
- Consider tanstack-virtual for more features
- Test on mobile devices; scrolling must feel native
11. React Compiler Integration
Purpose
Leverage Next.js's integration with the React Compiler for automatic optimization, eliminating manual memoization in many cases.
Overview
The React Compiler (React 19+) automatically optimizes component re-renders by:
- Automatically memoizing components
- Optimizing hooks and calculations
- Reducing unnecessary re-renders without manual memo/useMemo/useCallback
Configuration
Enable in next.config.js:
// next.config.js
module.exports = {
experimental: {
reactCompiler: true,
},
};
Selective Compilation:
// next.config.js
module.exports = {
experimental: {
reactCompiler: {
compilationMode: 'annotation', // Only compile marked components
},
},
};
Using Annotations:
'use client';
// Opt-in to compiler optimization
'use memo';
export default function OptimizedComponent({ data }) {
// Component automatically memoized by React Compiler
const processedData = data.map(item => item * 2);
return (
<div>
{processedData.map(value => (
<div key={value}>{value}</div>
))}
</div>
);
}
Before React Compiler (Manual Optimization):
'use client';
import { memo, useMemo, useCallback } from 'react';
const ExpensiveComponent = memo(function ExpensiveComponent({ items, onItemClick }) {
const sortedItems = useMemo(() => {
return items.sort((a, b) => a.priority - b.priority);
}, [items]);
const handleClick = useCallback((id) => {
onItemClick(id);
}, [onItemClick]);
return (
<div>
{sortedItems.map(item => (
<div key={item.id} onClick={() => handleClick(item.id)}>
{item.name}
</div>
))}
</div>
);
});
After React Compiler (Automatic Optimization):
'use client';
// No manual memoization needed
export default function ExpensiveComponent({ items, onItemClick }) {
const sortedItems = items.sort((a, b) => a.priority - b.priority);
const handleClick = (id) => {
onItemClick(id);
};
return (
<div>
{sortedItems.map(item => (
<div key={item.id} onClick={() => handleClick(item.id)}>
{item.name}
</div>
))}
</div>
);
}
Compiler Configuration Options:
// next.config.js
module.exports = {
experimental: {
reactCompiler: {
compilationMode: 'all', // 'all' | 'annotation' | 'inferred'
panicThreshold: 'critical_errors', // Error handling level
sources: (filename) => {
// Customize which files to compile
return filename.includes('components');
},
},
},
};
Points to Remember:
- React Compiler is experimental but production-ready in Next.js 15+
- Reduces boilerplate code significantly
- Automatic memoization is often smarter than manual
- Still use manual memo for very specific optimization cases
- Test thoroughly when migrating from manual memoization
- Compiler doesn't optimize Server Components (they don't need it)
- Can gradually adopt with 'annotation' mode
- Monitor bundle size; compiler adds minimal overhead
12. JavaScript Bundle Optimization
Purpose
Reduce JavaScript bundle size to improve download times, parsing, and execution performance.
Bundle Analysis
Installation:
npm install @next/bundle-analyzer
# or
yarn add @next/bundle-analyzer
Configuration:
// next.config.js
const withBundleAnalyzer = require('@next/bundle-analyzer')({
enabled: process.env.ANALYZE === 'true',
});
module.exports = withBundleAnalyzer({
// Your Next.js config
reactStrictMode: true,
swcMinify: true,
});
Usage:
// package.json
{
"scripts": {
"analyze": "ANALYZE=true next build",
"analyze:server": "ANALYZE=true BUNDLE_ANALYZE=server next build",
"analyze:browser": "ANALYZE=true BUNDLE_ANALYZE=browser next build"
}
}
Code Splitting Strategies
Selective Imports (Tree Shaking):
// ❌ Bad: Imports entire library
import _ from 'lodash';
const result = _.debounce(fn, 300);
// ✅ Good: Import only what you need
import debounce from 'lodash/debounce';
const result = debounce(fn, 300);
// ❌ Bad: Imports all icons
import * as Icons from 'react-icons/fa';
// ✅ Good: Import specific icons
import { FaUser, FaHome } from 'react-icons/fa';
// ❌ Bad: Large date library
import moment from 'moment';
// ✅ Good: Smaller alternative with tree-shaking
import { format, parseISO } from 'date-fns';
Dynamic Imports for Large Libraries:
'use client';
import { useState } from 'react';
export default function ChartPage() {
const [showChart, setShowChart] = useState(false);
const loadChart = async () => {
// Load Chart.js only when needed
const { Chart } = await import('chart.js/auto');
// Use Chart
setShowChart(true);
};
return (
<div>
<button onClick={loadChart}>Show Chart</button>
{showChart && <ChartComponent />}
</div>
);
}
Route-based Splitting:
// next.config.js
module.exports = {
experimental: {
optimizePackageImports: ['@mui/material', '@mui/icons-material'],
},
};
Webpack Configuration:
// next.config.js
module.exports = {
webpack: (config, { isServer }) => {
if (!isServer) {
// Don't bundle server-only packages in client bundle
config.resolve.fallback = {
...config.resolve.fallback,
fs: false,
net: false,
tls: false,
};
}
// Optimize specific packages
config.optimization.splitChunks = {
chunks: 'all',
cacheGroups: {
vendor: {
test: /[\\/]node_modules[\\/]/,
name(module) {
const packageName = module.context.match(
/[\\/]node_modules[\\/](.*?)([\\/]|$)/
)[1];
return `npm.${packageName.replace('@', '')}`;
},
},
},
};
return config;
},
};
Analyze and Replace Heavy Dependencies:
// Check package sizes before installing
npm install -g bundlephobia-cli
bundlephobia [package-name]
// Common replacements:
// moment (232KB) → date-fns (13KB)
// lodash (72KB) → lodash-es with tree-shaking
// axios (13KB) → native fetch API
// react-icons (full: 3MB) → selective imports
Polyfill Optimization:
// next.config.js
module.exports = {
compiler: {
// Remove console.log in production
removeConsole: {
exclude: ['error', 'warn'],
},
},
// Target modern browsers to reduce polyfills
experimental: {
browsersListForSwc: true,
},
};
ESLint for Bundle Size:
// .eslintrc.json
{
"plugins": ["import"],
"rules": {
"import/no-extraneous-dependencies": "error",
"import/no-unresolved": "error"
}
}
Points to Remember:
- Run bundle analyzer regularly (before each release)
- Look for duplicate dependencies in different versions
- Replace heavy libraries with lighter alternatives
- Use dynamic imports for rarely-used features
- Target modern browsers to reduce polyfills
- Remove unused exports from your own code
- Consider package.json "sideEffects: false" for better tree-shaking
- Test bundle size impact of each new dependency
- Aim for < 200KB initial JavaScript bundle
13. CSS Optimization
Purpose
Eliminate unused CSS to reduce file sizes and improve page load performance.
Using PurgeCSS
Installation:
npm install @fullhuman/postcss-purgecss
# or
yarn add @fullhuman/postcss-purgecss
Configuration with Tailwind:
// tailwind.config.js
module.exports = {
content: [
'./app/**/*.{js,ts,jsx,tsx,mdx}',
'./pages/**/*.{js,ts,jsx,tsx,mdx}',
'./components/**/*.{js,ts,jsx,tsx,mdx}',
],
theme: {
extend: {},
},
plugins: [],
};
PostCSS Configuration:
// postcss.config.js
module.exports = {
plugins: {
tailwindcss: {},
autoprefixer: {},
...(process.env.NODE_ENV === 'production'
? {
'@fullhuman/postcss-purgecss': {
content: [
'./app/**/*.{js,jsx,ts,tsx}',
'./components/**/*.{js,jsx,ts,tsx}',
],
defaultExtractor: (content) =>
content.match(/[\w-/:]+(?<!:)/g) || [],
safelist: ['html', 'body'],
},
}
: {}),
},
};
Manual PurgeCSS Setup:
// purgecss.config.js
module.exports = {
content: ['./app/**/*.{js,jsx,ts,tsx}'],
css: ['./styles/**/*.css'],
output: './styles/purged',
safelist: {
standard: ['body', 'html', 'active'],
deep: [/^modal-/, /^dropdown-/],
greedy: [/^btn-/],
},
};
CSS Modules
Component-scoped CSS:
// components/Button/Button.module.css
.button {
padding: 10px 20px;
background: blue;
color: white;
}
.primary {
background: #0070f3;
}
.secondary {
background: #666;
}
// components/Button/Button.jsx
import styles from './Button.module.css';
export default function Button({ variant = 'primary', children }) {
return (
<button className={`${styles.button} ${styles[variant]}`}>
{children}
</button>
);
}
Critical CSS
Inline Critical CSS:
// app/layout.js
export default function RootLayout({ children }) {
return (
<html>
<head>
<style dangerouslySetInnerHTML={{
__html: `
/* Critical CSS for above-the-fold content */
body { margin: 0; font-family: system-ui; }
.header { height: 60px; background: #000; }
.hero { min-height: 100vh; }
`
}} />
</head>
<body>{children}</body>
</html>
);
}
Extract Critical CSS:
// Use critters in next.config.js
module.exports = {
experimental: {
optimizeCss: true, // Uses critters to inline critical CSS
},
};
CSS-in-JS Optimization
Styled-components with Next.js:
// app/layout.js
import StyledComponentsRegistry from './registry';
export default function RootLayout({ children }) {
return (
<html>
<body>
<StyledComponentsRegistry>{children}</StyledComponentsRegistry>
</body>
</html>
);
}
// app/registry.js
'use client';
import { useState } from 'react';
import { useServerInsertedHTML } from 'next/navigation';
import { ServerStyleSheet, StyleSheetManager } from 'styled-components';
export default function StyledComponentsRegistry({ children }) {
const [styledComponentsStyleSheet] = useState(() => new ServerStyleSheet());
useServerInsertedHTML(() => {
const styles = styledComponentsStyleSheet.getStyleElement();
styledComponentsStyleSheet.instance.clearTag();
return <>{styles}</>;
});
if (typeof window !== 'undefined') return <>{children}</>;
return (
<StyleSheetManager sheet={styledComponentsStyleSheet.instance}>
{children}
</StyleSheetManager>
);
}
Optimize with Zero-Runtime CSS:
# Consider zero-runtime alternatives
npm install @vanilla-extract/css
# or
npm install @pigment-css/react
Points to Remember:
- Tailwind automatically purges unused CSS in production
- CSS Modules prevent global namespace pollution
- Critical CSS should be < 14KB for optimal performance
- Avoid @import in CSS; use bundler imports
- Use CSS containment for performance:
contain: layout style paint - Minimize CSS-in-JS runtime overhead
- Consider static CSS extraction for production
- Compress CSS with cssnano in production
14. API Route Caching
Purpose
Implement intelligent caching for API routes to reduce database load and improve response times.
Route-level Caching
Basic Revalidation:
// app/api/products/route.js
export const revalidate = 60; // Revalidate every 60 seconds
export async function GET() {
const products = await db.products.findMany();
return Response.json(products, {
headers: {
'Cache-Control': 'public, s-maxage=60, stale-while-revalidate=300',
},
});
}
Dynamic Caching:
// app/api/user/[id]/route.js
export async function GET(request, { params }) {
const { id } = params;
const user = await db.user.findUnique({ where: { id } });
if (!user) {
return Response.json({ error: 'User not found' }, { status: 404 });
}
return Response.json(user, {
headers: {
'Cache-Control': 'private, max-age=300', // Cache for 5 minutes
'CDN-Cache-Control': 'public, max-age=60',
},
});
}
Conditional Caching:
// app/api/data/route.js
export async function GET(request) {
const authHeader = request.headers.get('authorization');
const isAuthenticated = !!authHeader;
const data = await fetchData(isAuthenticated);
// Different cache strategy based on auth status
const cacheControl = isAuthenticated
? 'private, max-age=60' // Authenticated: private cache
: 'public, s-maxage=300, stale-while-revalidate=600'; // Public: aggressive cache
return Response.json(data, {
headers: { 'Cache-Control': cacheControl },
});
}
Database Query Caching
Using unstable_cache:
// app/api/posts/route.js
import { unstable_cache } from 'next/cache';
const getCachedPosts = unstable_cache(
async () => {
const posts = await db.post.findMany({
orderBy: { createdAt: 'desc' },
take: 10,
});
return posts;
},
['posts-list'], // Cache key
{
revalidate: 300, // 5 minutes
tags: ['posts'], // For tagged revalidation
}
);
export async function GET() {
const posts = await getCachedPosts();
return Response.json(posts);
}
Cache with Parameters:
import { unstable_cache } from 'next/cache';
const getCachedPostsByCategory = unstable_cache(
async (category) => {
return await db.post.findMany({
where: { category },
});
},
['posts-by-category'],
{ revalidate: 600 }
);
export async function GET(request) {
const category = request.nextUrl.searchParams.get('category');
const posts = await getCachedPostsByCategory(category);
return Response.json(posts);
}
Redis Caching
Setup:
npm install ioredis
Implementation:
// lib/redis.js
import Redis from 'ioredis';
const redis = new Redis(process.env.REDIS_URL);
export default redis;
// app/api/popular/route.js
import redis from '@/lib/redis';
export async function GET() {
const cacheKey = 'popular-items';
// Try to get from cache
const cached = await redis.get(cacheKey);
if (cached) {
return Response.json(JSON.parse(cached), {
headers: { 'X-Cache': 'HIT' },
});
}
// If not in cache, fetch from database
const items = await db.items.findMany({
orderBy: { views: 'desc' },
take: 20,
});
// Store in cache for 5 minutes
await redis.setex(cacheKey, 300, JSON.stringify(items));
return Response.json(items, {
headers: { 'X-Cache': 'MISS' },
});
}
Cache Invalidation:
// app/api/items/[id]/route.js
import redis from '@/lib/redis';
import { revalidateTag } from 'next/cache';
export async function PUT(request, { params }) {
const { id } = params;
const body = await request.json();
// Update database
const item = await db.item.update({
where: { id },
data: body,
});
// Invalidate Redis cache
await redis.del('popular-items');
// Revalidate Next.js cache
revalidateTag('items');
return Response.json(item);
}
Caching Strategy with TTL:
// lib/cache.js
import redis from './redis';
export async function cacheGet(key, ttl, fetcher) {
const cached = await redis.get(key);
if (cached) {
return JSON.parse(cached);
}
const fresh = await fetcher();
await redis.setex(key, ttl, JSON.stringify(fresh));
return fresh;
}
// Usage
// app/api/stats/route.js
import { cacheGet } from '@/lib/cache';
export async function GET() {
const stats = await cacheGet(
'site-stats',
3600, // 1 hour
async () => await calculateStats()
);
return Response.json(stats);
}
Points to Remember:
- Use
stale-while-revalidatefor better UX - Private data needs
privatecache control - Redis is ideal for distributed caching across instances
- Set appropriate TTLs based on data freshness requirements
- Implement cache warming for critical data
- Monitor cache hit rates
- Use cache tags for granular invalidation
- Consider cache stampede protection for high-traffic APIs
15. Edge Runtime
Purpose
Execute functions closer to users for reduced latency and better global performance.
Edge Runtime Basics
Converting to Edge:
// app/api/hello/route.js
export const runtime = 'edge';
export async function GET(request) {
return Response.json({
message: 'Hello from the Edge!',
location: request.geo?.city || 'Unknown',
});
}
Geolocation-based Response:
// app/api/content/route.js
export const runtime = 'edge';
export async function GET(request) {
const country = request.geo?.country || 'US';
const content = await fetch(`https://api.example.com/content/${country}`, {
next: { revalidate: 3600 },
}).then(res => res.json());
return Response.json(content);
}
Edge Middleware
Global Middleware:
// middleware.js
import { NextResponse } from 'next/server';
export function middleware(request) {
const country = request.geo?.country || 'US';
const city = request.geo?.city || 'Unknown';
// Add geo headers
const response = NextResponse.next();
response.headers.set('x-user-country', country);
response.headers.set('x-user-city', city);
return response;
}
export const config = {
matcher: '/api/:path*',
};
A/B Testing at the Edge:
// middleware.js
import { NextResponse } from 'next/server';
export function middleware(request) {
const bucket = Math.random() < 0.5 ? 'A' : 'B';
const url = request.nextUrl.clone();
// Assign user to experiment variant
const response = NextResponse.rewrite(url);
response.cookies.set('experiment-bucket', bucket, {
maxAge: 60 * 60 * 24 * 7, // 1 week
});
return response;
}
Authentication at Edge:
// middleware.js
import { NextResponse } from 'next/server';
import { verifyToken } from '@/lib/auth';
export async function middleware(request) {
const token = request.cookies.get('auth-token')?.value;
if (!token) {
return NextResponse.redirect(new URL('/login', request.url));
}
try {
const user = await verifyToken(token);
// Add user info to headers
const response = NextResponse.next();
response.headers.set('x-user-id', user.id);
return response;
} catch (error) {
return NextResponse.redirect(new URL('/login', request.url));
}
}
export const config = {
matcher: ['/dashboard/:path*', '/api/protected/:path*'],
};
Edge API Routes
Simple API:
// app/api/time/route.js
export const runtime = 'edge';
export async function GET() {
return Response.json({
timestamp: Date.now(),
iso: new Date().toISOString(),
});
}
Proxying Requests:
// app/api/proxy/route.js
export const runtime = 'edge';
export async function GET(request) {
const url = request.nextUrl.searchParams.get('url');
if (!url) {
return Response.json({ error: 'URL required' }, { status: 400 });
}
try {
const response = await fetch(url);
const data = await response.text();
return new Response(data, {
headers: {
'Content-Type': response.headers.get('Content-Type') || 'text/plain',
'Cache-Control': 'public, max-age=300',
},
});
} catch (error) {
return Response.json({ error: 'Fetch failed' }, { status: 500 });
}
}
Image Optimization at Edge:
// app/api/image-proxy/route.js
export const runtime = 'edge';
export async function GET(request) {
const imageUrl = request.nextUrl.searchParams.get('url');
const width = request.nextUrl.searchParams.get('w') || '800';
const response = await fetch(imageUrl);
const buffer = await response.arrayBuffer();
// In production, use edge-compatible image processing
return new Response(buffer, {
headers: {
'Content-Type': 'image/jpeg',
'Cache-Control': 'public, max-age=31536000, immutable',
},
});
}
Edge Limitations
What Works:
- Fetch API
- Web APIs (URL, Headers, Request, Response)
- Crypto API
- TextEncoder/TextDecoder
- Base64 encoding/decoding
- JSON operations
What Doesn't Work:
- Node.js APIs (fs, path, etc.)
- Native modules
- Large npm packages
- eval() and new Function()
Checking Runtime:
export const runtime = 'edge';
export async function GET() {
const isEdge = typeof EdgeRuntime !== 'undefined';
return Response.json({
runtime: isEdge ? 'edge' : 'nodejs',
available: {
fetch: typeof fetch !== 'undefined',
crypto: typeof crypto !== 'undefined',
},
});
}
Points to Remember:
- Edge runtime has lower cold start times
- Perfect for: auth, redirects, A/B testing, headers manipulation
- Not suitable for: heavy computations, large dependencies
- Bundle size limited to 1-4MB
- Use Node.js runtime for database-heavy operations
- Edge functions run globally on CDN nodes
- Monitor execution time; keep functions fast
- Test edge functions in production-like environment
16. Core Web Vitals
Purpose
Optimize for Google's Core Web Vitals metrics which directly impact SEO rankings and user experience.
Understanding Core Web Vitals
The Three Metrics:
- LCP (Largest Contentful Paint): Time to render largest content element (< 2.5s = Good)
- FID (First Input Delay) / INP (Interaction to Next Paint): Time from user interaction to response (< 100ms = Good)
- CLS (Cumulative Layout Shift): Visual stability (< 0.1 = Good)
Measuring Core Web Vitals
Built-in Web Vitals Reporting:
// app/layout.js
import { SpeedInsights } from '@vercel/speed-insights/next';
import { Analytics } from '@vercel/analytics/react';
export default function RootLayout({ children }) {
return (
<html lang="en">
<body>
{children}
<SpeedInsights />
<Analytics />
</body>
</html>
);
}
Custom Web Vitals Reporting:
// app/web-vitals.js
'use client';
import { useReportWebVitals } from 'next/web-vitals';
export function WebVitals() {
useReportWebVitals((metric) => {
console.log(metric);
// Send to analytics
window.gtag?.('event', metric.name, {
value: Math.round(metric.name === 'CLS' ? metric.value * 1000 : metric.value),
event_label: metric.id,
non_interaction: true,
});
// Send to custom endpoint
fetch('/api/vitals', {
method: 'POST',
body: JSON.stringify(metric),
headers: { 'Content-Type': 'application/json' },
});
});
return null;
}
// app/layout.js
import { WebVitals } from './web-vitals';
export default function RootLayout({ children }) {
return (
<html>
<body>
<WebVitals />
{children}
</body>
</html>
);
}
Optimizing LCP (Largest Contentful Paint)
1. Preload Critical Resources:
// app/layout.js
export default function RootLayout({ children }) {
return (
<html>
<head>
<link rel="preload" href="/hero-image.jpg" as="image" />
<link rel="preload" href="/fonts/inter.woff2" as="font" type="font/woff2" crossOrigin="anonymous" />
<link rel="preconnect" href="https://api.example.com" />
<link rel="dns-prefetch" href="https://cdn.example.com" />
</head>
<body>{children}</body>
</html>
);
}
2. Optimize Images:
import Image from 'next/image';
export default function Hero() {
return (
<Image
src="/hero.jpg"
alt="Hero"
width={1200}
height={600}
priority // Preload above-the-fold images
placeholder="blur"
blurDataURL="data:image/jpeg;base64,..."
/>
);
}
3. Server-Side Rendering:
// app/page.js - Rendered on server, faster LCP
export default async function Page() {
const data = await fetchData();
return (
<main>
<h1>{data.title}</h1>
<Image src={data.image} alt={data.title} priority />
</main>
);
}
4. Optimize Server Response Time:
// Use ISR for faster responses
export const revalidate = 3600;
// Use Edge Runtime for global speed
export const runtime = 'edge';
// Implement caching
export async function getData() {
const cached = await redis.get('key');
if (cached) return JSON.parse(cached);
const fresh = await fetchFromDb();
await redis.setex('key', 300, JSON.stringify(fresh));
return fresh;
}
Optimizing FID/INP (First Input Delay / Interaction to Next Paint)
1. Reduce JavaScript Execution:
// Use Server Components (no JS sent to client)
export default async function ProductList() {
const products = await getProducts();
return (
<div>
{products.map(product => (
<ProductCard key={product.id} product={product} />
))}
</div>
);
}
2. Code Splitting:
'use client';
import dynamic from 'next/dynamic';
const HeavyComponent = dynamic(() => import('./HeavyComponent'), {
ssr: false,
});
export default function Page() {
return (
<div>
<LightweightContent />
<HeavyComponent />
</div>
);
}
3. Optimize Event Handlers:
'use client';
import { useCallback } from 'react';
import { debounce } from 'lodash';
export default function SearchBar() {
const handleSearch = useCallback(
debounce((query) => {
// Heavy search logic
performSearch(query);
}, 300),
[]
);
return (
<input
type="text"
onChange={(e) => handleSearch(e.target.value)}
placeholder="Search..."
/>
);
}
4. Use Web Workers:
'use client';
import { useEffect, useState } from 'react';
export default function DataProcessor() {
const [result, setResult] = useState(null);
useEffect(() => {
const worker = new Worker(new URL('./worker.js', import.meta.url));
worker.postMessage({ data: heavyData });
worker.onmessage = (e) => {
setResult(e.data);
worker.terminate();
};
return () => worker.terminate();
}, []);
return <div>{result}</div>;
}
Optimizing CLS (Cumulative Layout Shift)
1. Specify Image Dimensions:
import Image from 'next/image';
export default function Gallery() {
return (
<div>
{/* Always specify width and height */}
<Image
src="/photo.jpg"
alt="Photo"
width={800}
height={600}
/>
</div>
);
}
2. Reserve Space for Dynamic Content:
/* Reserve space for ads */
.ad-container {
min-height: 250px;
width: 100%;
}
/* Reserve space for lazy-loaded images */
.image-placeholder {
aspect-ratio: 16 / 9;
background: #f0f0f0;
}
export default function AdSlot() {
return (
<div className="ad-container">
<DynamicAd />
</div>
);
}
3. Use CSS Containment:
.card {
contain: layout style paint;
}
.isolated-component {
content-visibility: auto;
contain-intrinsic-size: 0 500px;
}
4. Avoid Inserting Content Above Existing Content:
'use client';
import { useState, useEffect } from 'react';
export default function Feed() {
const [items, setItems] = useState([]);
const loadMore = async () => {
const newItems = await fetchItems();
// Append to bottom, don't prepend to top
setItems(prev => [...prev, ...newItems]);
};
return (
<div>
{items.map(item => <Item key={item.id} item={item} />)}
<button onClick={loadMore}>Load More</button>
</div>
);
}
5. Use Skeleton Screens:
import { Suspense } from 'react';
function ProductSkeleton() {
return (
<div className="skeleton">
<div className="skeleton-image" />
<div className="skeleton-title" />
<div className="skeleton-price" />
</div>
);
}
export default function ProductPage() {
return (
<Suspense fallback={<ProductSkeleton />}>
<ProductDetails />
</Suspense>
);
}
.skeleton {
animation: pulse 2s infinite;
}
.skeleton-image {
width: 100%;
height: 300px;
background: #e0e0e0;
}
@keyframes pulse {
0%, 100% { opacity: 1; }
50% { opacity: 0.5; }
}
Points to Remember:
- Measure Core Web Vitals in production, not just locally
- Test on real devices and network conditions
- Use Chrome DevTools Lighthouse for audits
- Monitor 75th percentile, not averages
- LCP: Optimize images, fonts, and server response
- FID/INP: Minimize JavaScript, use code splitting
- CLS: Reserve space, specify dimensions, avoid layout shifts
- Core Web Vitals affect SEO rankings
- Prioritize mobile performance
- Use field data (real users) over lab data
17. SEO Optimization
Purpose
Implement comprehensive SEO best practices to improve search engine visibility and rankings.
Metadata API
Basic Metadata:
// app/layout.js
export const metadata = {
title: {
default: 'My Website',
template: '%s | My Website', // Used by child pages
},
description: 'Welcome to my amazing website',
keywords: ['nextjs', 'react', 'web development'],
authors: [{ name: 'John Doe', url: 'https://example.com' }],
creator: 'John Doe',
publisher: 'My Company',
formatDetection: {
email: false,
address: false,
telephone: false,
},
};
Page-specific Metadata:
// app/blog/[slug]/page.js
export async function generateMetadata({ params }) {
const post = await getPost(params.slug);
return {
title: post.title,
description: post.excerpt,
keywords: post.tags,
authors: [{ name: post.author.name }],
openGraph: {
title: post.title,
description: post.excerpt,
url: `https://example.com/blog/${params.slug}`,
siteName: 'My Blog',
images: [
{
url: post.coverImage,
width: 1200,
height: 630,
alt: post.title,
},
],
locale: 'en_US',
type: 'article',
publishedTime: post.publishedAt,
modifiedTime: post.updatedAt,
authors: [post.author.name],
tags: post.tags,
},
twitter: {
card: 'summary_large_image',
title: post.title,
description: post.excerpt,
creator: '@yourusername',
images: [post.coverImage],
},
alternates: {
canonical: `https://example.com/blog/${params.slug}`,
languages: {
'en-US': `https://example.com/en/blog/${params.slug}`,
'es-ES': `https://example.com/es/blog/${params.slug}`,
},
},
};
}
export default async function BlogPost({ params }) {
const post = await getPost(params.slug);
return <article>{/* Post content */}</article>;
}
Advanced Metadata:
// app/products/[id]/page.js
export async function generateMetadata({ params }) {
const product = await getProduct(params.id);
return {
title: `${product.name} - Buy Online`,
description: `${product.name}: ${product.shortDescription}. Price: ${product.price}. ${product.availability}`,
openGraph: {
title: product.name,
description: product.shortDescription,
type: 'product',
images: product.images.map(img => ({
url: img.url,
width: 800,
height: 600,
alt: img.alt,
})),
product: {
price: {
amount: product.price,
currency: 'USD',
},
availability: product.inStock ? 'in stock' : 'out of stock',
condition: 'new',
},
},
alternates: {
canonical: `https://example.com/products/${params.id}`,
},
robots: {
index: product.isPublished,
follow: true,
googleBot: {
index: product.isPublished,
follow: true,
'max-image-preview': 'large',
'max-snippet': -1,
},
},
};
}
Structured Data (JSON-LD)
Article Schema:
// app/blog/[slug]/page.js
export default async function BlogPost({ params }) {
const post = await getPost(params.slug);
const jsonLd = {
'@context': 'https://schema.org',
'@type': 'Article',
headline: post.title,
description: post.excerpt,
image: post.coverImage,
datePublished: post.publishedAt,
dateModified: post.updatedAt,
author: {
'@type': 'Person',
name: post.author.name,
url: post.author.url,
},
publisher: {
'@type': 'Organization',
name: 'My Blog',
logo: {
'@type': 'ImageObject',
url: 'https://example.com/logo.png',
},
},
mainEntityOfPage: {
'@type': 'WebPage',
'@id': `https://example.com/blog/${params.slug}`,
},
};
return (
<>
<script
type="application/ld+json"
dangerouslySetInnerHTML={{ __html: JSON.stringify(jsonLd) }}
/>
<article>{/* Article content */}</article>
</>
);
}
Product Schema:
export default async function ProductPage({ params }) {
const product = await getProduct(params.id);
const jsonLd = {
'@context': 'https://schema.org',
'@type': 'Product',
name: product.name,
image: product.images.map(img => img.url),
description: product.description,
sku: product.sku,
brand: {
'@type': 'Brand',
name: product.brand,
},
offers: {
'@type': 'Offer',
url: `https://example.com/products/${params.id}`,
priceCurrency: 'USD',
price: product.price,
availability: product.inStock
? 'https://schema.org/InStock'
: 'https://schema.org/OutOfStock',
seller: {
'@type': 'Organization',
name: 'My Store',
},
},
aggregateRating: product.reviews.length > 0 ? {
'@type': 'AggregateRating',
ratingValue: product.averageRating,
reviewCount: product.reviews.length,
} : undefined,
};
return (
<>
<script
type="application/ld+json"
dangerouslySetInnerHTML={{ __html: JSON.stringify(jsonLd) }}
/>
<div>{/* Product content */}</div>
</>
);
}
Breadcrumb Schema:
export default function ProductPage({ params }) {
const breadcrumbList = {
'@context': 'https://schema.org',
'@type': 'BreadcrumbList',
itemListElement: [
{
'@type': 'ListItem',
position: 1,
name: 'Home',
item: 'https://example.com',
},
{
'@type': 'ListItem',
position: 2,
name: 'Products',
item: 'https://example.com/products',
},
{
'@type': 'ListItem',
position: 3,
name: params.name,
item: `https://example.com/products/${params.id}`,
},
],
};
return (
<>
<script
type="application/ld+json"
dangerouslySetInnerHTML={{ __html: JSON.stringify(breadcrumbList) }}
/>
<nav>{/* Breadcrumb UI */}</nav>
</>
);
}
Sitemap Generation
Static Sitemap:
// app/sitemap.js
export default function sitemap() {
return [
{
url: 'https://example.com',
lastModified: new Date(),
changeFrequency: 'yearly',
priority: 1,
},
{
url: 'https://example.com/about',
lastModified: new Date(),
changeFrequency: 'monthly',
priority: 0.8,
},
{
url: 'https://example.com/blog',
lastModified: new Date(),
changeFrequency: 'weekly',
priority: 0.5,
},
];
}
Dynamic Sitemap:
// app/sitemap.js
export default async function sitemap() {
const posts = await getAllPosts();
const products = await getAllProducts();
const postUrls = posts.map(post => ({
url: `https://example.com/blog/${post.slug}`,
lastModified: new Date(post.updatedAt),
changeFrequency: 'weekly',
priority: 0.7,
}));
const productUrls = products.map(product => ({
url: `https://example.com/products/${product.id}`,
lastModified: new Date(product.updatedAt),
changeFrequency: 'daily',
priority: 0.8,
}));
return [
{
url: 'https://example.com',
lastModified: new Date(),
changeFrequency: 'yearly',
priority: 1,
},
...postUrls,
...productUrls,
];
}
Robots.txt
// app/robots.js
export default function robots() {
return {
rules: [
{
userAgent: '*',
allow: '/',
disallow: ['/admin/', '/api/', '/private/'],
},
{
userAgent: 'Googlebot',
allow: '/',
crawlDelay: 2,
},
],
sitemap: 'https://example.com/sitemap.xml',
host: 'https://example.com',
};
}
Semantic HTML
Proper HTML Structure:
export default function BlogPost({ post }) {
return (
<article itemScope itemType="https://schema.org/Article">
<header>
<h1 itemProp="headline">{post.title}</h1>
<time itemProp="datePublished" dateTime={post.publishedAt}>
{formatDate(post.publishedAt)}
</time>
<address itemProp="author" itemScope itemType="https://schema.org/Person">
<span itemProp="name">{post.author.name}</span>
</address>
</header>
<div itemProp="articleBody">
{/* Post content */}
</div>
<footer>
<nav aria-label="Article tags">
<ul>
{post.tags.map(tag => (
<li key={tag}>
<a href={`/tags/${tag}`} rel="tag">{tag}</a>
</li>
))}
</ul>
</nav>
</footer>
</article>
);
}
Internal Linking
Automatic Internal Links:
import Link from 'next/link';
export default function RelatedPosts({ relatedPosts }) {
return (
<aside>
<h2>Related Articles</h2>
<nav>
<ul>
{relatedPosts.map(post => (
<li key={post.id}>
<Link href={`/blog/${post.slug}`} prefetch={true}>
{post.title}
</Link>
</li>
))}
</ul>
</nav>
</aside>
);
}
Internationalization (i18n)
Multi-language Support:
// next.config.js
module.exports = {
i18n: {
locales: ['en', 'es', 'fr', 'de'],
defaultLocale: 'en',
localeDetection: true,
},
};
// app/[lang]/page.js
export async function generateMetadata({ params }) {
const translations = await getTranslations(params.lang);
return {
title: translations.title,
description: translations.description,
alternates: {
canonical: `https://example.com/${params.lang}`,
languages: {
'en': 'https://example.com/en',
'es': 'https://example.com/es',
'fr': 'https://example.com/fr',
'de': 'https://example.com/de',
},
},
};
}
Points to Remember:
- Use descriptive, keyword-rich titles (50-60 characters)
- Write compelling meta descriptions (150-160 characters)
- Implement structured data for rich snippets
- Generate XML sitemaps for all important pages
- Use canonical URLs to avoid duplicate content
- Implement proper heading hierarchy (H1 > H2 > H3)
- Optimize URL structure (short, descriptive, keyword-rich)
- Use alt text for all images
- Implement breadcrumbs for better navigation
- Monitor Google Search Console for issues
- Focus on Core Web Vitals (affects rankings)
- Build quality internal links
- Use semantic HTML5 elements
18. Security Optimization
Purpose
Implement security headers and best practices to protect users and improve trust signals for SEO.
Content Security Policy (CSP)
Basic CSP:
// next.config.js
const cspHeader = `
default-src 'self';
script-src 'self' 'unsafe-eval' 'unsafe-inline' https://cdn.example.com;
style-src 'self' 'unsafe-inline';
img-src 'self' blob: data: https://cdn.example.com;
font-src 'self' data:;
object-src 'none';
base-uri 'self';
form-action 'self';
frame-ancestors 'none';
upgrade-insecure-requests;
`;
module.exports = {
async headers() {
return [
{
source: '/(.*)',
headers: [
{
key: 'Content-Security-Policy',
value: cspHeader.replace(/\n/g, ''),
},
],
},
];
},
};
Strict CSP with Nonce:
// middleware.js
import { NextResponse } from 'next/server';
export function middleware(request) {
const nonce = Buffer.from(crypto.randomUUID()).toString('base64');
const cspHeader = `
default-src 'self';
script-src 'self' 'nonce-${nonce}' 'strict-dynamic';
style-src 'self' 'nonce-${nonce}';
img-src 'self' blob: data:;
font-src 'self';
object-src 'none';
base-uri 'self';
form-action 'self';
frame-ancestors 'none';
upgrade-insecure-requests;
`;
const requestHeaders = new Headers(request.headers);
requestHeaders.set('x-nonce', nonce);
requestHeaders.set(
'Content-Security-Policy',
cspHeader.replace(/\s{2,}/g, ' ').trim()
);
const response = NextResponse.next({
request: {
headers: requestHeaders,
},
});
response.headers.set(
'Content-Security-Policy',
cspHeader.replace(/\s{2,}/g, ' ').trim()
);
return response;
}
// app/layout.js
import { headers } from 'next/headers';
import Script from 'next/script';
export default function RootLayout({ children }) {
const nonce = headers().get('x-nonce');
return (
<html>
<body>
{children}
<Script
src="/analytics.js"
strategy="afterInteractive"
nonce={nonce}
/>
</body>
</html>
);
}
Security Headers
Comprehensive Security Headers:
// next.config.js
module.exports = {
async headers() {
return [
{
source: '/:path*',
headers: [
{
key: 'X-DNS-Prefetch-Control',
value: 'on',
},
{
key: 'Strict-Transport-Security',
value: 'max-age=63072000; includeSubDomains; preload',
},
{
key: 'X-Frame-Options',
value: 'SAMEORIGIN',
},
{
key: 'X-Content-Type-Options',
value: 'nosniff',
},
{
key: 'X-XSS-Protection',
value: '1; mode=block',
},
{
key: 'Referrer-Policy',
value: 'origin-when-cross-origin',
},
{
key: 'Permissions-Policy',
value: 'camera=(), microphone=(), geolocation=()',
},
],
},
];
},
};
CORS Configuration
API CORS:
// app/api/data/route.js
export async function GET(request) {
const origin = request.headers.get('origin');
const allowedOrigins = [
'https://example.com',
'https://app.example.com',
];
const data = await fetchData();
return Response.json(data, {
headers: {
'Access-Control-Allow-Origin': allowedOrigins.includes(origin)
? origin
: allowedOrigins[0],
'Access-Control-Allow-Methods': 'GET, POST, PUT, DELETE, OPTIONS',
'Access-Control-Allow-Headers': 'Content-Type, Authorization',
'Access-Control-Max-Age': '86400',
},
});
}
export async function OPTIONS(request) {
return new Response(null, {
status: 204,
headers: {
'Access-Control-Allow-Origin': '*',
'Access-Control-Allow-Methods': 'GET, POST, PUT, DELETE, OPTIONS',
'Access-Control-Allow-Headers': 'Content-Type, Authorization',
},
});
}
Rate Limiting
Simple Rate Limiting:
// lib/rate-limit.js
const rateLimit = new Map();
export function checkRateLimit(identifier, limit = 10, window = 60000) {
const now = Date.now();
const userRequests = rateLimit.get(identifier) || [];
// Remove old requests outside the time window
const recentRequests = userRequests.filter(
timestamp => now - timestamp < window
);
if (recentRequests.length >= limit) {
return false; // Rate limit exceeded
}
recentRequests.push(now);
rateLimit.set(identifier, recentRequests);
return true;
}
// app/api/protected/route.js
import { checkRateLimit } from '@/lib/rate-limit';
export async function POST(request) {
const ip = request.headers.get('x-forwarded-for') || 'unknown';
if (!checkRateLimit(ip, 5, 60000)) { // 5 requests per minute
return Response.json(
{ error: 'Too many requests' },
{ status: 429 }
);
}
// Process request
const data = await processRequest(request);
return Response.json(data);
}
Advanced Rate Limiting with Upstash:
// lib/rate-limit.js
import { Ratelimit } from '@upstash/ratelimit';
import { Redis } from '@upstash/redis';
const redis = new Redis({
url: process.env.UPSTASH_REDIS_REST_URL,
token: process.env.UPSTASH_REDIS_REST_TOKEN,
});
export const ratelimit = new Ratelimit({
redis,
limiter: Ratelimit.slidingWindow(10, '1 m'), // 10 requests per minute
analytics: true,
prefix: '@upstash/ratelimit',
});
// app/api/data/route.js
import { ratelimit } from '@/lib/rate-limit';
export async function GET(request) {
const ip = request.headers.get('x-forwarded-for');
const { success, limit, reset, remaining } = await ratelimit.limit(ip);
if (!success) {
return Response.json(
{ error: 'Too many requests' },
{
status: 429,
headers: {
'X-RateLimit-Limit': limit.toString(),
'X-RateLimit-Remaining': remaining.toString(),
'X-RateLimit-Reset': reset.toString(),
},
}
);
}
const data = await fetchData();
return Response.json(data);
}
Input Validation
API Input Validation:
// app/api/users/route.js
import { z } from 'zod';
const userSchema = z.object({
name: z.string().min(2).max(50),
email: z.string().email(),
age: z.number().min(18).max(120),
website: z.string().url().optional(),
});
export async function POST(request) {
try {
const body = await request.json();
const validatedData = userSchema.parse(body);
// Process validated data
const user = await createUser(validatedData);
return Response.json(user, { status: 201 });
} catch (error) {
if (error instanceof z.ZodError) {
return Response.json(
{ error: 'Validation failed', issues: error.errors },
{ status: 400 }
);
}
return Response.json(
{ error: 'Internal server error' },
{ status: 500 }
);
}
}
SQL Injection Prevention
Using Prisma (Safe by Default):
// app/api/users/[id]/route.js
import { prisma } from '@/lib/prisma';
export async function GET(request, { params }) {
// Prisma automatically prevents SQL injection
const user = await prisma.user.findUnique({
where: { id: params.id },
});
if (!user) {
return Response.json({ error: 'User not found' }, { status: 404 });
}
return Response.json(user);
}
Using Parameterized Queries:
// If using raw SQL
import { sql } from '@/lib/db';
export async function GET(request) {
const userId = request.nextUrl.searchParams.get('id');
// ❌ Vulnerable to SQL injection
// const user = await sql`SELECT * FROM users WHERE id = ${userId}`;
// ✅ Safe parameterized query
const user = await sql`SELECT * FROM users WHERE id = ${sql.param(userId)}`;
return Response.json(user);
}
Environment Variables Security
// next.config.js
module.exports = {
// Public variables (safe to expose to browser)
env: {
NEXT_PUBLIC_API_URL: process.env.NEXT_PUBLIC_API_URL,
NEXT_PUBLIC_GA_ID: process.env.NEXT_PUBLIC_GA_ID,
},
// Server-side only variables (never exposed to browser)
serverRuntimeConfig: {
DATABASE_URL: process.env.DATABASE_URL,
API_SECRET: process.env.API_SECRET,
},
};
// .env.local (never commit this file!)
# Public variables (prefix with NEXT_PUBLIC_)
NEXT_PUBLIC_API_URL=https://api.example.com
# Private variables (server-only)
DATABASE_URL=postgresql://user:password@localhost:5432/db
API_SECRET=your-secret-key
JWT_SECRET=your-jwt-secret
Authentication Best Practices
Using NextAuth.js:
// app/api/auth/[...nextauth]/route.js
import NextAuth from 'next-auth';
import CredentialsProvider from 'next-auth/providers/credentials';
import { compare } from 'bcrypt';
const handler = NextAuth({
providers: [
CredentialsProvider({
name: 'Credentials',
credentials: {
email: { label: "Email", type: "email" },
password: { label: "Password", type: "password" }
},
async authorize(credentials) {
const user = await findUserByEmail(credentials.email);
if (!user) {
return null;
}
const isValid = await compare(credentials.password, user.password);
if (!isValid) {
return null;
}
return { id: user.id, email: user.email, name: user.name };
},
}),
],
session: {
strategy: 'jwt',
maxAge: 30 * 24 * 60 * 60, // 30 days
},
pages: {
signIn: '/login',
error: '/error',
},
callbacks: {
async jwt({ token, user }) {
if (user) {
token.id = user.id;
}
return token;
},
async session({ session, token }) {
session.user.id = token.id;
return session;
},
},
});
export { handler as GET, handler as POST };
Protected API Routes:
// app/api/protected/route.js
import { getServerSession } from 'next-auth';
import { authOptions } from '../auth/[...nextauth]/route';
export async function GET(request) {
const session = await getServerSession(authOptions);
if (!session) {
return Response.json(
{ error: 'Unauthorized' },
{ status: 401 }
);
}
const data = await fetchUserData(session.user.id);
return Response.json(data);
}
Points to Remember:
- Always use HTTPS in production
- Implement CSP to prevent XSS attacks
- Use security headers to protect against common vulnerabilities
- Rate limit API endpoints to prevent abuse
- Validate all user inputs on the server
- Use parameterized queries to prevent SQL injection
- Never expose secrets in client-side code
- Use environment variables for sensitive data
- Implement proper authentication and authorization
- Hash passwords with bcrypt or argon2
- Use CSRF tokens for state-changing operations
- Keep dependencies updated (npm audit)
- Implement logging and monitoring for security events
- Use Content Security Policy reporting to detect attacks
Conclusion
This comprehensive guide covers all essential aspects of Next.js performance and SEO optimization. Implementing these techniques will result in:
- Faster load times: Through optimized images, code splitting, and caching
- Better user experience: With improved Core Web Vitals and responsive interactions
- Higher search rankings: Through comprehensive SEO best practices
- Enhanced security: With proper headers and input validation
- Reduced costs: Through efficient resource usage and edge computing
- Improved developer experience: With automatic optimizations and better tooling
Quick Wins Checklist
✅ Enable Image optimization with next/image
✅ Implement ISR for frequently updated pages
✅ Use next/font for automatic font optimization
✅ Add metadata and structured data
✅ Configure security headers
✅ Enable bundle analysis
✅ Implement caching strategies
✅ Optimize Core Web Vitals
✅ Set up monitoring for Web Vitals
✅ Use Server Components by default
Performance Monitoring
Continuously monitor your application using:
- Vercel Analytics / Speed Insights
- Google PageSpeed Insights
- Chrome DevTools Lighthouse
- WebPageTest
- Google Search Console
- Real User Monitoring (RUM) tools
Remember: Measure, optimize, measure again. Performance optimization is an ongoing process, not a one-time task.
Top comments (2)
This is a very solid and comprehensive guide — what I especially appreciate is that performance and SEO are treated as system properties, not as isolated tricks.
What often gets missed in Next.js discussions is that most wins here come from ordering and discipline, not from any single optimization. Choosing the right rendering model, defining caching boundaries, and being explicit about data freshness does more for performance than endless micro-tuning.
I also like how this guide implicitly pushes toward determinism: predictable data fetching, explicit cache behavior, measurable Core Web Vitals. When performance becomes observable and repeatable, it stops being “black magic” and turns into engineering.
Great reference to bookmark and revisit as the stack evolves.
I hope I have covered everything in this post. Do comment if I have missed something important.