The trigger wasn’t a user complaint. It was a Lighthouse run on a production Next.js App Router performance audit — the kind you run on autopilot while your coffee brews — and the score that came back was 4.1 seconds.
Not catastrophic. Not the kind of number that gets escalated in Slack. But slow enough to matter, and slow enough that I wanted to understand why.
What followed wasn’t a heroic single fix. It was a diagnostic process — a sequence of questions, each one leading to the next — that ended with a 22% reduction in real-user load time. This is the methodology behind that number: how I approach Next.js App Router performance problems in 2026, what I measure and why, and the decisions that actually moved the needle.
Measuring Next.js App Router Performance: Start With the Data
The first mistake engineers make with performance is optimising before they understand what they’re actually measuring. Lighthouse is a useful forcing function, not a verdict.
I ran it five times on the production URL — throttled 4G, incognito, from outside the local network. Taking the median matters: a single Lighthouse run fluctuates by 10–15% depending on background processes. The numbers:
- Largest Contentful Paint (LCP): 4.1s (Poor — threshold is ≤2.5s for Good)
- Interaction to Next Paint (INP): 210ms (Needs Improvement — threshold is ≤200ms for Good)
- Cumulative Layout Shift (CLS): 0.09 (Good — threshold is ≤0.1)
Why INP Replaced FID — and What It Means for Next.js
A word on INP, since it replaced First Input Delay (FID) as a Core Web Vital in March 2024 and is still misunderstood. FID measured only the delay before the browser started processing the first interaction. INP, however, measures the full latency of every click, tap, and keystroke over the entire page lifetime — input delay, event handler execution, and the time to paint the next frame — then reports the worst one. More precisely: for pages with fewer than 50 interactions it reports the single worst; for pages with 50 or more, Chrome trims the highest outliers and reports the 98th percentile, preventing a single anomaly from skewing the score. In Next.js specifically, the most common INP killers are heavy hydration costs from oversized Client Component trees and third-party scripts competing for the main thread.
In contrast, those Lighthouse numbers are lab data. Simulated conditions, clean browser profile, no real users. The metric that actually determines how Google ranks your page is the 75th percentile (P75) field score — the P75 of real user experiences collected by Chrome over a 28-day rolling window.
For field data on a Vercel deployment, two tools are worth distinguishing. Vercel Speed Insights tracks Core Web Vitals telemetry from actual visitors across all browsers — LCP, INP, CLS — and surfaces P75 values per deployment. Vercel Web Analytics, on the other hand, is traffic data: page views, referrers, unique visitors. It does not measure performance. That distinction matters because they sit next to each other in the Vercel dashboard and are easy to conflate.
Checking Vercel Speed Insights showed a P75 LCP of 3.8 seconds. That’s the real baseline. Everything after this point was about moving that number.
One Directive, Two Seconds Lost
WebPageTest confirmed what the Lighthouse waterfall suggested: the page was making a round-trip to the API before rendering any above-the-fold content. As a result, users were staring at skeleton loaders for over two seconds before actual content appeared.
The page was an App Router page — a proper async Server Component, fetching data on the server, sending fully-rendered HTML. Fast, clean. Then someone added a filter panel that needed useState.
The "use client" directive went on the page component. One line.
In the App Router, "use client" is a boundary marker — more precisely, a module import boundary. Every module imported into that file becomes client-side code. However, Server Components passed as React children (for example via the children prop) to a Client Component remain server-side and are unaffected — a distinction that matters for keeping heavy data-fetching logic on the server even inside an otherwise client-heavy subtree. Consequently, the async data fetch that had been running on the server couldn’t exist there anymore. So it moved to useEffect.
Now the page worked the same way as before — except:
- The server sent an HTML shell with no content
- The browser downloaded and parsed the JavaScript bundle
- The component mounted and
useEffectfired - The API call went out
- The data arrived and triggered a re-render
The "use client" directive — placed at the wrong level — had silently recreated a Client-Side Rendering (CSR) waterfall from scratch. Nobody made this decision deliberately. It accumulated.
The "use client" Boundary Is the Rendering Decision
The fix was straightforward once the cause was clear: push "use client" down to the leaf node. The filter panel became its own isolated Client Component. The page went back to being an async Server Component, fetching data where it belongs — on the server.
// ❌ Before — "use client" on the page forces data fetching into useEffect
'use client';
import { useState, useEffect } from 'react';
export default function ProductPage() {
const [data, setData] = useState(null);
useEffect(() => {
fetch('/api/products').then(r => r.json()).then(setData);
}, []);
return (
<>
<FilterPanel />
<ProductGrid data={data} />
</>
);
}
// ✅ After — Server Component fetches, Client Component handles interaction
// app/products/page.tsx — no "use client", runs on the server
import { FilterPanel } from './FilterPanel'; // "use client" lives inside here
import { ProductGrid } from './ProductGrid'; // Server Component
export default async function ProductPage() {
const data = await fetch('<https://api.example.com/products>', {
next: { revalidate: 60 } // Fresh every 60s — ISR equivalent in App Router
}).then(res => res.json());
return (
<>
<FilterPanel />
<ProductGrid data={data} />
</>
);
}
Furthermore, the behaviour difference matters beyond just “faster.” App Router doesn’t server-render and flush a complete HTML document. It streams. The page shell arrives at the browser immediately; data-dependent sections follow as they resolve. loading.tsx in the same directory is automatically wrapped in a Suspense boundary — sent to the browser instantly while page.tsx resolves on the server.
// app/products/loading.tsx — server-sent HTML immediately, no JavaScript required
export default function Loading() {
return <ProductGridSkeleton />;
}
Users see real, server-sent content immediately. The skeleton is HTML, not a client-rendered placeholder. That distinction is what moves LCP.
Caching in Next.js 15 and 16: What Changed
Next.js 15 removed implicit fetch caching — fetch() is uncached by default. The next: { revalidate: 60 } above replicates what Incremental Static Regeneration (ISR) used to do in the Pages Router. In Next.js 16, the preferred approach is the "use cache" directive, giving you caching at function or file level with explicit, readable intent. It requires opting in via next.config.ts first:
// next.config.ts — required to unlock "use cache"
import type { NextConfig } from 'next'
const nextConfig: NextConfig = { cacheComponents: true }
export default nextConfig
// Then use the directive at function, component, or file level
async function getProducts() {
'use cache';
return fetch('<https://api.example.com/products>').then(r => r.json());
}
Without cacheComponents: true in the config, the "use cache" directive does nothing — pages continue rendering dynamically on every request regardless of the directive being present.
For data that must be fully fresh on every request: cache: 'no-store' on the fetch, or export const dynamic = 'force-dynamic' on the route segment to force the entire route to evaluate dynamically at request time.
After moving data fetching back to the Server Component and adding a proper Suspense boundary: LCP dropped from 4.1s to 3.1s. A full second from one boundary decision.
Lazy Loading: The Non-Obvious Parts
Image optimisation was table stakes in any Next.js App Router performance audit. next/image with priority on the above-the-fold hero, correct sizes attribute, WebP via Vercel‘s Edge Image Optimisation network — documented, well-understood, worth doing. It contributed around 190ms to the final number.
Beyond image optimisation, the more interesting work was everything else.
The charting library
For instance, the page included an interactive data visualisation — a usage trend chart, below the fold by two full screens on mobile. The library (Recharts) was imported at the top of the component, landing in the bundle and getting parsed before anything was visible. Replacing the import with next/dynamic took twenty minutes. As a result, around 100–150KB was removed from the critical path.
// 'use client' is required — ssr: false is only supported in Client Components
'use client';
import dynamic from 'next/dynamic';
const UsageChart = dynamic(() => import('@/components/UsageChart'), {
ssr: false,
loading: () => <div className="h-64 w-full animate-pulse bg-gray-200 rounded" />,
});
The third-party scripts
Additionally, both scripts — an analytics tag and a cookie consent manager — were loading synchronously, competing for main-thread time during hydration. Any long task on the main thread delays how quickly the browser can respond to user interaction, directly degrading INP. Understanding how JavaScript’s task scheduling affects the main thread is worth going deeper on — I covered the event loop in a previous post.
next/script strategies exist precisely for this. The analytics tag moved to afterInteractive — runs post-hydration, doesn’t block INP. The consent manager moved to lazyOnload — runs when the browser is idle, no urgency required.
One important caveat here: in a real production setup the consent manager should run at the same priority as or higher than the analytics script — not lower. Setting it to lazyOnload while analytics runs at afterInteractive creates a race condition where tracking fires before consent is granted, which is a GDPR compliance risk. Both should run at afterInteractive, with the analytics script gated behind an explicit consent check.
<Script src="<https://cdn.analytics.com/tag.js>" strategy="afterInteractive" />
<Script src="<https://cdn.consent.com/widget.js>" strategy="lazyOnload" />
The modal and its dependencies
Similarly, a form-heavy modal — validation library, date picker, roughly 45KB of JavaScript — was bundled eagerly despite never rendering on initial load. There was no reason for it to be in the initial bundle.
// 'use client' is required — ssr: false is only supported in Client Components
'use client';
import dynamic from 'next/dynamic';
const BookingModal = dynamic(() => import('@/components/BookingModal'), {
ssr: false,
});
The modal now loads when the user clicks the trigger. The delay is imperceptible. The initial bundle is lighter.
The Number That Actually Matters
After all of it — Server Component boundary fix, image priority, dynamic imports, script deferral — Lighthouse scores improved considerably. Five runs, same throttled 4G conditions as the baseline:
- LCP: 2.6s (down from 4.1s — Needs Improvement, 100ms from the Good threshold)
- INP: 124ms (down from 210ms — Good)
- CLS: 0.07 (down from 0.09 — Good, stable)
However, Lighthouse wasn’t the measurement that mattered.
Two weeks before and after the deploy, Vercel Speed Insights tracked real users across real devices:
- P75 LCP: 3.8s → 2.95s
- P75 INP: 238ms → 156ms
- P75 CLS: 0.11 → 0.08
That’s a 22% reduction in LCP. Not a lab score. Field data from actual users.
The gap between those numbers tells you something important. Lighthouse simulates a throttled 4G connection from a single machine. In contrast, real users are on mid-range Android devices, variable mobile networks, browsers with seventeen tabs open. The Server Component fix barely registered on Lighthouse — the Time to First Byte (TTFB) profile barely changed. Nevertheless, it made the biggest single difference in field LCP because it eliminated a CSR waterfall that disproportionately hurt users on slower connections.
Vercel Speed Insights has a built-in comparison view in the Vercel dashboard — filter by date range or deployment to overlay P75 metrics before and after a specific deploy. No exports, no custom dashboards. The before/after is right there.
Next.js App Router Performance Checklist: What I’d Have Done Differently
The "use client" boundary should have been a deliberate team decision, not a convenience. The moment it lands on a parent component, it’s a Next.js App Router performance decision that affects everything below it — including where data fetches live, how much JavaScript ships to the browser, and what the user sees first. That deserves the same deliberateness as any architecture choice. I wrote about why I focus exclusively on frontend engineering — this kind of leverage is exactly why the surface area is worth owning deeply.
Furthermore, the Next.js built-in bundle analyzer should be on every project from day one. In Next.js 16 with Turbopack, it’s built into the CLI — no plugin install required. Run npx next experimental-analyze and it opens a web UI that filters chunks by route and by environment (Server vs. Client), which is now the most important distinction in the bundle. Running it on this codebase for the first time: the Recharts import and the modal were immediately visible as Client-side weight that had no business being there.
Note: if you’re on a Webpack-based build (Next.js 15 without Turbopack), the equivalent is @next/bundle-analyzer with ANALYZE=true next build. The two tools are not interchangeable — @next/bundle-analyzer is explicitly incompatible with Turbopack.
Similarly, the Lighthouse habit — production URL, outside the network, real network profile — is worth making routine. Not as a score to optimise for, but as a forcing function to see the page the way a user does before Vercel Speed Insights field data tells you the same thing, more slowly.
22% is a meaningful number. The Next.js App Router performance methodology that produced it is reusable — and it starts well before you write a single line of optimisation code.
