Next.js provides excellent defaults for performance, but defaults only get you so far. Production applications serving real users need deliberate optimization.
This checklist covers the techniques we apply to every Next.js project we ship. It's organized by impact—start at the top and work your way down.
High-impact optimizations
1. Use Server Components by default
React Server Components are Next.js's biggest performance win. They render on the server, sending only HTML to the client with zero JavaScript overhead.
Rule of thumb: Everything is a Server Component unless it needs interactivity.
// Server Component (default) - no "use client"
async function ProductList() {
const products = await db.query("SELECT * FROM products");
return (
<ul>
{products.map((p) => (
<li key={p.id}>{p.name}</li>
))}
</ul>
);
}
// Client Component - only when necessary
"use client";
function AddToCartButton({ productId }: { productId: string }) {
const [loading, setLoading] = useState(false);
// ... interaction logic
}
Common mistake: Wrapping entire pages in "use client" because one small component needs state. Instead, push client components to the leaves of your component tree.
2. Optimize images with next/image
Images are typically the largest assets on any page. The next/image component handles lazy loading, responsive sizing, and format optimization automatically.
import Image from "next/image";
// Good: Let Next.js handle optimization
<Image
src="/hero.jpg"
alt="Hero image"
width={1200}
height={600}
priority // Use for above-the-fold images
/>
// Bad: Raw img tag bypasses all optimization
<img src="/hero.jpg" alt="Hero image" />
For dynamic images: Use sizes to prevent oversized images on mobile:
<Image
src={product.image}
alt={product.name}
fill
sizes="(max-width: 768px) 100vw, (max-width: 1200px) 50vw, 33vw"
/>
3. Implement proper caching
Next.js 14+ has nuanced caching. Understanding it prevents both stale data and unnecessary revalidation.
Static data (changes rarely):
// Cached indefinitely, revalidated every hour
async function getCategories() {
const res = await fetch("https://api.example.com/categories", {
next: { revalidate: 3600 },
});
return res.json();
}
Dynamic data (changes frequently):
// Never cached - fetched on every request
async function getStockLevel(productId: string) {
const res = await fetch(`https://api.example.com/stock/${productId}`, {
cache: "no-store",
});
return res.json();
}
For database queries: Use React's cache() function to deduplicate requests within a single render:
import { cache } from "react";
export const getUser = cache(async (userId: string) => {
return await db.users.findUnique({ where: { id: userId } });
});
4. Lazy load non-critical components
Not everything needs to load immediately. Use dynamic imports for components below the fold or behind interactions.
import dynamic from "next/dynamic";
// Loads only when rendered
const HeavyChart = dynamic(() => import("./HeavyChart"), {
loading: () => <ChartSkeleton />,
});
// Loads only on client (useful for browser-only libraries)
const MapComponent = dynamic(() => import("./MapComponent"), {
ssr: false,
});
Medium-impact optimizations
5. Font optimization
Use next/font to self-host fonts with zero layout shift:
import { Inter } from "next/font/google";
const inter = Inter({
subsets: ["latin"],
display: "swap", // Prevents invisible text during load
});
export default function RootLayout({ children }) {
return (
<html className={inter.className}>
<body>{children}</body>
</html>
);
}
For custom fonts: Use next/font/local:
import localFont from "next/font/local";
const customFont = localFont({
src: "./fonts/CustomFont.woff2",
display: "swap",
});
6. Optimize third-party scripts
Third-party scripts (analytics, chat widgets) are performance killers. Load them strategically:
import Script from "next/script";
// afterInteractive (default): Loads after page is interactive
<Script src="https://analytics.example.com/script.js" />
// lazyOnload: Loads during browser idle time
<Script
src="https://chat.example.com/widget.js"
strategy="lazyOnload"
/>
// beforeInteractive: Only for critical scripts
<Script
src="https://critical.example.com/script.js"
strategy="beforeInteractive"
/>
7. Use streaming and Suspense
Server-side streaming lets the browser start rendering before all data is ready:
import { Suspense } from "react";
export default function Page() {
return (
<div>
<Header /> {/* Renders immediately */}
<Suspense fallback={<ProductsSkeleton />}>
<Products /> {/* Streams in when ready */}
</Suspense>
<Suspense fallback={<ReviewsSkeleton />}>
<Reviews /> {/* Streams in independently */}
</Suspense>
</div>
);
}
This dramatically improves perceived performance—users see content faster even if total load time is unchanged.
8. Optimize metadata
Set viewport and theme-color in your root layout to prevent layout shift:
export const metadata = {
viewport: {
width: "device-width",
initialScale: 1,
maximumScale: 1, // Prevents zoom on input focus (accessibility trade-off)
},
themeColor: [
{ media: "(prefers-color-scheme: light)", color: "#ffffff" },
{ media: "(prefers-color-scheme: dark)", color: "#0a0a0a" },
],
};
Monitoring and validation
9. Set up Core Web Vitals monitoring
You can't improve what you don't measure. Use web-vitals to track real user metrics:
// app/layout.tsx
import { WebVitals } from "./WebVitals";
export default function RootLayout({ children }) {
return (
<html>
<body>
<WebVitals />
{children}
</body>
</html>
);
}
// app/WebVitals.tsx
"use client";
import { useReportWebVitals } from "next/web-vitals";
export function WebVitals() {
useReportWebVitals((metric) => {
// Send to your analytics
console.log(metric);
});
return null;
}
10. Run regular audits
Before every production deploy:
- Lighthouse audit: Aim for 90+ on Performance
- Bundle analysis:
npx @next/bundle-analyzerto find bloat - Test on slow devices: Use Chrome DevTools' throttling
- Check mobile: Real devices, not just responsive view
Quick wins checklist
Before shipping, verify:
- [ ] No
"use client"at page level - [ ] All images use
next/image - [ ] Above-the-fold images have
priority - [ ] Fonts use
next/font - [ ] Third-party scripts use appropriate
strategy - [ ] Heavy components are dynamically imported
- [ ] Suspense boundaries around async components
- [ ] Cache headers set appropriately
- [ ] No layout shift (CLS < 0.1)
- [ ] Largest Contentful Paint < 2.5s
Common pitfalls
1. Over-caching: Aggressive caching causes stale data bugs. Be explicit about revalidation.
2. Premature optimization: Don't optimize before measuring. Profile first.
3. Bundle size creep: Watch for heavy dependencies. date-fns vs moment, lodash-es vs lodash, etc.
4. Ignoring mobile: Test on real mid-range Android devices, not just iPhone Pros.
Need help optimizing your Next.js application? Get in touch for a performance audit.