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:
| Tool | Import | What it does for you |
|---|---|---|
Image |
|
An |
Font |
|
Downloads Google Fonts at build time and serves them from your own domain, generates the |
Script |
|
A |
Third-party components |
|
Ready-made components for Google Tag Manager, Google Analytics, Google Maps Embed and YouTube embeds, built on
|
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
widthandheight(or absolute positioning forfill), 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.
Source: Image Optimization
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):
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.
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("data:image/svg+xml;...")"
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,heightand, for JPEG, PNG, WebP and AVIF files that are not animated, ablurDataURL, soplaceholder="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
widthandheightfrom 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
widthandheight(andblurDataURLif you want a placeholder), because the build cannot fetch the file. The host must be allowed in configuration, covered below. -
widthandheightare the intrinsic size used to compute the aspect ratio. They do not set the rendered size; CSS does. If you set a CSS width, setheight: autoas well, as thestylein the first image does, to keep the aspect ratio. -
The
alttext is required. Usealt=""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.
<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 shortsrcsetof 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 fullsrcsetof 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
| Prop | Meaning | Note |
|---|---|---|
|
Source (static import, path or URL) and the required alternative text. |
An empty |
|
Intrinsic size in pixels, for the aspect ratio. |
Required unless statically imported or |
|
Stretch to the positioned parent. |
Pair with |
|
Rendered width per breakpoint. |
Switches |
|
1 to 100, default 75. |
Coerced to the closest entry of |
|
Insert a |
Replaces |
|
|
|
|
|
Remote images need a manual |
|
A function |
Needs a Client Component. |
|
Serve the file as-is. |
Automatic for |
|
Replace the generated |
Handy when migrating from |
|
|
A hint to the browser. |
|
Callbacks. |
Functions need a Client Component. |
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:
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. |
Source: Image: remotePatterns, localPatterns, qualities, formats
With the server running (next start), curl shows the allow-lists at work. Each row is a real request against the
scratch app:
Request (after /_next/image?) |
Result | Body or header |
|---|---|---|
|
200 |
|
the same with |
200, 200 |
|
|
400 |
|
|
200 |
allowed: it is in |
|
400 |
|
|
400 |
|
|
400 |
|
|
200 |
allowed by the second |
|
200 |
matches |
|
400 |
|
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:
| Mistake | Message (shortened) |
|---|---|
A remote host that is not configured |
|
A local |
|
A |
A warning: |
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:
| Key | Default | Effect |
|---|---|---|
|
none (no remote hosts) |
Allow-list for absolute URLs. Redirects of an allowed host are followed without re-checking the pattern, up to |
|
all local paths without a query string |
Allow-list for local paths; required as soon as a local |
|
|
Allowed |
|
|
Widths of the generated |
|
|
Extra widths for images smaller than the screen; should all be smaller than the smallest device size. |
|
|
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. |
|
|
Lower bound for the lifetime of an optimised image; the upstream |
|
|
Redirects followed when fetching a remote image. |
|
|
Largest source image the optimiser will fetch. Lower it on memory-constrained servers. |
|
50 percent of free disk at start-up |
LRU limit for the on-disk cache ( |
|
|
Lets the optimiser fetch from private IP addresses. A server-side request forgery (SSRF) risk; enable only on a private network you control. |
|
|
Allow SVG through the optimiser; pair with |
|
|
|
|
built-in loader, |
Replace the optimiser with a function or move the endpoint. |
|
|
Serve every image as-is. |
|
|
Turn off static image imports if they clash with another plugin. |
|
none |
Deprecated since 14; use |
The defaults in the middle of that table are not the ones Next.js 15 had; see what changed in version 16.
Source: Image: configuration options
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.
Source: Image: formats, Image: minimumCacheTTL
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:
// 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
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
loaderFileexample starts with"use client". With that directive in place, callinggetImagePropsfrom a Server Component (see below) failed the build withAttempted to call the default export of [project]/lib/image-loader.ts from the server, but it’s on the client. Removing the directive madeImageandgetImagePropsboth work from Server Components. Keep the directive only if the file really needs client-only code. -
With a custom loader configured, an
.svgsource also went through the loader (images.example.com/next.svg?w=…) instead of being served as-is. Passunoptimizedon SVG images, or have the loader returnsrcunchanged 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.
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.
Source: Image: preload, Image: loading
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.
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:
| Setting | Next.js 15 | Next.js 16 | If you need the old behaviour |
|---|---|---|---|
|
Local |
A local |
Add |
|
|
|
Set |
|
Began with |
|
Set |
|
Any quality allowed |
|
List the qualities you use |
Local IP addresses |
Allowed |
Blocked ( |
Set |
|
Unlimited |
|
Set |
|
The way to preload the LCP image |
Deprecated; use |
Rename |
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:
| Version | Change |
|---|---|
16.1.7 |
|
16.1.2 |
|
16.0.0 |
|
15.3.0 |
|
15.0.0 |
|
14.2.23, 14.2.15 |
|
14.1.0 |
|
14.0.0 |
|
13.0.0 |
|
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.
Source: Font Optimization, Font module
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.
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. |
@import "tailwindcss";
@theme inline {
--font-sans: var(--font-inter);
--font-title: var(--font-bookshelf);
}
body {
font-family: var(--font-sans), system-ui, sans-serif;
}
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" crossorigintags, one for the Latin subset of Inter and one for the local font. -
The generated stylesheet had
@font-facerules for Inter withfont-weight:100 900andfont-display:swap, one per Unicode range (Latin, Cyrillic, Greek, Vietnamese, and so on); only the Latin file is preloaded becausesubsets: ["latin"]. -
For the local font the stylesheet also contained a generated
bookshelf Fallbackrule:src:local(Arial)withascent-override:89.79%,descent-override:22.36%,line-gap-override:0.0%andsize-adjust:107.89%. That is the layout-shift protection, andadjustFontFallbackcontrols it (a boolean for Google fonts,"Arial","Times New Roman"orfalsefor local fonts). -
The computed
font-familyof theh1wasbookshelf, "bookshelf Fallback"and of the paragraphInter, "Inter Fallback";document.fontslisted both faces as loaded. -
No request went to
fonts.googleapis.comorfonts.gstatic.com, neither in the HTML nor from the browser.
Font options
| Option | google |
local |
Meaning |
|---|---|---|---|
|
no |
yes (required) |
A path relative to the file that calls |
|
yes |
yes |
A string ( |
|
yes |
yes |
|
|
yes |
no |
Which Unicode subsets to preload. Omitting it while |
|
yes |
no |
Extra axes of a variable font beyond weight, such as |
|
yes |
yes |
The |
|
yes |
yes |
Whether to emit the preload link. Default |
|
yes |
yes |
Fallback font families to append, for example |
|
yes |
yes |
Generate a metric-adjusted fallback. Boolean for Google fonts (default |
|
yes |
yes |
The CSS custom property to declare. |
|
no |
yes |
Extra |
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
Pacificoand noweight,next buildfailed withModule not found: Can’t resolve 'next/font/google/target.css'and the useful lineMissing weight for Pacifico. Available weights: 400. -
Use an underscore for multi-word names:
Roboto Monois imported asRoboto_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 setsfont-family,font-weightandfont-style),style(an object includingfontFamily) and, whenvariablewas set, avariableclass 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.
Source: Font: with Tailwind CSS
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
| Strategy | When it loads | Where it may live (App Router) | Typical use |
|---|---|---|---|
|
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. |
|
Injected on the client after some or all hydration. |
Any page or layout. |
Tag managers, analytics. |
|
Injected during browser idle time, after all resources are fetched. |
Any page or layout. |
Chat widgets, social buttons. |
|
Moved to a web worker with Partytown. |
Pages Router only, behind |
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:
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>
);
}
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"):
-
onLoadruns once after the script has loaded. It cannot be used withbeforeInteractive. -
onReadyruns after the script has loaded and again every time the component mounts (for example after a navigation back to the page). It does work withbeforeInteractive, which is what the documentation recommends instead ofonLoad. -
onErrorruns if the script fails to load. It cannot be used withbeforeInteractiveeither.
"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.
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>
);
}
"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.
| Component or function | What it does | Notes from the documentation |
|---|---|---|
|
Instantiates a GTM container; fetches the script after hydration. |
Place in the root layout for all routes, or in a page for one route. |
|
Adds GA4 through |
|
|
An iframe embed, lazy-loaded below the fold by default. |
Needs |
|
A facade using |
Options: |
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.
Source: Third Party Libraries
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.
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>
);
}
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>
);
}
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’sclassNameor a global style. -
Scripts for every page go in
pages/_app, andbeforeInteractivescripts go inpages/_document(supported since 12.2.2);strategy="worker"works only here. -
In
@next/third-parties, the Pages Router documentation putsGoogleTagManagerandGoogleAnalyticsin 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-partiescomponents read no request data, so a page that only uses them is part of the static shell. The entire gallery was built twice, withCACHE_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/imagesand governed byminimumCacheTTL, the upstreamCache-ControlandmaximumDiskCacheSize. This is independent of the Data Cache, the Full Route Cache anduse cache: revalidating a path or tag does not purge an optimised image, and there is no invalidation API. Change thesrc(for example with a version query string allowed bylocalPatterns) to force a new image.
For the two models of caching themselves, see Caching and Partial Prerendering and Cache Components.
Source: Image: minimumCacheTTL, cacheComponents
What changed recently
| Version | Change |
|---|---|
16.0 (2025-10-21) |
The image defaults tightened (see above); |
15.3 (2025-04-09) |
|
15.0 (2024-10-21) |
|
14.1 |
|
14.0 |
|
13.2 |
|
13.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:
| Book (as described) | Problem today | Current form |
|---|---|---|
Real-World Next.js (Packt, 2022): remote images allowed with |
|
|
Real-World Next.js: |
Removed in 13 (they belong to |
|
Real-World Next.js: remote images shown without discussing quality or allow-lists |
|
Configure |
Practical Next.js for E-Commerce (Apress, 2023): static imports from |
Still works, but the book targets the Pages Router and Next.js 13; there is no |
Set |
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
fillimage without a positioned parent collapses to zero height; afillor responsive image withoutsizesdownloads an image as wide as the viewport. -
Only one image per page should use
preload; for several candidate LCP elements preferfetchPriority="high". -
A
loaderoronLoad/onErrorfunction on a Server Component fails to serialise: move it to a Client Component. -
A
"use client"loader file breaksgetImagePropsin Server Components. -
SVG: leave it
unoptimized; enablingdangerouslyAllowSVGwithout 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-partiesembeds 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
| Job | Use |
|---|---|
Show a picture that is part of the codebase |
|
Show a CMS or user picture |
|
Make the hero image appear sooner |
|
Serve images from an image CDN or from a static export |
|
Art direction or CSS background |
|
Self-host web fonts without layout shift |
|
Load a third-party script |
|
Analytics, tag manager, maps, YouTube |
|
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.