skip to content

In a Next.js App Router app, calling `Inter({ subsets: [chosenSubset] })` from `next/font/google` inside a component body fails the build, even though `chosenSubset` holds a valid string. Why does next/font insist the loader be called at module scope with literal arguments?

level: middleimportance: should knowfreq 42%

answer

  1. nothing named Inter runs at runtime
  2. the compiler must read the arguments
  3. values that only exist later cannot be read
  4. static config, dynamic class application

basics

~20 s

next/font is a compile-time transform, not a runtime function. The compiler must statically read the options to fetch and emit the right font files during the build, so it only accepts a call at module scope whose arguments are literals it can evaluate without running the code.

solid answer

~50 s

The loader call never executes in the browser or on the server — it is rewritten during compilation. To rewrite it, the compiler has to know the family, weights, styles and subsets *by reading the source*, because that is what decides which font binaries to download and which `@font-face` CSS to generate. A variable's value is only known at runtime, and a call sitting inside a component body could in principle produce different options per render, so neither can be statically resolved; Next rejects both with a build error telling you the loader must be called and assigned at module scope with explicitly written literal values. The practical consequence is that you cannot choose a font dynamically at runtime. If a page needs two faces, you declare both statically at module scope and switch which returned `className` or CSS variable you apply — the decision moves from "which font do I load" to "which of the loaded fonts do I use".

code

typescript · 5 lines
typescript
import { Inter, Roboto_Mono } from 'next/font/google'

// Both resolved at build time; runtime only picks which className to apply.
export const sans = Inter({ subsets: ['latin'] })
export const mono = Roboto_Mono({ subsets: ['latin'], weight: '400' })

go deeper

for a junior

Remember the rule itself: the loader call goes at the top level of a module, assigned to a const, with options written out literally. Recognise the build error as this rule being broken.

for a middle

Explain the mechanism — the call is rewritten at compile time, so the compiler must read the family, weights and subsets from the source text to know which files to fetch and what CSS to generate.

for a senior

Show the workaround and its cost: declare every face statically and switch class names at runtime, then reason about where to declare a rarely used face so you are not shipping and preloading it on every route.

for a principal

Frame it as a build-time-configuration boundary. Decide how many faces the product commits to, how locale-specific typography is handled without unbounded font payload, and what your build does when the font source is unreachable.

## next/font is not a function you call It looks like a function call, and that is the source of the confusion: ```ts import { Inter } from 'next/font/google' const inter = Inter({ subsets: ['latin'], weight: '400' }) ``` But nothing named `Inter` runs at runtime. `next/font` is implemented as a compiler transform wired into Next's build. When the compiler encounters this call it does the work itself: resolves the family, fetches the matching font binaries for the requested weights, styles and subsets, writes them into the application's static output, generates `@font-face` CSS plus the class rules, and replaces the call expression with a plain object holding the generated `className`, `style` and — if requested — `variable`. Everything about that pipeline happens *before* any code executes. That is precisely why the runtime cost is a single same-origin font request and no JavaScript. ## Why literals, specifically A compiler transform can only act on what the source text says. Consider the rejected form: ```ts const chosenSubset = someCondition ? 'latin' : 'cyrillic' const inter = Inter({ subsets: [chosenSubset] }) // build error ``` To do its job the compiler would have to evaluate `someCondition` — which may depend on an environment variable, a network call, or user input that does not exist at build time. There is no correct answer it could pick. Rather than silently guessing (say, downloading every subset, or defaulting to latin and quietly shipping missing glyphs), Next refuses the input and reports a build error saying the values must be explicitly written literals. The same reasoning explains the module-scope rule. A call inside a component body implies it happens per render, with whatever arguments that render supplies. The transform has no per-render step to hook into; there is one build and one set of emitted font files. So Next requires the call to sit at module top level, assigned to a `const`, which is a shape it can statically identify, evaluate once, and rewrite. The restriction extends further than people expect. Spreading a variable object (`Inter({ ...options })`), computing a weight from a constant elsewhere, or reading a value from a config module all fail for the same reason: the compiler would need to run code to learn the value. ## What you do instead The fix is always to move the choice from *load time* to *apply time*. Declare every face you might need, statically, and let runtime pick which class to attach: ```ts // app/fonts.ts import { Inter, Roboto_Mono } from 'next/font/google' export const sans = Inter({ subsets: ['latin'] }) export const mono = Roboto_Mono({ subsets: ['latin'] }) ``` ```tsx // somewhere in a component <p className={useMonospace ? mono.className : sans.className}>…</p> ``` Both fonts are fetched and emitted at build time; the runtime decision is only which generated class name lands on the element. This is a genuine tradeoff and worth naming in an interview: you have traded "load exactly the font this user needs" for "the set of fonts is fixed and known", and you pay for any face you declared but rarely apply. If a locale-specific face is only needed on a handful of routes, declare it in a module those routes import rather than in the root layout, so its files are only associated with — and preloaded on — pages that actually use it. ## Why this design is a feature, not a limitation Because the configuration is static, several good properties follow. A misspelled family or an unpublished weight fails the build rather than degrading silently in production. The build knows exactly which font files exist, so it can emit accurate preload hints and correct fallback metrics. And no font-loading logic ships to the client at all — there is no JavaScript deciding what to fetch, which is what makes the whole feature zero-runtime. The mental model to carry into the interview: `next/font` sits in the same family as other build-time-resolved constructs — the arguments are part of the build's input, not part of your program's state. Once you hold that, the error message stops being mysterious and the workaround is obvious.

  • A page needs a different typeface per locale. How do you build that within this constraint?
    Declare every locale's face statically at module scope, then choose which returned `className` or CSS variable to apply based on the request's locale. If a face serves only a few routes, declare it in a module only those routes import, so its files are associated with — and preloaded on — those pages rather than every page.
  • Does calling the same loader with the same options in two different files cause the font to be downloaded twice?
    No — the build deduplicates identical configurations, so you get one set of emitted files. It is still worth exporting a single constant from a shared module: two call sites are two declarations to keep in sync, and a divergence in weights or subsets between them silently produces a second, near-duplicate font set.
  • What tells you at a glance that this API has no runtime cost?
    The call is replaced during compilation with a plain object of generated class names, and the font files plus `@font-face` CSS are emitted as build artifacts. Nothing from `next/font` is included in the client bundle, so there is no script deciding what to fetch — the browser learns about the font purely from CSS and preload hints.

saying these in an interview costs you the question

  • Thinks the loader executes on the server per request
  • Tries to pick a font from an environment variable at runtime
  • Believes the restriction is arbitrary API strictness
  • Assumes calling it in a component just memoizes per render
  • Says next/font ships a client-side font loader script

context