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.
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.
| Resource | Browser cost | Default optimization |
|---|---|---|
| Server Component | HTML and RSC payload | keep work on the server and reduce serialized props |
| Client Component | JavaScript transfer, parse, execution, and hydration | keep interactive boundaries small |
| Web font | transfer, decode, and font swap | reduce glyph coverage and route scope |
| CSS | may block rendering | keep 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.
Own font definitions in one file
Calling
localFont()again creates another hosted instance. Define each font once infonts.ts, then import the generated font objects from layouts.Select the source with the glyphs required by each locale
next/font/localdoes not provide the Google Fontsubsetsoption. This app therefore uses Pretendard's officialunicode-rangedynamic subset CSS for both English and Korean locale routes. The browser downloads only files containing glyphs used on the page. Pretendard Std throughnext/font/localremains 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-facerule owns a differentunicode-range; the app preserves all 92 official files and verifies the stylesheet, version, total size, and aggregate hash throughfont-assets.json.Treat preload and font swap as separate decisions
preloaddefaults totrue, 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 itsunicode-rangerules let the browser select only matching sources after CSS and text are known. The standalone fixture font keepspreload: falsebecause those test and diagram routes do not require an eager font request.display: "swap"renders fallback text first instead of hiding it. ExplicitadjustFontFallback: "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()ornext/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: falseonly 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.
| Metric | Risk it detects |
|---|---|
| font transfer and decode | wrong glyph source or full-font regression |
| stylesheet transfer and decode | render-blocking CSS or public asset regression |
| route JavaScript bytes | wider client boundaries or a heavy dependency |
| FCP and LCP | delayed first and primary content |
| CLS | movement while fallback text changes to the web font |
| INP and hydration | delayed 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:reportCompletion criteria
- Every locale uses the intended Pretendard source.
- English and Korean routes request only
unicode-rangesubsets 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.
How to Audit an Existing Tailwind and shadcn/ui Screen
Find ownership leaks, raw colors, dynamic classes, and risky shadcn updates, then refactor them safely.
Build Meaningful Visual Regression Tests with Playwright
Combine user contracts, deterministic rendering, and baseline review to build trustworthy Playwright visual regression tests.