Next.js App Router promises fast page loads and smooth user experiences out of the box. With features like React Server Components, automatic code splitting, and streaming, you expect your core web vitals to be in the green. Yet, many developers deploy their applications only to find poor mobile performance scores in their Google Search Console.
The shift from the Pages Router to the App Router changes how assets load, how data is fetched, and how hydration happens. If you do not adapt your optimization strategies, you will end up with high Largest Contentful Paint (LCP), slow Interaction to Next Paint (INP), and jarring Cumulative Layout Shift (CLS).
Let us look at how to identify these issues and apply practical fixes to get your scores back into the green.
Fixing Largest Contentful Paint (LCP)
LCP tracks how long it takes for the largest visual element on the screen to render. On most landing pages, this is either a hero image or a large heading block. In the App Router, LCP issues usually stem from two sources: slow server response times (Time to First Byte) and unoptimized image loading.
Optimizing the Hero Image
If your LCP element is an image, you must tell Next.js to prioritize it. By default, the framework lazy-loads images to save bandwidth. For a hero image, this causes a significant delay because the browser waits to discover the image until after the stylesheet and layout are processed.
Use the priority prop. This adds a link header to pre-load the image before the browser even parses the HTML body.
// app/components/Hero.tsx
import Image from 'next/image';
export default function Hero() {
return (
<div className="relative w-full h-[400px]">
<Image
src="/hero-banner.jpg"
alt="Product Banner"
fill
priority
sizes="(max-width: 768px) 100vw, 50vw"
className="object-cover"
/>
</div>
);
}The sizes attribute is equally important here. Without it, Next.js generates a source set containing large images that might be overkill for mobile devices. Specifying sizes ensures the browser downloads the smallest possible image that fits the viewport, saving critical milliseconds.
Using the default Next.js image loader works well for Vercel deployments. If you host elsewhere, Next.js falls back to a JS-based optimizer that can be slow. Consider using a dedicated image CDN like Cloudinary or Imgix. You can set up a custom loader in next.config.js to offload image processing from your Node.js server, which keeps LCP low even under high traffic.
Non-Blocking Server Data Fetching
Server Components fetch data directly on the server. This is great for security and bundle size, but if you fetch slow data in a layout or page without streaming, the server waits for the database query to complete before sending any HTML to the browser. This delays your Time to First Byte (TTFB), which directly delays your LCP.
To fix this, wrap slow data-fetching components in React Suspense boundaries. This allows Next.js to stream the static parts of your page (like the navigation bar and hero text) immediately, while loading states handle the slower database queries.
// app/page.tsx
import { Suspense } from 'react';
import Hero from './components/Hero';
import SlowProductList from './components/SlowProductList';
import ProductSkeleton from './components/ProductSkeleton';
export default function Page() {
return (
<main>
<Hero />
<Suspense fallback={<ProductSkeleton />}>
<SlowProductList />
</Suspense>
</main>
);
}By splitting the page with Suspense, the browser renders the Hero component instantly. The LCP element loads without waiting for the slow product list query to finish.
Fixing FID and INP
First Input Delay (FID) measured the delay between a user's first interaction and the browser's response. In March 2024, Google replaced FID with Interaction to Next Paint (INP). INP measures the latency of all interactions throughout the entire lifespan of a page, not just the first one.
In Next.js App Router applications, high INP is almost always caused by main thread blocking during React hydration.
Reducing Hydration Overhead
When a user lands on your page, they see the pre-rendered HTML. But they cannot interact with it until the JavaScript bundles download, execute, and attach event listeners. If you have a massive Client Component tree at the root of your application, the browser main thread freezes while hydrating.
To keep your main thread free, push Client Components down the tree. Do not mark your entire page as client-side. Keep the wrapper layout and parent components as Server Components, and only use Client Components for small interactive elements.
// Bad: Marking the whole page as client just for a search bar
'use client';
import { useState } from 'react';
export default function BadPage() {
const [query, setQuery] = useState('');
return (
<div>
<input value={query} onChange={(e) => setQuery(e.target.value)} />
<div className="heavy-static-content">...</div>
</div>
);
}Instead, isolate the interactive part:
// Good: Isolate the interactive input
import SearchInput from './SearchInput'; // Client Component
export default function GoodPage() {
return (
<div>
<SearchInput />
<div className="heavy-static-content">...</div>
</div>
);
}Code Splitting with Dynamic Imports
If you have components that only render after a user action, like modals, slide-out menus, or complex filters, do not include them in the initial page bundle. Use dynamic imports to load them lazily.
// app/components/Dashboard.tsx
'use client';
import { useState } from 'react';
import dynamic from 'next/dynamic';
const HeavyChart = dynamic(() => import('./HeavyChart'), {
loading: () => <p>Loading chart...</p>,
ssr: false,
});
export default function Dashboard() {
const [showChart, setShowChart] = useState(false);
return (
<div>
<button onClick={() => setShowChart(true)}>Show Chart</button>
{showChart && <HeavyChart />}
</div>
);
}This keeps the initial JavaScript payload small. The browser spends less time parsing and executing code during the initial render, which directly lowers your INP.
Use startTransition for State Updates
When a user types into an input or clicks a filter, React might trigger multiple state updates that rerender a large portion of the page. This blocks the main thread, causing noticeable lag.
You can use React's startTransition to mark these updates as non-blocking. This tells React to prioritize urgent updates (like typing in the input) while rendering the results in the background.
// app/components/SearchFilter.tsx
'use client';
import { useState, useTransition } from 'react';
export default function SearchFilter({ items }) {
const [filter, setFilter] = useState('');
const [filteredItems, setFilteredItems] = useState(items);
const [isPending, startTransition] = useTransition();
const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const value = e.target.value;
setFilter(value); // Urgent: Update the input text immediately
startTransition(() => {
// Non-urgent: Update the large list in the background
const filtered = items.filter((item) => item.includes(value));
setFilteredItems(filtered);
});
};
return (
<div>
<input value={filter} onChange={handleChange} />
{isPending && <p>Updating list...</p>}
<ul>
{filteredItems.map((item) => (
<li key={item}>{item}</li>
))}
</ul>
</div>
);
}This keeps the UI responsive, preventing the browser from freezing during heavy list rendering and keeping your INP scores in the green.
Fixing Cumulative Layout Shift (CLS)
CLS measures visual stability. A layout shift occurs when a visible element changes its position from one rendered frame to the next. Common causes of CLS in the App Router include images without dimensions, late-loading CSS, and dynamic content injection without reserved space.
Aspect Ratio and Image Dimensions
If you use the standard HTML image tag without setting explicit width and height attributes, the browser does not know how much space to reserve for the image. When the image finally loads, the text below it jumps down.
The Next.js Image component forces you to provide dimensions unless you use the fill prop. If you use fill, ensure the parent container has a fixed height or a relative layout with set dimensions.
// Reserving space with aspect-ratio
<div className="relative w-full aspect-video">
<Image
src="/hero.jpg"
alt="Hero Image"
fill
className="object-cover"
/>
</div>Using CSS classes like aspect-video or setting explicit dimensions on the wrapper container ensures the browser reserves the correct amount of space before the image downloads.
Reserving Space for Dynamic UI Elements
If you load ads, user reviews, or recommended products dynamically on the client side, they can push existing content down when they load. Always reserve space by setting a minimum height on the container element.
// app/components/AdBanner.tsx
export default function AdBanner() {
return (
<div className="min-h-[250px] w-full flex items-center justify-center bg-gray-50">
{/* Ad script loads here */}
</div>
);
}By setting min-h-[250px], the layout remains stable whether the ad loads instantly or takes three seconds.
Optimizing Font Loading
Fonts can cause layout shifts if the fallback font has different dimensions than the custom web font. When the web font loads, it swaps out the fallback font, causing text to reflow and shift elements.
Next.js provides next/font to automate font optimization. It self-hosts your Google Fonts and uses size-adjust properties to match the fallback system font dimensions to your custom font, minimizing layout shifts.
// app/layout.tsx
import { Inter } from 'next/font/google';
const inter = Inter({
subsets: ['latin'],
display: 'swap',
adjustFontFallback: true,
});
export default function RootLayout({
children,
}: {
children: React.ReactNode;
}) {
return (
<html lang="en" className={inter.className}>
<body>{children}</body>
</html>
);
}Setting display: 'swap' ensures text is visible immediately using a fallback font, while adjustFontFallback scales the fallback font to prevent the layout from shifting when the custom font arrives.
Nested Layouts and CSS Stability
In the App Router, nested routes share layout files. If a child page changes the height of header elements defined in the parent layout, it causes a layout shift. Keep parent layouts simple and avoid dynamic height calculations inside them. If you need dynamic headers, use CSS Grid or Flexbox with fixed header sizes to lock the layout in place.
Ensure global styles are loaded in the root layout.tsx to prevent unstyled content flash. Avoid loading global stylesheets dynamically inside nested pages.
Managing Server Component Serialization
When you pass data from a Server Component to a Client Component, Next.js must serialize that data into a JSON format and include it in the HTML payload. This is called the React Server Component (RSC) payload.
If you pass massive database objects containing fields that the Client Component does not use, you bloat the HTML payload. This increases the time it takes to download the page, which hurts your LCP, and increases the time the browser spends parsing the payload, which hurts your INP.
Always filter your data on the server before passing it to Client Components.
// Bad: Passing the entire database object
import prisma from '@/lib/db';
import ClientChart from './ClientChart';
export default async function Page() {
const users = await prisma.user.findMany(); // Contains hash, profile, logs
return <ClientChart data={users} />;
}Instead, map the data to only include the required fields:
// Good: Only pass the fields the client component needs
import prisma from '@/lib/db';
import ClientChart from './ClientChart';
export default async function Page() {
const users = await prisma.user.findMany();
const chartData = users.map((user) => ({
id: user.id,
createdAt: user.createdAt,
}));
return <ClientChart data={chartData} />;
}This keeps the HTML size small, reducing network latency and hydration time.
Optimizing Link Prefetching
Next.js pre-fetches pages linked with next/link as they enter the viewport. While this makes page transitions feel instant, it can cause excessive network requests on pages with many links, like long lists or grids.
On mobile devices with limited CPU and bandwidth, this background pre-fetching can compete with the current page's rendering, hurting both LCP and INP.
You can adjust pre-fetching behavior based on your needs. For secondary links or pages that users rarely click, disable pre-fetching.
import Link from 'next/link';
export default function Footer() {
return (
<footer>
<Link href="/terms" prefetch={false}>
Terms of Service
</Link>
<Link href="/privacy" prefetch={false}>
Privacy Policy
</Link>
</footer>
);
}By setting prefetch={false}, Next.js only pre-fetches the page when the user hovers over the link, saving bandwidth and CPU cycles for rendering the active page.
Monitoring Web Vitals in Production
Lighthouse runs in a clean environment with simulated network throttling. It does not reflect how real users interact with your site. Real user monitoring (RUM) is necessary to gather accurate Core Web Vitals data.
Next.js offers a built-in hook to track these metrics. You can set this up in a Client Component to capture the metrics and send them to your server or analytics provider.
// app/components/VitalsReporter.tsx
'use client';
import { useReportWebVitals } from 'next/web-vitals';
export default function VitalsReporter() {
useReportWebVitals((metric) => {
const body = JSON.stringify({
id: metric.id,
name: metric.name,
value: metric.value,
rating: metric.rating,
});
if (navigator.sendBeacon) {
navigator.sendBeacon('/api/vitals', body);
} else {
fetch('/api/vitals', {
method: 'POST',
body,
keepalive: true,
headers: { 'Content-Type': 'application/json' },
});
}
});
return null;
}Mount this component inside your root layout.tsx so it runs across all pages.
// app/layout.tsx
import VitalsReporter from './components/VitalsReporter';
export default function RootLayout({
children,
}: {
children: React.ReactNode;
}) {
return (
<html lang="en">
<body>
<VitalsReporter />
{children}
</body>
</html>
);
}Using real user data helps you identify exactly which pages fail to meet Core Web Vitals targets under real-world conditions. You can then prioritize fixes based on actual user impact rather than simulated scores.



