Next.js NotesRohit’s interview study guide
Chapter 10

Optimisations and Metadata

What next/image, next/font and next/script actually send to the browser, how metadata, Open Graph images, sitemaps and lazy loading work, and how Next.js affects LCP, CLS and INP.

7 topics
Parts marked Advanced are extra depth. Skip them on a first read or a quick revision.

#next/image

Added

A plain <img src="/hills.jpg"> makes every visitor download the same file: a 2400-pixel, 1.8 MB JPEG goes to a phone that shows it 375 pixels wide. It also gives the browser no idea how tall the image is until the file arrives, so the text below jumps when it does. next/image is a React component plus a small image server built into Next.js. The component writes an <img> with a srcset of URLs like /_next/image?url=%2Fhills.jpg&w=750&q=75; the browser picks one; the image server (the "optimiser") resizes the original to that width, re-encodes it as WebP or AVIF, caches the result and sends it.

A good mental model is a tailor with a rack of standard sizes. You hand over the original once (src) and describe how wide it will be worn (sizes); the component lists every standard size it can make (srcset); the browser, which knows the screen width and pixel density, asks for the one that fits; the tailor cuts it on the first request and keeps it on the rack for everyone after. width and height are not the size it is worn at: they are the proportions, used to reserve the right amount of space before the image arrives.

What next/image decides for you, and what it doesn't:

ConcernWhat next/image doesWhat you still decide
SizeGenerates srcset from deviceSizes and imageSizessizes: how wide it is on screen
FormatWebP (or AVIF if configured) from the Accept headerimages.formats
Layout shiftwidth/height attributes (or fill) reserve the boxThe real aspect ratio
Loadingloading="lazy" by defaultpreload or loading="eager" for the hero
SecurityOnly allowed widths, qualities and URLsremotePatterns, localPatterns, qualities
Browser picks a width from srcset GET /_next/image ?url=/hills.jpg &w=1080&q=75 1 Validate w, q and url must be allowed, otherwise 400 Bad Request 2 Look up the disk cache .next/cache/images: a HIT is sent straight back 3 Fetch the original a public/ file or a remote URL (no private IPs, at most 3 redirects) 4 Resize and re-encode (sharp) AVIF or WebP if the Accept header allows, else the original format 5 Store and respond Cache-Control: public, max-age=14400 · Vary: Accept · X-Nextjs-Cache image/webp 44.9 KB
One request to the image optimiser. The first request for a width does steps 1–5; the next one stops at step 2.

One page, three images, each sized a different way. public/hills.jpg is a 2400 × 1600 JPEG of 1,812,040 bytes (a test photo; any large photo behaves the same):

app/page.tsx
import Image from "next/image";

export default function Home() {
  return (
    <main>
      {/* 1. The hero: full width, above the fold, the page's LCP element */}
      <Image
        src="/hills.jpg"
        alt="Green hills under an orange sky at sunset"
        width={2400}
        height={1600}
        sizes="100vw"
        preload
        style={{ width: "100%", height: "auto" }}
      />

      {/* 2. A card: 420px wide, or the whole screen on phones narrower than that */}
      <div style={{ maxWidth: 420 }}>
        <Image
          src="/hills.jpg"
          alt="The same hills, as a card"
          width={2400}
          height={1600}
          sizes="(max-width: 420px) 100vw, 420px"
          style={{ width: "100%", height: "auto" }}
        />
      </div>

      {/* 3. A fixed-size thumbnail: always 240 × 160 CSS pixels */}
      <Image src="/hills.jpg" alt="Thumbnail of the hills" width={240} height={160} />
    </main>
  );
}
Result: the HTML next start sent (curl -s localhost:4510/), srcset lists shortened
<link rel="preload" as="image" imageSrcSet="/_next/image?url=%2Fhills.jpg&amp;w=640&amp;q=75 640w, … 3840w" imageSizes="100vw"/>

<img alt="Green hills under an orange sky at sunset" width="2400" height="1600" decoding="async" data-nimg="1"
     style="color:transparent;width:100%;height:auto" sizes="100vw"
     srcSet="/_next/image?url=%2Fhills.jpg&amp;w=640&amp;q=75 640w, …w=750… 750w, …w=828… 828w, …w=1080… 1080w,
             …w=1200… 1200w, …w=1920… 1920w, …w=2048… 2048w, …w=3840… 3840w"
     src="/_next/image?url=%2Fhills.jpg&amp;w=3840&amp;q=75"/>

<img alt="The same hills, as a card" loading="lazy" width="2400" height="1600" decoding="async" data-nimg="1"
     style="color:transparent;width:100%;height:auto" sizes="(max-width: 420px) 100vw, 420px"
     srcSet="(the same eight widths, 640w … 3840w)" src="/_next/image?url=%2Fhills.jpg&amp;w=3840&amp;q=75"/>

<img alt="Thumbnail of the hills" loading="lazy" width="240" height="160" decoding="async" data-nimg="1"
     style="color:transparent"
     srcSet="/_next/image?url=%2Fhills.jpg&amp;w=256&amp;q=75 1x, /_next/image?url=%2Fhills.jpg&amp;w=640&amp;q=75 2x"
     src="/_next/image?url=%2Fhills.jpg&amp;w=640&amp;q=75"/>

What headless Chrome downloaded (currentSrc of each image):
                      hero (sizes=100vw)     card (≤420px)          thumbnail (240×160)
1280×900, DPR 1       w=1920 (397,082 B)     w=640 (5,278 B)        w=256 (1,318 B)
375×812,  DPR 2       w=750  (9,710 B)       w=750 (same URL)       w=640 (5,278 B)
What’s happening
  1. The page is a Server Component, so all of this happens on the server at build time (the route is static): <Image> turns its props into a plain <img>. There is no client JavaScript for these images; the srcset does the work in the browser.
  2. The hero says sizes="100vw", so Next.js lists every deviceSizes width (640 … 3840) with a w descriptor. On the 1280-pixel desktop, Chrome needs 1280 device pixels and takes the next width up, 1920; on the 375-pixel phone at DPR 2 it needs 750 and takes exactly 750. Same HTML, 397 KB on one screen and 9.7 KB on the other, instead of 1.8 MB for both.
  3. preload did two things: the hero has no loading="lazy" attribute, and a <link rel="preload" as="image" imageSrcSet=… imageSizes="100vw"> went into the <head>, so the browser starts the download before it has even parsed the <img>. That is what you want for the LCP image, and only for it.
  4. The card says "full screen below 420px, otherwise 420px". The srcset is the same list (Next.js can't evaluate media queries; it only checks whether sizes uses vw), but the browser can: on the desktop 420 device pixels → w=640; on the phone 375 × 2 = 750 → w=750, the same URL as the hero, so it was downloaded once. It is lazy-loaded (loading="lazy"), the default.
  5. The thumbnail has no sizes, so Next.js treats it as fixed-size and writes 1x/2x descriptors: 240 rounds up to the 256 entry of imageSizes, and 480 (for 2× screens) rounds up to 640. The desktop got 256, the phone got 640.
  6. Every <img> keeps width and height attributes, which is how the browser reserves a 3:2 box before any bytes arrive. With style={{ width: "100%", height: "auto" }} the rendered size is controlled by CSS (1280×853 on the desktop, 375×250 on the phone) while the ratio still comes from the attributes: that is the CLS protection.
AdvancedThe optimiser's response, headers and cache

Each w in those URLs is a separate request to the optimiser. Here is what it sent back for one of them, twice in a row (curl -s -D - -o out.webp -H "Accept: image/avif,image/webp,*/*" "localhost:4510/_next/image?url=%2Fhills.jpg&w=1080&q=75"):

curl, first request then second request (date and connection headers left out)
HTTP/1.1 200 OK
Vary: Accept
Cache-Control: public, max-age=14400, must-revalidate
ETag: Rtm4wo8b43_9_XATXMEiuiz15KvS8w9gduR8tRRj390
Content-Type: image/webp
Content-Disposition: attachment; filename="hills.webp"
Content-Security-Policy: script-src 'none'; frame-src 'none'; sandbox;
X-Nextjs-Cache: MISS
Content-Length: 44934
time_total=0.093s

HTTP/1.1 200 OK
(the same headers)
X-Nextjs-Cache: HIT
Content-Length: 44934
time_total=0.0017s

$ file out.webp
RIFF (little-endian) data, Web/P image, VP8 encoding, 1080x720
What’s happening
  1. The first request found nothing in .next/cache/images, so the server read public/hills.jpg, resized it to 1080 × 720 with sharp and encoded WebP: 44,934 bytes from a 1,812,040-byte original, in 93 ms. X-Nextjs-Cache: MISS says the work was done now.
  2. The result was written to disk (.next/cache/images/<hash>/14400.<expiry>.<etag>….webp; the folder held one file per width and format afterwards). The second identical request was a HIT in 1.7 ms: no resizing.
  3. Cache-Control: public, max-age=14400, must-revalidate is the minimumCacheTTL default of 4 hours (it was 60 seconds before Next.js 16). The rule is max(minimumCacheTTL, the upstream file's max-age); public/ files are served with max-age=0, so 4 hours wins.
  4. Vary: Accept matters for CDNs: the same URL returns WebP to a browser that accepts it and JPEG to one that doesn't (sending Accept: image/jpeg,*/* got Content-Type: image/jpeg, 9,863 bytes at w=640), so a cache in front must key on Accept too.
  5. Content-Disposition: attachment and the Content-Security-Policy … sandbox header are defences: opening an optimised image directly downloads it instead of rendering it as a document (the default changed to attachment in Next.js 15).
  6. Sending the browser's If-None-Match with that ETag got 304 Not Modified, so repeat visits within a session cost no image bytes at all.
AdvancedRejected widths, qualities and URLs

The parameters are an allowlist, not free input: the optimiser rejects anything it wasn't configured to make. Each of these was a real request to the same app:

curl -s -w "%{http_code}" "localhost:4510/_next/image?…"
url=%2Fhills.jpg&w=1000&q=75     400  "w" parameter (width) of 1000 is not allowed
url=%2Fhills.jpg&w=640&q=80      400  "q" parameter (quality) of 80 is not allowed
url=https%3A%2F%2Fexample.com%2Fcat.jpg&w=640&q=75
                                 400  "url" parameter is not allowed
url=%2Fmissing.jpg&w=640&q=75    404  "url" parameter is valid but internal response is invalid
url=%2Fhills.jpg&w=3840&q=75     200  image/webp, 2400x1600, 885,270 bytes (never upscaled)
What’s happening
  1. w=1000 isn't in deviceSizes (640, 750, 828, 1080, 1200, 1920, 2048, 3840) or imageSizes (32, 48, 64, 96, 128, 256, 384), so it is refused. Without this rule anyone could ask for every width from 1 to 4000 and make your server encode thousands of variants: a cheap denial-of-service and a storage bill.
  2. q=80 fails because Next.js 16 allows only qualities: [75] by default; older versions accepted any quality. A quality={80} prop on <Image> is coerced to the closest allowed value (the docs say development logs a warning), but a hand-written URL with an unlisted q gets a 400.
  3. A remote URL is refused unless it matches images.remotePatterns; local paths can be restricted the same way with images.localPatterns.
  4. w=3840 is allowed, but sharp never makes an image larger than its source, so the 3840 slot returned the 2400-pixel original re-encoded (885 KB). That is why the hero's fallback src (w=3840) is the biggest variant: old browsers without srcset get the full-size image.
AdvancedWhat the optimiser costs

What it costs

The optimiser trades CPU and disk for bytes on the wire. Measured on this app: the first request for a width took 25–300 ms of server time (93 ms for 1080 wide, 300 ms for the 3840 slot), cached hits took 1–2 ms. Every distinct url × w × q × format is a separate file on disk. On Vercel it is metered usage on your plan; self-hosted it is your own CPU and disk, so many teams move it to a CDN image service with a custom loader (below), or set unoptimized for images that don't benefit (SVGs, tiny icons, animated GIFs).

AdvancedStatic imports: dimensions, blur and immutable URLs

Importing the file instead of passing a string gives Next.js the bytes at build time, so it fills in width, height and a tiny blurred placeholder for you, and fingerprints the URL. This version can't be built from this page (the checker can't carry a binary JPEG), so it was built in a copy of the app with the same hills.jpg next to the page:

app/page.tsx
import Image from "next/image";
import hills from "./hills.jpg"; // a 2400 × 1600 JPEG next to this file

export default function Home() {
  return (
    <main>
      <Image
        src={hills}
        alt="Green hills under an orange sky at sunset"
        placeholder="blur"
        sizes="100vw"
        preload
        style={{ width: "100%", height: "auto" }}
      />
    </main>
  );
}
What the build and next start produced
.next/static/media/hills.0m9f-qzn0u6z1.jpg      1,812,040 bytes (the original, with a content hash)

<img … width="2400" height="1600" … style="color:transparent;width:100%;height:auto;background-size:cover;
     background-position:50% 50%;background-repeat:no-repeat;
     background-image:url(&quot;data:image/svg+xml;charset=utf-8,%3Csvg … feGaussianBlur stdDeviation='20' …
     href='data:image/jpeg;base64,/9j/4AAQSkZJRgABAgAAAQABAAD/wAARCAAFAAgDAREA…'/%3E%3C/svg%3E&quot;)"
     src="/_next/image?url=%2F_next%2Fstatic%2Fmedia%2Fhills.0m9f-qzn0u6z1.jpg&amp;w=3840&amp;q=75"/>

curl -sI /_next/static/media/hills.0m9f-qzn0u6z1.jpg            Cache-Control: public, max-age=31536000, immutable
curl -sD - /_next/image?url=%2F_next%2Fstatic%2Fmedia%2Fhills…   Cache-Control: public, max-age=315360000, immutable
What’s happening
  1. The import is handled by the bundler: it copies the file to /_next/static/media/ with a hash in its name and gives you an object with src, width: 2400, height: 1600 and blurDataURL. That is why no width/height props were needed.
  2. placeholder="blur" paints the tiny JPEG (the base64 header says 8 × 5 pixels) scaled up and blurred through an inline SVG filter, as a CSS background of the <img> itself, so something shaped like the photo shows while the real bytes download. Next.js removes the background once the image loads.
  3. Because the URL contains a content hash, it can never point at different bytes, so both the original and the optimised variants are served with immutable (the optimised ones for 10 years) instead of the 4-hour TTL of public/ images. Change the file and the hash, URL and cache all change with it.
  4. Use static imports for images that ship with the code (marketing pages, illustrations). Use string paths for images that change without a deploy (public/ or a CMS), and accept the shorter TTL, which you can't invalidate except by changing the URL.
AdvancedRemote images and remotePatterns

A remote src needs width and height (Next.js can't read the file at build time) or fill, and the host must be allowed. In this app a second server on 127.0.0.1:4611 stands in for a CDN holding the same JPEG:

next.config.ts
import type { NextConfig } from "next";

const nextConfig: NextConfig = {
  cacheComponents: true,
  partialPrefetching: true,
  images: {
    // AVIF first, WebP as the fallback, the original format if the browser takes neither
    formats: ["image/avif", "image/webp"],
    // Only this host, port and folder may be optimised; everything else gets a 400
    remotePatterns: [new URL("http://127.0.0.1:4611/photos/**")],
    // Only because the stand-in CDN runs on 127.0.0.1: private IPs are blocked by default (SSRF)
    dangerouslyAllowLocalIP: true,
  },
};

export default nextConfig;
app/page.tsx
import Image from "next/image";

export default function Gallery() {
  return (
    <main style={{ display: "grid", gridTemplateColumns: "1fr 1fr", gap: 8 }}>
      {/* fill: the image takes the size of this positioned box */}
      <div style={{ position: "relative", aspectRatio: "3 / 2" }}>
        <Image
          src="http://127.0.0.1:4611/photos/hills.jpg"
          alt="Green hills under an orange sky at sunset"
          fill
          sizes="50vw"
          style={{ objectFit: "cover" }}
        />
      </div>
    </main>
  );
}
curl -H "Accept: …" "localhost:4510/_next/image?url=http%3A%2F%2F127.0.0.1%3A4611%2Fphotos%2Fhills.jpg&w=…&q=75"
Accept: image/avif,image/webp,*/*   w=640    200  image/avif   3,898 bytes   68 ms (MISS)
Accept: image/webp,*/*              w=640    200  image/webp   5,278 bytes   29 ms (MISS)
Accept: image/png,*/*               w=640    200  image/jpeg   9,863 bytes   25 ms (MISS)
Accept: image/avif,*/*              w=1920   200  image/avif 149,030 bytes  241 ms (MISS), then 1.2 ms (HIT)
Accept: image/webp,*/*              w=1920   200  image/webp 397,082 bytes  169 ms (MISS), then 1.5 ms (HIT)
url=…%2Fprivate%2Fhills.jpg (outside /photos/**)   400  "url" parameter is not allowed

The same app built without dangerouslyAllowLocalIP:
url=…%2Fphotos%2Fhills.jpg          400  "url" parameter is not allowed
next start logged:  ⨯ upstream image http://127.0.0.1:4611/photos/hills.jpg hostname resolved to private IP ["127.0.0.1"]
                    If this is expected and you understand SSRF risk, use images.dangerouslyAllowLocalIP = true to continue.
What’s happening
  1. fill drops the width/height attributes and renders the <img> with position:absolute;height:100%;width:100%;left:0;top:0;right:0;bottom:0, so the parent must be positioned and must have a size; here aspectRatio: "3 / 2" gives it one. objectFit: "cover" crops instead of stretching.
  2. With sizes="50vw" the srcset started at 384w: when sizes uses a fraction of the viewport, Next.js also adds the imageSizes entries that a half-width image could need (with 33vw in an earlier version of the page it added 256w and 384w).
  3. formats: ["image/avif", "image/webp"] makes the optimiser walk the list and use the first type the Accept header names. AVIF was 2.7× smaller than WebP at 1920 pixels (149 KB vs 397 KB) but took longer to encode on a miss (241 ms vs 169 ms); after the first request both are 1–2 ms hits. Each format is a separate cached file.
  4. remotePatterns matched protocol, host, port and the /photos/** path. A URL in another folder of the same host was refused: the pattern is the security boundary, so make it as narrow as your bucket.
  5. Without dangerouslyAllowLocalIP, the same allowed URL failed: since Next.js 16 the optimiser refuses to fetch anything that resolves to a private IP, even if the hostname matches. That stops someone using your image server to probe your internal network (SSRF). Leave it off in production.
AdvancedUsing a CDN instead: a custom loader

A loader is a function from { src, width, quality } to a URL. With loader: "custom" Next.js stops pointing at /_next/image and the CDN does the resizing:

next.config.ts
import type { NextConfig } from "next";

const nextConfig: NextConfig = {
  cacheComponents: true,
  partialPrefetching: true,
  images: {
    loader: "custom",
    // relative to the project root; this project keeps its code in src/
    loaderFile: "./src/lib/image-loader.ts",
  },
};

export default nextConfig;
lib/image-loader.ts
"use client";

import type { ImageLoaderProps } from "next/image";

// Builds an imgix-style URL: the CDN resizes and re-encodes, Next.js never sees the bytes
export default function cdnLoader({ src, width, quality }: ImageLoaderProps) {
  return `https://img.example-cdn.com${src}?w=${width}&q=${quality ?? 75}&auto=format`;
}
app/page.tsx
import Image from "next/image";

export default function Home() {
  return <Image src="/hills.jpg" alt="Green hills at sunset" width={240} height={160} />;
}
.next/server/app/index.html after next build
<img alt="Green hills at sunset" loading="lazy" width="240" height="160" decoding="async" data-nimg="1"
     style="color:transparent"
     srcSet="https://img.example-cdn.com/hills.jpg?w=256&amp;q=75&amp;auto=format 1x,
             https://img.example-cdn.com/hills.jpg?w=640&amp;q=75&amp;auto=format 2x"
     src="https://img.example-cdn.com/hills.jpg?w=640&amp;q=75&amp;auto=format"/>
What’s happening
  1. The component still chooses the widths (256 for 1×, 640 for 2×, as before) and still writes width, height and loading="lazy"; only the URLs come from your function.
  2. The loader file starts with "use client" because a function can't be passed from a Server Component to the client; marking the module lets Next.js use it on both sides.
  3. Nothing reaches /_next/image, so remotePatterns, qualities and the disk cache no longer apply: the CDN's own rules and caching do. The docs list ready-made loaders for Cloudinary, imgix, Cloudflare, Akamai, Contentful and others.
AdvancedProps and defaults that changed in 13–16

The props that changed between versions

  • priority → preload (Next.js 16). priority still exists but is deprecated. The docs also point out that in most cases loading="eager" or fetchPriority="high" (passed through to the <img>) is enough, and that preload is wrong when different images are the LCP element at different screen sizes, because every preloaded image is downloaded.
  • Defaults tightened in 16: qualities: [75], minimumCacheTTL 4 hours (was 60 s), imageSizes without 16, maximumRedirects: 3, private IPs blocked, local src with a query string needs localPatterns.search.
  • next/legacy/image is the pre-13 component (wrapper <span>, layout="responsive", objectFit props); it is deprecated in 16. In Next.js 13 the old next/future/image became next/image, alt became required and the wrapper element disappeared.
  • onLoadingComplete is deprecated since 14; use onLoad, which (like any function prop) only works in a Client Component.
Which file does a 375 × 812 phone with a 2× screen download?

The card in the main example: sizes="(max-width: 420px) 100vw, 420px", srcSet with 640w, 750w, 828w, … 3840w. Which w?

Show answer

w=750, observed in headless Chrome at 375 × 812, DPR 2.

What’s happening
  1. The browser evaluates sizes first: the viewport is 375 CSS pixels, so (max-width: 420px) matches and the slot is 100vw = 375 CSS pixels.
  2. It multiplies by the device pixel ratio: 375 × 2 = 750 device pixels needed.
  3. It picks the smallest candidate that is at least that wide: 750w. (At 1280 × 900, DPR 1, the second condition applies: 420 device pixels → 640w.)
  4. Next.js never ran on the phone for this choice: the server wrote the list, the browser did the arithmetic. That is why a wrong sizes can't be fixed by the optimiser; it just obeys the URL it's given.
AdvancedGotcha: missing or wrong sizes

#next/font

Added

Web fonts cause two problems. Privacy and latency: a <link href="https://fonts.googleapis.com/css2?family=Inter"> makes every visitor's browser connect to two Google hosts (one for the CSS, one for the files) before text can render in that font. Layout shift: while the font downloads the browser shows a fallback such as Arial, which has different letter widths and line height, so when Inter arrives every line re-wraps and the page jumps.

next/font moves the font into your build. Calling Inter({ subsets: ["latin"] }) in a module runs at build time: Next.js downloads the CSS and font files from Google (or reads your local file), writes the files into /_next/static/media/ with hashed names, generates the @font-face rules, and generates a second font face, "Inter Fallback", which is the local Arial stretched with size-adjust and ascent-override so that it takes up almost exactly the same space as Inter. The function call returns class names and a CSS variable that point at both.

The mental model: next/font is a build step that rewrites a font import into self-hosted files plus a measured stand-in. The browser never hears of Google, and the stand-in means the swap from fallback to real font barely moves anything.

app/layout.tsx
import { Inter } from "next/font/google";
import "./globals.css";

// Runs at build time: downloads Inter from Google, writes the files into the build
const inter = Inter({
  subsets: ["latin"], // only the Latin subset is preloaded
  variable: "--font-inter", // also expose it as a CSS variable
});

export default function RootLayout({ children }: LayoutProps<"/">) {
  return (
    <html lang="en" className={inter.variable}>
      <body className={inter.className}>{children}</body>
    </html>
  );
}
Result: what the build wrote and what the browser received
.next/static/media/   7 Inter files, one per unicode-range subset, e.g.
  07c7676b89ed2778-s.p.2bn3s6zvc0dyp.woff2   48,432 bytes   (latin; ".p." = preloaded)
  ba63f6a0e436df8e-s.3-6t-g6q0vh0a.woff2     85,272 bytes   (latin-ext; not preloaded)

Generated CSS (.next/static/chunks/1c0bag5adhl11.css, one rule per line):
@font-face{font-family:Inter;font-style:normal;font-weight:100 900;font-display:swap;
  src:url(../media/07c7676b89ed2778-s.p.2bn3s6zvc0dyp.woff2)format("woff2");unicode-range:U+??,U+131,U+152-153,…}
  (six more @font-face rules: cyrillic-ext, cyrillic, greek-ext, greek, vietnamese, latin-ext)
@font-face{font-family:Inter Fallback;src:local(Arial);ascent-override:90.44%;descent-override:22.52%;
  line-gap-override:0.0%;size-adjust:107.12%}
.ec5Qua_className{font-family:Inter,Inter Fallback;font-style:normal}
.ec5Qua_variable{--font-inter:"Inter", "Inter Fallback"}

curl -sD - localhost:4510/
link: </_next/static/media/07c7676b89ed2778-s.p.2bn3s6zvc0dyp.woff2>; rel=preload; as="font"; crossorigin=""; type="font/woff2"
<html lang="en" class="ec5Qua_variable"><head>… <meta name="next-size-adjust" content=""/> …
<body class="ec5Qua_className">

headless Chrome, cache disabled:
hosts contacted:   localhost:4510            (nothing from fonts.googleapis.com or fonts.gstatic.com)
font requests:     /_next/static/media/07c7676b89ed2778-s.p.2bn3s6zvc0dyp.woff2
computed font:     Inter, "Inter Fallback"
font file headers: Cache-Control: public, max-age=31536000, immutable
What’s happening
  1. At build time Inter({...}) fetched Inter from Google and wrote seven files, one per unicode-range subset that Google splits the font into. Inter is a variable font, so one file per subset covers every weight: the rule says font-weight:100 900 and no weight option was needed.
  2. subsets: ["latin"] chose which file to preload: only the Latin one got the .p. marker and a preload hint. The other subsets are still declared, and the browser downloads one only if the page contains characters in its unicode-range (a Greek word, say).
  3. The preload went out as an HTTP Link header, not a <link> tag: during prerendering React emits resource hints as response headers (up to reactMaxHeadersLength, 6,000 characters by default), so the browser starts the 48 KB download before it has parsed a byte of HTML. crossorigin="" is required for font preloads even on the same origin, or the browser would fetch the font twice.
  4. The fallback face is the CLS fix. Inter Fallback is local Arial scaled by size-adjust:107.12% with Inter's ascent and descent. Measured in Chrome on this page, the same 90-character sentence at 16px was 659 × 20 pixels in Inter, 657 × 19 in Inter Fallback and 614 × 18 in plain Arial: the stand-in is within 2 pixels, plain Arial is 45 pixels narrower, which is the re-wrap you would otherwise see.
  5. inter.className sets font-family:Inter,Inter Fallback on <body>; inter.variable defines --font-inter on <html> for CSS that wants to use it (Tailwind's --font-sans, a CSS Module). font-display:swap (the default) shows the fallback immediately and swaps when the file arrives.
  6. In the browser, the only host contacted was the app itself, and the file came with immutable caching because its name contains a content hash. No request to Google at runtime: that is the privacy and the latency win.
AdvancedPreloading is per route

Where you call the font function decides where it is preloaded: the root layout's fonts on every route, a page's fonts on that page only.

app/page.tsx
export default function Home() {
  return <h1>Hello from Inter</h1>;
}
app/docs/page.tsx
import { Roboto_Mono } from "next/font/google";

// Called in a page, so it is preloaded on /docs only
const mono = Roboto_Mono({ subsets: ["latin"], weight: ["400", "700"] });

export default function Docs() {
  return <pre className={mono.className}>npm run build</pre>;
}
next build
Route (app)
┌ ○ /
├ ○ /_not-found
└ ○ /docs


○  (Static)  prerendered as static content
curl -sD - and headless Chrome
/       link: <…/07c7676b89ed2778-s.p.2bn3s6zvc0dyp.woff2>; rel=preload …            (Inter)
        stylesheets: 1c0bag5adhl11.css
/docs   link: <…/07c7676b89ed2778-s.p.2bn3s6zvc0dyp.woff2>; rel=preload …,
              <…/af77a86cf1e51fa0-s.p.22iyan98j1_2e.woff2>; rel=preload …             (Inter + Roboto Mono)
        stylesheets: 1c0bag5adhl11.css, 2cztr3xv7br7m.css
        font requests: both files; font faces actually used on the page: Roboto Mono 400 only

Roboto Mono CSS: @font-face … font-weight:400 … src:url(../media/af77a86cf1e51fa0-s.p.22iyan98j1_2e.woff2) …
                 @font-face … font-weight:700 … src:url(../media/af77a86cf1e51fa0-s.p.22iyan98j1_2e.woff2) …
                 @font-face{font-family:Roboto Mono Fallback;src:local(Arial);…;size-adjust:134.61%}
What’s happening
  1. Both routes stay static (○): fonts are resolved at build time and never make a page dynamic.
  2. / preloads only Inter; Roboto Mono's CSS is in a separate stylesheet that only /docs links. A font in a page costs nothing on other pages.
  3. /docs preloads both, and Chrome downloaded both, although nothing on /docs is set in Inter. A font called in the root layout is preloaded on every route, used or not: keep the root layout to the fonts the whole site uses.
  4. weight: ["400", "700"] produced two sets of rules pointing at the same files: Google served Roboto Mono's variable file for both weights. Listing weights only matters for fonts that aren't variable (then each weight is its own file, and you must list them).
  5. The generated fallback for a monospace font is still Arial (size-adjust:134.61% to match the width), because next/font/google picks Arial or Times New Roman as the base. Add fallback: ["ui-monospace", "monospace"] if a proportional stand-in would look wrong while the font loads.
AdvancedLocal fonts

next/font/local does the same for a file you ship. Run in a copy of the app with a variable woff2 saved as app/fonts/Brand.woff2 (the page can't carry the binary, so this one isn't built by the checker):

app/layout.tsx
import localFont from "next/font/local";

// The file sits next to this layout: app/fonts/Brand.woff2
const brand = localFont({
  src: "./fonts/Brand.woff2",
  weight: "100 900", // it is a variable font: one file, every weight
  variable: "--font-brand",
  display: "swap",
});

export default function RootLayout({ children }: LayoutProps<"/">) {
  return (
    <html lang="en" className={brand.variable}>
      <body className={brand.className}>{children}</body>
    </html>
  );
}
The build output
.next/static/media/Brand-s.p.3rd3ws2mg35i0.woff2   28,356 bytes
@font-face{font-family:brand;src:url(../media/Brand-s.p.3rd3ws2mg35i0.woff2)format("woff2");font-display:swap;font-weight:100 900}
@font-face{font-family:brand Fallback;src:local(Arial);ascent-override:94.47%;descent-override:27.73%;line-gap-override:0.0%;size-adjust:106.39%}
.veGYVa_className{font-family:brand,brand Fallback}
link header: </_next/static/media/Brand-s.p.3rd3ws2mg35i0.woff2>; rel=preload; as="font"; crossorigin=""; type="font/woff2"
What’s happening
  1. The src path is relative to the file that calls localFont, and the file can live anywhere in the project, app/ included.
  2. Next.js read the font's metrics from the file itself to compute the fallback's overrides, so local fonts get the same no-shift stand-in (the base is Arial unless you set adjustFontFallback: "Times New Roman" or false).
  3. The family name brand came from the variable the result was assigned to; declarations: [{ prop: "font-family", value: "Brand" }] overrides it if other CSS needs a fixed name.
  4. For a family split across files (regular, italic, bold), pass src as an array of { path, weight, style }.
AdvancedFont handling before next/font

#next/script and third-party scripts

Added

Third-party scripts (analytics, tag managers, consent banners, chat widgets, maps) are the most common reason a fast Next.js page becomes slow: they compete with your own JavaScript for the network and the main thread, and a plain <script src> in the <head> blocks parsing. In a React app there is a second problem: a <script> tag rendered by a component behaves differently depending on how the user arrived. A plain one isn't run when React adds it during a client-side navigation, and an async one can run twice (both observed below).

next/script is a loader that decides when a script is fetched and runs, and makes sure each one runs once. You pick a strategy, and Next.js handles the timing relative to the two moments that matter: when React has hydrated the page (your app is interactive) and when the browser has finished loading everything and is idle.

The mental model is boarding groups. beforeInteractive boards first, before the app itself (and holds nobody up while doing it); afterInteractive (the default) boards as soon as the app is interactive; lazyOnload boards last, when everyone else is seated and the browser has nothing better to do. Each passenger boards once, however many pages list them.

StrategyFetchedRunsUse for
beforeInteractivePreloaded from the HTMLBefore Next.js's own code and hydrationConsent managers, bot detection
afterInteractive (default)Preloaded (from the layout), injected on the clientRight after hydrationAnalytics, tag managers
lazyOnloadOnly after the load eventWhen the browser is idleChat widgets, social embeds
worker (experimental)In a web worker via PartytownOff the main threadPages Router only, not stable
HTML arrives preload hints for consent, analytics beforeInteractive consent.js 66 ms React hydrates page interactive 84 ms afterInteractive analytics.js, inline 84–85 ms lazyOnload chat-widget.js 89 ms after the load event (83 ms) and requestIdleCallback Order observed in every run; times from run 1 (cold browser).
When each strategy runs, relative to hydration and the load event.

Each test script in this app just records when it ran, in milliseconds since the navigation started; a small Client Component records when hydration happened:

app/layout.tsx
import Script from "next/script";
import { HydrationMarker } from "./hydration-marker";

export default function RootLayout({ children }: LayoutProps<"/">) {
  return (
    <html lang="en">
      <body>
        {children}
        <HydrationMarker />
        {/* Must run before the app: only allowed in the root layout */}
        <Script src="/consent.js" strategy="beforeInteractive" />
        {/* The default: loads once the page has hydrated */}
        <Script src="/analytics.js" strategy="afterInteractive" />
        {/* Low priority: after the load event, when the browser is idle */}
        <Script src="/chat-widget.js" strategy="lazyOnload" />
        {/* Inline code needs an id so Next.js can track it */}
        <Script id="inline-config">{`(window.__log ??= []).push("inline @ " + Math.round(performance.now()) + "ms")`}</Script>
      </body>
    </html>
  );
}
Result: window.__log in headless Chrome, three fresh loads of / (next start)
run 1: consent.js @ 66ms   hydrated @ 84ms   inline @ 84ms   analytics.js @ 85ms   chat-widget.js @ 89ms   (load event 83ms)
run 2: consent.js @ 12ms   hydrated @ 15ms   inline @ 16ms   analytics.js @ 16ms   chat-widget.js @ 26ms   (load event 15ms)
run 3: consent.js @ 10ms   hydrated @ 12ms   inline @ 13ms   analytics.js @ 13ms   chat-widget.js @ 15ms   (load event 12ms)

In the server HTML (curl -s localhost:4510/):
<head> … <link rel="preload" href="/analytics.js" as="script"/><link rel="preload" href="/consent.js" as="script"/> …
<body> … <script>(self.__next_s=self.__next_s||[]).push(["/consent.js",{}])</script> …
         no <script src="/analytics.js">, no <script src="/chat-widget.js"> (they appear only in the RSC payload)

After hydration, document.scripts: /consent.js (head), /analytics.js (body), #inline-config (body), /chat-widget.js (body)
What’s happening
  1. beforeInteractive is the only strategy that is in the server HTML: a <link rel="preload"> in the head starts the download immediately, and an inline self.__next_s.push(["/consent.js",{}]) queues it. Next.js's client runtime injects queued scripts into the <head> before it starts hydrating, which is why consent.js ran first in every run (66, 12 and 10 ms).
  2. React then hydrated (hydrated logged by the Client Component's useEffect). The docs say beforeInteractive scripts don't block hydration; they just get a head start.
  3. afterInteractive scripts are not <script> tags in the HTML at all: they travel as <Script> elements in the RSC payload, and the Script component injects a real <script> into the body after hydration. Both the inline script and analytics.js ran within a millisecond of hydration. The layout's analytics.js also got a preload hint in the head, so the file was already downloaded when it was injected.
  4. lazyOnload waited for the page's load event (83, 15 and 12 ms) and then for idle time (Next.js uses requestIdleCallback), so chat-widget.js was always last. On this tiny page that is only a few milliseconds; on a real page with images still downloading it can be seconds, which is the point.
  5. The inline script got id="inline-config": Next.js uses the id (or src) as the key that stops a script from running twice. The ESLint rule @next/next/inline-script-id reports inline <Script>s without one.
AdvancedScripts and client-side navigation
app/page.tsx
import Link from "next/link";

export default function Home() {
  return (
    <main>
      <h1>Scripts</h1>
      <Link href="/store-finder">Store finder</Link>
    </main>
  );
}
app/store-finder/page.tsx
import Script from "next/script";

export default function StoreFinder() {
  return (
    <main>
      <h1>Store finder</h1>
      {/* Already loaded by the layout: not loaded again */}
      <Script src="/analytics.js" />
      {/* Only this page needs it: loaded when the page is first shown */}
      <Script src="/maps.js" />
    </main>
  );
}
window.__log after clicking the link on / (a client-side navigation)
consent.js @ 10ms
hydrated @ 12ms
inline @ 13ms
analytics.js @ 13ms
chat-widget.js @ 15ms
maps.js @ 4514ms
What’s happening
  1. Clicking the <Link> is a client-side navigation: no new document, so the layout's scripts are not re-run (the layout didn't even re-render).
  2. /store-finder renders <Script src="/analytics.js" /> again, and it did not run a second time: next/script keeps a cache of the srcs (and ids) it has already loaded and skips repeats. A raw <script> element has no such memory.
  3. maps.js was new, so the Script component injected it when the page mounted, about 4.5 seconds into the session when the test clicked. It runs however the user reaches the page, which is not true of a raw tag (next box).
  4. beforeInteractive scripts run once per document load; a client navigation never runs them again, even one that changes a root param such as /en → /fi.
AdvancedEvent handlers and where scripts may go
Advanced@next/third-parties

@next/third-parties is a separate, still experimental package of ready-made components for the most common Google embeds. It wasn't installed in this sandbox, so nothing here was run; this is what the 16.4.3 package's source contains:

app/layout.tsx
import { GoogleAnalytics, GoogleTagManager } from "@next/third-parties/google";

export default function RootLayout({ children }: LayoutProps<"/">) {
  return (
    <html lang="en">
      <GoogleTagManager gtmId="GTM-XYZ" />
      <body>{children}</body>
      <GoogleAnalytics gaId="G-XYZ" />
    </html>
  );
}
What’s happening
  1. GoogleAnalytics is a "use client" component that renders two next/script elements with the default strategy (afterInteractive): an inline one with id="_next-ga-init" that creates window.dataLayer and calls gtag('config', gaId), and https://www.googletagmanager.com/gtag/js?id=… with id="_next-ga". So it is exactly the code you would write by hand, with the ids already set.
  2. GoogleTagManager does the same for gtm.js (with gtmScriptUrl for server-side tagging), and sendGTMEvent / sendGAEvent push onto the data layer from Client Components.
  3. YouTubeEmbed uses lite-youtube-embed: it renders a lightweight placeholder and loads the real player only on interaction, and GoogleMapsEmbed renders a lazy-loaded <iframe>. These two save far more than the analytics components, because a YouTube iframe costs hundreds of kilobytes before anyone presses play.
  4. Page views on client-side navigation are tracked by Google Analytics itself (its "Enhanced Measurement" listens to history changes), not by the component.
AdvancedThe test scripts and hydration marker

The test scripts and the hydration marker

The rest of the files of this app:

public/consent.js
(window.__log ??= []).push(`consent.js @ ${Math.round(performance.now())}ms`);
public/analytics.js
(window.__log ??= []).push(`analytics.js @ ${Math.round(performance.now())}ms`);
public/chat-widget.js
(window.__log ??= []).push(`chat-widget.js @ ${Math.round(performance.now())}ms`);
public/maps.js
(window.__log ??= []).push(`maps.js @ ${Math.round(performance.now())}ms`);
app/hydration-marker.tsx
"use client";

import { useEffect } from "react";

declare global {
  interface Window {
    __log?: string[];
  }
}

// Logs the moment React has hydrated the page
export function HydrationMarker() {
  useEffect(() => {
    (window.__log ??= []).push(`hydrated @ ${Math.round(performance.now())}ms`);
  }, []);
  return null;
}

#Metadata and SEO

Added

Search engines and link previews read a page's <head>: the <title>, the description, the canonical URL, the robots directives, the Open Graph and Twitter tags. In the App Router you don't write those tags in JSX; you export data, and Next.js renders the tags. A layout or page exports either a static metadata object or an async generateMetadata function, and Next.js resolves them for the matched route, from the root layout down to the page, merges them, and writes the result into the HTML.

The mental model is layers of tracing paper. The root layout draws the site defaults (site name, title template, metadataBase); each nested layout and finally the page lays its sheet on top. Where a sheet writes a key, it covers the one below: completely, because the merge is shallow. Write openGraph on the page and the layout's whole openGraph object is replaced, not combined. Only title has special rules (template, default, absolute) for combining across levels.

Metadata is server-only (it must be known before the HTML is sent), and it has the same rendering rules as the page: static metadata is baked into prerendered HTML; metadata that needs request data is computed per request and, for browsers, streamed after the page body.

app/layout.tsx
import type { Metadata } from "next";

export const metadata: Metadata = {
  metadataBase: new URL("https://acme.dev"), // relative URLs below resolve against this
  title: {
    template: "%s | Acme Blog", // applied to titles set by child segments
    default: "Acme Blog", // used when a child sets no title
  },
  description: "Notes on building for the web.",
  openGraph: { siteName: "Acme Blog", type: "website" },
};

export default function RootLayout({ children }: LayoutProps<"/">) {
  return (
    <html lang="en">
      <body>{children}</body>
    </html>
  );
}
Result: the <head> of / (curl -s localhost:4510/, one tag per line)
<meta charSet="utf-8"/>
<meta name="viewport" content="width=device-width, initial-scale=1"/>
<title>Acme Blog</title>
<meta name="description" content="Notes on building for the web."/>
<meta property="og:title" content="Acme Blog"/>
<meta property="og:description" content="Notes on building for the web."/>
<meta property="og:site_name" content="Acme Blog"/>
<meta property="og:type" content="website"/>
<meta name="twitter:card" content="summary"/>
<meta name="twitter:title" content="Acme Blog"/>
<meta name="twitter:description" content="Notes on building for the web."/>
<link rel="icon" href="/favicon.ico?favicon.117ezoe8m31dk.ico" sizes="48x48" type="image/x-icon"/>
What’s happening
  1. charSet and viewport are added to every page whether you define metadata or not (the viewport can be changed with a separate viewport export).
  2. The home page (app/page.tsx, a plain <h1>) sets no title, so title.default was used: Acme Blog. The template did nothing here, because a template applies only to titles set by child segments, never to the segment that defines it.
  3. og:title and og:description were not written anywhere: Next.js filled them from title and description because openGraph didn't set them. The twitter:* tags were derived the same way, and twitter:card defaulted to summary because there is no image.
  4. The favicon link came from the file convention: app/favicon.ico in the template. File-based metadata (favicon.ico, icon.png, opengraph-image.*, robots.ts, sitemap.ts) takes priority over the same fields in the metadata object.
  5. metadataBase printed nothing by itself; it is the base for every URL field below it (canonical, Open Graph images). Without it, a relative URL in those fields is a build error, and in production people forget it and preview images point at localhost.
AdvancedgenerateMetadata for dynamic routes

When metadata depends on data (a post's title), export generateMetadata. It receives the same params and searchParams as the page (both Promises since Next.js 15) and can await data:

lib/posts.ts
import "server-only";
import { cacheLife } from "next/cache";

const posts = {
  "image-optimisation": { title: "How next/image picks a width", summary: "srcset, sizes and the optimiser.", published: "2026-10-01" },
  "font-loading": { title: "Fonts without layout shift", summary: "What next/font generates at build time.", published: "2026-10-12" },
};

export type Slug = keyof typeof posts;

export async function getPost(slug: string) {
  "use cache";
  cacheLife("hours");
  return posts[slug as Slug] ?? null; // in a real app: a CMS or database query
}

export async function listSlugs() {
  return Object.keys(posts);
}
app/blog/[slug]/page.tsx
import type { Metadata } from "next";
import { notFound } from "next/navigation";
import { getPost, listSlugs } from "@/lib/posts";

export async function generateStaticParams() {
  return (await listSlugs()).map((slug) => ({ slug }));
}

export async function generateMetadata({ params }: PageProps<"/blog/[slug]">): Promise<Metadata> {
  const { slug } = await params;
  const post = await getPost(slug); // same cached call as the page: one lookup
  if (!post) return {};
  return {
    title: post.title, // becomes "… | Acme Blog" through the layout's template
    description: post.summary,
    alternates: { canonical: `/blog/${slug}` },
    openGraph: { type: "article", title: post.title, publishedTime: post.published },
  };
}

export default async function PostPage({ params }: PageProps<"/blog/[slug]">) {
  const { slug } = await params;
  const post = await getPost(slug);
  if (!post) notFound();
  return (
    <article>
      <h1>{post.title}</h1>
      <p>{post.summary}</p>
    </article>
  );
}
app/page.tsx
export default function Home() {
  return <h1>Acme Blog</h1>;
}
next build
Route (app)
┌ ○ /
├ ○ /_not-found
├   /blog/[slug]
│ ├ ◐ /blog/[slug]
│ ├ ○ /blog/image-optimisation
│ └ ○ /blog/font-loading
├ ○ /robots.txt
├ ◐ /search
└ ○ /sitemap.xml


○  (Static)             prerendered as static content
◐  (Partial Prerender)  prerendered as static HTML with dynamic server-streamed content
The <head> of /blog/image-optimisation (curl -s), and its headers
<title>How next/image picks a width | Acme Blog</title>
<meta name="description" content="srcset, sizes and the optimiser."/>
<link rel="canonical" href="https://acme.dev/blog/image-optimisation"/>
<meta property="og:title" content="How next/image picks a width"/>
<meta property="og:description" content="srcset, sizes and the optimiser."/>
<meta property="og:type" content="article"/>
<meta property="article:published_time" content="2026-10-01"/>
<meta name="twitter:card" content="summary"/>
<meta name="twitter:title" content="How next/image picks a width"/>
<meta name="twitter:description" content="srcset, sizes and the optimiser."/>

x-nextjs-cache: HIT
x-nextjs-prerender: 1
Cache-Control: s-maxage=3600, stale-while-revalidate=82800
What’s happening
  1. generateStaticParams listed both slugs, so both posts were prerendered at build time (○ /blog/image-optimisation, ○ /blog/font-loading), metadata included: the title is in the static HTML's <head>, which is what a crawler wants. ◐ /blog/[slug] is the shell for slugs that weren't listed.
  2. generateMetadata and the page both call getPost(slug). It is a 'use cache' function, so the second call is served from the cache; for fetch calls Next.js would also de-duplicate within the render (request memoization). Either way, fetching the same data for metadata and page is the normal pattern, not a bug.
  3. The title became How next/image picks a width | Acme Blog: a string title in a child is put into the parent's template. title: { absolute: "…" } would skip the template.
  4. alternates.canonical: "/blog/image-optimisation" was resolved against metadataBase into https://acme.dev/blog/image-optimisation. The canonical tag tells search engines which URL is the real one when the same page is reachable through several (query strings, trailing slashes, a preview domain).
  5. The Cache-Control: s-maxage=3600, stale-while-revalidate=82800 comes from cacheLife("hours") on the data the page used: the cache lifetime of a prerendered page follows the shortest cacheLife inside it.
What happened to og:site_name?

The root layout sets openGraph: { siteName: "Acme Blog", type: "website" }. The post page's generateMetadata returns openGraph: { type: "article", title, publishedTime }. Does the post's HTML have <meta property="og:site_name">?

Show answer

No. The <head> above has og:title, og:description, og:type and article:published_time, but no og:site_name.

What’s happening
  1. Metadata objects are resolved root first, then each layout, then the page, and shallowly merged: each top-level key from a later segment replaces the same key from an earlier one.
  2. The page returned an openGraph key, so the layout's whole openGraph object (with siteName in it) was replaced by the page's object, which has no siteName.
  3. title and description are separate top-level keys, so the layout's description survived where the page didn't set one, and the template still applied to the title.
  4. The fix is to spread a shared object: keep const sharedOpenGraph = { siteName: "Acme Blog" } in a module and write openGraph: { ...sharedOpenGraph, type: "article", … } in the page. Or, since generateMetadata receives parent (the resolved parent metadata) as its second argument, read (await parent).openGraph and extend it.
Advancedrobots.txt and sitemap.xml

Both are file conventions in the root of app: a static robots.txt/sitemap.xml file, or a robots.ts/sitemap.ts that returns data. They are special Route Handlers, prerendered like pages unless they use request data:

app/robots.ts
import type { MetadataRoute } from "next";

export default function robots(): MetadataRoute.Robots {
  return {
    rules: { userAgent: "*", allow: "/", disallow: "/drafts/" },
    sitemap: "https://acme.dev/sitemap.xml",
  };
}
app/sitemap.ts
import type { MetadataRoute } from "next";
import { listSlugs } from "@/lib/posts";

export default async function sitemap(): Promise<MetadataRoute.Sitemap> {
  const slugs = await listSlugs();
  return [
    { url: "https://acme.dev", changeFrequency: "weekly", priority: 1 },
    ...slugs.map((slug) => ({ url: `https://acme.dev/blog/${slug}`, changeFrequency: "monthly" as const })),
  ];
}
curl -s localhost:4510/robots.txt ; curl -s localhost:4510/sitemap.xml
User-Agent: *
Allow: /
Disallow: /drafts/

Sitemap: https://acme.dev/sitemap.xml

<?xml version="1.0" encoding="UTF-8"?>
<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">
<url>
<loc>https://acme.dev</loc>
<changefreq>weekly</changefreq>
<priority>1</priority>
</url>
<url>
<loc>https://acme.dev/blog/image-optimisation</loc>
<changefreq>monthly</changefreq>
</url>
<url>
<loc>https://acme.dev/blog/font-loading</loc>
<changefreq>monthly</changefreq>
</url>
</urlset>

sitemap.xml headers: content-type: application/xml · x-nextjs-cache: HIT · cache-control: public, max-age=0, must-revalidate
What’s happening
  1. Both were built at build time (○ /robots.txt, ○ /sitemap.xml in the route table) and served from the prerender cache (x-nextjs-cache: HIT).
  2. The sitemap reads the same listSlugs() as generateStaticParams, so a new post can't be prerendered and forgotten in the sitemap. URLs in a sitemap must be absolute (metadataBase doesn't apply to these files).
  3. robots.txt controls crawling, not indexing: a disallowed page can still be indexed from links. To keep a page out of search results, let it be crawled and send robots: { index: false } in its metadata (a <meta name="robots" content="noindex">).
  4. For more than 50,000 URLs, generateSitemaps splits the sitemap into several files; since Next.js 16 the id it passes to sitemap() is a Promise, like params.
AdvancedStreaming metadata

When generateMetadata needs request data (searchParams, cookies()), it can't be prerendered. Since Next.js 15.2, Next.js doesn't make the browser wait for it: it sends the page and streams the tags in later, unless the visitor is a bot that only reads the initial HTML:

app/search/page.tsx
import type { Metadata } from "next";
import { Suspense } from "react";

// Reads searchParams (runtime data), so it can only run at request time
export async function generateMetadata({ searchParams }: PageProps<"/search">): Promise<Metadata> {
  const { q } = await searchParams;
  await new Promise((r) => setTimeout(r, 500)); // stands in for a slow lookup
  return { title: `Results for "${q ?? ""}"`, robots: { index: false } };
}

async function Results({ searchParams }: { searchParams: PageProps<"/search">["searchParams"] }) {
  const { q } = await searchParams;
  return <p>No posts match “{q}”.</p>;
}

export default function SearchPage({ searchParams }: PageProps<"/search">) {
  return (
    <main>
      <h1>Search</h1>
      <Suspense fallback={<p>Searching…</p>}>
        <Results searchParams={searchParams} />
      </Suspense>
    </main>
  );
}
curl -sN -A "<user agent>" "localhost:4510/search?q=fonts" — byte offsets of each piece in the response
Chrome 155 user agent                           Twitterbot/1.0
time to first byte   0.024 s                    time to first byte   0.507 s
590    </head>                                  517    <title>Results for &quot;fonts&quot; | Acme Blog</title>
694    <h1>Search</h1>                          640    <meta name="robots" content="noindex"/>
751    Searching…                               1298   </head>
1385   No posts match “fonts”                   1371   <h1>Search</h1>
10611  <title>Results for &quot;fonts&quot; …    1397   No posts match “fonts”
10734  <meta name="robots" content="noindex"/>
What’s happening
  1. The route is ◐ /search: the <h1> and the Searching… fallback are in the prerendered shell; the results and the metadata depend on searchParams, so they are rendered per request.
  2. For the browser, the first byte left after 24 ms: the shell went out at once, the head closed at byte 590 without a <title>, and the <title> and noindex tag arrived at byte 10,611, after the 500 ms metadata lookup, at the end of the body. They stay there (in headless Chrome afterwards both elements were children of <body>), and the browser still uses them: document.title was Results for "fonts" | Acme Blog.
  3. For Twitterbot, the first byte took 507 ms: Next.js recognised an "HTML-limited bot" (one that reads the initial <head> and doesn't run JavaScript) and waited for generateMetadata before sending anything, so the tags are inside <head> where the bot looks.
  4. The list of such bots is a regular expression in Next.js (Bingbot, Twitterbot, Slackbot, facebookexternalhit, LinkedInBot, Discordbot, WhatsApp, *-Google and Google-* crawlers among them), overridable with htmlLimitedBots in next.config.ts; htmlLimitedBots: /.*/ turns streaming metadata off entirely. Googlebot runs JavaScript and reads the full DOM, so it gets the streamed version (requested with Googlebot's user agent: first byte after 24 ms, <title> at byte 10,611, the same as Chrome). The same list also contains Chrome-Lighthouse: a request with Lighthouse's user agent waited for the metadata (first byte after 505 ms, <title> at byte 517), so a Lighthouse run measures a slower TTFB on this page than real browsers get.
  5. Pages whose metadata is prerendered (the blog posts) never stream: the tags are in the static <head> for everyone.
AdvancedWhere metadata can't go, and 404s
AdvancedMetadata changes from 13.2 to 16

#Open Graph images

Added

When someone pastes your link into Slack, LinkedIn, WhatsApp or X, the preview card's picture comes from <meta property="og:image"> (and twitter:image). A generic logo gets ignored; a card with the article's title gets clicked. Designing one image per post by hand doesn't scale, so Next.js lets you generate them with JSX.

Two pieces work together. The opengraph-image file convention: put opengraph-image.png (a static file) or opengraph-image.tsx (code) in a route segment and Next.js serves it at <segment>/opengraph-image and adds the og:image tags, with width, height, type and alt, to every page in that segment. And ImageResponse from next/og: you return JSX styled with inline CSS, and it is turned into a PNG by Satori (JSX + flexbox → SVG) and Resvg (SVG → PNG), no browser involved.

The mental model: an opengraph-image.tsx is a Route Handler that returns a picture of a React component, and it follows the same rendering rules as a page: prerendered at build time unless it needs request data, one image per known params if you give it generateStaticParams.

app/blog/[slug]/opengraph-image.tsx
import { ImageResponse } from "next/og";
import { getPost, listSlugs } from "@/lib/posts";

export const alt = "Acme Blog post";
export const size = { width: 1200, height: 630 };
export const contentType = "image/png";

// Prerender one image per known post, like the page itself
export async function generateStaticParams() {
  return (await listSlugs()).map((slug) => ({ slug }));
}

export default async function Image({ params }: { params: Promise<{ slug: string }> }) {
  const { slug } = await params;
  const post = await getPost(slug);

  return new ImageResponse(
    (
      <div
        style={{
          width: "100%",
          height: "100%",
          display: "flex",
          flexDirection: "column",
          justifyContent: "space-between",
          padding: 80,
          background: "linear-gradient(135deg, #1d3b6e, #f29e5c)",
          color: "white",
        }}
      >
        <div style={{ fontSize: 36, opacity: 0.8 }}>Acme Blog</div>
        <div style={{ fontSize: 72, fontWeight: 700, lineHeight: 1.1 }}>{post?.title ?? "Not found"}</div>
        <div style={{ fontSize: 32 }}>{`by ${post?.author ?? "Acme"}`}</div>
      </div>
    ),
    size,
  );
}
Result: the post's <head> and the image (next start, curl)
curl -s localhost:4510/blog/font-loading   (Open Graph and Twitter tags)
<title>Fonts without layout shift | Acme Blog</title>
<meta property="og:title" content="Fonts without layout shift | Acme Blog"/>
<meta property="og:image" content="https://acme.dev/blog/font-loading/opengraph-image?75768a4d6a2a4528"/>
<meta property="og:image:type" content="image/png"/>
<meta property="og:image:width" content="1200"/>
<meta property="og:image:height" content="630"/>
<meta property="og:image:alt" content="Acme Blog post"/>
<meta name="twitter:card" content="summary_large_image"/>
<meta name="twitter:title" content="Fonts without layout shift | Acme Blog"/>
<meta name="twitter:image" content="https://acme.dev/blog/font-loading/opengraph-image?75768a4d6a2a4528"/>
<meta name="twitter:image:alt" content="Acme Blog post"/>
<meta name="twitter:image:type" content="image/png"/>
<meta name="twitter:image:width" content="1200"/>
<meta name="twitter:image:height" content="630"/>

curl -s -D - -o og.png "localhost:4510/blog/font-loading/opengraph-image?75768a4d6a2a4528"
HTTP/1.1 200 OK
x-nextjs-cache: HIT
cache-control: public, max-age=0, must-revalidate
content-type: image/png
size=78618 bytes, 0.029 s
$ file og.png
PNG image data, 1200 x 630, 8-bit/color RGBA, non-interlaced
What’s happening
  1. Because the file sits in app/blog/[slug]/, every post page got og:image tags pointing at /blog/<slug>/opengraph-image, made absolute with the layout's metadataBase. The ?75768a4d6a2a4528 suffix is a version hash: after changing one colour in the image code and rebuilding, it became ?52df20c8f730e778. A changed image gets a new URL, which matters because Slack, LinkedIn and X keep old preview images for a long time.
  2. The alt, size and contentType exports became og:image:alt, og:image:width/height and og:image:type. They are plain exports so that Next.js can write the tags into the page without generating the image.
  3. With an image present, Next.js switched twitter:card to summary_large_image and copied the image into the twitter:image tags. A twitter-image.tsx next to it would override just those.
  4. generateStaticParams made the image prerendered per post at build time (● in the route table below), so the request was an x-nextjs-cache: HIT served in 29 ms: a 1200 × 630 PNG of 78,618 bytes. Satori laid out the column with flexbox (justifyContent: "space-between" put the three lines at the top, middle and bottom) and painted the gradient.
  5. params is a Promise here, as in pages: that changed in Next.js 16 for image functions (in 15 it was a plain object).
AdvancedSatori is not a browser
AdvancedfontWeight: 700 with no bold font
AdvancedGotcha: unknown slugs fill the image cache
AdvancedStatic OG files, icons and other conventions
AdvancedThe rest of the app and its route table

A layout with metadataBase, the data module and the post page:

app/layout.tsx
import type { Metadata } from "next";

export const metadata: Metadata = {
  metadataBase: new URL("https://acme.dev"),
  title: { template: "%s | Acme Blog", default: "Acme Blog" },
};

export default function RootLayout({ children }: LayoutProps<"/">) {
  return (
    <html lang="en">
      <body>{children}</body>
    </html>
  );
}
lib/posts.ts
import "server-only";

const posts: Record<string, { title: string; author: string }> = {
  "image-optimisation": { title: "How next/image picks a width", author: "Rohit" },
  "font-loading": { title: "Fonts without layout shift", author: "Rohit" },
};

export async function getPost(slug: string) {
  "use cache";
  return posts[slug] ?? null;
}

export async function listSlugs() {
  return Object.keys(posts);
}
app/blog/[slug]/page.tsx
import type { Metadata } from "next";
import { notFound } from "next/navigation";
import { getPost, listSlugs } from "@/lib/posts";

export async function generateStaticParams() {
  return (await listSlugs()).map((slug) => ({ slug }));
}

export async function generateMetadata({ params }: PageProps<"/blog/[slug]">): Promise<Metadata> {
  const post = await getPost((await params).slug);
  return post ? { title: post.title } : {};
}

export default async function PostPage({ params }: PageProps<"/blog/[slug]">) {
  const post = await getPost((await params).slug);
  if (!post) notFound();
  return <h1>{post.title}</h1>;
}
next build
Route (app)
┌ ○ /_not-found
├   /blog/[slug]
│ ├ ◐ /blog/[slug]
│ ├ ○ /blog/image-optimisation
│ └ ○ /blog/font-loading
└   /blog/[slug]/opengraph-image
  ├ ● /blog/image-optimisation/opengraph-image
  └ ● /blog/font-loading/opengraph-image


○  (Static)             prerendered as static content
●  (SSG)                prerendered as static HTML (uses generateStaticParams)
◐  (Partial Prerender)  prerendered as static HTML with dynamic server-streamed content
What’s happening
  1. The image route appears in the route table like a page: /blog/[slug]/opengraph-image with one ● entry per slug from its own generateStaticParams. Without that export, the images would have been rendered on the first request instead.
  2. og:title here is the full Fonts without layout shift | Acme Blog, because this layout doesn't set openGraph: Open Graph inherits the resolved title, template included. In the previous topic's app the page set openGraph.title itself and got the bare title.
  3. The image and the page both call getPost; with 'use cache' that is one lookup per slug however many routes ask.

#Lazy loading and bundle size

Added

Every route in the App Router is already split: a page only downloads the Client Components it renders, and Server Components send no JavaScript at all. What's left to optimise is the code inside a route's client bundle that isn't needed at first: a modal nobody has opened, a chart below the fold, a 40 KB parser used by one button. Shipping it up front costs download time and, worse on phones, parse and compile time on the main thread before the page responds.

next/dynamic moves a Client Component out of the route's bundle into its own chunk, fetched the first time it renders. It is React.lazy() plus Suspense with a loading placeholder and one extra switch, ssr: false. The mental model is a book with a separate appendix: the main volume (the route's bundle) ships with the page; the appendix (the dynamic chunk) is ordered only when someone turns to it, and after that it's on the shelf (the browser cache).

The other levers are about what goes into the bundle at all: keep heavy work in Server Components, import only what you use from big packages (optimizePackageImports), and measure with the bundle analyser.

The example: a settings page with an "Import settings from YAML" panel that needs js-yaml, a full YAML parser. The panel's preview is loaded with next/dynamic:

components/import-panel.tsx
"use client";

import { useState } from "react";
import dynamic from "next/dynamic";

// Split into its own chunk; fetched the first time it renders
const YamlPreview = dynamic(() => import("./yaml-preview"), {
  loading: () => <p>Loading the YAML parser…</p>,
});

export function ImportPanel() {
  const [open, setOpen] = useState(false);
  return (
    <section>
      <button onClick={() => setOpen(true)}>Import settings from YAML</button>
      {open && <YamlPreview />}
    </section>
  );
}
Result: JavaScript downloaded by headless Chrome (cache disabled), /settings vs /settings-eager (static import)
                         initial page load                         after clicking the button
/settings (dynamic)      6 files, 136.0 KB transferred (451.8 KB)  +2 files, 14.6 KB (41.6 KB):
                                                                     2upi9bqhwf-cn.js  14,533 B gzip / 42,489 B  ← js-yaml
                                                                     3-0zdovrgrr_4.js     449 B gzip / 149 B
/settings-eager          7 files, 148.7 KB transferred (488.7 KB)  nothing new
  (static import)          includes 2upi9bqhwf-cn.js
(numbers in brackets: decoded size, what the browser has to parse)

grep -l YAMLException .next/static/chunks/*.js   →   2upi9bqhwf-cn.js
What’s happening
  1. dynamic(() => import("./yaml-preview")) tells the bundler to put yaml-preview.tsx and everything only it imports (js-yaml) into a separate chunk. The import function isn't called until React first renders <YamlPreview />.
  2. On the initial load of /settings Chrome downloaded six files, 136 KB compressed; most of it is React and the Next.js runtime that every page shares. The js-yaml chunk was not among them.
  3. Clicking the button set open to true, React rendered <YamlPreview />, next/dynamic showed Loading the YAML parser… and fetched two more files: 2upi9bqhwf-cn.js (14.5 KB compressed, 42.5 KB to parse) is the parser, as the YAMLException grep confirms. Then the preview rendered {"theme": "dark", "fontSize": 14}.
  4. /settings-eager imports the same component normally; its first load included 2upi9bqhwf-cn.js (12.7 KB more transferred, 36.9 KB more to parse) and the click needed nothing. Same chunk name in both builds: chunks are content-hashed, so a cached copy is reused across routes and deploys until the code changes.
  5. The trade: the eager page pays 14.5 KB on every visit; the lazy page pays a short delay on the first click (one round trip). Worth it for code that most visitors never use; not worth it for something above the fold that everyone sees.
AdvancedThe rest of the lazy-loading app

The rest of the app

yaml-preview.tsx is an ordinary Client Component; nothing in it knows it is lazy-loaded:

components/yaml-preview.tsx
"use client";

import { useState } from "react";
import { load } from "js-yaml"; // the heavy part: a full YAML parser

export default function YamlPreview() {
  const [text, setText] = useState("theme: dark\nfontSize: 14");
  let result: string;
  try {
    result = JSON.stringify(load(text), null, 2);
  } catch (e) {
    result = (e as Error).message;
  }
  return (
    <div>
      <textarea value={text} onChange={(e) => setText(e.target.value)} rows={4} />
      <pre>{result}</pre>
    </div>
  );
}
types/js-yaml.d.ts
// js-yaml ships no types; a real project would install @types/js-yaml instead
declare module "js-yaml" {
  export function load(text: string): unknown;
}
app/settings/page.tsx
import { ImportPanel } from "@/components/import-panel";

export default function SettingsPage() {
  return (
    <main>
      <h1>Settings</h1>
      <ImportPanel />
    </main>
  );
}
components/import-panel-eager.tsx
"use client";

import { useState } from "react";
import YamlPreview from "./yaml-preview"; // a normal import: in the page's bundle

export function ImportPanelEager() {
  const [open, setOpen] = useState(false);
  return (
    <section>
      <button onClick={() => setOpen(true)}>Import settings from YAML</button>
      {open && <YamlPreview />}
    </section>
  );
}
app/settings-eager/page.tsx
import { ImportPanelEager } from "@/components/import-panel-eager";

export default function SettingsEagerPage() {
  return (
    <main>
      <h1>Settings (eager)</h1>
      <ImportPanelEager />
    </main>
  );
}
next build
Route (app)
┌ ○ /_not-found
├ ○ /editor
├ ○ /settings
└ ○ /settings-eager


○  (Static)  prerendered as static content
What’s happening
  1. All routes are static (○): lazy loading is about client JavaScript and has no effect on how the HTML is rendered. The prerendered /settings HTML contains the button; the preview isn't in it because open starts false.
  2. The page files are Server Components; the dynamic() call sits in a Client Component (import-panel.tsx). That is where it splits client code. The docs note that when a Server Component dynamically imports a Client Component, automatic code splitting is currently not supported.
  3. A dynamically imported module needs a default export, or a .then((mod) => mod.Named) in the import function to pick a named one.
Advancedssr: false, for components that can't render on the server

A Client Component still renders on the server for the initial HTML. A component that touches window, localStorage or a browser-only library at render time breaks there. ssr: false skips the server render and renders the loading placeholder instead:

components/saved-draft.tsx
"use client";

// Reads localStorage while rendering: there is no localStorage on the server
export default function SavedDraft() {
  const draft = localStorage.getItem("draft") ?? "(no saved draft)";
  return <p>Saved draft: {draft}</p>;
}
components/draft-slot.tsx
"use client";

import dynamic from "next/dynamic";

// ssr: false is only allowed here, inside a Client Component
const SavedDraft = dynamic(() => import("./saved-draft"), {
  ssr: false,
  loading: () => <p>Loading draft…</p>,
});

export function DraftSlot() {
  return <SavedDraft />;
}
app/editor/page.tsx
import { DraftSlot } from "@/components/draft-slot";

export default function EditorPage() {
  return (
    <main>
      <h1>Editor</h1>
      <DraftSlot />
    </main>
  );
}
What happened
curl -s localhost:4510/editor   (the <main> of the HTML)
<main><h1>Editor</h1><template data-dgst=""></template><p>Loading draft…</p></main>

headless Chrome, after localStorage.setItem("draft", "Hello from last week") and a reload:
main.innerText = "Editor\n\nSaved draft: Hello from last week"

The same component imported normally (import SavedDraft from "@/components/saved-draft") in the page:
Error occurred prerendering page "/editor".
TypeError: Cannot read properties of undefined (reading 'getItem')
    at <unknown> (src/components/saved-draft.tsx:5:30)

dynamic(..., { ssr: false }) written in the Server Component page instead:
Error: `ssr: false` is not allowed with `next/dynamic` in Server Components. Please move it into a Client Component.
What’s happening
  1. With ssr: false, the server HTML has the loading placeholder where the component would be; the empty <template data-dgst=""> marks the spot React will fill on the client.
  2. In the browser, after hydration, next/dynamic fetched the chunk and rendered the real component with the saved value.
  3. Imported normally, the component ran during prerendering at build time and crashed on localStorage. On the Node.js 26 used here localStorage evaluated to undefined rather than throwing a ReferenceError, hence "reading 'getItem'"; older Node versions say "localStorage is not defined". Either way, the build fails.
  4. ssr: false in a Server Component is a compile error, because Server Components never render on the client, so "only on the client" means nothing there. The usual pattern is exactly this one: a tiny client wrapper (draft-slot.tsx) that holds the dynamic() call.
  5. ssr: false has a cost: that part of the page is missing from the HTML (no SEO, and a placeholder until JavaScript runs). Often reading the browser value in useEffect after mount is enough and keeps the rest of the component server-rendered.
AdvancedSeeing what's in the bundle

Next.js 16.1 added a bundle analyser built on Turbopack's module graph, as next experimental-analyze (the command's name in the published 16.1.0–16.3.0 CLIs); 16.4 calls it next analyze and keeps the old name as an alias. next analyze opens an interactive treemap, next analyze --output writes it to .next/diagnostics/analyze, and since 16.4 next analyze export prints it as JSON Lines for scripts:

npx next analyze --output --snapshot lazy ; npx next analyze export --snapshot lazy --route /settings
✓ Analyze completed in 1032ms. Results written to …/lazy-yaml/.next/diagnostics/analyze.

1,792 records for /settings: meta 1, route 1, group 16, output 153, module 1,010, part 611. Two of the groups:
{"type":"group","route":"/settings","id":1,"kind":"async",
 "trigger_module_ident":"[project]/…/src/components/yaml-preview.tsx [app-client] (ecmascript, next/dynamic entry)",
 "outputs":["[client-fs]/_next/static/chunks/3met7rq6m2lln.js","[client-fs]/_next/static/chunks/1ftuc-lhfg1e0.js"]}
{"type":"group","route":"/settings","id":12,"kind":"render_dependent",
 "trigger_module_ident":"[project]/…/src/components/import-panel.tsx [app-client] (ecmascript)",
 "outputs":["…/04pk29n8pf9g2.css","…/3ekqz7uysnhqr.js","…/2-5jtj5-qt7nv.js"]}
{"type":"module","ident":"[project]/…/node_modules/js-yaml/dist/js-yaml.mjs [app-client] (ecmascript)", …}
What’s happening
  1. next analyze does its own production compile ("Does not produce an application build"), which is why its chunk names differ from the next build above; compare snapshots with each other, not with a deployed build.
  2. The async group is the next/dynamic boundary: triggered by yaml-preview.tsx as a "next/dynamic entry", with its own output files. That is how you confirm a split happened.
  3. The render_dependent group for import-panel.tsx is what the page needs to render, and js-yaml is not in it. In the interactive UI the same information is a treemap you can filter by route and by client or server, with the import chain for each module.
  4. With webpack (next build --webpack), the older tool is the @next/bundle-analyzer plugin (ANALYZE=true next build), which opens webpack-bundle-analyzer reports.
  5. Next.js 16 removed the Size and First Load JS columns that 13–15 printed in the build output (the upgrade guide says they were inaccurate with Server Components), which is why the route tables in this chapter have none. Read sizes from the analyser or the browser's network panel instead.
AdvancedOther ways to shrink the client bundle
  • Keep work in Server Components. A syntax highlighter, a Markdown renderer or a date library used only to format text doesn't need to run in the browser: call it in a Server Component and only the HTML is sent. This often beats any amount of lazy loading. The 'use client' boundary decides what ships (see the 'use client' boundary).
  • optimizePackageImports (in experimental): for packages with hundreds of exports re-exported from one index file, import { Check } from "lucide-react" can make the bundler process the whole barrel. Listing the package makes Next.js rewrite the import to the one module you use. Many popular libraries are optimised by default (lucide-react, date-fns, lodash-es, @mui/material, @mui/icons-material, react-icons/*, rxjs, recharts, antd among them); none were installed in this sandbox, so this wasn't measured here.
  • serverExternalPackages: the server-side counterpart. Packages used in Server Components and Route Handlers are bundled by default; listing a package (one with native modules, say) makes Next.js require it from node_modules at runtime instead.
  • Dynamic import() for libraries in event handlers: const Fuse = (await import("fuse.js")).default inside an onChange loads a library only when the user types, without wrapping anything in a component.
  • Route-level splitting is automatic: unlike a React Router SPA, there is no need for React.lazy per route.

#Core Web Vitals in Next.js

Added

Core Web Vitals are Google's three field metrics for user experience: LCP (Largest Contentful Paint: how soon the main content appears), CLS (Cumulative Layout Shift: how much things jump around) and INP (Interaction to Next Paint: how quickly the page responds to clicks and keys). The metrics themselves, their thresholds and how browsers compute them are covered on the HTML & CSS and System Design sites; this topic is about what Next.js does to each, and how to measure them from inside a Next.js app.

The mental model: each Next.js feature in this chapter is a fix for one vital, and each has a way to undo it. Static rendering and streaming make the HTML (and so LCP) early; preload on the hero image and next/font's preloaded files make the LCP element's bytes early. width/height on images and the generated font fallback keep CLS near zero. Server Components, code splitting and next/script strategies keep the main thread free, which is what INP measures. A plain <img>, a font from a third-party CSS link, a blocking third-party script or a big 'use client' tree undoes them.

useReportWebVitals is how a Next.js app reports the real numbers from real users. It wraps Google's web-vitals library (bundled with Next.js) and calls your function once per metric:

app/web-vitals.tsx
"use client";

import { useReportWebVitals } from "next/web-vitals";

type Metric = Parameters<Parameters<typeof useReportWebVitals>[0]>[0];

// A stable function (defined outside the component), so each metric is reported once
function report(metric: Metric) {
  const body = JSON.stringify({ name: metric.name, value: Math.round(metric.value * 1000) / 1000, rating: metric.rating, page: location.pathname });
  console.log(body); // a real app sends it: navigator.sendBeacon("/api/vitals", body)
}

export function WebVitals() {
  useReportWebVitals(report);
  return null;
}
Result: what report() logged in headless Chrome (1280×900, network throttled to ~20 Mbit/s and 40 ms latency, cache off)
/plain-img     FCP 68 good   TTFB 29.9 good   CLS 0.201 needs-improvement   LCP 916 good
/next-image    FCP 56 good   TTFB 14.2 good   CLS 0 good                     LCP 492 good
/slow-button   FCP 64 good   TTFB 6.2 good    LCP 64 good   INP 304 needs-improvement   CLS 0 good

Three runs: /plain-img CLS 0.201 every time, LCP 916 / 928 / 900 ms
            /next-image CLS 0 every time, LCP 492 / 336 / 360 ms
            /slow-button INP 304 ms in runs 1 and 3; in run 2 no INP was reported
What’s happening
  1. WebVitals is a Client Component (the hook needs the browser) rendered once in the root layout below, so every page reports. It renders nothing, and keeping it in its own small file keeps the client boundary tiny.
  2. report is defined outside the component on purpose: the docs warn that a new function on each render makes the hook report metrics again. Each metric has name, value, rating (good, needs-improvement or poor), delta, id and navigation details such as navigationType.
  3. TTFB and FCP were reported as soon as they were known. CLS, LCP and INP were only reported when the page became hidden (the test opened a second tab, which is what a user switching tabs does): they can change for as long as the page is open, so web-vitals waits. That is why real apps send them with navigator.sendBeacon, which survives the page closing.
  4. The ratings came from the library's thresholds: CLS 0.201 is "needs-improvement" (above 0.1), INP 304 ms is "needs-improvement" (above 200 ms). The next box explains where each number came from.
  5. One run out of three reported no INP for the same click. Field data is like that: some page views don't produce every metric, which is why you look at the 75th percentile of many visits, not at one.
AdvancedGotcha: measuring in development
AdvancedAttribution, Pages Router and FID
AdvancedThe pages that produced those numbers

The two image pages show the same 1.8 MB photo above eight paragraphs of text:

app/layout.tsx
import { WebVitals } from "./web-vitals";

export default function RootLayout({ children }: LayoutProps<"/">) {
  return (
    <html lang="en">
      <body>
        <WebVitals />
        {children}
      </body>
    </html>
  );
}
components/article.tsx
// Enough text to fill the screen while the image is still downloading
export function Article() {
  return Array.from({ length: 8 }, (_, i) => (
    <p key={i} style={{ fontSize: 20, lineHeight: 1.6 }}>
      Paragraph {i + 1}. The article text the reader has already started reading when the image arrives.
    </p>
  ));
}
app/plain-img/page.tsx
import { Article } from "@/components/article";

export default function PlainImg() {
  return (
    <main>
      <h1>Plain img</h1>
      {/* No width or height: the browser can't reserve space until the file arrives */}
      {/* eslint-disable-next-line @next/next/no-img-element */}
      <img src="/hills.jpg" alt="Green hills at sunset" style={{ width: "100%", height: "auto" }} />
      <Article />
    </main>
  );
}
app/next-image/page.tsx
import Image from "next/image";
import { Article } from "@/components/article";

export default function NextImg() {
  return (
    <main>
      <h1>next/image</h1>
      <Image src="/hills.jpg" alt="Green hills at sunset" width={2400} height={1600} sizes="100vw" preload style={{ width: "100%", height: "auto" }} />
      <Article />
    </main>
  );
}
app/slow-button/page.tsx
"use client";

import { useState } from "react";

export default function SlowButton() {
  const [count, setCount] = useState(0);
  return (
    <main>
      <h1>INP</h1>
      <button
        onClick={() => {
          const end = performance.now() + 300;
          while (performance.now() < end) {} // 300 ms of main-thread work in the handler
          setCount(count + 1);
        }}
      >
        Clicked {count} times
      </button>
    </main>
  );
}
What’s happening
  1. CLS on /plain-img: the <img> has no width/height, so the browser gave it zero height, painted the eight paragraphs at the top, and when the JPEG's header arrived the image grew to its full height (two thirds of the page width) and pushed all the text down. That one jump scored 0.201, on every run. The <Image> page writes width="2400" height="1600", the browser reserved the 3:2 box from the start, nothing moved: CLS 0.
  2. LCP: on both pages the largest element is the photo. The plain page made the browser download the original 1.8 MB JPEG (LCP 900–928 ms on this throttled connection). The <Image> page preloaded the 1920-wide WebP (397 KB) from the <head>, so it finished in 336–492 ms. Same picture, half the LCP.
  3. INP on /slow-button: the click handler blocks the main thread for 300 ms before React can render the new count, so the next paint came 304 ms after the click. Next.js can't fix a slow handler; it reduces the other work competing for the main thread (less hydration with Server Components, scripts after hydration).
  4. The ESLint rule @next/next/no-img-element flags plain <img> in a Next.js project for exactly reason 1; the comment above the tag silences it here on purpose.
  5. Lab numbers like these (one machine, localhost, simulated network) are only for comparing two versions. Real thresholds are judged on field data at the 75th percentile, which is what useReportWebVitals collects.
AdvancedWhat Next.js does for each metric
MetricWhat helps in Next.jsCommon regression
LCPStatic or PPR shell served from cache; streaming so the shell isn't blocked by slow data; preload on the LCP image; next/font preloadsA dynamic page waiting for slow data before any HTML; the hero lazy-loaded or ssr: false; a client-rendered hero that needs JS first
CLSwidth/height or fill in a sized box; generated font fallbacks; loading.tsx and Suspense fallbacks the same size as the contentPlain <img>; fonts from a CSS <link>; skeletons that don't match; banners injected at the top after hydration
INPServer Components (less JS to hydrate); code splitting and next/dynamic; lazyOnload/afterInteractive scripts; useTransition for heavy updatesLarge 'use client' trees and context providers at the root; tag managers in the head; heavy work in event handlers
TTFBPrerendered routes and the static shell; CDN in front; after() for work that can waitUncached data on every request; middleware/proxy doing slow work; a serverless cold start

Written fresh for interviews (there was no Google Doc for this one). Examples target Next.js 16.4.3 and React 19.3 and were built with next build by scripts/check-next.mjs.

RohitDownloads · All chapters

Sync progress across devices

Type the same private phrase on your Mac and your phone, and your Learned ticks follow you between them — on every study site.

The phrase never leaves this device: only a fingerprint of it is sent, and the server stores a fingerprint of that. Anyone who knows the phrase could see or change your ticks, so pick something you don’t use elsewhere.

Sync is on in this browser.

Sync ID

This ID must be the same on every device. If another device shows a different one, its phrase is different (capital letters count): tap “Turn off here” on it and type the phrase again exactly.