GetLaunchpad
Back to blog
6 min read

Next.js performance optimization: a practical guide

Server components, dynamic imports, the Image component, next/font, bundle analysis, and caching with fetch revalidate and unstable_cache — every technique that moves the Lighthouse needle.

Performance is not an afterthought you bolt on before launch. It is a set of architectural decisions you make from the start. Next.js gives you powerful tools to ship fast applications, but only if you know when and how to use them. This guide covers the techniques that move the needle most, with concrete code examples for each.

Server components vs client components

The single biggest lever in Next.js App Router is deciding what renders on the server versus what renders in the browser. Server components are the default. They fetch data and render HTML on the server — no JavaScript bundle sent to the client, no hydration overhead.

Client components are opt-in. You add “use client” at the top of the file when you need interactivity: event handlers, browser APIs, useState, useEffect. Everything else should be a server component.

// Server component — no "use client" needed
// Fetches data directly, sends zero JS to the browser
export default async function ProductList() {
  const products = await db.query("SELECT * FROM products LIMIT 20");
  return (
    <ul>
      {products.map((p) => (
        <li key={p.id}>{p.name}</li>
      ))}
    </ul>
  );
}

// Client component — only when interactivity is required
"use client";
import { useState } from "react";

export function AddToCartButton({ productId }: { productId: string }) {
  const [added, setAdded] = useState(false);
  return (
    <button onClick={() => setAdded(true)}>
      {added ? "Added!" : "Add to cart"}
    </button>
  );
}

The pattern that works best: keep your pages and layouts as server components that own data fetching, then pass data down to small, focused client components that handle interaction. Never make an entire page a client component just because one button needs an onClick.

Dynamic imports for heavy client components

Some client components are unavoidably large — rich text editors, chart libraries, drag and drop interfaces. Dynamic imports let you split these into a separate chunk that only loads when the component is actually needed:

import dynamic from "next/dynamic";

// The chart library is NOT included in the initial JS bundle.
// It loads only when ChartComponent mounts.
const ChartComponent = dynamic(() => import("@/components/ChartComponent"), {
  loading: () => <p>Loading chart...</p>,
  ssr: false, // Skip server render for browser-only libraries
});

export default function DashboardPage() {
  return (
    <main>
      <h1>Dashboard</h1>
      <ChartComponent />
    </main>
  );
}

Use ssr: false for libraries that use browser APIs like window or document directly. Without it, Next.js will try to render the component on the server and throw.

Next.js Image component

Never use a plain <img> tag in a Next.js project. The built-in Image component handles resizing, format conversion (WebP/AVIF), lazy loading, and layout shift prevention automatically:

import Image from "next/image";

export function Avatar({ src, name }: { src: string; name: string }) {
  return (
    <Image
      src={src}
      alt={name}
      width={64}
      height={64}
      className="rounded-full"
    />
  );
}

// For images that fill their container:
<div className="relative h-48 w-full">
  <Image
    src="/hero.jpg"
    alt="Hero image"
    fill
    className="object-cover"
    priority // Load eagerly — use for above-the-fold images only
  />
</div>

Set priority only on the largest image visible on initial load (your hero image, your logo). For everything else, the default lazy loading is correct.

Font optimization with next/font

Custom fonts are a common source of layout shift and wasted render-blocking requests. Next.js has a built-in font system that self-hosts Google Fonts at build time — no runtime request to Google's servers, no flash of unstyled text:

// app/layout.tsx
import { Inter, Sora } from "next/font/google";

const inter = Inter({
  subsets: ["latin"],
  variable: "--font-inter",
  display: "swap",
});

const sora = Sora({
  subsets: ["latin"],
  variable: "--font-sora",
  weight: ["400", "600", "700"],
  display: "swap",
});

export default function RootLayout({ children }: { children: React.ReactNode }) {
  return (
    <html lang="en" className={`${inter.variable} ${sora.variable}`}>
      <body>{children}</body>
    </html>
  );
}

Define fonts once at the root layout, expose them as CSS variables, then reference the variables in your Tailwind config. Avoid importing the same font in multiple components — each import triggers a separate optimisation pass at build time.

Bundle analysis

Before you can optimise your JavaScript bundle, you need to see what is in it. Install the bundle analyzer:

npm install --save-dev @next/bundle-analyzer

Update next.config.ts:

import withBundleAnalyzer from "@next/bundle-analyzer";

const nextConfig = withBundleAnalyzer({
  enabled: process.env.ANALYZE === "true",
})({
  // your existing config
});

export default nextConfig;

Run ANALYZE=true npm run build to open an interactive treemap in your browser. Look for large packages that are unexpectedly included in client bundles, duplicate dependencies, and libraries with smaller alternatives. Common culprits: moment (use date-fns), lodash (use native methods or cherry-pick imports), large icon libraries imported wholesale.

Caching with fetch and unstable_cache

Next.js extends the native fetch API with caching options. Use revalidate to serve cached responses and refresh them in the background:

// Revalidate this data at most once every 60 seconds
const data = await fetch("https://api.example.com/products", {
  next: { revalidate: 60 },
}).then((r) => r.json());

// Never cache — always fresh (equivalent to no-store)
const liveData = await fetch("https://api.example.com/orders", {
  cache: "no-store",
}).then((r) => r.json());

For database queries or functions that do not use fetch, use unstable_cache:

import { unstable_cache } from "next/cache";

const getCachedProducts = unstable_cache(
  async () => {
    return await db.query("SELECT * FROM products");
  },
  ["products-list"],
  { revalidate: 60 }
);

export default async function ProductsPage() {
  const products = await getCachedProducts();
  return <ProductList products={products} />;
}

Lighthouse scores: what to target

Open Chrome DevTools, go to the Lighthouse tab, and run an audit in incognito mode with mobile throttling enabled. Target these scores for a production SaaS:

Performance:    90+
Accessibility:  95+
Best Practices: 100
SEO:            100

The metrics that matter most for performance are Largest Contentful Paint (LCP) under 2.5 seconds, Total Blocking Time (TBT) under 200ms, and Cumulative Layout Shift (CLS) under 0.1. Each of these maps directly to one of the techniques above: server components and caching reduce LCP, dynamic imports reduce TBT, and the Image component and next/font eliminate CLS.

Run Lighthouse against your staging environment before every major release. A single large third-party script or an un-optimised image can drop your score from 95 to 60 overnight.


Server components, dynamic imports, image and font optimisation, bundle analysis, and caching are all built into GetLaunchpad, a Next.js 16 SaaS boilerplate configured for production performance from day one. Skip the tuning and start building your product.

Share this article:Share on X

Ready to ship faster?

GetLaunchpad gives you everything covered in this guide — pre-configured, tested, and production-ready. Skip the setup and focus on your product.

Get the boilerplate →

More articles