Images, Fonts, Scripts, and Bundle Analysis

Images, fonts, third-party scripts, and JavaScript bundles shape how fast a Next.js page becomes useful. This lesson teaches how those pieces are handled by the framework, how to configure them deliberately, and how to prove that a change improved the page instead of only moving cost somewhere less visible.

In the performance and operations section of this Next.js course, the goal is not to memorize component props. The goal is to reduce real user delay: fewer oversized images, less layout shift, fewer render-blocking font requests, safer third-party scripts, and smaller client bundles. These are operational concerns because every deployment can accidentally add weight to the browser path.

What Next.js Optimizes

Next.js sits between your source code and the browser. At build time it analyzes imports, creates route-level JavaScript chunks, and emits HTML instructions for fonts, images, scripts, and styles. At request time it may serve optimized image variants, stream server-rendered output, or reuse cached assets. The browser then decides what to download, decode, execute, and paint.

The important distinction is that not every asset has the same cost. Images usually consume network bytes and decoding time. Fonts can delay readable text or cause reflow if the fallback font has different metrics. Scripts consume network, parse, compile, and main-thread execution time. Client bundles are especially sensitive because code imported beneath a use client boundary is shipped to the browser even when the original reason for the import was small.

Image Mechanism

The next/image component wraps the browser img element with sizing, lazy loading, responsive source generation, and optional server-side optimization. When you provide width and height, Next.js can reserve the image box before the file arrives, which reduces cumulative layout shift. When you provide sizes, the browser can choose a smaller candidate for narrow viewports instead of downloading a desktop-sized image on a phone.

For local images imported from the project, Next.js can infer dimensions at build time. For remote images, you must allow the host in next.config.js using images.remotePatterns. That restriction prevents your application from becoming an unrestricted image proxy. The trade-off is operational: every trusted image origin becomes part of your performance and availability path.

Font Mechanism

The next/font APIs generate self-hosted font files and CSS during the build. For Google fonts, Next.js fetches the font during build and serves it from the same origin as the app. This avoids runtime requests to a font provider and gives the framework enough information to preload the files it knows the page needs. A font import returns an object with a className or variable that you attach to markup.

The key font choices are subset, weight, display behavior, and fallback. Loading every weight and subset increases bytes. Loading too few can cause synthetic browser styles. display: swap favors readable text quickly, then swaps when the real font is ready. That is usually better than invisible text, but you still need to watch for visible movement when the font changes.

Script Mechanism

The next/script component controls when third-party scripts enter the page. beforeInteractive is for scripts required before hydration, such as a critical bot detector or consent bootstrap. afterInteractive runs after some hydration work has started and is a common default for analytics. lazyOnload waits until the browser is idle after load, which is useful for non-critical widgets. worker may be available only in specific setups and should be tested carefully before relying on it.

Scripts are not just network resources. They often schedule work on the main thread, mutate the DOM, attach event listeners, and call external endpoints. A script that downloads quickly can still damage interaction latency if it performs heavy work during input. Treat every third-party script as code running in the user’s session.

Bundle Analysis Mechanism

Bundle analysis answers a specific question: which imports are increasing shipped JavaScript, and in which route chunks do they appear? Next.js already splits code by route and by shared chunks. A bundle analyzer visualizes the output so you can see large dependencies, duplicated packages, and accidental client-side imports.

The most common surprise is a server-friendly library entering the client bundle because it was imported by a Client Component. Another is importing a full utility package for one function. Bundle analysis is useful because the source line that feels harmless may pull in transitive dependencies that are expensive after minification and compression.

Example 1: A Responsive Product Image

This example reserves layout space, marks the above-the-fold image as important, and gives the browser realistic size hints.

import Image from "next/image";
import hero from "./hero.jpg";

export default function ProductHero() {
  return (
    <section>
      <Image
        src={hero}
        alt="Ceramic pour-over coffee set on a kitchen counter"
        priority
        sizes="(max-width: 768px) 100vw, 640px"
        placeholder="blur"
      />
      <h1>Morning Pour-Over Kit</h1>
    </section>
  );
}

Because hero is a local import, Next.js knows the intrinsic width, height, and blur placeholder. The rendered HTML includes responsive image candidates, and the browser chooses a candidate based on viewport and device pixel ratio. The deterministic behavior is that the image box is reserved before the image finishes loading, so the heading should not jump downward when the image decodes.

Example 2: A Self-Hosted Font

This example configures a variable font once in the root layout and exposes it as a CSS variable for the rest of the application.

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

const inter = Inter({
  subsets: ["latin"],
  display: "swap",
  variable: "--font-inter"
});

export default function RootLayout({ children }) {
  return (
    <html lang="en" className={inter.variable}>
      <body>{children}</body>
    </html>
  );
}

The expected result is that font CSS and files are produced by the build and served from your application origin. In CSS, font-family: var(--font-inter) can now be applied without adding a browser request to Google at runtime. The trade-off is build-time dependency on the font source and the need to choose subsets accurately.

Example 3: Loading Analytics After Hydration

This example loads analytics without blocking the first render path.

import Script from "next/script";

export default function AnalyticsScript() {
  return (
    <Script
      id="analytics"
      src="https://example.com/analytics.js"
      strategy="afterInteractive"
      onLoad={() => {
        window.dispatchEvent(new Event("analytics-ready"));
      }}
    />
  );
}

The script is inserted once, after the page has started hydrating. When it loads, the browser dispatches an analytics-ready event. This does not guarantee the vendor code is cheap; it only controls scheduling. Verification should include the browser performance panel or field interaction metrics, not just a successful network request.

Example 4: Inspecting Bundle Size

This configuration enables a bundle report only when requested, so normal builds remain unchanged.

const withBundleAnalyzer = require("@next/bundle-analyzer")({
  enabled: process.env.ANALYZE === "true"
});

module.exports = withBundleAnalyzer({
  images: {
    remotePatterns: [
      {
        protocol: "https",
        hostname: "assets.example.com",
        pathname: "/products/**"
      }
    ]
  }
});

Running a build with ANALYZE=true should open or emit visual reports for client and server bundles, depending on the analyzer setup. The report should show route chunks and dependency rectangles. If a chart reveals a large date, charting, editor, or markdown library inside a route that barely needs it, move the work to a Server Component, dynamically import the component, or replace the dependency with a smaller API.

Design Choices

For images, choose between local imports, remote optimized images, and plain img. Use next/image when sizing, responsive variants, or optimization matter. Use plain img for unusual browser behavior that Image does not support, but then you own dimensions, lazy loading, and source selection.

For fonts, prefer one or two families and only the needed weights. A brand system with many weights may look precise in design tools but cost too much in production. For scripts, default to the latest strategy that still preserves required behavior. A chat widget can wait; a security challenge may not. For bundles, analyze before optimizing. Removing one large dependency from a Server Component may not affect the browser at all, while moving a small import above a client boundary can have a visible impact.

Failure Modes and Troubleshooting

Symptom: images return a 400 or fail in production. Cause: the remote host is not allowed or the URL does not match remotePatterns. Diagnose: inspect the image request path, compare protocol, host, and pathname to next.config.js, and check deployment logs. Correction: add the narrowest matching remote pattern and redeploy.

Symptom: text shifts after load. Cause: fallback and final fonts have different metrics, or too many font files delay the swap. Diagnose: record a performance trace and watch the layout shift regions. Correction: reduce weights, use a better fallback, keep display: swap, and apply font variables consistently at the layout level.

Symptom: a page hydrates slowly after adding a component. Cause: a heavy dependency entered a Client Component bundle. Diagnose: run the bundle analyzer and inspect the affected route chunk. Correction: move non-interactive rendering to a Server Component, pass serialized data into a small client component, or use dynamic import for code needed only after user action.

Symptom: analytics breaks a consent flow. Cause: the script strategy runs later than the consent code expects, or the vendor global is read before the script loads. Diagnose: inspect script order in the Elements panel and add a temporary load event log. Correction: gate reads behind onLoad, move only the minimal bootstrap earlier, and keep non-critical vendor code later.

Hands-On Lab

Prerequisites: a working Next.js app, Node.js installed, an image file under app or public, and permission to install a development dependency. Use a branch or disposable project so cleanup is easy.

  1. Add a local hero image and render it with next/image, including alt, sizes, and either explicit dimensions or a local import.
  2. Configure one font with next/font in the root layout. Use one subset and apply the returned variable or class at the top of the document.
  3. Add a test third-party script with next/script and strategy="afterInteractive". Log a small event from onLoad during local testing.
  4. Install the bundle analyzer package and wrap next.config.js so analysis runs only when ANALYZE=true.
  5. Run a normal production build, then run an analyzed build. Compare the largest client chunks and identify one dependency that belongs on the server or behind a dynamic import.

Verification: confirm that the build succeeds, the image reserves space without a layout jump, font files are served from the app origin, the script appears with the intended scheduling behavior, and the analyzer report identifies route-level client chunks. Cleanup: remove the test script, uninstall the analyzer if you do not want it committed, and revert any experimental dependency changes that did not improve measured output.

Assessment Exercises

  1. A remote product image works locally but fails after deployment. Which exact fields would you compare against remotePatterns, and why should the pattern be narrow?
  2. A designer asks for five font weights across three families. How would you estimate the performance cost and propose a smaller loading plan?
  3. An analytics vendor says its script must be loaded immediately. What questions determine whether beforeInteractive is justified?
  4. A bundle report shows a large markdown parser inside a checkout page client chunk. Describe two ways to keep the checkout interactive while reducing shipped JavaScript.
  5. How can moving a component from server to client change the bundle even if the JSX output looks identical?

Summary

Next.js performance work is concrete: reserve image space, give the browser accurate responsive hints, self-host and limit fonts, schedule third-party scripts according to user impact, and inspect actual client bundles. The best optimization is the one tied to a measured route and a known user outcome.