EmpireUI
Get Pro
← Blog8 min read#next.js#fonts#optimization

Next.js Font Optimization: next/font, Variable Fonts, Layout Shift

Stop serving fonts the old way. Learn how next/font, variable fonts, and CLS fixes actually work together in a real Next.js project.

Typography design tools and font specimen sheets on a desk

Why fonts still tank your Core Web Vitals

Fonts are one of the last things developers think about and the first thing Google's crawler notices. Your Lighthouse score can look decent until a late-loading Google Font blows up your Cumulative Layout Shift (CLS) above 0.1 — and suddenly you're ranking behind pages that look worse than yours.

Here's what's actually happening: the browser fetches your HTML, parses a <link rel='stylesheet'> pointing to fonts.googleapis.com, waits on that external request, then repaints text once the font loads. That repaint is your CLS. It's not subtle either — on a slow 3G connection you can see it with your own eyes: a flash of unstyled text, then a jump as the glyphs land.

Honestly, a lot of teams just live with this. They add font-display: swap and call it done. That's better than nothing, but you're still shipping a render-blocking waterfall request to a third-party domain that you don't control. Next.js 13+ gives you a real answer to this.

Worth noting: this isn't just a performance concern. Since 2023, CLS is a confirmed ranking signal in Google's Page Experience update. A high CLS score directly costs you traffic — that's a business problem, not just a DX one.

How next/font works under the hood

The next/font package, introduced in Next.js 13 and polished up through 14 and 15, downloads font files at build time and serves them from your own domain. No external request, no DNS lookup to fonts.gstatic.com, no cross-origin latency. The font CSS gets injected inline into the <head> with zero JavaScript.

What that means in practice: your font is just a static asset sitting next to your JS chunks, served from the same CDN edge node that's already handling your app. The browser never has to open a new connection to a third-party host. This alone kills most of your CLS because the font is available before first paint.

The API is dead simple. You import the font from next/font/google, call it as a function with your options, and attach the returned className or CSS variable to your root element:

// app/layout.tsx
import { Inter } from 'next/font/google'

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

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

The variable option is the key move here. Instead of tying yourself to a single hardcoded className, you expose a CSS custom property that your entire design system can reference. Your Tailwind config, your CSS modules, your inline styles — everything can reach var(--font-inter) without needing to re-import the font object.

Variable fonts and why you should be using them

A variable font is a single font file that contains the entire design space of a typeface — weights, widths, optical size, italic angle — all encoded in one binary. Before variable fonts, if you wanted Inter at 400, 500, 600, and 700 you were shipping four separate font files. With variable fonts, that's one file. The size reduction is real: Inter Variable is around 310KB total for the full Latin character set, versus 400KB+ for four separate weights.

Next.js 13.2 added explicit variable font support. Google Fonts has been shipping variable fonts for most of its major families since 2021. The way you opt into it with next/font is by not specifying a weight at all — or by passing a range:

import { Inter } from 'next/font/google'

// This gives you the full variable font (all weights in one file)
const inter = Inter({
  subsets: ['latin'],
  variable: '--font-inter',
})

// Or for a typeface that requires a range
import { Raleway } from 'next/font/google'
const raleway = Raleway({
  subsets: ['latin'],
  weight: ['400', '700'], // only fetch these two axes if variable isn't available
  variable: '--font-raleway',
})

In your CSS you then drive weight via font-weight on individual elements as normal — the browser interpolates across the variable font's weight axis. Quick aside: if you're using Tailwind, you still write font-bold or font-semibold. Tailwind emits font-weight: 700 or font-weight: 600 respectively, and the browser does the rest.

Look, you don't need to understand OpenType variable font internals to benefit from this. Just stop specifying weight arrays on Google Font imports unless you have a concrete reason. Let next/font fetch the variable version and move on.

Eliminating layout shift for real

CLS from fonts has two main causes: the font loading late and the fallback font having different metrics. font-display: swap solves the first part — it says 'show text immediately with the fallback, then swap when the real font arrives.' But the swap itself is the layout shift if your fallback metrics don't match.

Next.js 13 introduced adjustFontFallback, which is enabled by default. It generates a synthesized fallback @font-face rule with size-adjust, ascent-override, and descent-override properties tuned to match the metrics of the requested font as closely as possible. This means the text block stays the same height before and after the swap. No jump.

const inter = Inter({
  subsets: ['latin'],
  display: 'swap',
  adjustFontFallback: true, // default — generates metric-matched fallback
})

If you want zero layout shift instead of minimal layout shift, use display: 'optional'. This tells the browser not to swap at all if the font hasn't loaded within the first render pass. On a warm cache (second visit, CDN hit) the font is already there. On a cold first visit the user sees your system fallback permanently for that page load — which sounds bad but scores a perfect 0.0 CLS. That tradeoff is worth it for sites where repeat visitors matter more than first impressions.

One more thing — if you're self-hosting a font outside Google Fonts (custom brand typeface, licensed font), use next/font/local instead. Same API, same benefits, you just point it at a file in your project:

import localFont from 'next/font/local'

const brandFont = localFont({
  src: [
    { path: './fonts/BrandFont-Regular.woff2', weight: '400' },
    { path: './fonts/BrandFont-Bold.woff2', weight: '700' },
  ],
  variable: '--font-brand',
  display: 'swap',
})

Wiring fonts into Tailwind CSS

The CSS variable approach pays off most when you're using Tailwind. You define the font in your layout, expose it as a CSS variable, then register it in tailwind.config.ts so you can use it as a utility class anywhere in your project.

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

const config: Config = {
  content: ['./src/**/*.{ts,tsx}'],
  theme: {
    extend: {
      fontFamily: {
        sans: ['var(--font-inter)', 'system-ui', 'sans-serif'],
        heading: ['var(--font-clash)', 'system-ui', 'sans-serif'],
      },
    },
  },
}

export default config

Now font-sans and font-heading are available as Tailwind utilities. Your components don't import any font module — they just use className='font-heading text-4xl font-bold' and it works. The CSS variable bridges next/font's output to Tailwind's utility layer cleanly.

That said, watch out for one footgun: if you forget to attach the CSS variable to the <html> or <body> element in your layout, the variable is never defined and your Tailwind utility falls back to whatever comes next in the font-family stack. Check your root layout once and move on. It takes 30 seconds to verify in DevTools.

For inspiration on how a well-designed type system actually looks in practice — sizes, weights, variable fonts composing together — look at what Empire UI does with its glassmorphism components. The typography handling there is deliberate, not accidental, and you can inspect it right in the browser.

Measuring what you've actually fixed

Don't trust the theory. After you swap in next/font, actually measure. Open Chrome DevTools, go to the Performance tab, and record a cold-cache load of your page. Look at the Layout Shift records — you want to see zero or near-zero in the 0–2 second window where font swaps usually happen.

Lighthouse is useful but not sufficient here. It runs on a simulated Moto G4 at 4x CPU slowdown and a 150ms RTT. That's aggressive. Run Lighthouse to catch regressions, but also use Chrome's Core Web Vitals extension or the CrUX field data in Search Console to see what real users are experiencing. Lab data and field data don't always agree.

If you're still seeing CLS after switching to next/font, the culprit is usually not the font itself — it's something else shifting the layout. Images without width and height set are the number-one offender after fonts. After that it's dynamically-injected content (cookie banners, chat widgets, ads) that loads after the initial paint.

In practice, the full next/font setup — variable font, CSS variable, Tailwind integration, adjustFontFallback enabled — gets most Next.js projects to a CLS under 0.05. That's the 'good' threshold. Getting to exactly 0.0 requires display: optional and accepting the first-visit fallback tradeoff. The choice depends on your traffic profile. For a UI component library like Empire UI, where returning visitors are the core audience, display: optional is the right call. For a marketing landing page, display: swap with fallback adjustment is fine.

Common mistakes and quick fixes

The most common mistake is importing from next/font/google inside a component file rather than in layout.tsx. Every time that component re-renders in development, Next.js will complain because font instances are supposed to be singletons created at module load time outside the component tree. Define fonts in layout.tsx or a dedicated fonts.ts file and import the result.

// lib/fonts.ts — define once, import everywhere
import { Inter, Clash_Display } from 'next/font/google'

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

// For local fonts or less common typefaces:
import localFont from 'next/font/local'
export const clashDisplay = localFont({
  src: './ClashDisplay-Variable.woff2',
  variable: '--font-clash',
})

Second mistake: loading too many subsets. subsets: ['latin', 'latin-ext', 'vietnamese', 'cyrillic'] on a site that's 99% English is sending your users 3x the font data they need. Pick ['latin'] and add subsets only if you have actual content in those scripts. You can verify this with the Network tab — filter by 'Font' and check file sizes.

Third mistake: forgetting preload. By default, next/font preloads the font files it needs. Don't override this unless you actually know what you're doing — setting preload: false kills the main performance benefit of the whole system. Specifically, you'd lose the <link rel='preload'> that tells the browser to fetch the font file before it parses the CSS.

Worth noting: if you're building something visually heavy — think vaporwave or cyberpunk aesthetics with display typefaces — you'll probably want multiple font families. That's fine. Just define each in fonts.ts, attach each variable to the root element (you can combine classNames), and register each in your Tailwind config. The build step fetches them all at compile time. Your users never wait on a runtime font download.

FAQ

Does next/font work with the Pages Router or only App Router?

It works with both, but the setup differs. In App Router you attach the className to your root <html> element in layout.tsx. In Pages Router you do the same in _document.tsx or _app.tsx. The font loading behavior and build-time download are identical either way.

Can I use a paid or licensed font with next/font?

Yes — use next/font/local and point it at your font files. Put them in app/fonts/ or public/fonts/. The API accepts a src array where each entry has a path, weight, and optionally style. Next.js serves them as static assets from your domain, no external dependency needed.

Will switching to next/font affect my existing CSS that references font-family by name?

Only if you were relying on the font being globally registered by a Google Fonts <link>. With next/font you control the font-family name via the CSS variable or the generated className. Update your CSS to reference var(--font-inter) instead of the string 'Inter' and you're covered.

What's the actual CLS impact of not using next/font on a Google Fonts setup?

It depends on connection speed and cache state, but lab measurements on a simulated fast 4G connection typically show CLS between 0.05–0.25 from font swaps alone — that's in the 'needs improvement' to 'poor' range. Switching to next/font with adjustFontFallback usually drops this to under 0.05, often to 0.0 on repeat visits.

Free components in 40 styles
React & Tailwind, copy-paste ready.
Browse →

Read next

Next.js OG Image Generation: @vercel/og, Edge Runtime, Custom FontsNext.js App Router in 2026: What's Changed and What Still Trips People UpWeb Font Loading in 2026: next/font, variable fonts and CLSCLS Optimization Guide: Aspect Ratios, Font Fallbacks, Ad Slots