Most slow Next.js applications we audit are not slow because of the framework. They are slow because of three habits: putting 'use client' too high in the tree, fetching data in sequence, and treating caching as something to figure out later. This guide is the checklist we use on client audits, in the order that usually produces the biggest wins.
Measure real users first
Lab scores from Lighthouse are useful for debugging, but rankings and conversion depend on field data. Start by sending Core Web Vitals from real sessions to your analytics, segmented by route and device class.
'use client';
import { useReportWebVitals } from 'next/web-vitals';
export function VitalsReporter() {
useReportWebVitals((m) => {
navigator.sendBeacon('/api/vitals', JSON.stringify({ name: m.name, value: m.value, id: m.id, route: location.pathname }));
});
return null;
}
Watch Interaction to Next Paint (INP) closely. It replaced First Input Delay and it is where heavy client bundles show up first, especially on mid-range Android phones.
Keep the client boundary low
Every component under a 'use client' boundary ships to the browser along with its imports. A single directive at the top of a layout can pull a date library, an icon set and a markdown parser into every page.
- Keep pages and layouts as Server Components; push
'use client'down to the leaf that needs state or effects. - Pass server-rendered children into client components instead of importing server content from them.
- Audit with
@next/bundle-analyzerafter each major feature, not once a year.
Remove waterfalls
Sequential awaits in a Server Component are the App Router equivalent of nested useEffect fetches. Start independent requests together, and stream the slow parts.
export default async function Dashboard() {
const [account, plan] = await Promise.all([getAccount(), getPlan()]); // in parallel
return (
<>
<Header account={account} plan={plan} />
<Suspense fallback={<ChartSkeleton />}>
<UsageChart /> {/* slow query streams in after the shell */}
</Suspense>
</>
);
}
Make caching explicit
Decide, per data source, how fresh it must be and what invalidates it. Then encode that decision in code with tags, so a mutation can invalidate exactly what it changed.
- Catalogue and marketing content: cache with a revalidation window and tag-based invalidation on publish.
- Per-user data: render dynamically, but cache expensive shared lookups underneath it.
- Never cache responses that depend on cookies or headers without keying on them.
Images, fonts and third parties
- Use
next/imagewith accuratesizes, and mark only the LCP image aspriority. - Self-host fonts with
next/fontto remove layout shift and an extra connection. - Load chat widgets, A/B testing and tag managers with
next/scriptusinglazyOnload, and question whether each one earns its cost.
Field note: on a recent commerce audit, deferring two third-party widgets improved mobile INP more than a month of component-level optimisation.
Key takeaways
- Measure field data per route before optimising anything.
- Keep client components at the leaves; bundle size drives INP.
- Parallelise data fetching and stream slow sections with Suspense.
- Treat caching as a product decision with explicit invalidation.
- Audit third-party scripts as rigorously as your own code.