Images, Fonts & Scripts

This content was generated with the assistance of AI and should be verified against the official documentation before being relied on in production.

The reference material consulted while preparing these pages is listed in the section bibliography.

This page is written against Next.js 16.3.8 (the latest release on 2026-10-03, when the facts and examples below were checked; the official documentation reports @doc-version 16.3.8), React 19.3.0, Node.js 20.9.0 or later, TypeScript 5.1 or later and @next/third-parties 16.3.8. It covers the App Router in the main text and shows the Pages Router differences in a short section of their own. Caching matters little for these APIs: none of the four tools reads request data, so every example prerenders and behaves the same under the previous model (the default) and under Cache Components (cacheComponents: true); a dedicated section shows what was checked under both. The image optimiser has its own on-disk cache that neither model controls. Every example was built and run with Next.js 16.3.8, Node.js 24.16 and npm 11.13 on macOS (next build, next start, curl, and headless Chrome for the behaviour that only a browser shows), in a scratch copy of the Bookshelf — the catalogue of books that the rest of this section grows — and each one is followed by the official page it derives from.

Images, fonts and third-party scripts are the three kinds of resource that most often decide a page’s Largest Contentful Paint (LCP), Cumulative Layout Shift (CLS) and Interaction to Next Paint (INP). Next.js ships one built-in component for each, and each one is a thin layer over a platform feature that you could use by hand:

Table 1. The four tools and the browser feature each one wraps
Tool Import What it does for you

Image

next/image

An img element with a generated srcset, lazy loading, reserved space (no layout shift), optional blur-up placeholder and on-demand resizing and re-encoding through the /_next/image endpoint.

Font

next/font/google, next/font/local

Downloads Google Fonts at build time and serves them from your own domain, generates the @font-face rules, a size-adjusted fallback font and a preload link, so the browser makes no request to Google and the text does not jump when the font arrives.

Script

next/script

A script element with a loading strategy, de-duplication across client-side navigations and onLoad, onReady and onError callbacks.

Third-party components

@next/third-parties/google

Ready-made components for Google Tag Manager, Google Analytics, Google Maps Embed and YouTube embeds, built on next/script.

If the React side of this (components, props, events) is new, read React Getting Started first. For the general performance picture that these tools serve, see Performance: Loading Resources and React Performance; Core Web Vitals measurement, the React Compiler and bundle analysis have a page of their own in this section (Performance and Web Vitals).

next/image

next/image extends the HTML img element. A bare img has three well-known problems: it downloads the same large file on every device, it shifts the layout when it arrives because the browser does not know its size, and it loads even when it is far below the fold. The component fixes all three:

  • Size optimisation: it serves a correctly sized, modern-format image for each device (WebP by default).

  • Visual stability: it writes width and height (or absolute positioning for fill), so the browser reserves the space before the file arrives.

  • Faster page loads: it lazy-loads with the browser’s native loading="lazy" and can show a blurred placeholder.

  • Asset flexibility: it resizes on demand, including images that live on a remote server.

How an image request flows

The component itself only renders HTML. The optimisation happens when the browser follows the generated src and srcset URLs, which all point at the /next/image endpoint of your Next.js server (the _default loader):

sequenceDiagram autonumber participant B as Browser participant N as Next.js server participant C as Disk cache participant O as Origin of the image B->>N: GET /_next/image?url=/covers/solaris.jpg&w=256&q=75 with an Accept header N->>N: check url against localPatterns or remotePatterns, w against the size lists, q against qualities alt not allowed N-->>B: 400 Bad Request else allowed N->>C: look up this url, width, quality and output format alt cache hit C-->>N: optimised file else cache miss N->>O: fetch the source image (public folder, build output or remote host) O-->>N: original bytes N->>N: resize and re-encode (AVIF, WebP or the original format) N->>C: store, honouring minimumCacheTTL end N-->>B: 200 with Cache-Control, Vary Accept and Content-Disposition attachment end
Figure 1. A request to the built-in image optimiser

Two consequences follow. First, every allowed value is an allow-list entry: the width must be one of the configured sizes, the quality one of the configured qualities and the URL one of the configured patterns, because otherwise anyone could ask your server to resize arbitrary images at arbitrary settings. Second, optimisation needs a running Node.js server (or a platform that implements the endpoint); a static export has none, so it needs a custom loader or unoptimized — see the static export section for that case.

Source: Image component

Three kinds of source: static import, public folder, remote URL

src can be a static import, a path under public/, or an absolute URL. They differ in who knows the image size.

app/gallery/images/page.tsx (shortened to the sources)
import Image from "next/image";
import dune from "../covers/dune.jpg"; (1)

export default function Page() {
  return (
    <main className="p-6 space-y-8">
      {/* 1. Static import: width, height and blurDataURL are inferred */}
      <Image
        src={dune}
        alt="Cover of Dune"
        placeholder="blur"
        sizes="200px"
        style={{ width: 200, height: "auto" }}
      />

      {/* 2. File in public/: you supply the intrinsic size */}
      <Image src="/covers/solaris.jpg" alt="Cover of Solaris" width={200} height={300} />

      {/* 3. Remote image: width and height are mandatory */}
      <Image
        src="https://upload.wikimedia.org/wikipedia/commons/4/47/PNG_transparency_demonstration_1.png"
        alt="PNG transparency demonstration"
        width={280}
        height={210}
      />
    </main>
  );
}
1 The file lives next to the page (app/gallery/covers/dune.jpg). Importing it lets Next.js read its dimensions at build time and hash its name for long-lived caching.

next build prerenders the page (○ /gallery/images), and the HTML for the three images contains, among other attributes (shortened here):

<!-- 1. static import: size inferred from the file, blur placeholder inlined as a data URL -->
<img alt="Cover of Dune" loading="lazy" width="1200" height="1800" decoding="async"
     style="color:transparent;width:200px;height:auto;background-size:cover;background-image:url(&quot;data:image/svg+xml;...&quot;)"
     sizes="200px"
     srcSet="/_next/image?url=%2F_next%2Fstatic%2Fmedia%2Fdune.2i-r4fzomd67d.jpg&w=32&q=75 32w, ... 3840w"
     src="/_next/image?url=%2F_next%2Fstatic%2Fmedia%2Fdune.2i-r4fzomd67d.jpg&w=3840&q=75"/>

<!-- 2. public folder, fixed size: a 1x and a 2x candidate -->
<img alt="Cover of Solaris" loading="lazy" width="200" height="300" decoding="async"
     srcSet="/_next/image?url=%2Fcovers%2Fsolaris.jpg&w=256&q=75 1x, /_next/image?url=%2Fcovers%2Fsolaris.jpg&w=640&q=75 2x"
     src="/_next/image?url=%2Fcovers%2Fsolaris.jpg&w=640&q=75"/>

<!-- 3. remote: the whole URL is the url parameter -->
<img alt="PNG transparency demonstration" loading="lazy" width="280" height="210" decoding="async"
     srcSet="/_next/image?url=https%3A%2F%2Fupload.wikimedia.org%2F...%2FPNG_transparency_demonstration_1.png&w=384&q=75 1x, ... 2x"
     src="..."/>

Reading it:

  • A static import gives width, height and, for JPEG, PNG, WebP and AVIF files that are not animated, a blurDataURL, so placeholder="blur" works with no extra data. The file name is content-hashed (dune.2i-r4fzomd67d.jpg), so it can be cached forever.

  • A public-folder path needs width and height from you, because Next.js does not open the file at build time. Leaving them out is an error in development (Image with src "/covers/solaris.jpg" is missing required "width" property) and the page answers 500 until you fix it.

  • A remote URL also needs width and height (and blurDataURL if you want a placeholder), because the build cannot fetch the file. The host must be allowed in configuration, covered below.

  • width and height are the intrinsic size used to compute the aspect ratio. They do not set the rendered size; CSS does. If you set a CSS width, set height: auto as well, as the style in the first image does, to keep the aspect ratio.

  • The alt text is required. Use alt="" for purely decorative images.

A fourth source avoids static imports when the file name is only known at render time: a dynamic import() in a Server Component still returns width, height and blurDataURL, provided the path starts with a static prefix (the path is a template string such as ../content/blog/images/ followed by the file name). Every file under that prefix is bundled, so keep the prefix as narrow as possible.

Responsive images: fill and sizes

When the aspect ratio is not known, or the image must cover a box that CSS sizes, use fill. The image then becomes absolutely positioned and stretches to its parent, so the parent must be positioned (relative, fixed or absolute), and object-fit decides cropping.

A responsive grid of covers
<ul className="grid grid-cols-2 md:grid-cols-4 gap-4">
  {covers.map((cover) => (
    <li key={cover.id} className="relative aspect-[2/3]">
      <Image
        src={cover.src}
        alt={`Cover of ${cover.title}`}
        fill
        sizes="(min-width: 768px) 25vw, 50vw"
        className="object-cover"
      />
    </li>
  ))}
</ul>

sizes is the most commonly forgotten prop, and it matters more than any other for download size. It tells the browser how wide the image will be at each breakpoint, before layout, so that the browser can pick a candidate from the srcset. It changes what Next.js generates too:

  • Without sizes, Next.js emits a short srcset of density descriptors (1x, 2x), which suits a fixed-size image, and the browser assumes the image is as wide as the viewport (100vw).

  • With sizes, it emits a full srcset of width descriptors (256w, 384w, 640w, …​), filtered to the widths that can matter for the breakpoints you declared.

This was checked in a browser. The grid above, rendered with sizes, produced a srcset that starts at 256w and ends at 3840w. The same fill image without sizes, in a 400 pixel wide box at a 1000 pixel wide viewport, made the browser request w=1080 (about the viewport width) instead of w=256/384, and development printed:

Image with src "/covers/solaris.jpg" has "fill" but is missing "sizes" prop.
Please add it to improve page performance.

Use sizes for any image that uses fill, or that CSS makes responsive. A static import with explicit sizes="100vw" and style={{ width: "100%", height: "auto" }} is the pattern for a full-width image with a known ratio.

The props at a glance

Table 2. Props of next/image
Prop Meaning Note

src, alt

Source (static import, path or URL) and the required alternative text.

An empty alt marks a decorative image.

width, height

Intrinsic size in pixels, for the aspect ratio.

Required unless statically imported or fill.

fill

Stretch to the positioned parent.

Pair with sizes.

sizes

Rendered width per breakpoint.

Switches srcset to width descriptors.

quality

1 to 100, default 75.

Coerced to the closest entry of images.qualities.

preload

Insert a link rel="preload" in the head.

Replaces priority (deprecated in 16).

loading

lazy (default) or eager.

eager loads regardless of position.

placeholder, blurDataURL

empty, blur or a data:image/…​ URL.

Remote images need a manual blurDataURL.

loader

A function (src, width, quality) returning a URL.

Needs a Client Component.

unoptimized

Serve the file as-is.

Automatic for .svg with the default loader.

overrideSrc

Replace the generated src attribute but keep the srcset.

Handy when migrating from img to keep SEO-relevant URLs.

decoding

async (default), sync, auto.

A hint to the browser.

onLoad, onError

Callbacks.

Functions need a Client Component. onLoadingComplete is deprecated since 14.

Source: Image: props

Allow-lists: remotePatterns, localPatterns and qualities

Because the endpoint does work on behalf of whoever calls it, Next.js refuses anything you did not allow. This is the part of next/image that most often produces a 400 in production, so it is worth reading as a security feature, not as an inconvenience. The final scratch configuration used by this page:

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

const nextConfig: NextConfig = {
  images: {
    // External images: be as specific as possible (protocol, host, path)
    remotePatterns: [
      new URL("https://upload.wikimedia.org/wikipedia/commons/**"), (1)
    ],
    // Local images: which paths under public/ or the build output may be optimised
    localPatterns: [
      { pathname: "/covers/**", search: "" },      (2)
      { pathname: "/covers/**", search: "?v=2" },
    ],
    qualities: [50, 75, 90],                        (3)
    formats: ["image/avif", "image/webp"],          (4)
  },
};

export default nextConfig;
1 A URL object works since 15.3. The object form { protocol, hostname, port, pathname, search } is equivalent and is the form to use for wildcards in the host (.example.com allows any subdomain; * matches one path segment or one subdomain, matches any number at the end of a path or the beginning of a host).
2 localPatterns limits which local paths may be optimised; search pins the query string. An empty string forbids any query string; omitting search allows every query string, which is not recommended.
3 Only these qualities may be requested. The default is [75].
4 The order matters: the first format that matches the browser’s Accept header wins.

With the server running (next start), curl shows the allow-lists at work. Each row is a real request against the scratch app:

Table 3. Requests to /_next/image with the configuration above
Request (after /_next/image?) Result Body or header

url=/covers/solaris.jpg&w=256&q=75 with Accept: image/avif,image/webp

200

Content-Type: image/avif, Vary: Accept, Cache-Control: public, max-age=14400, must-revalidate, Content-Disposition: attachment

the same with Accept: image/webp, then with Accept: image/jpeg

200, 200

image/webp, then image/jpeg (the source format)

…​&q=80

400

"q" parameter (quality) of 80 is not allowed

…​&q=90

200

allowed: it is in qualities

…​&w=300

400

"w" parameter (width) of 300 is not allowed

url=/solaris.jpg (not under /covers)

400

"url" parameter is not allowed

url=/covers/solaris.jpg?v=3

400

"url" parameter is not allowed (only ?v=2 and no query string match)

url=/covers/solaris.jpg?v=2

200

allowed by the second localPatterns entry

url=https://upload.wikimedia.org/…​/PNG_transparency_demonstration_1.png

200

matches remotePatterns

url=https://example.com/a.png

400

"url" parameter is not allowed

In development the same mistakes show up earlier and louder, as errors that crash the render of that page (HTTP 500) and point to a documentation message:

Table 4. Development errors for the three common mistakes
Mistake Message (shortened)

A remote host that is not configured

Invalid src prop (https://example.com/cover.png) on next/image, hostname "example.com" is not configured under images in your next.config.js, see next-image-unconfigured-host

A local src with a query string that no localPatterns entry allows

Invalid src prop (/covers/solaris.jpg?v=3) on next/image does not match images.localPatterns configured in your next.config.js, see next-image-unconfigured-localpatterns

A quality prop that is not in qualities

A warning: Image with src "/covers/solaris.jpg" is using quality "80" which is not configured in images.qualities [50, 75, 90], and the browser requests q=75 instead (coerced to the closest entry), see next-image-unconfigured-qualities

Two details are easy to miss. These checks run in development; the production build of a page with a bad src still succeeds (it was checked: next build completes), and the failure appears later, as a 400 from /_next/image, which the browser shows as a broken image. And new URL(…​) or a pattern with no pathname is not a harmless shortcut: omitted protocol, port, pathname or search fields default to the wildcard **, which lets a caller optimise URLs you never intended.

Image configuration reference

Everything in images is optional. This table lists the keys, their defaults in 16.3, and the effect that matters operationally:

Table 5. The images configuration keys
Key Default Effect

remotePatterns

none (no remote hosts)

Allow-list for absolute URLs. Redirects of an allowed host are followed without re-checking the pattern, up to maximumRedirects.

localPatterns

all local paths without a query string

Allow-list for local paths; required as soon as a local src has a query string.

qualities

[75]

Allowed quality values. A quality prop outside the list is coerced to the closest entry; a direct request with a disallowed q is a 400.

deviceSizes

[640, 750, 828, 1080, 1200, 1920, 2048, 3840]

Widths of the generated srcset for images that use sizes.

imageSizes

[32, 48, 64, 96, 128, 256, 384]

Extra widths for images smaller than the screen; should all be smaller than the smallest device size.

formats

["image/webp"]

Output formats in order of preference. AVIF encodes about 50 percent slower and compresses about 20 percent smaller than WebP; each format is cached separately.

minimumCacheTTL

14400 (4 hours)

Lower bound for the lifetime of an optimised image; the upstream Cache-Control wins if larger. There is no invalidation, so keep it low or change the src.

maximumRedirects

3

Redirects followed when fetching a remote image. 0 disables them.

maximumResponseBody

50_000_000 (50 MB)

Largest source image the optimiser will fetch. Lower it on memory-constrained servers.

maximumDiskCacheSize

50 percent of free disk at start-up

LRU limit for the on-disk cache (<distDir>/cache/images); 0 disables it.

dangerouslyAllowLocalIP

false

Lets the optimiser fetch from private IP addresses. A server-side request forgery (SSRF) risk; enable only on a private network you control.

dangerouslyAllowSVG

false

Allow SVG through the optimiser; pair with contentDispositionType: "attachment" and a contentSecurityPolicy of default-src 'self'; script-src 'none'; sandbox;.

contentDispositionType

attachment

Content-Disposition of the optimised response (default changed in 15.0.0).

loader, loaderFile, path

built-in loader, /_next/image

Replace the optimiser with a function or move the endpoint.

unoptimized

false

Serve every image as-is.

disableStaticImages

false

Turn off static image imports if they clash with another plugin.

domains

none

Deprecated since 14; use remotePatterns.

The defaults in the middle of that table are not the ones Next.js 15 had; see what changed in version 16.

Formats and caching in practice

For the scratch cover, the same source and width produced three different files depending on the Accept header: AVIF 4,867 bytes, WebP 12,248 bytes and JPEG 13,397 bytes. The cover is a synthetic picture, so the ratio is not representative of photographs; what matters is that the response varies on Accept (Vary: Accept) and that each variant is cached separately. With the default formats (image/webp only), a browser that sends Accept: image/avif,image/webp still got WebP, as the table promises.

If a CDN or reverse proxy sits in front of Next.js, it must forward the Accept header and honour Vary: Accept, or every visitor gets the variant that was cached first. The Cache-Control header of an optimised image is public, max-age=<ttl>, must-revalidate, where the TTL is minimumCacheTTL or the upstream header, whichever is larger. Statically imported images are the exception: their hashed file name lets Next.js cache them as immutable.

Loaders: the built-in one, a custom one, and unoptimized

A loader is a function from { src, width, quality } to a URL. The default loader returns a /_next/image URL. You replace it when images are already served by an image CDN (Cloudinary, imgix, Cloudflare Images, Supabase and others; the documentation lists ready-made loaders for sixteen services) or when you cannot run the optimiser at all, as in a static export.

Per image, pass loader. Because a function cannot cross from a Server Component to the browser, the component that passes it must be a Client Component:

app/gallery/loader/loader.ts and cover.tsx
// loader.ts
import type { ImageLoaderProps } from "next/image";

// A pretend CDN: any function that returns a URL works.
export default function cdnLoader({ src, width, quality }: ImageLoaderProps) {
  const url = new URL(`https://images.example.com${src}`);
  url.searchParams.set("w", width.toString());
  url.searchParams.set("q", (quality || 75).toString());
  return url.href;
}

// cover.tsx
"use client";

import Image from "next/image";
import cdnLoader from "./loader";

export function CdnCover({ src, title }: { src: string; title: string }) {
  return (
    <Image loader={cdnLoader} src={src} alt={`Cover of ${title}`} width={200} height={300} sizes="200px" />
  );
}

The rendered img has src="https://images.example.com/covers/solaris.jpg?w=3840&q=75" and a srcset of the same shape for every width in deviceSizes and imageSizes: the browser talks to the CDN directly and /_next/image is not involved, so remotePatterns and qualities no longer apply to that image.

Source: Image: loader

For every image, set loader: "custom" and loaderFile, a path relative to the project root whose default export is the loader:

next.config.ts and lib/image-loader.ts
// next.config.ts
import type { NextConfig } from "next";

const nextConfig: NextConfig = {
  images: { loader: "custom", loaderFile: "./lib/image-loader.ts" },
};

export default nextConfig;

// lib/image-loader.ts
export default function cdnLoader({ src, width, quality }: { src: string; width: number; quality?: number }) {
  const url = new URL(`https://images.example.com${src}`);
  url.searchParams.set("w", width.toString());
  url.searchParams.set("q", (quality || 75).toString());
  return url.href;
}

Built and run, every Image in the app (including those in Server Components) rendered images.example.com URLs. Two findings from that experiment are not in the documentation’s examples:

  • The official loaderFile example starts with "use client". With that directive in place, calling getImageProps from a Server Component (see below) failed the build with Attempted to call the default export of [project]/lib/image-loader.ts from the server, but it’s on the client. Removing the directive made Image and getImageProps both work from Server Components. Keep the directive only if the file really needs client-only code.

  • With a custom loader configured, an .svg source also went through the loader (images.example.com/next.svg?w=…​) instead of being served as-is. Pass unoptimized on SVG images, or have the loader return src unchanged for them.

To turn optimisation off, pass unoptimized on one image or set images.unoptimized: true. It is the right choice for files that gain nothing from re-encoding: images under about 1 KB, vector images (SVG) and animated GIFs. With the default loader, a src ending in .svg is served unoptimised automatically (the HTML contains src="/next.svg"), and a request to /_next/image?url=/next.svg is refused with "url" parameter is valid but image type is not allowed. Enabling dangerouslyAllowSVG lifts that, but SVG can carry scripts, so it should come with contentDispositionType: "attachment" and a restrictive contentSecurityPolicy.

The LCP image: preload

The Largest Contentful Paint element of a page is very often an image: a hero banner or the first product photo. The browser normally discovers an img only when it parses the body, which delays it. The preload prop (added in 16) inserts a link rel="preload" as="image" into the document head, so the download starts with the HTML.

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

export default function Page() {
  return (
    <main>
      <Image
        src="/covers/hero.jpg"
        alt="Spring reading list banner"
        width={1200}
        height={450}
        preload
        sizes="100vw"
        style={{ width: "100%", height: "auto" }}
      />
      <h1>Spring reading list</h1>
    </main>
  );
}

preload replaces priority (deprecated in 16). A preloaded image is also fetched eagerly: the rendered img has no loading="lazy" attribute. The prerendered HTML contains, in the head, a link rel="preload" as="image" imageSrcSet="/_next/image?url=%2Fcovers%2Fhero.jpg&w=640&q=75 640w, …​" whose imageSrcSet matches the img’s `srcset, so the browser downloads the same candidate it will later use.

When not to use preload, according to the documentation: when several images could be the LCP element depending on the viewport (a desktop and a mobile banner), when loading is set, and when fetchPriority is set. In most cases loading="eager" or fetchPriority="high" is the better tool. Development also helps you find the right image: the browser console printed Image with src "/covers/solaris.jpg" was detected as the Largest Contentful Paint (LCP). Please add the loading="eager" property if this image is above the fold. for a lazy-loaded image that turned out to be the LCP element.

getImageProps: art direction, themes and CSS backgrounds

getImageProps (stable since 14.1.0) returns the props the component would pass to its img, so you can use them elsewhere. The typical case is art direction: a wide banner for desktop and a tall one for phones, chosen by the browser through picture.

app/gallery/art/page.tsx
import { getImageProps } from "next/image";

export default function Page() {
  const common = { alt: "Spring reading list banner", sizes: "100vw" };
  const {
    props: { srcSet: desktop },
  } = getImageProps({ ...common, src: "/covers/hero.jpg", width: 1440, height: 540 });
  const {
    props: { srcSet: mobile, ...rest },
  } = getImageProps({ ...common, src: "/covers/hero-mobile.jpg", width: 750, height: 1000 });

  return (
    <picture>
      <source media="(min-width: 1000px)" srcSet={desktop} />
      <source media="(min-width: 500px)" srcSet={mobile} />
      <img {...rest} style={{ width: "100%", height: "auto" }} />
    </picture>
  );
}

The page prerendered and the HTML contains two source elements whose srcset values point at /_next/image for hero.jpg and hero-mobile.jpg. getImageProps does not call useState, which makes it lighter than the component, but for the same reason it cannot be combined with placeholder. The documentation also uses it to turn a srcset into a CSS image-set() for background images, and shows a theme-aware component that renders two Image elements and hides one with prefers-color-scheme; because both exist in the DOM, use fetchPriority="high" rather than preload or loading="eager" there.

What changed for images in Next.js 16

Version 16 tightened the defaults of next/image to reduce server cost and close request-forgery and enumeration holes. Each row is a breaking change in the upgrade guide:

Table 6. Image defaults before and after Next.js 16
Setting Next.js 15 Next.js 16 If you need the old behaviour

images.localPatterns and query strings

Local src with a query string allowed

A local src with a query string needs a localPatterns entry whose search matches (next-image-unconfigured-localpatterns otherwise)

Add localPatterns: [{ pathname: "/assets/**", search: "?v=1" }]

minimumCacheTTL

60 seconds

14400 (4 hours)

Set minimumCacheTTL: 60

imageSizes

Began with 16

[32, 48, 64, 96, 128, 256, 384] (16 removed; a 2x display fetches 32 anyway)

Set imageSizes: [16, 32, 48, 64, 96, 128, 256, 384]

qualities

Any quality allowed

[75]; other values are coerced or rejected

List the qualities you use

Local IP addresses

Allowed

Blocked (dangerouslyAllowLocalIP: false)

Set dangerouslyAllowLocalIP: true only on a private network

maximumRedirects

Unlimited

3

Set maximumRedirects to 0 or a larger number

priority prop

The way to preload the LCP image

Deprecated; use preload

Rename priority to preload, or use loading="eager" or fetchPriority="high"

The local IP restriction was verified by pointing a remotePatterns entry at the optimiser’s own origin (http://localhost:3702/covers/**): with dangerouslyAllowLocalIP: false (the default) the request returned 400 "url" parameter is not allowed; after setting it to true the same request returned 200 image/webp. The message does not mention the IP restriction, which makes this one hard to diagnose; remember it when a self-hosted app in a private network suddenly gets 400s after the upgrade to 16. Also verified with a configuration that sets no images key at all: q=90 returned 400, w=16 returned 400, w=32 returned 200, a ?v=2 query string on a local src returned 400, and an Accept: image/avif,image/webp request returned WebP.

Other image changes in the version history, for orientation:

Table 7. Selected version history of next/image
Version Change

16.1.7

maximumDiskCacheSize added.

16.1.2

maximumResponseBody added.

16.0.0

qualities default [75], preload added, priority deprecated, dangerouslyAllowLocalIP and maximumRedirects added.

15.3.0

remotePatterns accepts URL objects.

15.0.0

contentDispositionType default changed to attachment.

14.2.23, 14.2.15

qualities added; decoding prop and localPatterns added.

14.1.0

getImageProps() stable.

14.0.0

onLoadingComplete prop and domains config deprecated.

13.0.0

next/image renamed to next/legacy/image and the new component took the name next/image: no wrapper span, no layout, objectFit, objectPosition, lazyBoundary or lazyRoot props, alt required.

next/font

Web fonts hurt performance in two ways: the browser cannot start downloading a font until it has parsed the CSS that declares it, and while it waits, text is either invisible or rendered in a fallback font whose different metrics make the layout jump when the real font arrives. next/font addresses both. It downloads Google Fonts at build time, serves the files from your own domain with the rest of the static assets (the browser sends no request to Google), emits the @font-face rule, preloads the files that the route needs, and generates a fallback font adjusted to the metrics of the real font so the swap barely moves the text.

A Google font and a local font, as CSS variables

The usage pattern is the same for both sources: call a font function at module scope, then apply the result. There are three ways to apply it (className, style, and a CSS variable); the CSS variable is the most flexible and is what Tailwind CSS v4 expects.

app/layout.tsx
import type { ReactNode } from "react";
import { Inter } from "next/font/google";
import localFont from "next/font/local";
import "./globals.css";

// A variable Google font: no `weight` is needed.
const inter = Inter({
  subsets: ["latin"],
  variable: "--font-inter", (1)
  display: "swap",
});

// A local variable font, resolved relative to this file.
const bookshelf = localFont({
  src: "./fonts/bookshelf-sans.woff2",
  weight: "100 900", (2)
  variable: "--font-bookshelf",
  display: "swap",
});

export default function RootLayout({ children }: { children: ReactNode }) {
  return (
    <html lang="en" className={`${inter.variable} ${bookshelf.variable}`}>
      <body>{children}</body>
    </html>
  );
}
1 variable names the CSS custom property that will hold the font family, including the generated fallback.
2 A range such as "100 900" declares a variable font’s weight axis. For a static (non-variable) local font you list each file with its own weight and style in an array under src.
app/globals.css
@import "tailwindcss";

@theme inline {
  --font-sans: var(--font-inter);
  --font-title: var(--font-bookshelf);
}

body {
  font-family: var(--font-sans), system-ui, sans-serif;
}
app/gallery/fonts/page.tsx
export default function Page() {
  return (
    <main className="p-6">
      <h1 className="font-title text-3xl font-semibold">Bookshelf</h1>
      <p className="font-sans">Body text set in Inter via the --font-inter variable.</p>
    </main>
  );
}

The scratch file bookshelf-sans.woff2 is the Latin variable build of the open-licensed Inter family (taken from the @fontsource-variable/inter package), standing in for any variable WOFF2 you are licensed to ship. The build succeeded, and the page was loaded in Chrome:

  • The head contained two link rel="preload" as="font" type="font/woff2" crossorigin tags, one for the Latin subset of Inter and one for the local font.

  • The generated stylesheet had @font-face rules for Inter with font-weight:100 900 and font-display:swap, one per Unicode range (Latin, Cyrillic, Greek, Vietnamese, and so on); only the Latin file is preloaded because subsets: ["latin"].

  • For the local font the stylesheet also contained a generated bookshelf Fallback rule: src:local(Arial) with ascent-override:89.79%, descent-override:22.36%, line-gap-override:0.0% and size-adjust:107.89%. That is the layout-shift protection, and adjustFontFallback controls it (a boolean for Google fonts, "Arial", "Times New Roman" or false for local fonts).

  • The computed font-family of the h1 was bookshelf, "bookshelf Fallback" and of the paragraph Inter, "Inter Fallback"; document.fonts listed both faces as loaded.

  • No request went to fonts.googleapis.com or fonts.gstatic.com, neither in the HTML nor from the browser.

Font options

Table 8. Options of next/font/google and next/font/local
Option google local Meaning

src

no

yes (required)

A path relative to the file that calls localFont, or an array of { path, weight, style }.

weight

yes

yes

A string ("400", or a range "100 900" for a variable font) or, for Google fonts only, an array. Required when the font is not variable.

style

yes

yes

normal or italic; an array of them for Google fonts only.

subsets

yes

no

Which Unicode subsets to preload. Omitting it while preload is true gives a warning.

axes

yes

no

Extra axes of a variable font beyond weight, such as slnt.

display

yes

yes

The font-display value. Default swap.

preload

yes

yes

Whether to emit the preload link. Default true.

fallback

yes

yes

Fallback font families to append, for example ["system-ui", "arial"].

adjustFontFallback

yes

yes

Generate a metric-adjusted fallback. Boolean for Google fonts (default true); "Arial" (default), "Times New Roman" or false for local fonts.

variable

yes

yes

The CSS custom property to declare.

declarations

no

yes

Extra @font-face descriptors, for example [{ prop: "ascent-override", value: "90%" }].

Source: Font: reference

Practical rules that follow from the options and from experiments:

  • Prefer variable fonts. One file covers all weights, so there is nothing to list and less to download. For a font that is not variable the weight is mandatory: with Pacifico and no weight, next build failed with Module not found: Can’t resolve 'next/font/google/target.css' and the useful line Missing weight for Pacifico. Available weights: 400.

  • Use an underscore for multi-word names: Roboto Mono is imported as Roboto_Mono.

  • Call each font function once. Every call creates a self-hosted instance. Export fonts from a shared module (the documentation calls it a font definitions file, for example app/fonts.ts) and import the object wherever you need it.

  • Scope decides preloading. A font used in a page is preloaded for that route only, in a layout for every route the layout wraps, in the root layout for all routes. Put a font in the root layout only when the whole site uses it.

  • Use few fonts. Each family and style is another file the client downloads.

  • Subset deliberately. subsets: ["latin"] is not a filter on what the stylesheet declares (all Unicode ranges were still declared above); it selects what is preloaded.

  • A font object offers className (a class that sets font-family, font-weight and font-style), style (an object including fontFamily) and, when variable was set, a variable class name.

Tailwind CSS: v4 and v3

With Tailwind CSS v4 (the version the scratch app uses) the variable goes into the CSS @theme block, as shown in globals.css above; see Tailwind CSS Getting Started and Upgrading Tailwind from v3 to v4. The older JavaScript configuration, still in the Next.js documentation for v3 projects, maps the same variable in tailwind.config.js (theme.extend.fontFamily.sans: ["var(--font-inter)"]); the layout code is identical.

next/script

Third-party scripts (analytics, consent managers, chat widgets) are the most common reason for a slow main thread and a poor INP. The Script component is an script element with a loading strategy and de-duplication. Placed in a layout, it loads once for every route in that layout and is not reloaded by client-side navigation; the documentation recommends adding third-party scripts only to the pages or layouts that need them.

The four strategies

Table 9. Loading strategies of next/script
Strategy When it loads Where it may live (App Router) Typical use

beforeInteractive

Preloaded and fetched before any Next.js code and before hydration; executed in the order written; does not block hydration.

Only in the root layout.

Bot detection, cookie consent.

afterInteractive (default)

Injected on the client after some or all hydration.

Any page or layout.

Tag managers, analytics.

lazyOnload

Injected during browser idle time, after all resources are fetched.

Any page or layout.

Chat widgets, social buttons.

worker (experimental)

Moved to a web worker with Partytown.

Pages Router only, behind experimental.nextScriptWorkers.

Advanced; not guaranteed for all scripts.

The next example loads three local widget scripts with different strategies, plus an inline script. The widgets are tiny files in public/widgets/ that push a label into window.__order, so the real execution order can be read in the browser. The root layout holds the beforeInteractive script:

app/layout.tsx (the script part)
import Script from "next/script";

export default function RootLayout({ children }: { children: ReactNode }) {
  return (
    <html lang="en">
      <body>
        {children}
        <Script src="/widgets/consent.js" strategy="beforeInteractive" />
      </body>
    </html>
  );
}
app/gallery/scripts/layout.tsx
import type { ReactNode } from "react";
import Script from "next/script";

export default function ScriptsLayout({ children }: { children: ReactNode }) {
  return (
    <>
      <section>{children}</section>
      <Script src="/widgets/analytics.js" /> {/* afterInteractive by default */}
      <Script src="/widgets/chat.js" strategy="lazyOnload" />
      <Script id="show-banner"> {/* inline scripts need an id */}
        {`document.documentElement.dataset.banner = 'shown'`}
      </Script>
    </>
  );
}

Loaded in Chrome, window.order was ["consent (beforeInteractive)", "analytics (afterInteractive)", "chat (lazyOnload)"] and <html data-banner="shown"> had been set by the inline script. In the server HTML, the beforeInteractive script appeared as a link rel="preload" as="script" in the head plus an inline (self.next_s=self.__next_s||[]).push(["/widgets/consent.js",{}]) bootstrap call; the afterInteractive script appeared as a preload link, and the lazyOnload one had no preload link at all. The App Router therefore does not write a literal script src for beforeInteractive into the head: the Next.js runtime inserts it at the very start of hydration. (The documentation says such scripts are injected into the head of the initial HTML; the observable guarantee is the ordering relative to Next.js code.) The beforeInteractive script runs once per document load: a client-side navigation does not run it again, even one that changes a root param, because the root layout stays mounted.

Reacting to loading: onLoad, onReady and onError

Event handlers run code when the script has loaded. They are functions, so Script must be used inside a Client Component ("use client"):

  • onLoad runs once after the script has loaded. It cannot be used with beforeInteractive.

  • onReady runs after the script has loaded and again every time the component mounts (for example after a navigation back to the page). It does work with beforeInteractive, which is what the documentation recommends instead of onLoad.

  • onError runs if the script fails to load. It cannot be used with beforeInteractive either.

app/gallery/scripts/chat-button.tsx
"use client";

import { useState } from "react";
import Script from "next/script";

declare global {
  interface Window {
    ChatWidget?: { open: () => string };
  }
}

export function ChatButton() {
  const [state, setState] = useState("loading");
  return (
    <>
      <Script
        id="chat-loader"
        src="/widgets/chat.js?client"
        strategy="lazyOnload"
        onLoad={() => setState("loaded")}
        onReady={() => console.log("ready (runs again after every mount)")}
        onError={(e) => setState(`failed: ${e.message}`)}
      />
      <button disabled={state !== "loaded"} onClick={() => window.ChatWidget?.open()}>
        Chat ({state})
      </button>
    </>
  );
}

The page built, and in Chrome the button text changed from Chat (loading) to Chat (loaded) and the console printed ready (runs again after every mount). Remember that the handler is not the place to put analytics logic that has to run before the user can interact: the lazyOnload strategy fires at idle time, which can be seconds after hydration.

Extra attributes that Script does not use itself (a CSP nonce, data-* attributes) are forwarded to the final script element; the nonce is covered together with Content Security Policy in the security pages of this section. Inline scripts must have an id, or Next.js cannot track and optimise them; dangerouslySetInnerHTML works as an alternative to children.

The worker strategy

strategy="worker" offloads a script to a web worker with Partytown so that the main thread stays free for your own code. It requires experimental: { nextScriptWorkers: true }, asks you to install @qwik.dev/partytown, and the documentation states that it is not stable and does not work with the App Router: it is for the pages/ directory only. It was not run for this page. Partytown has real trade-offs (scripts that need synchronous access to the DOM can misbehave), so treat it as an experiment and measure.

Third-party libraries with @next/third-parties

@next/third-parties is a package of components that wrap popular third-party embeds with sensible loading behaviour. It is documented as experimental and under active development, and it is versioned in lockstep with Next.js (16.3.8 at the time of writing). Install it next to next:

npm install @next/third-parties@latest next@latest

All Google integrations come from @next/third-parties/google. The next page loads Google Tag Manager, Google Analytics and a YouTube embed, and sends a custom event. The IDs are placeholders (GTM-XXXXXXX, G-XXXXXXXXXX); replace them with your own, which are public by design.

app/gallery/third/page.tsx
import { GoogleAnalytics, GoogleTagManager, YouTubeEmbed } from "@next/third-parties/google";
import { AddToShelf } from "./add-to-shelf";

export default function Page() {
  return (
    <main>
      <GoogleTagManager gtmId="GTM-XXXXXXX" />
      <GoogleAnalytics gaId="G-XXXXXXXXXX" />
      <AddToShelf bookId="42" />
      <YouTubeEmbed videoid="ogfYd705cRs" height={400} params="controls=0" />
    </main>
  );
}
app/gallery/third/add-to-shelf.tsx
"use client";

import { sendGAEvent } from "@next/third-parties/google";

export function AddToShelf({ bookId }: { bookId: string }) {
  return (
    <button onClick={() => sendGAEvent("event", "add_to_shelf", { book_id: bookId })}>
      Add to shelf
    </button>
  );
}

next build prerendered the route. In the HTML the head contained link rel="preload" as="script" tags for https://www.googletagmanager.com/gtm.js?id=GTM-XXXXXXX and https://www.googletagmanager.com/gtag/js?id=G-XXXXXXXXXX and the page contained a lite-youtube element. In Chrome the page requested the two Google scripts after load and a collect call to Google Analytics, and clicking the button appended ["event","add_to_shelf",{"book_id":"42"}] to window.dataLayer.

Table 10. The components of @next/third-parties/google
Component or function What it does Notes from the documentation

GoogleTagManager (gtmId)

Instantiates a GTM container; fetches the script after hydration.

Place in the root layout for all routes, or in a page for one route. sendGTMEvent pushes to dataLayer and needs the component somewhere above. Options include gtmScriptUrl (server-side tagging), dataLayer, dataLayerName, auth and preview.

GoogleAnalytics (gaId)

Adds GA4 through gtag.js; fetches after hydration.

sendGAEvent. GA records page views itself on history changes, so client-side navigations are counted if Enhanced Measurement and "Page changes based on browser history events" are on; disable that setting before sending page views manually, or they double-count. If GTM is already present, configure GA through GTM instead.

GoogleMapsEmbed

An iframe embed, lazy-loaded below the fold by default.

Needs apiKey and mode. An embed key is visible in the HTML, so restrict it in the provider’s console.

YouTubeEmbed (videoid)

A facade using lite-youtube-embed that loads the real player only on interaction.

Options: width, height, playlabel, params, style.

One more finding, from the network log of the experiment: YouTubeEmbed also fetched lite-yt-embed.css and lite-yt-embed.js from cdn.jsdelivr.net, in addition to YouTube thumbnails from i.ytimg.com. That has two practical consequences. A Content Security Policy must allow those origins (Google’s script hosts for GTM and GA as well), and a site that promises not to contact third parties without consent must not render these components before consent is given. Load them conditionally, from a Client Component that checks the consent state.

Pages Router differences

The components are the same in the Pages Router; only the places you put them change. The following was built together with the App Router routes in the same project (a pages/ folder next to app/), and the route table listed Route (pages): /_app, /legacy.

pages/_app.tsx
import type { AppProps } from "next/app";
import { Inter } from "next/font/google";
import Script from "next/script";

const inter = Inter({ subsets: ["latin"] });

export default function MyApp({ Component, pageProps }: AppProps) {
  return (
    <main className={inter.className}>
      <Component {...pageProps} />
      <Script src="/widgets/analytics.js" strategy="lazyOnload" />
    </main>
  );
}
pages/_document.tsx
import { Html, Head, Main, NextScript } from "next/document";
import Script from "next/script";

export default function Document() {
  return (
    <Html lang="en">
      <Head />
      <body>
        <Main />
        <NextScript />
        <Script src="/widgets/consent.js" strategy="beforeInteractive" />
      </body>
    </Html>
  );
}
pages/legacy.tsx
import Image from "next/image";

export default function Legacy() {
  return <Image src="/covers/solaris.jpg" alt="Cover of Solaris" width={200} height={300} preload />;
}

Results: the font class applied to the main wrapper (class="inter_…​-module…​className") with a preload link carrying data-next-font="size-adjust"; the Image rendered the same /_next/image srcset with a link rel="preload" as="image" in the head; and the beforeInteractive script was written into the HTML as a literal script src="/widgets/consent.js" defer data-nscript="beforeInteractive" — and executed before the other script (window.__order began with consent (beforeInteractive)). So the Pages Router differences are:

  • Fonts apply in pages/_app (there is no root layout), usually through a wrapper’s className or a global style.

  • Scripts for every page go in pages/_app, and beforeInteractive scripts go in pages/_document (supported since 12.2.2); strategy="worker" works only here.

  • In @next/third-parties, the Pages Router documentation puts GoogleTagManager and GoogleAnalytics in the custom _app.

Cache Components and the previous model

These APIs interact with caching in only two ways, and neither depends on the cacheComponents setting:

  • Prerendering. Image, Script, a font function and the @next/third-parties components read no request data, so a page that only uses them is part of the static shell. The entire gallery was built twice, with CACHE_COMPONENTS=1 (Cache Components) and without it (previous model); in both builds all routes were ○ (Static), every route answered 200 and the browser checks for scripts and fonts gave the same results.

  • The image cache. Optimised images are stored in <distDir>/cache/images and governed by minimumCacheTTL, the upstream Cache-Control and maximumDiskCacheSize. This is independent of the Data Cache, the Full Route Cache and use cache: revalidating a path or tag does not purge an optimised image, and there is no invalidation API. Change the src (for example with a version query string allowed by localPatterns) to force a new image.

For the two models of caching themselves, see Caching and Partial Prerendering and Cache Components.

What changed recently

Table 11. Changes affecting images, fonts and scripts, by version
Version Change

16.0 (2025-10-21)

The image defaults tightened (see above); preload replaces priority; the version 16 guide lists next/legacy/image and images.domains as deprecated (the Image reference dates the domains deprecation from 14); the middleware file became proxy, whose matcher should still exclude _next/image, as the Redirects, Rewrites and Proxy page shows.

15.3 (2025-04-09)

remotePatterns accepts URL objects.

15.0 (2024-10-21)

contentDispositionType defaults to attachment.

14.1

getImageProps() is stable.

14.0

@next/font is gone: use next/font (codemod built-in-next-font). onLoadingComplete and domains deprecated.

13.2

@next/font renamed to next/font, no installation required.

13.0

next/image rewritten (native loading, no wrapper, no layout/objectFit); the old one is next/legacy/image. next/script supports the app directory (13.0.0).

Beyond the new image options maximumResponseBody (16.1.2) and maximumDiskCacheSize (16.1.7), the version history of the Image, Font and Script references lists no API change in 16.1, 16.2 or 16.3.

Where the books are outdated

The two reference books were consulted for the topics they cover; neither teaches next/font or next/script. Their image chapters predate the changes above:

Table 12. Outdated image statements in the reference books
Book (as described) Problem today Current form

Real-World Next.js (Packt, 2022): remote images allowed with images.domains

domains is deprecated since 14 because it cannot restrict protocol, port or path, so any path on the host can be optimised.

remotePatterns with protocol, host and path.

Real-World Next.js: layout="fill" and objectFit="cover" props on Image

Removed in 13 (they belong to next/legacy/image, now deprecated). An Image with those props is not styled as expected.

fill plus sizes and className="object-cover" (or style={{ objectFit: "cover" }}), in a positioned parent. The codemod next-image-experimental migrates old code.

Real-World Next.js: remote images shown without discussing quality or allow-lists

qualities defaults to [75] and local IP addresses are blocked since 16.

Configure qualities, remotePatterns and, only if needed, dangerouslyAllowLocalIP.

Practical Next.js for E-Commerce (Apress, 2023): static imports from public/ with string width values

Still works, but the book targets the Pages Router and Next.js 13; there is no sizes, preload or getImageProps.

Set sizes for responsive images and preload for the LCP image.

Both books pre-date the App Router stabilisation, so the way they load fonts and scripts (global stylesheet links and tags in a custom _document) is replaced by next/font and next/script as shown above.

Pitfalls checklist

  • An unconfigured remote host, a disallowed quality or width, or a query string not in localPatterns: development throws or warns; production returns 400 and the browser shows a broken image.

  • A fill image without a positioned parent collapses to zero height; a fill or responsive image without sizes downloads an image as wide as the viewport.

  • Only one image per page should use preload; for several candidate LCP elements prefer fetchPriority="high".

  • A loader or onLoad/onError function on a Server Component fails to serialise: move it to a Client Component.

  • A "use client" loader file breaks getImageProps in Server Components.

  • SVG: leave it unoptimized; enabling dangerouslyAllowSVG without a CSP is a risk.

  • Do not call a font function inside a component; it must be at module scope, and call it once per font.

  • A non-variable Google font needs weight; multi-word names use underscores.

  • Third-party scripts affect INP: prefer lazyOnload, place a script in the narrowest layout, and measure.

  • @next/third-parties embeds contact third-party hosts (Google, jsDelivr, YouTube): put them behind consent and allow them in the Content Security Policy.

  • Do not put secrets in client-side script URLs or props; the IDs of GTM and GA are public, a server API key is not.

Summary

Table 13. Which tool for which job
Job Use

Show a picture that is part of the codebase

next/image with a static import.

Show a CMS or user picture

next/image with remotePatterns, explicit width/height and sizes.

Make the hero image appear sooner

preload (or loading="eager" / fetchPriority="high").

Serve images from an image CDN or from a static export

loader or loaderFile, or unoptimized.

Art direction or CSS background

getImageProps.

Self-host web fonts without layout shift

next/font/google or next/font/local, with variable CSS custom properties.

Load a third-party script

next/script with the right strategy.

Analytics, tag manager, maps, YouTube

@next/third-parties/google.

For BlurHash placeholders, custom loaders for image CDNs and the media backend that feeds them, see Next.js Image with BlurHash and Custom Loaders in the Media Optimization section.