Next.js Optimization
Next.js provides many built-in optimizations โ automatic code splitting, image optimization, font loading, and caching. But there are additional strategies and configurations that can significantly improve your application's performance. This guide covers the most impactful optimizations for Core Web Vitals and user experience.
info
The next/image component optimizes images automatically โ lazy loading, responsive sizing, format conversion (WebP/AVIF), and preventing layout shift. It is one of the highest-impact optimizations you can make.
| 1 | import Image from "next/image"; |
| 2 | |
| 3 | // Basic usage with automatic optimization |
| 4 | export function HeroImage() { |
| 5 | return ( |
| 6 | <Image |
| 7 | src="/hero.jpg" |
| 8 | alt="Hero banner" |
| 9 | width={1200} |
| 10 | height={600} |
| 11 | priority // Load immediately (above the fold) |
| 12 | /> |
| 13 | ); |
| 14 | } |
| 15 | |
| 16 | // Responsive image with srcSet |
| 17 | export function ResponsiveImage() { |
| 18 | return ( |
| 19 | <Image |
| 20 | src="/photo.jpg" |
| 21 | alt="A beautiful landscape" |
| 22 | fill // Fill parent container |
| 23 | sizes="(max-width: 768px) 100vw, (max-width: 1200px) 50vw, 33vw" |
| 24 | className="object-cover" |
| 25 | /> |
| 26 | ); |
| 27 | } |
| 28 | |
| 29 | // Remote image |
| 30 | export function Avatar({ src, name }: { src: string; name: string }) { |
| 31 | return ( |
| 32 | <Image |
| 33 | src={src} |
| 34 | alt={name} |
| 35 | width={48} |
| 36 | height={48} |
| 37 | className="rounded-full" |
| 38 | /> |
| 39 | ); |
| 40 | } |
| Prop | Purpose | When to Use |
|---|---|---|
| priority | Preload image (skip lazy loading) | Above-the-fold images, hero banners |
| fill | Fill parent container | Background images, cards with unknown size |
| sizes | Responsive size hints | Always set with fill or responsive images |
| placeholder | Blur-up or empty placeholder | Images that take time to load |
| quality | Image quality (1-100) | Default 75 is usually optimal |
| 1 | // Image with blur placeholder |
| 2 | import Image from "next/image"; |
| 3 | import { blurDataURL } from "@/lib/blur"; |
| 4 | |
| 5 | export function GalleryImage() { |
| 6 | return ( |
| 7 | <Image |
| 8 | src="/gallery/photo.jpg" |
| 9 | alt="Gallery photo" |
| 10 | width={400} |
| 11 | height={300} |
| 12 | placeholder="blur" |
| 13 | blurDataURL={blurDataURL} // Small base64 encoded blur image |
| 14 | /> |
| 15 | ); |
| 16 | } |
| 17 | |
| 18 | // Configure remote image domains in next.config.ts |
| 19 | // next.config.ts |
| 20 | const nextConfig: NextConfig = { |
| 21 | images: { |
| 22 | remotePatterns: [ |
| 23 | { protocol: "https", hostname: "images.unsplash.com" }, |
| 24 | { protocol: "https", hostname: "**.amazonaws.com" }, |
| 25 | ], |
| 26 | formats: ["image/avif", "image/webp"], |
| 27 | }, |
| 28 | }; |
pro tip
next/font optimizes font loading by downloading fonts at build time, self-hosting them, and providing automaticsize-adjust to prevent Cumulative Layout Shift (CLS).
| 1 | // app/layout.tsx |
| 2 | import { Inter, JetBrains_Mono } from "next/font/google"; |
| 3 | |
| 4 | const inter = Inter({ |
| 5 | subsets: ["latin"], |
| 6 | display: "swap", // Use swap to prevent FOIT |
| 7 | variable: "--font-inter", // CSS variable |
| 8 | }); |
| 9 | |
| 10 | const jetbrainsMono = JetBrains_Mono({ |
| 11 | subsets: ["latin"], |
| 12 | display: "swap", |
| 13 | variable: "--font-mono", |
| 14 | }); |
| 15 | |
| 16 | export default function RootLayout({ |
| 17 | children, |
| 18 | }: { |
| 19 | children: React.ReactNode; |
| 20 | }) { |
| 21 | return ( |
| 22 | <html lang="en" className={`${inter.variable} ${jetbrainsMono.variable}`}> |
| 23 | <body className="font-sans"> |
| 24 | {children} |
| 25 | </body> |
| 26 | </html> |
| 27 | ); |
| 28 | } |
| 1 | // Local fonts |
| 2 | import localFont from "next/font/local"; |
| 3 | |
| 4 | const myFont = localFont({ |
| 5 | src: [ |
| 6 | { path: "./fonts/Inter-Regular.woff2", weight: "400", style: "normal" }, |
| 7 | { path: "./fonts/Inter-Bold.woff2", weight: "700", style: "normal" }, |
| 8 | { path: "./fonts/Inter-Italic.woff2", weight: "400", style: "italic" }, |
| 9 | ], |
| 10 | display: "swap", |
| 11 | variable: "--font-inter", |
| 12 | }); |
| 13 | |
| 14 | // Use in layout |
| 15 | export default function Layout({ children }) { |
| 16 | return ( |
| 17 | <html className={myFont.variable}> |
| 18 | <body className="font-sans">{children}</body> |
| 19 | </html> |
| 20 | ); |
| 21 | } |
info
The Metadata API generates optimized <head> tags for SEO, social sharing, and browser behavior. Use the static metadata export or the dynamic generateMetadata function.
| 1 | // Static metadata in layout.tsx (applies to all pages) |
| 2 | import type { Metadata, Viewport } from "next"; |
| 3 | |
| 4 | export const metadata: Metadata = { |
| 5 | title: { |
| 6 | template: "%s | MyApp", |
| 7 | default: "MyApp โ Build Something Amazing", |
| 8 | }, |
| 9 | description: "A comprehensive platform for developers", |
| 10 | keywords: ["next.js", "react", "web development"], |
| 11 | authors: [{ name: "Your Name" }], |
| 12 | creator: "Your Name", |
| 13 | openGraph: { |
| 14 | type: "website", |
| 15 | locale: "en_US", |
| 16 | url: "https://myapp.com", |
| 17 | siteName: "MyApp", |
| 18 | title: "MyApp โ Build Something Amazing", |
| 19 | description: "A comprehensive platform for developers", |
| 20 | images: [ |
| 21 | { url: "/og-default.png", width: 1200, height: 630, alt: "MyApp" }, |
| 22 | ], |
| 23 | }, |
| 24 | twitter: { |
| 25 | card: "summary_large_image", |
| 26 | title: "MyApp", |
| 27 | description: "A comprehensive platform for developers", |
| 28 | images: ["/og-default.png"], |
| 29 | }, |
| 30 | robots: { |
| 31 | index: true, |
| 32 | follow: true, |
| 33 | googleBot: { |
| 34 | index: true, |
| 35 | follow: true, |
| 36 | "max-video-preview": -1, |
| 37 | "max-image-preview": "large", |
| 38 | "max-snippet": -1, |
| 39 | }, |
| 40 | }, |
| 41 | icons: { |
| 42 | icon: "/favicon.ico", |
| 43 | shortcut: "/favicon-16x16.png", |
| 44 | apple: "/apple-touch-icon.png", |
| 45 | }, |
| 46 | }; |
| 47 | |
| 48 | export const viewport: Viewport = { |
| 49 | width: "device-width", |
| 50 | initialScale: 1, |
| 51 | maximumScale: 5, |
| 52 | themeColor: [ |
| 53 | { media: "(prefers-color-scheme: dark)", color: "#000000" }, |
| 54 | { media: "(prefers-color-scheme: light)", color: "#ffffff" }, |
| 55 | ], |
| 56 | }; |
| 1 | // Dynamic metadata for individual pages |
| 2 | import type { Metadata } from "next"; |
| 3 | |
| 4 | export async function generateMetadata({ |
| 5 | params, |
| 6 | }: { |
| 7 | params: Promise<{ slug: string }>; |
| 8 | }): Promise<Metadata> { |
| 9 | const { slug } = await params; |
| 10 | const post = await getPost(slug); |
| 11 | |
| 12 | if (!post) { |
| 13 | return { title: "Post Not Found" }; |
| 14 | } |
| 15 | |
| 16 | return { |
| 17 | title: post.title, |
| 18 | description: post.excerpt, |
| 19 | openGraph: { |
| 20 | title: post.title, |
| 21 | description: post.excerpt, |
| 22 | type: "article", |
| 23 | publishedTime: post.publishedAt.toISOString(), |
| 24 | authors: [post.author.name], |
| 25 | tags: post.tags, |
| 26 | images: [post.coverImage], |
| 27 | }, |
| 28 | }; |
| 29 | } |
Generate sitemaps dynamically using the sitemap.ts file convention. This creates asitemap.xml at the root of your site.
| 1 | // app/sitemap.ts |
| 2 | import type { MetadataRoute } from "next"; |
| 3 | |
| 4 | export default async function sitemap(): Promise<MetadataRoute.Sitemap> { |
| 5 | const baseUrl = "https://myapp.com"; |
| 6 | |
| 7 | // Static pages |
| 8 | const staticPages = [ |
| 9 | { url: baseUrl, lastModified: new Date(), changeFrequency: "daily" as const, priority: 1.0 }, |
| 10 | { url: `${baseUrl}/about`, lastModified: new Date(), changeFrequency: "monthly" as const, priority: 0.8 }, |
| 11 | { url: `${baseUrl}/pricing`, lastModified: new Date(), changeFrequency: "monthly" as const, priority: 0.8 }, |
| 12 | ]; |
| 13 | |
| 14 | // Dynamic pages from database |
| 15 | const posts = await db.post.findMany({ |
| 16 | select: { slug: true, updatedAt: true }, |
| 17 | where: { published: true }, |
| 18 | }); |
| 19 | |
| 20 | const blogPages = posts.map(post => ({ |
| 21 | url: `${baseUrl}/blog/${post.slug}`, |
| 22 | lastModified: post.updatedAt, |
| 23 | changeFrequency: "weekly" as const, |
| 24 | priority: 0.6, |
| 25 | })); |
| 26 | |
| 27 | // Dynamic pages from API |
| 28 | const products = await db.product.findMany({ |
| 29 | select: { id: true, updatedAt: true }, |
| 30 | }); |
| 31 | |
| 32 | const productPages = products.map(product => ({ |
| 33 | url: `${baseUrl}/products/${product.id}`, |
| 34 | lastModified: product.updatedAt, |
| 35 | changeFrequency: "daily" as const, |
| 36 | priority: 0.7, |
| 37 | })); |
| 38 | |
| 39 | return [...staticPages, ...blogPages, ...productPages]; |
| 40 | } |
For Next.js 15+, use the route segment config:
| 1 | // Alternative: app/sitemap.xml/route.ts |
| 2 | import { db } from "@/lib/database"; |
| 3 | |
| 4 | export async function GET() { |
| 5 | const baseUrl = "https://myapp.com"; |
| 6 | |
| 7 | const posts = await db.post.findMany({ |
| 8 | select: { slug: true, updatedAt: true }, |
| 9 | where: { published: true }, |
| 10 | }); |
| 11 | |
| 12 | const urls = posts |
| 13 | .map( |
| 14 | (post) => ` <url> |
| 15 | <loc>${baseUrl}/blog/${post.slug}</loc> |
| 16 | <lastmod>${post.updatedAt.toISOString()}</lastmod> |
| 17 | <changefreq>weekly</changefreq> |
| 18 | <priority>0.6</priority> |
| 19 | ) |
| 20 | .join("\n"); |
| 21 | |
| 22 | const sitemap = `<?xml version="1.0" encoding="UTF-8"?> |
| 23 | <urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9"> |
| 24 | <url> |
| 25 | <loc>${baseUrl}</loc> |
| 26 | <changefreq>daily</changefreq> |
| 27 | <priority>1.0</priority> |
| 28 | </url> |
| 29 | ${urls} |
| 30 | </urlset>`; |
| 31 | |
| 32 | return new Response(sitemap, { |
| 33 | headers: { "Content-Type": "application/xml" }, |
| 34 | }); |
| 35 | } |
Generate robots.txt dynamically with the robots.ts file convention.
| 1 | // app/robots.ts |
| 2 | import type { MetadataRoute } from "next"; |
| 3 | |
| 4 | export default function robots(): MetadataRoute.Robots { |
| 5 | return { |
| 6 | rules: [ |
| 7 | { |
| 8 | userAgent: "*", |
| 9 | allow: "/", |
| 10 | disallow: ["/admin", "/api", "/dashboard", "/_next/"], |
| 11 | }, |
| 12 | { |
| 13 | userAgent: "GPTBot", |
| 14 | disallow: "/", |
| 15 | }, |
| 16 | ], |
| 17 | sitemap: "https://myapp.com/sitemap.xml", |
| 18 | }; |
| 19 | } |
Next.js automatically code-splits at the route level. Each page only loads the JavaScript needed for that route. You can further optimize by dynamically importing heavy components.
| 1 | // Dynamic import โ component is code-split into a separate chunk |
| 2 | import dynamic from "next/dynamic"; |
| 3 | |
| 4 | // Load a heavy chart component only when needed |
| 5 | const HeavyChart = dynamic(() => import("@/components/heavy-chart"), { |
| 6 | loading: () => ( |
| 7 | <div className="h-96 bg-gray-800 animate-pulse rounded-lg" /> |
| 8 | ), |
| 9 | ssr: false, // Skip SSR for purely client-side components |
| 10 | }); |
| 11 | |
| 12 | // Load a modal only when it is opened |
| 13 | const VideoPlayer = dynamic(() => import("@/components/video-player"), { |
| 14 | loading: () => <div className="h-64 bg-gray-800 rounded-lg" />, |
| 15 | }); |
| 16 | |
| 17 | export function AnalyticsSection() { |
| 18 | const [showChart, setShowChart] = useState(false); |
| 19 | |
| 20 | return ( |
| 21 | <div> |
| 22 | <button onClick={() => setShowChart(true)}> |
| 23 | Show Analytics |
| 24 | </button> |
| 25 | {showChart && <HeavyChart />} |
| 26 | </div> |
| 27 | ); |
| 28 | } |
info
Analyze your bundle to find opportunities for optimization. Use @next/bundle-analyzer to visualize your JavaScript bundle composition.
| 1 | # Install bundle analyzer |
| 2 | npm i -D @next/bundle-analyzer |
| 3 | |
| 4 | # next.config.ts |
| 5 | import withBundleAnalyzer from "@next/bundle-analyzer"; |
| 6 | |
| 7 | const withAnalyzer = withBundleAnalyzer({ enabled: true }); |
| 8 | export default withAnalyzer(nextConfig); |
| 9 | |
| 10 | # Build and analyze |
| 11 | ANALYZE=true npm run build |
| 1 | // next.config.ts with bundle analyzer |
| 2 | import type { NextConfig } from "next"; |
| 3 | import withBundleAnalyzer from "@next/bundle-analyzer"; |
| 4 | |
| 5 | const nextConfig: NextConfig = { |
| 6 | // ... other config |
| 7 | }; |
| 8 | |
| 9 | const wrappedConfig = withBundleAnalyzer({ |
| 10 | enabled: process.env.ANALYZE === "true", |
| 11 | })(nextConfig); |
| 12 | |
| 13 | export default wrappedConfig; |
Set appropriate cache headers for static assets and API responses to maximize CDN cache hits.
| 1 | // next.config.ts โ Cache headers for static assets |
| 2 | const nextConfig: NextConfig = { |
| 3 | async headers() { |
| 4 | return [ |
| 5 | { |
| 6 | // Cache immutable static assets for 1 year |
| 7 | source: "/_next/static/(.*)", |
| 8 | headers: [ |
| 9 | { |
| 10 | key: "Cache-Control", |
| 11 | value: "public, max-age=31536000, immutable", |
| 12 | }, |
| 13 | ], |
| 14 | }, |
| 15 | { |
| 16 | // Cache images for 30 days |
| 17 | source: "/images/(.*)", |
| 18 | headers: [ |
| 19 | { |
| 20 | key: "Cache-Control", |
| 21 | value: "public, max-age=2592000, stale-while-revalidate=86400", |
| 22 | }, |
| 23 | ], |
| 24 | }, |
| 25 | ]; |
| 26 | }, |
| 27 | }; |
Use middleware at the edge for geo-based redirects, A/B testing, and authentication checks without hitting your origin server.
| 1 | // middleware.ts โ Edge-optimized request handling |
| 2 | import { NextResponse } from "next/server"; |
| 3 | import type { NextRequest } from "next/server"; |
| 4 | |
| 5 | export function middleware(request: NextRequest) { |
| 6 | const response = NextResponse.next(); |
| 7 | |
| 8 | // A/B testing โ assign variant based on cookie |
| 9 | const variant = request.cookies.get("ab-variant")?.value; |
| 10 | if (!variant) { |
| 11 | const randomVariant = Math.random() > 0.5 ? "a" : "b"; |
| 12 | response.cookies.set("ab-variant", randomVariant, { |
| 13 | maxAge: 60 * 60 * 24 * 30, // 30 days |
| 14 | path: "/", |
| 15 | }); |
| 16 | } |
| 17 | |
| 18 | // Geo-based redirect (if using edge runtime) |
| 19 | const country = request.geo?.country; |
| 20 | if (country === "DE" && !request.nextUrl.pathname.startsWith("/de")) { |
| 21 | return NextResponse.redirect(new URL(`/de${request.nextUrl.pathname}`, request.url)); |
| 22 | } |
| 23 | |
| 24 | // Security headers |
| 25 | response.headers.set("X-Request-Id", crypto.randomUUID()); |
| 26 | |
| 27 | return response; |
| 28 | } |
| 29 | |
| 30 | export const config = { |
| 31 | matcher: ["/((?!_next/static|_next/image|favicon.ico).*)"], |
| 32 | }; |
Key metrics and how to optimize them in Next.js:
LCP (Largest Contentful Paint)
Optimize with priority on the hero Image, self-hosted fonts with next/font, and streaming with Suspense. Target: under 2.5 seconds.
INP (Interaction to Next Paint)
Keep Client Components small and at the leaf of the tree. Use startTransition for non-urgent state updates. Target: under 200ms.
CLS (Cumulative Layout Shift)
Always set width/height on images, use next/font with display: "swap", and reserve space for dynamic content. Target: under 0.1.
best practice