ProgrUmar Logo
Module 3: Styling & UI

Global Styles & Fonts with next/font

Duration: 12 mins

next/font

The next/font module downloads font files at build time, self-hosts them, and injects font-display: swap — eliminating the external network request and preventing layout shift.

Global Styles & Fonts with next/font

Typography is one of the first things users notice about a web app — and one of the most common sources of subtle performance problems. Loading fonts from Google Fonts the traditional way (<link href="https://fonts.googleapis.com/...">) adds an extra network request, blocks rendering, and causes layout shift as the font swaps in. On a slow connection this can make your perfectly optimised Next.js app feel sluggish.

next/font solves all of this. It downloads fonts at build time, self-hosts them alongside your app, eliminates the external network request, and guarantees zero layout shift. This lesson covers Google Fonts, local fonts, CSS variables, multiple fonts, and the global styles patterns that tie everything together.


1. Why next/font Exists

Here's what happens when you load a Google Font the traditional way:

  1. Browser requests your HTML
  2. Browser parses HTML, finds the Google Fonts <link> tag
  3. Browser makes a second request to fonts.googleapis.com
  4. Google returns a CSS file with a @font-face declaration pointing to fonts.gstatic.com
  5. Browser makes a third request to download the actual font file
  6. Font loads — text re-renders with the new font (layout shift)

That's two extra network round trips and a layout shift that tanks your CLS (Cumulative Layout Shift) Core Web Vitals score.

Here's what next/font does instead:

  1. At build time, Next.js downloads the font files from Google Fonts
  2. Stores them in your .next output alongside your JavaScript
  3. Generates a @font-face rule pointing to your own domain
  4. Injects font-display: optional or swap to prevent layout shift

The result: fonts load from your own server, zero external requests, zero layout shift, and your CLS score stays at zero.


2. Loading a Google Font

Import the font you want from next/font/google. Every Google Font is available as a named export:

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

// Configure the font
const inter = Inter({
  subsets: ["latin"],        // Only download the character subsets you need
  weight: ["400", "500", "600", "700"], // Only download the weights you use
  display: "swap",           // Show fallback font until custom font loads
});

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

inter.className is a string like "__Inter_abc123" that Next.js generates at build time. Applying it to <body> makes Inter the default font for your entire app via CSS inheritance.

Variable Fonts — The Recommended Approach

If the font supports variable font format (most modern Google Fonts do), you can omit the weight array entirely — a variable font contains all weights in a single file, which is smaller and more flexible:

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

// No weight array needed — variable font handles all weights automatically
const inter = Inter({
  subsets: ["latin"],
  display: "swap",
});

With a variable font loaded, you can use any font weight in CSS (font-weight: 350, font-weight: 720) and it just works — no extra downloads needed.


3. Using Multiple Fonts

Most designs use two fonts — one for body text and one for headings or display text. The cleanest way to handle this is with CSS variables so both fonts are available throughout your app:

// app/layout.tsx
import { Inter, Playfair_Display } from "next/font/google";

// Body font — clean, readable sans-serif
const inter = Inter({
  subsets: ["latin"],
  display: "swap",
  variable: "--font-sans", // ← Expose as a CSS variable
});

// Display font — elegant serif for headings
const playfair = Playfair_Display({
  subsets: ["latin"],
  display: "swap",
  weight: ["400", "700", "900"],
  variable: "--font-display", // ← Expose as a CSS variable
});

export default function RootLayout({
  children,
}: {
  children: React.ReactNode;
}) {
  return (
    <html lang="en">
      {/*
        Apply both font variables to body.
        inter.variable adds --font-sans to the element.
        playfair.variable adds --font-display to the element.
        Then font-sans applies Inter as the default body font.
      */}
      <body className={`${inter.variable} ${playfair.variable} font-sans`}>
        {children}
      </body>
    </html>
  );
}

Now both CSS variables are available anywhere in your CSS or Tailwind classes:

/* Using in CSS Modules */
.heading {
  font-family: var(--font-display);
  font-size: 3rem;
  font-weight: 700;
}

.body {
  font-family: var(--font-sans);
}
/* Using in Tailwind — after configuring @theme */
/* app/globals.css */
@import "tailwindcss";

@theme {
  --font-sans: var(--font-sans);    /* Maps Tailwind's font-sans to next/font variable */
  --font-display: var(--font-display);
}
// Now use Tailwind's font utilities
<h1 className="font-display text-5xl font-bold">Big Heading</h1>
<p className="font-sans text-base">Body text paragraph</p>

4. Popular Google Font Combinations

Here are some well-tested font pairings commonly used in Next.js projects:

Pairing Body Headings Best for
Clean SaaS Inter Inter (same font, heavier weight) Dashboards, apps, B2B
Editorial Source Serif 4 Playfair Display Blogs, magazines, content sites
Modern agency DM Sans Fraunces Portfolios, agencies, creative
Technical Geist Geist (same font) Developer tools, docs, code
Warm startup Plus Jakarta Sans Plus Jakarta Sans Startups, landing pages
// Example: Modern agency pairing
import { DM_Sans, Fraunces } from "next/font/google";

const dmSans = DM_Sans({
  subsets: ["latin"],
  display: "swap",
  variable: "--font-sans",
});

const fraunces = Fraunces({
  subsets: ["latin"],
  display: "swap",
  variable: "--font-display",
  axes: ["SOFT", "WONK"], // Variable font axes for extra control
});

5. Loading Local Fonts

For custom or paid fonts (not available on Google Fonts), use next/font/local. Put the font files in your public/fonts/ directory and reference them:

// app/layout.tsx
import localFont from "next/font/local";

const geist = localFont({
  src: [
    {
      path: "../public/fonts/GeistVF.woff2",
      weight: "100 900",  // Variable font — range of weights
      style: "normal",
    },
    {
      path: "../public/fonts/GeistMonoVF.woff2",
      weight: "100 900",
      style: "normal",
    },
  ],
  variable: "--font-geist",
  display: "swap",
});

For non-variable fonts with separate files per weight:

// app/layout.tsx
import localFont from "next/font/local";

const myFont = localFont({
  src: [
    { path: "../public/fonts/MyFont-Regular.woff2",    weight: "400", style: "normal" },
    { path: "../public/fonts/MyFont-Medium.woff2",     weight: "500", style: "normal" },
    { path: "../public/fonts/MyFont-SemiBold.woff2",   weight: "600", style: "normal" },
    { path: "../public/fonts/MyFont-Bold.woff2",       weight: "700", style: "normal" },
    { path: "../public/fonts/MyFont-Italic.woff2",     weight: "400", style: "italic" },
    { path: "../public/fonts/MyFont-BoldItalic.woff2", weight: "700", style: "italic" },
  ],
  variable: "--font-body",
  display: "swap",
});

Font File Formats

Always use .woff2 — it has the best compression and is supported by every modern browser. If you receive font files in other formats, convert them to .woff2 using a tool like CloudConvert or fonttools. Serving .ttf or .otf directly bloats your page weight significantly.


6. A Complete globals.css

Here's a production-ready globals.css that combines Tailwind, custom font variables, a CSS reset, design tokens, and base typography:

/* app/globals.css */
@import "tailwindcss";

/* ─── Design Tokens ──────────────────────────────────────────────────────── */
@theme {
  /* Fonts — set by next/font via CSS variables on <body> */
  --font-sans: var(--font-inter);
  --font-display: var(--font-playfair);
  --font-mono: var(--font-geist-mono);

  /* Brand colours */
  --color-brand-50:  #eff6ff;
  --color-brand-100: #dbeafe;
  --color-brand-500: #3b82f6;
  --color-brand-600: #2563eb;
  --color-brand-700: #1d4ed8;
  --color-brand-900: #1e3a8a;

  /* Semantic colours */
  --color-success: #10b981;
  --color-warning: #f59e0b;
  --color-danger:  #ef4444;

  /* Spacing extras */
  --spacing-18: 4.5rem;

  /* Border radius extras */
  --radius-4xl: 2rem;
}

/* ─── CSS Reset ──────────────────────────────────────────────────────────── */
*, *::before, *::after {
  box-sizing: border-box;
}

/* ─── Base Styles ─────────────────────────────────────────────────────────── */
html {
  scroll-behavior: smooth;
  text-size-adjust: 100%;
}

body {
  color: theme(colors.gray.900);
  background-color: theme(colors.white);
  -webkit-font-smoothing: antialiased;
  -moz-osx-font-smoothing: grayscale;
}

/* ─── Focus Styles ────────────────────────────────────────────────────────── */
/* Remove default focus outline and use a consistent custom one */
:focus-visible {
  outline: 2px solid theme(colors.blue.500);
  outline-offset: 2px;
}

/* ─── Typography ──────────────────────────────────────────────────────────── */
/* Sensible defaults for rendered HTML content (blog posts, CMS content) */
.prose h1 { font-family: var(--font-display); font-size: 2.25rem; font-weight: 700; line-height: 1.2; margin-bottom: 0.5em; }
.prose h2 { font-family: var(--font-display); font-size: 1.875rem; font-weight: 700; line-height: 1.3; margin-top: 1.5em; margin-bottom: 0.5em; }
.prose h3 { font-size: 1.5rem; font-weight: 600; line-height: 1.4; margin-top: 1.25em; margin-bottom: 0.4em; }
.prose p  { line-height: 1.75; margin-bottom: 1.25em; }
.prose a  { color: theme(colors.blue.600); text-decoration: underline; }
.prose a:hover { color: theme(colors.blue.800); }
.prose ul { list-style: disc; padding-left: 1.5em; margin-bottom: 1.25em; }
.prose ol { list-style: decimal; padding-left: 1.5em; margin-bottom: 1.25em; }
.prose li { margin-bottom: 0.375em; line-height: 1.7; }
.prose code {
  font-family: var(--font-mono);
  font-size: 0.875em;
  background: theme(colors.gray.100);
  padding: 0.125em 0.375em;
  border-radius: 4px;
}
.prose pre {
  background: theme(colors.gray.900);
  color: theme(colors.gray.100);
  padding: 1rem 1.25rem;
  border-radius: 8px;
  overflow-x: auto;
  margin-bottom: 1.25em;
}
.prose pre code {
  background: transparent;
  padding: 0;
  font-size: 0.875rem;
  color: inherit;
}
.prose blockquote {
  border-left: 4px solid theme(colors.gray.200);
  padding-left: 1rem;
  font-style: italic;
  color: theme(colors.gray.600);
  margin: 1.5em 0;
}
.prose img {
  border-radius: 8px;
  margin: 1.5em 0;
}

/* ─── Accessibility ───────────────────────────────────────────────────────── */
.sr-only {
  position: absolute;
  width: 1px;
  height: 1px;
  padding: 0;
  margin: -1px;
  overflow: hidden;
  clip: rect(0, 0, 0, 0);
  white-space: nowrap;
  border-width: 0;
}

/* Reduce motion for users who prefer it */
@media (prefers-reduced-motion: reduce) {
  *, *::before, *::after {
    animation-duration: 0.01ms !important;
    animation-iteration-count: 1 !important;
    transition-duration: 0.01ms !important;
    scroll-behavior: auto !important;
  }
}

7. Adding a Monospace Font for Code Blocks

If your app displays code, a dedicated monospace font dramatically improves readability. Geist Mono (Vercel's font) and Fira Code are popular choices:

// app/layout.tsx
import { Inter, Fira_Code } from "next/font/google";

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

const firaCode = Fira_Code({
  subsets: ["latin"],
  variable: "--font-mono",
  display: "swap",
  weight: ["400", "500"],
});

export default function RootLayout({ children }: { children: React.ReactNode }) {
  return (
    <html lang="en">
      <body className={`${inter.variable} ${firaCode.variable} font-sans`}>
        {children}
      </body>
    </html>
  );
}
// Now use it in CSS or Tailwind
<code className="font-mono text-sm bg-gray-100 px-1.5 py-0.5 rounded">
  npm install next
</code>

8. Preloading and Font Subsets

Next.js preloads fonts automatically — it adds a <link rel="preload"> tag to the HTML <head> so the font starts downloading before the CSS is even parsed. This means fonts are available almost immediately.

The subsets option controls which character ranges to download. Always specify only the subsets you actually use:

// For English-only sites
const font = Inter({ subsets: ["latin"] });

// For sites with extended Latin characters (French, German, Polish, etc.)
const font = Inter({ subsets: ["latin", "latin-ext"] });

// For sites with Cyrillic characters (Russian, Bulgarian, etc.)
const font = Inter({ subsets: ["latin", "cyrillic"] });

// For sites with Greek characters
const font = Inter({ subsets: ["latin", "greek"] });

Downloading unnecessary subsets bloats your font files. The latin subset covers all standard English and Western European characters — it's the right choice for the vast majority of English-language apps.


9. font-display Options

The display option controls what the browser shows while the custom font is loading:

Value Behaviour Layout Shift? Best for
"optional" Use custom font only if it loads within ~100ms, otherwise use fallback forever None Best CLS score — perfect for most apps
"swap" Show fallback immediately, swap to custom font when loaded Yes (brief) When brand font is critical to identity
"block" Brief invisible text, then custom font No Rarely — causes invisible text briefly
"fallback" Short block period then swap, timeout to fallback Minimal Balance between swap and optional

For most apps, "swap" is the safest default — users always see text immediately, and the font swap is barely noticeable because next/font also adjusts the fallback font's metrics to match the custom font's size and line height, minimising any shift.


10. Common Gotchas

  • Defining fonts outside the module scope. Font functions must be called at the module level — not inside a component function or a useEffect. Next.js processes them at build time, so they must be statically analysable:
// ❌ Inside a component — doesn't work
export default function Layout({ children }) {
  const inter = Inter({ subsets: ["latin"] }); // Error!
  return <body className={inter.className}>{children}</body>;
}

// ✅ At module level — correct
const inter = Inter({ subsets: ["latin"] });

export default function Layout({ children }) {
  return <body className={inter.className}>{children}</body>;
}
  • Forgetting to apply the variable to the HTML element. If you use variable: "--font-sans" but don't add inter.variable to the <body> or <html> tag, the CSS variable is never defined in the DOM and your font won't load.
  • Loading too many font weights. Each weight is a separate file download. Only include the weights you actually use in your design — most apps need regular (400), medium (500), semibold (600), and bold (700). Loading 100 through 900 just to have them available bloats your page.
  • Using Google Fonts CDN alongside next/font. Don't import the same font both via next/font and a Google Fonts <link> tag — you'll download it twice. Pick one approach and stick to it.
  • Not converting local font files to woff2. If your designer hands you .ttf or .otf files, convert them to .woff2 before adding them to your project. The size difference is significant — a 200KB TTF becomes ~60KB as woff2.

Key Takeaways

  • next/font downloads fonts at build time, self-hosts them, and eliminates external font requests and layout shift.
  • Import Google Fonts from next/font/google — call the font function at module level and apply .className or .variable to your root layout.
  • Use variable: "--font-name" to expose a font as a CSS custom property — essential for multiple fonts and Tailwind integration.
  • Use next/font/local for custom or paid fonts — always provide .woff2 files.
  • Specify only the subsets and weight values you actually use to minimise download size.
  • Variable fonts are preferred — one file covers all weights.
  • display: "swap" is the safest default — users always see text, and next/font adjusts fallback metrics to minimise shift.
  • Define fonts at module level, not inside component functions.

Next up: Lesson 304 — Dark Mode with Tailwind & next-themes. You'll implement flicker-free dark mode with system preference detection, a user-controlled toggle, and localStorage persistence — all without a white flash on page load.

Chat with us