50% off SaaS Starter Kit — only for the first 100 buildersGrab it →
← Back to blog
next.jsMay 22, 2026·10 min read

Next.js Font Optimization: Why Your Fonts Are Slowing You Down

next/font exists and most devs still load fonts the slow way. Here's what's actually happening and how to fix it properly.

Robert Seghedi

Robert Seghedi

Co-founder, peal.dev

Next.js Font Optimization: Why Your Fonts Are Slowing You Down

We once spent an entire afternoon chasing a 0.8 second LCP regression on a client project. Lighthouse kept pointing at render-blocking resources. We checked images, checked bundle sizes, checked everything we could think of. Turns out it was a Google Fonts link tag sitting in the `<head>` — something we'd copy-pasted from the Google Fonts website and never questioned. Classic.

Here's the uncomfortable truth: fonts are one of the most impactful performance decisions you'll make for a web app, and they're also one of the most ignored. Devs obsess over bundle size and lazy loading but then casually drop a `<link rel='stylesheet' href='https://fonts.googleapis.com/css2?family=Inter...' />` in their layout and call it a day. That single line can cost you 300-500ms on a cold load.

What Actually Happens When You Load a Font

When a browser encounters your page, it has to figure out what fonts to load. The typical Google Fonts flow goes like this: browser hits your page, finds the stylesheet link, makes a DNS lookup to fonts.googleapis.com, downloads the CSS file, parses it to find the actual font file URLs, makes another request (possibly to fonts.gstatic.com), downloads the font files, and only THEN starts rendering text. That's two additional network round trips to external domains before your user sees any text.

This is what causes FOUT (Flash of Unstyled Text) — that annoying moment where your page renders with system fonts then jumps to your custom font. Or FOIT (Flash of Invisible Text) if the browser decides to hide text entirely until the font loads. Both are terrible experiences and both trash your CLS (Cumulative Layout Shift) score.

  • DNS lookup to fonts.googleapis.com: ~50-150ms depending on user location
  • CSS file download: another round trip
  • Font file download from fonts.gstatic.com: the actual heavy payload
  • Layout recalculation when fonts finally load: CLS nightmare
  • No caching guarantees across deploys when you self-host wrong

The next/font Solution (and How Most People Get It Wrong)

Next.js ships with `next/font` — a font optimization system that self-hosts fonts at build time, inlines critical CSS, and eliminates external network requests entirely. It's been available since Next.js 13 and it's genuinely excellent. Yet we still see projects in 2025 using old-school Google Fonts links. So let's actually go through how to use it correctly.

// app/layout.tsx — the RIGHT way
import { Inter, Fira_Code } from 'next/font/google'

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

const firaCode = Fira_Code({
  subsets: ['latin'],
  weight: ['400', '500'],
  display: 'swap',
  variable: '--font-fira-code',
})

export default function RootLayout({
  children,
}: {
  children: React.ReactNode
}) {
  return (
    <html lang="en" className={`${inter.variable} ${firaCode.variable}`}>
      <body className={inter.className}>
        {children}
      </body>
    </html>
  )
}

A few things worth calling out here. First, you initialize fonts outside the component — this is intentional. `next/font` is optimized to run at build time, not at runtime. If you put the initialization inside the component, you'll get a warning and lose the optimization benefits. Second, we're using CSS variables (`variable` option) which lets you use the font in Tailwind or any CSS-in-JS solution without coupling your component tree to the font import.

The `display: 'swap'` option is important. It tells the browser to render text with a fallback font immediately and swap to your custom font when it loads. This prevents FOIT (invisible text) at the cost of a potential layout shift — which is still better than invisible text.

Variable Fonts: Load One File, Get All the Weights

Here's something that saves significant bandwidth: variable fonts. Instead of loading Inter 400, Inter 500, Inter 600, and Inter 700 as separate files (four network requests), a variable font ships one file that contains the entire weight range. Inter is a variable font. So is Plus Jakarta Sans, Outfit, and most of the modern ones you probably want to use.

// Variable font — ONE file covers all weights
import { Inter } from 'next/font/google'

const inter = Inter({
  subsets: ['latin'],
  display: 'swap',
  variable: '--font-inter',
  // No need to specify weights — the variable font handles it
})

// Non-variable font — you need to be explicit
import { Roboto } from 'next/font/google'

const roboto = Roboto({
  subsets: ['latin'],
  display: 'swap',
  weight: ['400', '500', '700'], // Required for non-variable fonts
  variable: '--font-roboto',
})

When you don't specify weights on a variable font, `next/font` downloads the full variable font file. When you do specify weights on a non-variable font, it downloads only those specific weight files. The mistake we see is people specifying weights on variable fonts — it doesn't error, but you end up downloading multiple static font files instead of one variable file. Check if your font is variable before deciding how to configure it.

Subsets: Don't Load Characters You Don't Need

Font files contain character sets. Inter latin is about 100KB. Inter with latin-ext (extended Latin for accented characters), Greek, Cyrillic, and Vietnamese is several times larger. If your app is English-only, you're loading a bunch of characters nobody will ever see.

The `subsets` option in `next/font` controls exactly this. For most English-language apps, `['latin']` is all you need. If you're building for users in Eastern Europe (hi, we're Romanian), you probably want `['latin', 'latin-ext']` to get proper rendering of characters like ș, ț, ă, â, î. If you're building multilingual, be deliberate about what subsets you include.

// For a Romanian/Eastern European audience
const inter = Inter({
  subsets: ['latin', 'latin-ext'],
  display: 'swap',
  variable: '--font-inter',
})

// For a global app with Cyrillic support
const inter = Inter({
  subsets: ['latin', 'latin-ext', 'cyrillic'],
  display: 'swap',
  variable: '--font-inter',
})

Self-Hosted Fonts with next/font/local

Sometimes you're using a paid font — a custom brand typeface, or something from a type foundry that doesn't exist on Google Fonts. For that, `next/font/local` gives you the same optimization benefits but with font files you supply yourself. Drop the files in your project and point the config at them.

// app/layout.tsx — local font
import localFont from 'next/font/local'

const brandFont = localFont({
  src: [
    {
      path: '../fonts/BrandFont-Regular.woff2',
      weight: '400',
      style: 'normal',
    },
    {
      path: '../fonts/BrandFont-Medium.woff2',
      weight: '500',
      style: 'normal',
    },
    {
      path: '../fonts/BrandFont-Bold.woff2',
      weight: '700',
      style: 'normal',
    },
  ],
  display: 'swap',
  variable: '--font-brand',
  fallback: ['system-ui', 'sans-serif'],
})

// Or if you have a variable font file
const brandFontVariable = localFont({
  src: '../fonts/BrandFont-Variable.woff2',
  display: 'swap',
  variable: '--font-brand',
})

One thing `next/font/local` does that's easy to miss: it automatically generates the `size-adjust`, `ascent-override`, `descent-override`, and `line-gap-override` CSS properties for your fallback fonts. These properties adjust the fallback font metrics to match your custom font as closely as possible. The result is that when your custom font loads and swaps in, the layout barely shifts because the fallback was already sized almost identically. CLS basically goes to zero.

Connecting next/font to Tailwind

The CSS variable approach (`variable: '--font-inter'`) is exactly what you need to wire up `next/font` with Tailwind. Set up your CSS variables in the layout, then reference them in your Tailwind config.

// tailwind.config.ts
import type { Config } from 'tailwindcss'

const config: Config = {
  content: [
    './pages/**/*.{js,ts,jsx,tsx,mdx}',
    './components/**/*.{js,ts,jsx,tsx,mdx}',
    './app/**/*.{js,ts,jsx,tsx,mdx}',
  ],
  theme: {
    extend: {
      fontFamily: {
        sans: ['var(--font-inter)', 'system-ui', 'sans-serif'],
        mono: ['var(--font-fira-code)', 'monospace'],
        brand: ['var(--font-brand)', 'sans-serif'],
      },
    },
  },
  plugins: [],
}

export default config

// Now use it in components:
// <p className="font-sans">Normal text</p>
// <code className="font-mono">Code text</code>
// <h1 className="font-brand">Brand heading</h1>

The fallback array after `var(--font-inter)` matters. If somehow the CSS variable isn't defined — say, in an email preview context or some weird edge case — you want a sensible fallback. `system-ui` renders the OS default sans-serif which is fine for those rare cases.

Common Mistakes We've Seen (and Made)

  • Importing the same font multiple times across different files — next/font deduplicates but you should still initialize once and export the instance
  • Using @import in CSS for Google Fonts instead of next/font — yes, people still do this
  • Forgetting to add the font's className or variable to the HTML/body element — the fonts get downloaded but never actually applied
  • Loading 6+ font weights 'just in case' — load what you actually use, audit your CSS and find out which weights your design system actually needs
  • Using a non-variable font and forgetting to specify weights — next/font will throw an error in this case, but it's a confusing one
  • Mixing next/font and a CSS framework's font import — some UI libraries include their own font imports that conflict
Quick audit: open your Network tab, filter by 'Font', and look at how many font files load and from where. If you see requests to fonts.googleapis.com or fonts.gstatic.com, you're not using next/font correctly yet.

Preloading and the font-display Cascade

`next/font` automatically adds `<link rel='preload'>` tags for your critical fonts. You don't have to think about this — it just happens. But it's worth understanding what it does: preloading tells the browser to fetch the font file with high priority before it even parses the CSS. This eliminates the delay between 'browser discovers it needs the font' and 'browser starts downloading it'.

What `next/font` won't do for you is preload fonts for every route. It preloads fonts used in the layout file, which is correct — those are the fonts that affect your above-the-fold content. For page-specific fonts (rare, but it happens), those get loaded on demand. If you find yourself with a font that only loads on a specific heavy-traffic page, consider whether it should actually be in the layout instead.

All the templates on peal.dev ship with `next/font` configured correctly out of the box — Google Fonts properly self-hosted, CSS variables wired to Tailwind, and reasonable subset choices already made. We got tired of seeing this be the first thing developers break when starting from scratch.

Measuring the Impact

If you're migrating an existing project from Google Fonts links to `next/font`, here's how to actually measure the improvement instead of just eyeballing it.

  • Run Lighthouse before and after — look at LCP, CLS, and 'Eliminate render-blocking resources' in Opportunities
  • WebPageTest with a throttled connection (Slow 3G or Fast 3G) — font performance differences are way more obvious on slow connections
  • Check 'First Contentful Paint' — this often drops by 200-400ms just from eliminating the external font DNS lookup
  • Network tab waterfall — you should see zero requests to fonts.googleapis.com after the migration
  • CLS score — should drop significantly once next/font's fallback metric adjustment kicks in

The gains are real and consistent. We've seen CLS go from 0.15+ (bad) to near-zero just from the fallback metric adjustments. LCP improvements of 200-500ms are common when eliminating the external font requests. For a content-heavy site where typography is the main element above the fold, fonts are often the single biggest performance win left on the table.

One last thing: audit how many font families you're actually loading. We've worked on projects with four different font families — a heading font, a body font, a mono font, and some 'accent' font someone thought looked cool. Each family potentially means additional files. Every font you add has a cost. Ask whether you really need it, or whether good use of weights and styles in a single variable font gets you 90% of the visual variety you want.

The best font optimization is often picking one good variable font and using it well, rather than loading a whole typography system. Boring but fast.
Newsletter

Liked this post? There's more where it came from.

Dev guides, honest build stories, and the occasional 2am debugging confession — straight to your inbox. No spam, unsubscribe anytime.

Browse templates
Written by humansWeekly dropsSubscriber perks

Join the Discord

Ask questions, share builds, get help from founders