How-toFrontend

Reduce Initial Font and JavaScript Transfer in Next.js

Use next/font, Server Components, and lazy loading to reduce initial font and client JavaScript cost and protect it with route budgets.

Updated Verified SourceEdit this page

Core decision

Production browsers do not download TypeScript source, but they do download and execute JavaScript reachable from Client Components. Fonts do not execute like JavaScript, yet they compete for the same network bandwidth, so optimize route glyph coverage and client boundaries together.

Separate the resources a browser receives

An initial App Router visit can include HTML, the React Server Component payload, CSS, fonts, and Client Component JavaScript. Browsers do not receive .ts or .tsx source files; they receive only the route JavaScript chunks produced by the build.

ResourceBrowser costDefault optimization
Server ComponentHTML and RSC payloadkeep work on the server and reduce serialized props
Client ComponentJavaScript transfer, parse, execution, and hydrationkeep interactive boundaries small
Web fonttransfer, decode, and font swapreduce glyph coverage and route scope
CSSmay block renderingkeep ownership aligned with routes and components

The Server and Client Components guide recommends Client Components only where state, event handlers, or browser APIs are required. A "use client" declaration adds that module's imports and descendants to the client module graph, so place the boundary as low as practical.

Apply the next/font optimization boundary

The Next.js Font Module self-hosts fonts as build assets and removes external browser requests. It supports variable fonts, display, fallback metric adjustment, CSS variables, and layout-scoped preloading.

  1. Own font definitions in one file

    Calling localFont() again creates another hosted instance. Define each font once in fonts.ts, then import the generated font objects from layouts.

  2. Select the source with the glyphs required by each locale

    next/font/local does not provide the Google Font subsets option. This app therefore uses Pretendard's official unicode-range dynamic subset CSS for both English and Korean locale routes. The browser downloads only files containing glyphs used on the page. Pretendard Std through next/font/local remains limited to standalone fixtures without a locale layout.

    import localFont from "next/font/local";
    
    export const fixtureFont = localFont({
      src: "./fonts/PretendardStdVariable.woff2",
      adjustFontFallback: "Arial",
      display: "swap",
      preload: false,
      variable: "--font-pretendard",
      weight: "45 920",
    });
    
    const localeFontClasses = {
      en: "font-pretendard-dynamic",
      ko: "font-pretendard-dynamic",
    } as const;
    
    export const pretendardStylesheetHref =
      "/fonts/pretendard-variable/dynamic-subset.css";

    The shared locale class points to the self-hosted official dynamic subset family. Each @font-face rule owns a different unicode-range; the app preserves all 92 official files and verifies the stylesheet, version, total size, and aggregate hash through font-assets.json.

  3. Treat preload and font swap as separate decisions

    preload defaults to true, and its route scope depends on whether the font is used by a page, layout, or root layout. Locale layouts link one same-origin dynamic subset stylesheet, and its unicode-range rules let the browser select only matching sources after CSS and text are known. The standalone fixture font keeps preload: false because those test and diagram routes do not require an eager font request.

    display: "swap" renders fallback text first instead of hiding it. Explicit adjustFontFallback: "Arial" uses the local-font fallback metric contract to reduce layout shift during the swap.

Keep Client JavaScript in interactive islands

The Next.js lazy-loading guide explains that Server Components are code-split by default and that lazy loading primarily applies to Client Components and client libraries.

  • Keep document content, metadata, page trees, and static navigation in Server Components.
  • Split UI that is unnecessary before an action, such as a search dialog, with React.lazy() or next/dynamic.
  • Load heavy search engines or editors with import() after input or an open action.
  • Place providers close to their consumers instead of wrapping the entire <html> tree.
  • Use ssr: false only for Client Components that require browser APIs.
"use client";

import { lazy } from "react";

const SearchDialog = lazy(() =>
  import("./SearchDialog").then((module) => ({
    default: module.SearchDialog,
  })),
);

The Tech search in this app disables provider preloading and lazy-imports the dialog, keeping the search UI and query client out of the initial client chunk until the user opens search.

Verify bytes and user metrics together

Do not infer rendering improvements from transfer reduction alone. Measure same-origin resources with PerformanceResourceTiming, keep transfer and decoded sizes separate, then observe user-facing metrics.

MetricRisk it detects
font transfer and decodewrong glyph source or full-font regression
stylesheet transfer and decoderender-blocking CSS or public asset regression
route JavaScript byteswider client boundaries or a heavy dependency
FCP and LCPdelayed first and primary content
CLSmovement while fallback text changes to the web font
INP and hydrationdelayed interaction from client JavaScript execution

English routes now decode Home 91,844 bytes, Tech 37,996 bytes, and Invest 37,996 bytes of font subsets. Korean routes decode Home 375,888 bytes, Tech 314,612 bytes, and Invest 311,860 bytes. Compared with the original 2,057,688-byte full source, the representative routes reduce initial font decode size by about 81.7–98.2%.

The shared dynamic subset stylesheet is 59,318 bytes decoded and about 14.7 KB transferred with compression. The route budget includes all initial stylesheets, not just bundled Next.js CSS, so moving this file to public does not hide its render-path cost.

Initial JavaScript decoded in the same navigation is 673,144 bytes for Home, 1,024,160 bytes for Tech, and 823,237 bytes for Invest. Network transfer is 212,202, 336,306, and 263,502 bytes, respectively. initial-transfer-budget.json keeps independent transfer and decoded caps to catch wider client boundaries without confusing compressed network cost with parse input.

pnpm --filter @jongminchung/web run build
pnpm --filter @jongminchung/web exec playwright test app/initial-transfer.e2e.test.ts --project tech-chromium
pnpm --filter @jongminchung/web run bundle:report

Completion criteria

  • Every locale uses the intended Pretendard source.
  • English and Korean routes request only unicode-range subsets needed by their initial text.
  • Features needed only after an action stay out of initial route JavaScript.
  • The font byte budget and bundle report pass independently.
  • FCP, LCP, CLS, and interaction behavior do not regress.