Performance work in Next.js often starts with the wrong question: “what can we optimize?”. That is too broad. Soon the team is compressing images, moving components, removing libraries, changing cache settings and hoping the graphs improve. Sometimes they do. More often, nobody knows which change helped or whether the original bottleneck was even in that area.
A better question is: which route is slow, for whom, at which moment, and why? Only then does optimization become useful. This guide walks through a practical process: measurement, diagnosis and concrete Next.js techniques — Server Components, caching, Cache Components, Partial Prerendering, bundle analysis, images and third-party scripts.
A fast application is not an accident
Speed is not one metric. An application can have a fast first byte but ship too much JavaScript. It can have a small bundle but wait for a slow CMS. It can score well on the homepage and still be painful on a logged-in detail view.
Do not start with “let’s optimize Next.js”. Start with one route and one symptom:
the server response takes too long,
users see a blank screen,
interaction becomes available too late,
one section loads much later than the rest,
production is slow while local development looks fine.
Each symptom points to a different cause. A slow fetch is diagnosed differently from a large client component or a heavy marketing script.
Establish a baseline
Before changing code, record the baseline. At minimum, build the app and capture repeatable measurements for the selected route.
pnpm build
pnpm check
The build output shows first-load JavaScript per route. If one page ships much more JavaScript than similar views, inspect client components and imports before touching server code.
If the project has a bundle analyzer, run it separately:
ANALYZE=true pnpm build
Do not compare from memory. Write down concrete numbers: first-load JS, response time, LCP, TBT, request count and the largest assets.
Separate server problems from browser problems
The first split is simple: is the user waiting for HTML, or for JavaScript and browser resources?
If TTFB is high, inspect the server: CMS, database, third-party APIs, cache, metadata generation and dynamic Next.js features. If TTFB is reasonable but interaction is late, inspect bundle size, hydration, third-party scripts and heavy client components.
A small helper can reveal expensive server operations quickly:
export async function measure<T>(label: string, fn: () => Promise<T>): Promise<T> {
const start = performance.now();
try {
return await fn();
} finally {
const duration = Math.round(performance.now() - start);
console.info(`[perf] ${label}: ${duration}ms`);
}
}
In production, send this data to your observability tool. Locally, even a simple measurement can show whether the bottleneck is the CMS, rendering, or duplicated queries.
Server Components should be the default
In the App Router, a component should stay on the server until it truly needs the browser. use client is for state, effects, click handlers and browser APIs. It is not needed to render text, links, cards, article lists or data fetched on the server.
export default async function BlogPage() {
const posts = await getBlogPosts();
return <BlogList posts={posts} />;
}
If BlogList only renders data, it should not be a client component. Move interactivity into the smallest possible child:
// Server component
export function BlogCard({ post }: { post: BlogPost }) {
return (
<article>
<h2>{post.title}</h2>
<SaveButton postId={post.id} />
</article>
);
}
The browser receives less JavaScript while the server still renders complete HTML.
Cache is not one switch
Caching in Next.js works well when the freshness rule is explicit. Otherwise it is easy to cache something user-specific or fetch live data that could safely be revalidated every few minutes.
export async function getCaseStudies() {
const response = await fetch("https://example.com/api/case-studies", {
next: { revalidate: 3600 },
});
return response.json();
}
For CMS content, a time-based revalidation window or publish webhook is often enough. For user-specific data, you need a different strategy because shared cache can become a security bug.
Avoid fetching the same data repeatedly
One route can fetch the same document multiple times: once for metadata, once for page content, once for JSON-LD and once for related sections. It is easy to miss because each request hides in a different helper.
import { cache } from "react";
export const getPost = cache(async (slug: string, locale: "en" | "pl") => {
return sanityClient.fetch(postQuery, { slug, locale });
});
Cache at the right level. If the result depends on locale, preview, userId or a feature flag, those values must be part of the arguments or cache key.
Cache Components and use cache
In newer Next.js versions, look beyond classic fetch with revalidate. Cache Components let you describe which parts of the tree are cacheable while other parts remain dynamic.
export async function FeaturedPosts() {
'use cache';
const posts = await getFeaturedPosts();
return <PostGrid posts={posts} />;
}
This is especially useful for pages with a stable marketing shell and a few dynamic islands. Not everything has to wait for the slowest section.
Partial Prerendering: static shell, dynamic islands
Partial Prerendering lets you treat a page as a combination of a stable shell and dynamic parts. The user sees structure earlier, while slower sections resolve through Suspense.
import { Suspense } from "react";
export default function Page() {
return (
<>
<Hero />
<Suspense fallback={<RelatedPostsSkeleton />}>
<RelatedPosts />
</Suspense>
</>
);
}
This is not an excuse to hide slow queries behind a skeleton. It is a way to prevent one slower section from blocking the whole page when the business experience allows it to load later.
Reduce bundle size through use client boundaries
If one use client directive sits high in the tree, a large imported subtree can move to the browser. This is a common reason route bundles grow after a seemingly small UI change.
Instead of marking a whole section as client-side, extract only the interactive element:
// Client component
"use client";
export function ExpandButton() {
const [open, setOpen] = useState(false);
return <button onClick={() => setOpen((value) => !value)}>Toggle</button>;
}
The rest of the section can stay on the server. This usually helps more than micro-optimizing code inside the component.
Load heavy UI only when needed
Maps, charts, editors, animation libraries and complex forms do not always need to be part of the first load. If they are below the fold or behind an interaction, use a dynamic import.
import dynamic from "next/dynamic";
const PricingCalculator = dynamic(() => import("./pricing-calculator"), {
loading: () => <div>Loading calculator...</div>,
});
Verify the build after the change. A dynamic import only helps if it actually reduces the main route bundle or moves cost to the moment when the user needs it.
Images: inspect LCP, do not guess
Images are often the largest payload, but not every image needs the same treatment. First identify the LCP element. If it is the hero, set dimensions, use an appropriate format and mark it as priority. If the image is lower on the page, priority can hurt.
import Image from "next/image";
<Image
src={hero.url}
alt={hero.alt}
width={1600}
height={900}
priority
/>;
Do not set priority on many images at once. The browser cannot treat everything as the most important asset.
Third-party scripts are part of performance
Analytics, widgets, chat tools, consent managers and advertising pixels can hurt more than your application code. In Next.js, choose a loading strategy that matches the script's importance.
import Script from "next/script";
<Script
src="https://example.com/widget.js"
strategy="afterInteractive"
/>;
If the script is not needed for the first interaction, consider loading it later or only after user consent.
Verify every change
Good optimization ends with comparison. After a change, record:
route bundle size before and after,
duration of the slowest server operations,
LCP/TBT/CLS for the same route,
request count and largest resources,
draft mode or preview behavior if the route uses CMS data.
The worst scenario is several changes at once and one final score. Then you do not know what helped, what hurt and what was neutral.
A practical order of work
If you do not know where to start, use this order:
Pick one route and record baseline metrics.
Decide whether the bottleneck is server-side or browser-side.
Remove obvious duplicated fetching.
Shrink
use clientboundaries.Move heavy UI behind
Suspenseor dynamic imports.Make caching and revalidation explicit.
Then tune images and third-party scripts.
A fast Next.js application is the result of small, verified decisions. Measure, change one thing, and write down the result. That turns optimization from guessing into engineering work.
Top comments (0)