Performance in Next.js isn't just about user experience — Core Web Vitals directly affect Google rankings. A slow site ranks lower. This guide focuses on optimizations that have measurable impact, not micro-optimizations that move the needle by 5ms.
The next/image component is one of the biggest wins available — but only if you use it correctly.
import Image from 'next/image'
// WRONG: Fixed dimensions that don't match actual image
<Image src="/hero.jpg" width={800} height={400} alt="Hero" />
// CORRECT for hero images: priority + explicit dimensions
<Image
src="/hero.jpg"
width={1200}
height={630}
alt="Hero image"
priority // LCP image — load immediately, no lazy loading
quality={85} // Default is 75; 85 is a good balance
placeholder="blur" // Show blur while loading
blurDataURL="data:image/jpeg;base64,..."
/>
// CORRECT for below-fold images: lazy loading (default)
<Image
src={post.thumbnail}
fill // Fills parent container
sizes="(max-width: 768px) 100vw, (max-width: 1200px) 50vw, 33vw"
alt={post.title}
style={{ objectFit: 'cover' }}
/>The sizes prop is critical for responsive images — it tells the browser which image size to download based on viewport width. Without it, you download a large image and shrink it via CSS.
Next.js has built-in font optimization — it downloads fonts at build time, eliminating the network round trip:
// app/layout.tsx
import { Inter, Fira_Code } from 'next/font/google'
const inter = Inter({
subsets: ['latin'],
display: 'swap',
variable: '--font-inter',
})
const firaCode = Fira_Code({
subsets: ['latin'],
display: 'swap',
variable: '--font-fira-code',
preload: false, // Only preload fonts used above-fold
})
export default function RootLayout({ children }) {
return (
<html lang="en" className={`${inter.variable} ${firaCode.variable}`}>
<body>{children}</body>
</html>
)
}Never load Google Fonts via a <link> tag in Next.js — use next/font/google instead. It eliminates the external network request entirely.
The App Router has granular caching controls:
// Static page — cached indefinitely until manually revalidated
export const revalidate = false
// Time-based revalidation — revalidate every 3600 seconds
export const revalidate = 3600
// Dynamic page — never cache
export const dynamic = 'force-dynamic'
// Revalidate specific data without full page revalidation
async function getPosts() {
const posts = await fetch('https://api.example.com/posts', {
next: { revalidate: 3600 }, // Cache for 1 hour
})
return posts.json()
}
// Tag-based revalidation — revalidate when specific data changes
async function getPost(slug: string) {
const post = await fetch(`https://api.example.com/posts/${slug}`, {
next: { tags: [`post-${slug}`] },
})
return post.json()
}// When a post is updated, purge just that post's cache
import { revalidateTag } from 'next/cache'
export async function updatePost(slug: string, data: PostData) {
await db.post.update({ where: { slug }, data })
revalidateTag(`post-${slug}`) // Only this post's cached pages update
}Sequential data fetching creates waterfalls. Fetch in parallel:
// BAD: Sequential — total time = time(getUser) + time(getPosts) + time(getComments)
export default async function Dashboard({ params }) {
const user = await getUser(params.id)
const posts = await getPosts(params.id) // Waits for user
const comments = await getComments(params.id) // Waits for posts
// ...
}
// GOOD: Parallel — total time = max(getUser, getPosts, getComments)
export default async function Dashboard({ params }) {
const [user, posts, comments] = await Promise.all([
getUser(params.id),
getPosts(params.id),
getComments(params.id),
])
// ...
}Next.js prefetches routes on hover. For critical navigation paths, prefetch explicitly:
import Link from 'next/link'
// Default: prefetch on hover (good)
<Link href="/dashboard">Dashboard</Link>
// Prefetch immediately (for most likely next navigation)
<Link href="/dashboard" prefetch={true}>Dashboard</Link>
// Disable prefetching (for rarely visited or heavy pages)
<Link href="/heavy-report" prefetch={false}>View Report</Link>npm install --save-dev @next/bundle-analyzer// next.config.js
const withBundleAnalyzer = require('@next/bundle-analyzer')({
enabled: process.env.ANALYZE === 'true',
})
module.exports = withBundleAnalyzer({
// your config
})ANALYZE=true npm run buildThis opens a treemap of your bundle. Common culprits:
moment.js — replace with date-fns (tree-shakeable) or dayjslodash — import specific functions: import debounce from 'lodash/debounce'Instead of waiting for slow data before showing anything:
// app/dashboard/page.tsx
import { Suspense } from 'react'
export default function DashboardPage() {
return (
<div>
{/* Fast data — loads immediately */}
<UserHeader />
{/* Slow data — streams in when ready */}
<Suspense fallback={<RecentOrdersSkeleton />}>
<RecentOrders /> {/* Hits slow database query */}
</Suspense>
<Suspense fallback={<AnalyticsSkeleton />}>
<Analytics /> {/* Hits external analytics API */}
</Suspense>
</div>
)
}The browser receives the page shell immediately. Slow sections stream in as they resolve. Time To First Byte drops dramatically.
For lightweight API routes or middleware, the Edge Runtime runs at CDN nodes worldwide:
// app/api/health/route.ts
export const runtime = 'edge'
export function GET() {
return Response.json({ status: 'ok' })
}Edge functions have ~0ms cold start (vs ~100-300ms for serverless) and run near the user's geographic location.
Limitations: no Node.js APIs, no native modules. Use for: auth checks, redirects, lightweight APIs.
Complete metadata improves click-through rates from search results:
// app/post/[slug]/page.tsx
export async function generateMetadata({ params }): Promise<Metadata> {
const post = await getPost(params.slug)
return {
title: post.title,
description: post.excerpt,
robots: { index: true, follow: true, 'max-image-preview': 'large' },
openGraph: {
title: post.title,
description: post.excerpt,
url: `https://yourdomain.com/post/${post.slug}`,
type: 'article',
publishedTime: post.createdAt.toISOString(),
images: [{ url: post.thumbnail, width: 1200, height: 630, alt: post.title }],
},
twitter: {
card: 'summary_large_image',
title: post.title,
description: post.excerpt,
images: [post.thumbnail],
},
alternates: {
canonical: `https://yourdomain.com/post/${post.slug}`,
},
}
}Blog posts, documentation, and marketing pages should be statically generated:
// app/post/[slug]/page.tsx
export async function generateStaticParams() {
const posts = await getAllPostSlugs()
return posts.map(slug => ({ slug }))
}
// Page is generated at build time — serves from CDN, no server compute
export default async function PostPage({ params }) {
const post = await getPost(params.slug)
return <ArticleLayout post={post} />
}Static pages served from a CDN are faster than any server response, and they scale infinitely without load balancer concerns.
priority on your LCP image — it's the single biggest CWV winsizes on responsive images to prevent downloading oversized assetsnext/font/google instead of <link> tags to eliminate external font requestsPromise.all data fetching prevents server-side waterfalls