Fixing Core Web Vitals in Next.js and Nuxt
Next.js and Nuxt give you good building blocks for fast pages: server rendering, static generation, image and font components, and automatic code splitting. They also make it easy to undo those benefits by rendering marketing pages on every request, marking large parts of the tree as client code, or loading every widget during hydration.
This guide covers the framework-specific decisions that decide Core Web Vitals in these two frameworks: how each route is rendered, how the hero image and fonts are loaded, how much JavaScript the browser must hydrate, and how third-party scripts are added.
Which framework decision affects which metric
| Decision | Metric it moves | What goes wrong |
|---|---|---|
| Rendering mode per route | TTFB, then LCP | A page that is the same for everyone is rendered on the server for every request, or worse, rendered only in the browser. |
| Client versus server components | INP, and LCP on slower phones | A 'use client'
directive near the top of the tree sends most of the page's code to the browser to hydrate. |
| Image component usage | LCP and CLS | Hero images lazy-loaded by default, raw img
tags without dimensions, or fill images in containers with no height. |
| Font loading | CLS and LCP | Fonts linked from an external stylesheet in the document head instead of through the framework's font tooling. |
| Third-party scripts | INP, and LCP through bandwidth | Analytics, tag managers and chat added with a plain script tag that loads during hydration. |
| Data fetching | TTFB and LCP | One slow API call holds back the whole server response. |
The thresholds are the same as everywhere: LCP 2.5 seconds or less, INP 200 milliseconds or less and CLS 0.1 or less, at the 75th percentile of real visits. Framework sites often pass LCP comfortably on desktop and fail INP on mobile, because the cost of hydration lands on the visitor's processor.
Choosing a rendering mode per route
Both frameworks let you decide per route whether HTML is built once at build time, rebuilt in the background on a timer, or built fresh for each request. The right answer depends on how often the content changes and whether it differs between visitors.
| Route type | Good choice | Why |
|---|---|---|
| Home, service pages, articles, documentation | Static at build time | Same for everyone and changes rarely, so HTML can come straight from a CDN. |
| Product and category pages, listings that change during the day | Incremental: static with background revalidation | Served from cache like a static page, refreshed on a schedule or when content changes. |
| Search results, personalised pages | Server rendered per request, streamed | Depends on the request, but the main content still arrives in HTML. |
| Account dashboards behind a login | Server or client rendered | Not indexed, and visitors are already engaged; still keep INP in mind. |
Next.js App Router
// app/products/[slug]/page.tsx
export const revalidate = 3600; // rebuild at most once an hour
export async function generateStaticParams() {
const products = await getTopProducts();
return products.map(p => ({ slug: p.slug }));
}
export default async function ProductPage({ params }) {
const { slug } = await params;
const product = await getProduct(slug);
return <ProductView product={product} />;
}
Watch out for things that silently switch a route to per-request rendering, such as reading cookies or headers in a layout shared by every page. Check the build output, which marks each route as static or dynamic, after every significant change. Caching defaults have changed between major Next.js versions, so confirm the behaviour in the documentation for the version you run.
Nuxt
// nuxt.config.ts
export default defineNuxtConfig({
routeRules: {
'/': { prerender: true },
'/about': { prerender: true },
'/blog/**': { isr: 3600 },
'/products/**': { swr: 600 },
'/account/**': { ssr: false }
}
});
How isr and swr behave depends on where you deploy, since the cache lives
in the hosting platform or in Nuxt's server. Check the response headers on a deployed page to confirm
pages are actually being served from cache.
The fix, step by step
Step 1
Measure each route with real-user reporting
Run one URL per route type through the Core Web Vitals checker and note which metric
fails on mobile and desktop. Then add real-user reporting: in Next.js the useReportWebVitals
hook from next/web-vitals, and in Nuxt the web-vitals library in a client plugin.
Send the route pattern with each result rather than the full URL, so you can see which route type fails.
Single-page navigations are reported differently from full loads, so record both.
Step 2
Choose the rendering mode per route
For
each route, decide whether its HTML can be built once, refreshed on a schedule, or must be built per
request. Move content pages to static generation, listings and products to incremental regeneration with
a sensible revalidate time, and keep per-request rendering for search and personalised pages only. In
Next.js check the build output for routes that became dynamic by accident. In Nuxt set the choice in
routeRules and confirm cache headers on the deployed site.
Step 3
Keep components on the server by default
In the Next.js App Router, components are server components unless marked with 'use client'.
Search for that directive and push each one as far down the tree as possible, so only the interactive
button, form or menu becomes client code rather than the whole page or layout. Pass server-rendered content
into client components as children. In Nuxt, avoid wrapping static content in components that need client
state, and use lazy components for widgets.
Step 4
Give the hero image priority through the image component
Replace raw img tags with next/image or NuxtImg,
always with width and height, or with fill inside a container that has a set aspect ratio.
The image components lazy-load by default, which is wrong for the LCP image, so mark the hero as eager
and high priority using the prop your framework version provides. Give it an accurate sizes
value so phones do not download the desktop file.
Step 5
Load fonts through the framework font tools
Remove font stylesheet links from the document head and load fonts through next/font
in Next.js or the Nuxt Fonts module in Nuxt. Both download the font files at build time and serve them
from your own domain, and both can generate a fallback font with adjusted metrics so text does not reflow
when the web font arrives. Load only the weights and subsets you use, and apply the font once in the
root layout.
Step 6
Defer third-party scripts and client-only widgets
Add analytics, tag managers and chat through next/script with the afterInteractive
or lazyOnload strategy, or through the Nuxt Scripts module, rather than plain script tags.
Load heavy client-only widgets such as maps, carousels and review panels with next/dynamic
or Nuxt lazy components, and only when they are about to be seen or opened. This keeps hydration short,
so the page responds sooner to the first tap.
Step 7
Stream slow data instead of blocking the page
If a route must render per request, do not let one slow API call hold back the whole
response. In the Next.js App Router, wrap slow sections in Suspense with a fallback of the
same size, or add a loading file for the route, so the page shell and main content are sent
first. In Nuxt, fetch non-essential data with lazy: true so it does not block rendering.
Reserve space for streamed sections to avoid layout shift.
Step 8
Check the bundle and hydration cost before release
Run your framework's bundle analyser and look for large libraries in the shared client bundle, such as date, chart or icon packages imported whole. Record a page load and a few interactions in DevTools with a 4x CPU slowdown and check the hydration task and any long tasks after it. Deploy, watch the route-level real-user results, and allow 28 days for the Chrome UX Report and Search Console to reflect the change.
Framework code for images, fonts and scripts
Hero image and font in Next.js
// app/layout.tsx
import { Inter } from 'next/font/google';
const inter = Inter({ subsets: ['latin'], display: 'swap' });
export default function RootLayout({ children }) {
return (
<html lang="en-GB" className={inter.className}>
<body>{children}</body>
</html>
);
}
// app/page.tsx
import Image from 'next/image';
import hero from './hero-kitchen.jpg';
export default function Home() {
return (
<Image
src={hero}
alt="Fitted oak kitchen with island"
sizes="100vw"
loading="eager"
fetchPriority="high"
/>
);
}
A statically imported image gives next/image its width and height automatically. Older
versions use a priority prop for the hero, and newer versions have changed this prop, so
check the image documentation for your version; setting eager loading and high fetch priority directly
works as shown.
Third-party script in Next.js
import Script from 'next/script';
<Script src="https://widgets.example.com/chat.js" strategy="lazyOnload" />
Hero image and a lazy widget in Nuxt
<template>
<NuxtImg
src="/img/hero-kitchen.jpg"
width="1600" height="900"
sizes="100vw md:100vw lg:1600px"
alt="Fitted oak kitchen with island"
loading="eager"
fetchpriority="high"
preload
/>
<LazyReviewCarousel v-if="showReviews" />
</template>
Prefixing a component name with Lazy makes Nuxt split it into its own chunk and load
it only when it is rendered. Recent Nuxt versions can also delay hydration of a component until it is
visible or the browser is idle, which is useful for heavy widgets further down the page.
Pushing the client boundary down
// Before: the whole product page is client code
'use client';
export default function ProductPage() { /* ... */ }
// After: page stays on the server, only the button hydrates
// app/products/[slug]/page.tsx (server)
import AddToBasket from './AddToBasket';
export default async function ProductPage({ params }) {
const { slug } = await params;
const product = await getProduct(slug);
return (
<article>
<h1>{product.name}</h1>
<ProductDetails product={product} />
<AddToBasket id={product.id} />
</article>
);
}
// app/products/[slug]/AddToBasket.tsx
'use client';
export default function AddToBasket({ id }) { /* state and handlers */ }
Hydration and INP
Hydration is the step where the framework runs in the browser, attaches event handlers and makes server-rendered HTML interactive. Until it finishes, taps may not work, and while it runs it occupies the main thread in long tasks. On a mid-range phone, hydrating a large page can take long enough that a visitor's first tap waits, which shows up as input delay in INP.
- Less client code, less hydration. Server components in Next.js send no component code to the browser at all. Every component you keep on the server reduces the work.
- Hydration mismatches. If the server and client render different HTML, for example
because of dates, random values or reading
windowduring render, the framework may re-render parts of the page, which costs time and can cause layout shift. Fix the warnings in the console rather than suppressing them. - Large lists. Hydrating hundreds of interactive list items is expensive. Paginate, virtualise, or render the list on the server and hydrate only the controls.
- Expensive state updates. In React, mark non-urgent updates such as filtering with
startTransitionso input stays responsive.
next/dynamic with ssr: false
can only be used inside a client component. Using it to skip server rendering of main content also removes
that content from the HTML, which hurts LCP and indexing. Keep it for widgets that genuinely only work
in the browser.Edge cases
- Client-side navigations. After the first page, moving between pages in a single-page app does not trigger a new full load, and Chrome's field data is attributed mainly to the landing page. INP and CLS continue to accumulate across the session, so slow interactions on later views still count against the page where the visit started.
- Prefetching. Both frameworks prefetch linked routes. On pages with hundreds of links this can compete with the page's own resources on slow connections; limit prefetching on very link-heavy pages if you see it in the Network panel.
- Serverless cold starts. Per-request routes on serverless hosting can be slow on the first request after a quiet period. Static and incremental routes avoid this.
- Middleware on every request. Middleware that runs for every route, including static assets, adds time to each response. Limit it with a matcher.
- Image optimisation on the fly. The first request for each image size is generated on demand and can be slow. Configure a sensible set of sizes and make sure optimised images are cached.
Keeping framework sites fast
- Review the build output for routes that changed from static to dynamic.
- Set a bundle budget and check the analyser on every pull request that adds a dependency.
- Keep route-level real-user reporting so a regression on one route type is visible quickly.
- Make server components the default in code review and ask for a reason each time
'use client'is added.
For the individual metrics, see poor INP, slow TTFB and CLS in React and Next.js. Image specifics are in images in Next.js.
1. Are Next.js and Nuxt sites fast by default?
They start from a good position, with server rendering, automatic code splitting and image and font components, but defaults do not guarantee a pass. Sites commonly fail Core Web Vitals by rendering pages per request that could be static, marking large parts of the component tree as client code, adding third-party scripts with plain script tags, and using images without dimensions. The framework gives you the tools; each route still needs the right choices.
2. Should I use static generation or server rendering?
Use static generation for pages that are the same for every visitor and change rarely, such as the home page, service pages and articles, because the HTML can be served from a CDN with a very fast response. Use incremental regeneration for pages that change during the day, such as products and listings. Keep per-request server rendering for search results and personalised pages. Choose per route, not once for the whole site, and review the choice when a page type changes.
3. Does 'use client' make a page slower?
It sends that component and everything it imports to the browser, where it has to be downloaded, parsed and hydrated. Placed at the top of a page or layout, it turns the whole tree below into client code, which increases JavaScript and hydration time and can hurt INP on phones. The page is still rendered on the server first, so content appears in the HTML, but interactivity arrives later. Put the directive only on interactive components.
4. Why is my Next.js hero image hurting LCP?
The image component lazy-loads by default, which delays the most important image on the page. Mark the hero image as eager with high fetch priority, using the prop your Next.js version supports, and give it an accurate sizes value so phones download a suitably small file. Also check that the hero is not loaded inside a client-only component or behind a carousel, which would delay it until JavaScript runs. Test the result at a phone width with throttling.
5. Is next/font better than loading Google Fonts with a link tag?
Usually yes. next/font downloads the font files at build time and serves them from your own domain, removing the connection to an external font service. It also generates a fallback font with adjusted size metrics, which reduces the layout shift when the web font replaces the fallback. Nuxt's Fonts module does similar work for Nuxt sites. Limit the weights and subsets you load either way, since each extra file adds download time.
6. How do I add Google Analytics or a tag manager without hurting INP?
Load them through the framework rather than with plain script tags: next/script with the afterInteractive or lazyOnload strategy in Next.js, the official Next.js third-parties package, or the Nuxt Scripts module in Nuxt. This stops them loading before your page is interactive. Keep the number of tags small, since each still runs on the main thread, and check a throttled DevTools recording for long tasks owned by tag scripts after adding one.
7. Why does my site pass in the lab but fail INP in the field?
Lab tests load the page once and do not interact with it, so they miss slow handlers and hydration delays that real visitors hit when they tap early. Real visitors are also often on slower phones than the simulated device. Add route-level real-user reporting with the web-vitals library or the framework's reporting hook, record the interaction target and phases, and reproduce the slowest interactions in DevTools with CPU throttling switched on.
The Core Web Vitals checker shows real-user field data and a Lighthouse lab test side by side for your URL, breaks down each metric and points to the assets and code behind every failure, so you can see which route type and which bundles need work.
Open Core Web Vitals Fixes โ