cf.completefrontend
Open lab My progress
THE NEXT.JS FIELD GUIDE

Optimizing fonts

Load Google and local fonts with next/font: self-hosted at build time, preloaded, with a size-adjusted fallback so text doesn't jump, and used as CSS variables.

By the end, you can
  • 01
    Load a font with next/fontPick a Google font with its subsets, weights and display, or a font file of your own, and apply it with className.
  • 02
    Tell what the visitor downloadsRead the font files, the preload and the @font-face rules next/font writes, and see that no request goes to Google.
  • 03
    Use fonts across your CSSGive each font a CSS variable, and use it from global CSS, a CSS Module or Tailwind CSS's @theme.

The tea shop gets its own font

A web font is a font file the browser downloads, so a page can show text in a font the visitor's computer doesn't have. The tea shop wants Lora, a serif font from Google Fonts, Google's free library of fonts. The usual way to add one is a <link> to Google's servers. Then every visitor's browser asks Google for the font, and the text waits, or jumps, while it arrives.

Next.js has its own way: next/font. You import the font as a function, call it, and put the class it gives you on an element. Here is the shop with Lora. It was built with real Next.js 16.4.3 and run with next start, and the page was opened in real Chrome, in a window 800 px wide. The Browser tab shows Chrome's picture of it.

The tea shop, in Lora from next/font/googleNext.js
app/
app/layout.tsx
import { Lora } from "next/font/google";import "./globals.css"; const lora = Lora({  subsets: ["latin"],}); export const metadata = { title: "Tea shop" }; export default function RootLayout({ children }: LayoutProps<"/">) {  return (    <html lang="en">      <body className={lora.className}>{children}</body>    </html>  );}
Recorded from Next.js 16.4.3
localhost:3000/Tea shop
The page at /, as Chrome drew it with its styles. It says: Tea shop. Fresh tea, sent to your door. Every box is packed by hand.

A picture of the page in real Chrome, in a window 800 px wide, taken once the page had loaded with its styles. Open it full size

Line 1 of app/layout.tsx imports Lora from next/font/google. Every Google font is there as a function, named after the font. Lines 4 to 6 call it: subsets: ["latin"] says the shop is written in latin letters. The call returns an object, and line 13 puts its className on <body>, so all the shop's text is in Lora. The call is at the top of the file, outside the component, and saved in a const: next/font needs it there, because it reads the call while the app builds.

Now look at what the visitor gets. Open the Network tab:

  • A class. The body has class="LwFUCa_className", a name next/font made up for this build. In the CSS, that class says font-family:Lora,Lora Fallback.
  • The font, in the shop's own CSS. The stylesheet has @font-face rules: the CSS that tells the browser where a font's file is. Each src points at ../media/, a file in /_next/static/media/ on the shop's own site. There's no Google address in it.
  • A preload. The response has a link header with rel=preload; as="font". A preload tells the browser to start downloading a file now, before it reads the CSS that needs it.

Then open the Steps tab. It lists every file Chrome asked for while it opened the page: the page itself, then the font, then the stylesheet and the page's scripts. Every address is on the shop's own site. The browser never asked Google for anything.

Two halves. Left, when you build: an arrow from Google Fonts down to next build, which downloads the font, and another arrow down to the .next/static/media folder, where it saves the files. Right, when someone visits: a browser window with an arrow to the shop's own site, which sends the HTML, the CSS and the font. Google Fonts is dashed and crossed out: the browser never asks it.When you buildGoogle Fontsdownloadsnext buildsaves.next/static/mediaWhen someone visitsbrowserasksthe shop's siteHTML, CSS and the fontGooglenever asked
next build asks Google Fonts once, and saves the font with the app's other files. A visitor's browser gets the HTML, the CSS and the font from the shop itself, and never asks Google.

That's because the shop got Lora long before: next build asked Google Fonts for the CSS and the font files, saved the files with the app's other files, and wrote CSS that points at them. Next.js calls this self-hosting: your site hosts the font itself.

Real-life analogyA café that bakes its own bread

A café could send every guest to the bakery across town for bread. Each guest waits, and the bakery learns who came. Or the café buys flour once, early in the morning, and bakes the bread itself. When guests arrive, the bread is already on the counter, and the plates are already the right size for it.

In real life: Each guest sent to the bakery across town for bread
In Next.js: Each visitor's browser asking Google for the font
In real life: The café buys flour once, early in the morning
In Next.js: next build downloads the font once
In real life: The bread is on the counter when guests arrive
In Next.js: The font comes from your own site, preloaded
In real life: A plate that's already the bread's size
In Next.js: A fallback font sized like the real one
The main idea

next/font downloads a font when you build, and serves it from your own site. You call the font's function once, at the top of a file, and use the className or variable it gives you. Next.js writes the @font-face rules, preloads the font where it's used, and adds a fallback font sized like it, so the text barely moves while the font arrives. The visitor's browser never asks Google.

A Google font, downloaded when you build

Each Google font is a function in next/font/google, named after the font. A font with a space in its name has an underscore instead: Roboto Mono is Roboto_Mono. Call it at the top of a file, outside any component, and save what it returns in a variable. Each row is one option you can pass:

Options for a Google font
OptionWhat it doesExample
subsetsWhich parts of the font to preload, like latin. The other parts stay in the CSS, downloaded only for pages that use their letters.subsets: ["latin"]
weightWhich weights to load. A variable font needs none; a font that isn't variable must have at least one.weight: ["400", "700"]
displayWhat the browser shows while the font downloads. The default, swap, shows the fallback first, then swaps.display: "swap"
variableAlso make a class that defines a CSS variable with the font's name.variable: "--font-lora"
preloadWhether to tell the browser to start downloading the font early. On by default.preload: false

subsets: which letters to preload

Google splits each font into subsets: one file for latin letters, one for Cyrillic, one for Vietnamese, and so on. The recorded CSS has an @font-face rule for each of Lora's subsets, and each rule has a unicode-range: the letters its file covers. subsets: ["latin"] doesn't remove the others; it picks which files to preload. In the recording, only the latin file is in the link header, so the browser starts downloading it right away, whatever letters the page has. A file that isn't preloaded is downloaded only when the page has a letter in its unicode-range. Next.js's docs say it warns you if you leave subsets out while preload is on.

display: what shows while the font downloads

Every rule in the recorded CSS says font-display:swap, next/font's default. It means: show the text at once in the fallback font, and swap to Lora when it arrives. The other values come from CSS: optional, for example, uses the font only if it arrives very quickly, and keeps the fallback otherwise.

Whom the browser asks

Compare the two ways to load Lora. Before you switch, guess: which version's page asks another site for something?

Playground: whom does the visitor's browser ask for the font?
The tea shop's root layout, with this way of loading LoraNext.js
app/
app/layout.tsx
import "./globals.css"; export const metadata = { title: "Tea shop" }; export default function RootLayout({ children }: LayoutProps<"/">) {  return (    <html lang="en">      <head>        <link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=Lora:wght@400..700&display=swap" />      </head>      <body>{children}</body>    </html>  );}
Recorded from Next.js 16.4.3

Each step was taken in real Chrome against next start. Pick one to see the page after it.

localhost:3000/Tea shop
Tea shop

Fresh tea, sent to your door. Every box is packed by hand.

Focus
nothing: the page itself
Scrolled
0 px down

Requests the page made during this step (8), files and other sites included

  1. GET/200a whole page
  2. GET/_next/static/chunks/37iayjfhyo4qp.css200text/css
  3. GET/_next/static/chunks/3cfn99dbrcvvp.js200application/javascript
  4. GEThttps://fonts.googleapis.com/css2?family=Lora:wght@400..700&display=swapnot sent: another sitestylesheet
  5. GET/_next/static/chunks/1j_b1xq_woyiy.js200application/javascript
  6. GET/_next/static/chunks/2ki52vdnbo2ft.js200application/javascript
  7. GET/_next/static/chunks/turbopack-3-ry682tkyiqk.js200application/javascript
  8. GET/_next/static/chunks/1tq6ppe__m8m-.js200application/javascript
Try it yourself
A <link> to Google Fonts

Line 9 links Google's stylesheet. The request list has an address on fonts.googleapis.com: the visitor's browser asks Google for the font's CSS. The recorder stopped it there; a real answer would send the browser on to Google's font files.

Both versions were built with next build from Next.js 16.4.3 and opened in real Chrome, which listed every file the page asked for. The recorder stops any request to another site before it leaves, and lists it as not sent. Reset goes back to the first version.

With the <link>, the page's requests include fonts.googleapis.com: each visitor's browser asks Google for the CSS, and then for the font files on Google's servers. That's another site to connect to before the text can change, and Google sees each visitor's address. In these recordings, Chrome never reached Google: the recorder stops every request to another site before it leaves, and the list shows it as not sent. With next/font, the list has only the shop's own addresses.

Weights, and fonts that hold them all

A font's weight is how thick its letters are: 400 is regular, 700 is bold. Most fonts are a set of files, one per weight. A variable font is one file that holds a range of weights, and the browser draws any weight in that range from it. Lora is variable, from 400 to 700, so the shop's Lora call needed no weight: every recorded Lora rule says font-weight:400 700, and the bold heading and the regular text come from the same file. Next.js's docs recommend variable fonts for that reason.

Poppins isn't variable. Try it without a weight, then with two:

Playground: a font that isn't variable
The tea shop's root layout, with PoppinsNext.js
app/
app/layout.tsx
import { Poppins } from "next/font/google";import "./globals.css"; const poppins = Poppins({  subsets: ["latin"],}); export const metadata = { title: "Tea shop" }; export default function RootLayout({ children }: LayoutProps<"/">) {  return (    <html lang="en">      <body className={poppins.className}>{children}</body>    </html>  );}
next build failed · Next.js 16.4.3
$ next build▲ Next.js 16.4.3 (Turbopack)- Cache Components enabled- Partial Prefetching enabled   Creating an optimized production build ... > Build error occurredError: Turbopack build failed with 1 error:./app/layout.tsxError: Module not found: Can't resolve 'next/font/google/target.css'Missing weight for Poppins. Available weights: 100, 200, 300, 400, 500, 600, 700, 800, 900 Debug info:- Execution of <ModuleAssetContext as AssetContext>::resolve_asset failed- Execution of resolve failed- Execution of resolve_internal failed- Execution of NextFontGoogleReplacer::import_map_result failed- Execution of *get_font_css_properties failed- Execution of font_options_from_query_map failed- Missing weight for Poppins. Available weights: 100, 200, 300, 400, 500, 600, 700, 800, 900Error while looking up import map: Missing weight for Poppins. Available weights: 100, 200, 300, 400, 500, 600, 700, 800, 900 Debug info:- Execution of NextFontGoogleReplacer::import_map_result failed- Execution of *get_font_css_properties failed- Execution of font_options_from_query_map failed- Missing weight for Poppins. Available weights: 100, 200, 300, 400, 500, 600, 700, 800, 900 https://nextjs.org/docs/messages/module-not-found     at <unknown> (https://nextjs.org/docs/messages/module-not-found)
Try it yourself
No weight

Poppins isn't a variable font, and lines 4 to 6 don't say which weight to load. The build stops with Missing weight for Poppins, and lists the weights Poppins has.

Both versions were built with next build from Next.js 16.4.3. Open the Network tab of the second to read the @font-face rules it wrote. Reset goes back to the first version.

Without a weight, the build stops: Missing weight for Poppins. Available weights: 100, 200, 300, 400, 500, 600, 700, 800, 900. Next.js can't guess which files you want. With weight: ["400", "700"] on line 6, it builds, and the CSS has rules for weight 400 and for weight 700, each with its own file. Every weight you add means more files in the build, and more for the browser to download when a page uses it, so ask only for the ones the design uses. A variable font can also take weight, as a range like "400 700".

Two halves. Left: Poppins with weight 400 and 700 becomes two font files, one for 400 and one for 700. Right: Lora, a variable font, is one font file that covers every weight from 400 to 700.Poppins, weight: ["400", "700"]400700two filesLora, a variable font400 to 700one file
Poppins isn't a variable font: each weight you ask for is a file of its own, so you must say which. Lora is a variable font: one file holds every weight from 400 to 700. (In the recorded builds, that's per subset: Poppins has three subsets, so six files, and Lora one file for each of its subsets.)

The fallback that keeps text from jumping

Until Lora arrives, the browser shows the text in a fallback font, one already on the computer. When Lora arrives, it swaps. If the two fonts take different room, every line changes length, and the text below moves: the page shifts. Visitors lose their place, or click the wrong thing. It has a name, Cumulative Layout Shift, and it's one of the Core Web Vitals, the numbers Google measures for how a page feels to use.

next/font makes a fallback that takes the font's room. The shop's recorded CSS, at the top of the page, has this rule after Lora's:

  • font-family:Lora Fallback and src:local(Times New Roman): a font named Lora Fallback, which is Times New Roman from the visitor's computer. next/font picks Times New Roman for a serif font like Lora, and Arial for a sans-serif one.
  • size-adjust:115.2%: draw it 15.2% bigger, so its letters are as wide as Lora's, on average.
  • ascent-override:87.33%, descent-override:23.78% and line-gap-override:0.0%: how much room each line takes above and below the letters, set to Lora's.

The class says font-family:Lora,Lora Fallback: Lora when it has arrived, the resized Times New Roman until then. The numbers come from measurements of each font that Next.js ships with. To see the difference, the shop's comparison page draws one sentence three times, each in one font, with a box around it:

One sentence in Lora, in Lora Fallback, and in Times New RomanNext.js
app/compare/
app/compare/page.tsx
import styles from "./compare.module.css"; const sentence = "Green tea, mint tea and oolong"; export default function ComparePage() {  return (    <main>      <p className={styles.label}>Lora, once it has loaded</p>      <p className={styles.lora}>{sentence}</p>      <p className={styles.label}>Lora Fallback: Times New Roman, resized</p>      <p className={styles.fallback}>{sentence}</p>      <p className={styles.label}>Times New Roman as it is</p>      <p className={styles.plain}>{sentence}</p>    </main>  );}
Recorded from Next.js 16.4.3
localhost:3000/compareTea shop
The page at /compare, as Chrome drew it with its styles. It says: Lora, once it has loaded. Green tea, mint tea and oolong. Lora Fallback: Times New Roman, resized. Green tea, mint tea and oolong. Times New Roman as it is. Green tea, mint tea and oolong.

A picture of the page in real Chrome, in a window 800 px wide, taken once the page had loaded with its styles. Open it full size

In app/compare/page.tsx, lines 9, 11 and 13 draw the sentence with three classes from compare.module.css, a CSS Module. Lines 14 to 16 of the CSS Module name "Lora Fallback" directly, only to show it on its own. Real Chrome's picture shows the result: the resized fallback's box is almost exactly as wide and as tall as Lora's, and plain Times New Roman's is clearly narrower and shorter. Swap the second line for the first and almost nothing moves. Swap the third for the first and the line grows.

Two halves. Left, plain Times New Roman: the heading is narrow while Lora downloads, then wider once Lora arrives, and the text moves. Right, Lora Fallback: the heading is the same width before and after Lora arrives, and the text stays put.Times New Roman as it isbeforeTea shopafterTea shopthe text movesLora FallbackbeforeTea shopafterTea shopit stays put
With display: "swap", the browser shows a fallback font while Lora downloads, then swaps. Plain Times New Roman takes less room than Lora, so the text moves when they swap. Lora Fallback is Times New Roman resized to take Lora's room, so it barely moves.

You get this without asking. Two options change it, and they're easy to mix up:

  • adjustFontFallback is meant to control the resized fallback itself. For a Google font it's true or false, and true by default. webpack honours it, but with Turbopack, the default, 16.4.3 ignores it for a Google font: the Rust code that reads the options expects adjust_font_fallback (issue 74134), so adjustFontFallback: false stays true. Images and fonts under the hood records it. For a local font it names the font to resize: "Arial" (the default), "Times New Roman", or false for none.
  • fallback is a list of font families of your own, for the CSS to try when the font isn't there, like ["Georgia", "serif"].

Here is Lora with fallback, built the same way:

Lora with fallback families of the shop's ownNext.js
app/
app/layout.tsx
import { Lora } from "next/font/google";import "./globals.css"; const lora = Lora({  subsets: ["latin"],  fallback: ["Georgia", "serif"],}); export const metadata = { title: "Tea shop" }; export default function RootLayout({ children }: LayoutProps<"/">) {  return (    <html lang="en">      <body className={lora.className}>{children}</body>    </html>  );}
Recorded from Next.js 16.4.3

GET /200

Response headers
Vary rsc, next-router-state-tree, next-router-prefetch, next-router-segment-prefetch, Accept-Encoding
x-nextjs-cache HIT
x-nextjs-prerender 1
x-nextjs-stale-time 300
X-Powered-By Next.js
Cache-Control s-maxage=31536000
Content-Type text/html; charset=utf-8

The HTML that arrived, one element per line, with the code of its 14 scripts left out

<!DOCTYPE html>
<html lang="en">
  <head>
    <meta charset="utf-8">
    <meta name="viewport" content="width=device-width, initial-scale=1">
    <link rel="stylesheet" href="/_next/static/chunks/2b4wod6nsrv18.css" data-precedence="next">
    <link rel="preload" as="script" fetchpriority="low" href="/_next/static/chunks/3cfn99dbrcvvp.js">
    <script src="/_next/static/chunks/1j_b1xq_woyiy.js" async=""></script>
    <script src="/_next/static/chunks/2ki52vdnbo2ft.js" async=""></script>
    <script src="/_next/static/chunks/turbopack-3-ry682tkyiqk.js" async=""></script>
    <script src="/_next/static/chunks/1tq6ppe__m8m-.js" async=""></script>
    <title>Tea shop</title>
    <script src="/_next/static/chunks/0cz1d0mv5g_q7.js" nomodule=""></script>
  </head>
  <body class="_9nL0yG_className">
    <div hidden="">
      <!--$-->
      <!--/$-->
    </div>
    <!--&-->
    <main>
      <h1>Tea shop</h1>
      <p>Fresh tea, sent to your door. Every box is packed by hand.</p>
    </main>
    <!--$-->
    <!--/$-->
    <!--/&-->
    <script id="_R_">…</script>
    <script src="/_next/static/chunks/3cfn99dbrcvvp.js" async=""></script>
    <script>…</script>
    <script>…</script>
    <script>…</script>
    <script>…</script>
    <script>…</script>
    <script>…</script>
    <script>…</script>
  </body>
</html>

The stylesheet the HTML links, with the CSS Next.js built for it, laid out to read

  1. /_next/static/chunks/2b4wod6nsrv18.css
    @font-face {
      font-family:Lora;
      font-style:normal;
      font-weight:400 700;
      font-display:swap;
      src:url(../media/c713a0aab7a8ca36.0mybutugvu-lq.woff2)format("woff2");
      unicode-range:U+460-52F,U+1C80-1C8A,U+20B4,U+2DE0-2DFF,U+A640-A69F,U+FE2E-FE2F
    }
    @font-face {
      font-family:Lora;
      font-style:normal;
      font-weight:400 700;
      font-display:swap;
      src:url(../media/2c6bb81ef2959eb3.2fpqrm51ez0iq.woff2)format("woff2");
      unicode-range:U+301,U+400-45F,U+490-491,U+4B0-4B1,U+2116
    }
    @font-face {
      font-family:Lora;
      font-style:normal;
      font-weight:400 700;
      font-display:swap;
      src:url(../media/e929b158eb81aae9.02frcczqg7k-8.woff2)format("woff2");
      unicode-range:U+302-303,U+305,U+307-308,U+310,U+312,U+315,U+31A,U+326-327,U+32C,U+32F-330,U+332-333,U+338,U+33A,U+346,U+34D,U+391-3A1,U+3A3-3A9,U+3B1-3C9,U+3D1,U+3D5-3D6,U+3F0-3F1,U+3F4-3F5,U+2016-2017,U+2034-2038,U+203C,U+2040,U+2043,U+2047,U+2050,U+2057,U+205F,U+2070-2071,U+2074-209F,U+20D0-20DC,U+20E1,U+20E5-20EF,U+2100-2112,U+2114-2115,U+2117-2121,U+2123-214F,U+2190,U+2192,U+2194-21AE,U+21B0-21E5,U+21F1-21F2,U+21F4-2211,U+2213-2214,U+2216-22FF,U+2308-230B,U+2310,U+2319,U+231C-2321,U+2336-237A,U+237C,U+2395,U+239B-23B7,U+23D0,U+23DC-23E1,U+2474-2475,U+25AF,U+25B3,U+25B7,U+25BD,U+25C1,U+25CA,U+25CC,U+25FB,U+266D-266F,U+27C0-27FF,U+2900-2AFF,U+2B0E-2B11,U+2B30-2B4C,U+2BFE,U+3030,U+FF5B,U+FF5D,U+1D400-1D7FF,U+1EE??
    }
    @font-face {
      font-family:Lora;
      font-style:normal;
      font-weight:400 700;
      font-display:swap;
      src:url(../media/02ee680d6f7b72e4.2936i88_6qsfd.woff2)format("woff2");
      unicode-range:U+1-C,U+E-1F,U+7F-9F,U+20DD-20E0,U+20E2-20E4,U+2150-218F,U+2190,U+2192,U+2194-2199,U+21AF,U+21E6-21F0,U+21F3,U+2218-2219,U+2299,U+22C4-22C6,U+2300-243F,U+2440-244A,U+2460-24FF,U+25A0-27BF,U+28??,U+2921-2922,U+2981,U+29BF,U+29EB,U+2B??,U+4DC0-4DFF,U+FFF9-FFFB,U+10140-1018E,U+10190-1019C,U+101A0,U+101D0-101FD,U+102E0-102FB,U+10E60-10E7E,U+1D2C0-1D2D3,U+1D2E0-1D37F,U+1F0??,U+1F100-1F1AE,U+1F1E6-1F1FF,U+1F30D-1F30F,U+1F315,U+1F31C,U+1F31E,U+1F320-1F32C,U+1F336,U+1F378,U+1F37D,U+1F382,U+1F393-1F39F,U+1F3A7-1F3A8,U+1F3AC-1F3AF,U+1F3C2,U+1F3C4-1F3C6,U+1F3CA-1F3CE,U+1F3D4-1F3E0,U+1F3ED,U+1F3F1-1F3F3,U+1F3F5-1F3F7,U+1F408,U+1F415,U+1F41F,U+1F426,U+1F43F,U+1F441-1F442,U+1F444,U+1F446-1F449,U+1F44C-1F44E,U+1F453,U+1F46A,U+1F47D,U+1F4A3,U+1F4B0,U+1F4B3,U+1F4B9,U+1F4BB,U+1F4BF,U+1F4C8-1F4CB,U+1F4D6,U+1F4DA,U+1F4DF,U+1F4E3-1F4E6,U+1F4EA-1F4ED,U+1F4F7,U+1F4F9-1F4FB,U+1F4FD-1F4FE,U+1F503,U+1F507-1F50B,U+1F50D,U+1F512-1F513,U+1F53E-1F54A,U+1F54F-1F5FA,U+1F610,U+1F650-1F67F,U+1F687,U+1F68D,U+1F691,U+1F694,U+1F698,U+1F6AD,U+1F6B2,U+1F6B9-1F6BA,U+1F6BC,U+1F6C6-1F6CF,U+1F6D3-1F6D7,U+1F6E0-1F6EA,U+1F6F0-1F6F3,U+1F6F7-1F6FC,U+1F700-1F80B,U+1F810-1F847,U+1F850-1F859,U+1F860-1F887,U+1F890-1F8AD,U+1F8B0-1F8BB,U+1F8C0-1F8C1,U+1F8D0-1F8D8,U+1F900-1F90B,U+1F93B,U+1F946,U+1F984,U+1F996,U+1F9E9,U+1FA00-1FA6F,U+1FA70-1FA7C,U+1FA80-1FAC6,U+1FAC8,U+1FACC-1FADD,U+1FADF-1FAEB,U+1FAEF-1FAFA,U+1FB??
    }
    @font-face {
      font-family:Lora;
      font-style:normal;
      font-weight:400 700;
      font-display:swap;
      src:url(../media/80aaaae4466959af.15gdzqknx46iu.woff2)format("woff2");
      unicode-range:U+102-103,U+110-111,U+128-129,U+168-169,U+1A0-1A1,U+1AF-1B0,U+300-301,U+303-304,U+308-309,U+323,U+329,U+1EA0-1EF9,U+20AB
    }
    @font-face {
      font-family:Lora;
      font-style:normal;
      font-weight:400 700;
      font-display:swap;
      src:url(../media/1854c6960aa5e9fd.2qdkzeru_ecot.woff2)format("woff2");
      unicode-range:U+100-2BA,U+2BD-2C5,U+2C7-2CC,U+2CE-2D7,U+2DD-2FF,U+304,U+308,U+329,U+1D00-1DBF,U+1E00-1E9F,U+1EF2-1EFF,U+2020,U+20A0-20AB,U+20AD-20C4,U+2113,U+2C60-2C7F,U+A720-A7FF
    }
    @font-face {
      font-family:Lora;
      font-style:normal;
      font-weight:400 700;
      font-display:swap;
      src:url(../media/c3d9c74ef0d9c07b.p.23aeddxv5enbo.woff2)format("woff2");
      unicode-range:U+??,U+131,U+152-153,U+2BB-2BC,U+2C6,U+2DA,U+2DC,U+304,U+308,U+329,U+2000-206F,U+20AC,U+2122,U+2191,U+2193,U+2212,U+2215,U+FEFF,U+FFFD
    }
    ._9nL0yG_className {
      font-family:Lora,Georgia,serif;
      font-style:normal
    }
    body {
      color:#2f3b2f;
      background:#f6f3ea;
      margin:0;
      padding:24px 32px
    }

Line 6 of app/layout.tsx passes fallback: ["Georgia", "serif"]. In the Network tab, the class says font-family:Lora,Georgia,serif, and the CSS has no Lora Fallback rule: in this recorded build, with Turbopack, the families you list took the resized fallback's place. How the two combine depends on the bundler in Next.js 16.4.3: Turbopack uses your list instead (font_fallback.rs, lines 67 and 68), while the webpack loader keeps the resized fallback and adds your families after it (postcss-next-font.ts, lines 132 to 136). To keep text from jumping, leave fallback out.

A font file of your own

The shop's tea of the day is written in Caveat, a handwriting font, from a file the shop keeps in its project. (Caveat is free to share under the SIL Open Font License; the license travels with the file.) For a font file, use localFont from next/font/local. The shop keeps its fonts in one file, app/fonts.ts, and imports them where they're used:

Caveat from a font file, on the teas pageNext.js
app/fonts/teas/
app/fonts.ts
import localFont from "next/font/local"; export const caveat = localFont({  src: "./fonts/Caveat.woff2",  weight: "400 700",});
Recorded from Next.js 16.4.3
localhost:3000/teasTea shop
The page at /teas, as Chrome drew it with its styles. It says: Tea of the day: mint. Picked this morning, packed by hand.

A picture of the page in real Chrome, in a window 800 px wide, taken once the page had loaded with its styles. Open it full size

Line 1 of app/fonts.ts imports localFont. Lines 3 to 6 call it and export the result as caveat. Line 4 is the file's path, relative to app/fonts.ts: the file is app/fonts/Caveat.woff2 in the tree. (WOFF2 is the compressed font format every current browser reads.) Caveat is a variable font, so line 5 gives its range of weights, "400 700". For a font that comes as several files, like a regular and a bold one, src takes a list, with a path, weight and style for each. In app/teas/page.tsx, line 1 imports caveat, and line 6 puts its className on the heading.

Open the Network tab, and pick each page above the HTML:

  • The CSS has an @font-face rule whose src is a copy of the file in /_next/static/media/, with font-weight:400 700, and a fallback rule: src:local(Arial) with size-adjust:74.77%. For a local font, next/font measures the file itself while it builds, and uses Arial unless you say otherwise.
  • /teas's link header preloads two fonts: Caveat and Lora. /'s preloads only Lora. Next.js preloads a font on the routes whose files use it: Lora is in the root layout, so every route preloads it; Caveat is only in the teas page.

The rules are the same as for a Google font, without the download: the build copies the file, writes the CSS, preloads it, and sizes a fallback. Load each font once, in one place like app/fonts.ts, and import it where you need it: each call of a font function adds the font to the app again.

Fonts as CSS variables

className sets the font on one element. A shop with two fonts usually wants them in its CSS instead: Lora for the text, Caveat for every heading. For that, give each font a variable. Each row is one way to apply a font:

Three ways to apply a font
What you useWhat it isWhat it reaches
lora.classNameA class that sets font-family.One element and everything inside it.
lora.styleA style object with fontFamily, for the style prop.One element, without a class.
lora.variableA class that defines a CSS variable, like --font-lora.Any CSS inside that element: global CSS, a CSS Module, Tailwind CSS.
Both fonts as CSS variables, used from globals.cssNext.js
app/fonts/
app/fonts.ts
import { Lora } from "next/font/google";import localFont from "next/font/local"; export const lora = Lora({  subsets: ["latin"],  variable: "--font-lora",}); export const caveat = localFont({  src: "./fonts/Caveat.woff2",  weight: "400 700",  variable: "--font-caveat",});
Recorded from Next.js 16.4.3
localhost:3000/Tea shop
The page at /, as Chrome drew it with its styles. It says: Tea of the day: mint. Fresh tea, sent to your door. Every box is packed by hand.

A picture of the page in real Chrome, in a window 800 px wide, taken once the page had loaded with its styles. Open it full size

In app/fonts.ts, line 6 names Lora's variable --font-lora, and line 12 names Caveat's --font-caveat. Now lora.variable is a class that defines --font-lora. Line 8 of app/layout.tsx puts both classes on <html>. In app/globals.css, line 6 gives the body var(--font-lora), and line 10 gives every h1 var(--font-caveat). The Network tab shows the variables' classes (their names made up for this build), like .fAxLeq_variable{--font-lora:"Lora", "Lora Fallback"}: the value is the font and its fallback. Caveat's class sets --font-caveat:"caveat", "caveat Fallback" the same way, with the family names next/font gave the local font in this build.

On the left, an html window holding the two CSS variables its classes define: --font-lora and --font-caveat. Arrows go right to two CSS rules: body uses var(--font-lora), and h1 uses var(--font-caveat).html--font-lora--font-caveatbody: var(--font-lora)h1: var(--font-caveat)your CSS
lora.variable and caveat.variable are classes on <html>. Each defines a CSS variable, and every element inside can read it: body reads --font-lora, and h1 reads --font-caveat.

A variable is just CSS, so it works from a CSS Module too, as font-family: var(--font-lora) in any class. It also works with Tailwind CSS. Tailwind CSS in Next.js pointed font-sans at Geist this way. A theme variable in Tailwind CSS's --font-* namespace makes a class of its own, so the shop can have a font-hand class for Caveat:

The two fonts as Tailwind CSS's font-serif and font-handNext.js
app/fonts/
app/globals.css
@import "tailwindcss"; @theme inline {  --font-serif: var(--font-lora);  --font-hand: var(--font-caveat);}
Recorded from Next.js 16.4.3
localhost:3000/Tea shop
The page at /, as Chrome drew it with its styles. It says: Tea of the day: mint. Fresh tea, sent to your door.

A picture of the page in real Chrome, in a window 800 px wide, taken once the page had loaded with its styles. Open it full size

Lines 3 to 6 of app/globals.css point --font-serif at Lora's variable and add --font-hand for Caveat's. The Network tab has .font-hand{font-family:var(--font-caveat)} and .font-serif{font-family:var(--font-lora)}, and the layout's body uses font-serif. Line 4 of app/page.tsx gives the heading font-hand, and the picture shows it in Caveat. app/fonts.ts and app/layout.tsx are the same as before.

Does the visitor's browser ask Google?

Fonts reach a page in many ways. Sort these: does each one make the visitor's browser ask Google, or only the shop's own site?

Who does the browser ask for the font?
  • Lora({ subsets: ["latin"] }) from next/font/google
  • <link rel="stylesheet" href="https://fonts.googleapis.com/css2?…">
  • @import url("https://fonts.googleapis.com/css2?family=Lora"); in globals.css
  • localFont({ src: "./fonts/Caveat.woff2" }) from next/font/local
  • Lora({ subsets: ["latin"], variable: "--font-lora" })
  • @font-face with src: url("https://fonts.gstatic.com/…")
Try it yourself
0 of 6 correct

Sort each way of loading a font: does the visitor's browser ask only the shop's own site, or Google too?

Choose a category for every card. You can change an answer at any time; Reset clears them all.

Common mix-ups

  • "next/font/google loads the font from Google in the browser." No. The build downloads it, and the visitor's browser gets it from your site. The recorded visit asked no other site.
  • "subsets removes every other alphabet." No. The CSS still has a rule for each subset; subsets picks which file to preload.
  • "Every font needs a weight." Only fonts that aren't variable. A variable font like Lora has every weight in its range in one file.
  • "The fallback font is a second download." No. It's a font already on the computer, like Times New Roman or Arial, resized with size-adjust.
  • "Calling the font function inside a component is fine." Call it once, at the top of a file, and import the result. Each call adds the font to the app again.
  • "A font in the root layout and a font in one page are preloaded the same way." No. A font is preloaded on the routes that use it: the root layout's on every route, a page's only on that page.
  • "variable gives you the font's name." It gives you a class name. The class defines the CSS variable, so put it on an element around the text.

Practice exercises

A Next.js project can't run inside this page, so answer first, then use the hints.

Exercise 1 · Warm-upWhere the font comes from

A visitor opens the shop with this root layout. Which site does their browser download Lora from?

Starter codeTSX
import { Lora } from "next/font/google";
import "./globals.css";

const lora = Lora({
  subsets: ["latin"],
});

export const metadata = { title: "Tea shop" };

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

Answer, then press Check. Spacing and letter case don’t matter.

    Exercise 2 · Warm-upFix the build

    The build stops with this layout. Which option is missing?

    Starter codeTSX
    import { Poppins } from "next/font/google";
    import "./globals.css";
    
    const poppins = Poppins({
      subsets: ["latin"],
    });
    
    export const metadata = { title: "Tea shop" };
    
    export default function RootLayout({ children }: LayoutProps<"/">) {
      return (
        <html lang="en">
          <body className={poppins.className}>{children}</body>
        </html>
      );
    }
    

    Answer, then press Check. Spacing and letter case don’t matter.

      Exercise 3 · PracticeWhere Caveat is preloaded

      app/fonts.ts loads Caveat, and only app/teas/page.tsx, below, uses it. The root layout uses Lora. Which of the shop's two pages, / and /teas, preloads Caveat?

      Starter codeTSX
      import { caveat } from "../fonts";
      
      export default function TeasPage() {
        return (
          <main>
            <h1 className={caveat.className}>Tea of the day: mint</h1>
            <p>Picked this morning, packed by hand.</p>
          </main>
        );
      }
      

      Answer, then press Check. Spacing and letter case don’t matter.

        Exercise 4 · PracticeUse a font from the CSS

        Both fonts have a variable, and both classes are on <html>. What goes after font-family: so that every heading is in Caveat?

        Starter codeCSS
        body {
          font-family: var(--font-lora);
        }
        
        h1 {
          font-family: /* Caveat goes here */;
          font-size: 48px;
        }
        

        Answer, then press Check. Spacing and letter case don’t matter.

          Exercise 5 · ChallengeA font-hand class

          The shop uses Tailwind CSS. Add one line to this globals.css so that the class font-hand puts text in Caveat.

          Starter codeCSS
          @import "tailwindcss";
          
          @theme inline {
            --font-serif: var(--font-lora);
          }
          

          Answer, then press Check. Spacing and letter case don’t matter.

            Exercise 6 · ChallengeWhy the heading jumps

            Another shop writes its own @font-face rule for a font file, with font-family: "Lora", serif. When the page loads, the heading appears in a plain serif font, then jumps wider when Lora arrives. Moving to next/font fixes the jump. What does next/font add that does it?

            Answer, then press Check. Spacing and letter case don’t matter.

              Check your understanding

              For each question, ask when the font is downloaded, by whom, and what the browser shows until it arrives.

              The Optimizing fonts quiz · 7 questionsScore: first tries count
              1. Question 1 of 7When does this layout's Lora font get downloaded from Google?

                Read the code, then predictTSX
                import { Lora } from "next/font/google";
                import "./globals.css";
                
                const lora = Lora({
                  subsets: ["latin"],
                });
                
                export const metadata = { title: "Tea shop" };
                
                export default function RootLayout({ children }: LayoutProps<"/">) {
                  return (
                    <html lang="en">
                      <body className={lora.className}>{children}</body>
                    </html>
                  );
                }
                

                Choose an answer to see the explanation.

              2. Question 2 of 7What does subsets: ["latin"] change?

                Choose an answer to see the explanation.

              3. Question 3 of 7Why does Poppins({ subsets: ["latin"] }) fail to build, when Lora({ subsets: ["latin"] }) builds?

                Choose an answer to see the explanation.

              4. Question 4 of 7What is the Lora Fallback font in the recorded CSS?

                Choose an answer to see the explanation.

              5. Question 5 of 7This app/fonts.ts gives Lora a variable. What does lora.variable give you?

                Read the code, then predictTS
                import { Lora } from "next/font/google";
                import localFont from "next/font/local";
                
                export const lora = Lora({
                  subsets: ["latin"],
                  variable: "--font-lora",
                });
                
                export const caveat = localFont({
                  src: "./fonts/Caveat.woff2",
                  weight: "400 700",
                  variable: "--font-caveat",
                });
                

                Choose an answer to see the explanation.

              6. Question 6 of 7A font is loaded in app/teas/page.tsx only. Where does Next.js preload it?

                Choose an answer to see the explanation.

              7. Question 7 of 7In this globals.css, what does line 5 make?

                Read the code, then predictCSS
                @import "tailwindcss";
                
                @theme inline {
                  --font-serif: var(--font-lora);
                  --font-hand: var(--font-caveat);
                }
                

                Choose an answer to see the explanation.

              Key takeaways

              • next/font/google downloads a Google font during next build, and your site serves it from /_next/static/media/. The visitor's browser never asks Google.
              • Call a font function once, at the top of a file, and use its className, style or variable. Put fonts you share in one file, like app/fonts.ts.
              • subsets picks the file to preload. A variable font needs no weight; any other font needs at least one, and each weight is another file.
              • next/font adds a fallback font sized like yours, with size-adjust and the line overrides, so text barely moves when the font arrives. display is swap by default.
              • next/font/local does the same for a font file of your own. A font is preloaded on the routes that use it.
              • With variable, a font becomes a CSS variable that global CSS, CSS Modules and Tailwind CSS's @theme can use.

              Remember the one-liner.
              next/font fetches the font when you build, serves it from your site, and sizes a fallback so nothing jumps.

              Coming next: Scripts and third-party code. It loads analytics, a chat widget, a map and a video with next/script, and records when each one loads.

              CompleteFrontend Clear concepts. Working examples.