Home/Learn/Next.js/Fonts and Styling

Fonts and Styling

Intermediate
UX & Assets

next/font self-hosts a webfont at build time so there is no third-party request and no layout shift. Styling is Tailwind or CSS Modules, with one server-side wrinkle for themes.

Overview

Two asset problems Next solves properly. Fonts are the bigger win: `next/font` downloads the file at build time, self-hosts it, and generates a size-adjusted fallback so the swap does not move the page — which removes both a privacy-relevant request to Google and a chunk of your Cumulative Layout Shift. Styling is less opinionated: Tailwind and CSS Modules both work with zero configuration, CSS-in-JS mostly does not in server components, and dark mode has a specific gotcha because the server cannot know the user's theme.

next/font

Self-hosted, preloaded, and metric-matched.

Build-time download, preload, metric-matched fallback
import { Inter, JetBrains_Mono } from 'next/font/google'

const inter = Inter({
  subsets: ['latin'],           // required — keeps the file small
  display: 'swap',
  variable: '--font-sans',      // exposes a CSS variable
})
const mono = JetBrains_Mono({ subsets: ['latin'], variable: '--font-mono' })

// app/layout.tsx
<html lang="en" className={`${inter.variable} ${mono.variable}`}>

/* globals.css */
body { font-family: var(--font-sans), system-ui, sans-serif }
code { font-family: var(--font-mono), monospace }

// What this does that a <link> to Google Fonts does not:
//   - downloads the file at BUILD time and serves it from your origin
//     (no runtime request to fonts.gstatic.com, so no third-party
//      round trip and nothing to consent to)
//   - preloads it automatically
//   - generates a size-adjusted fallback, so the swap does not shift
//     the layout — measurable CLS, removed

// A local font
import localFont from 'next/font/local'
const brand = localFont({ src: './brand.woff2', variable: '--font-brand' })

// Load fonts in the ROOT layout only. Per-page font loading means
// a different subset per route and no shared cache.

Styling Options

What works in server components, and what does not.

Tailwind or CSS Modules; avoid CSS-in-JS here
// Tailwind — the default choice, and it works everywhere because it
// is just class names. No runtime, nothing to serialize.
<div className="rounded-xl border bg-card p-5" />

// CSS Modules — scoped, zero runtime, works in server components
import styles from './Card.module.css'
<div className={styles.card} />

// Global CSS — imported once, in the root layout
import './globals.css'

// CSS-in-JS (styled-components, emotion) needs a client boundary and
// a registry for SSR. It works, but every styled component becomes a
// client component, which fights the whole model. For a new App
// Router project, do not.

// Conditional classes without string soup
import { cn } from '@/lib/utils'          // clsx + tailwind-merge
<button className={cn('rounded-lg px-4', isActive && 'bg-primary',
                       className)} />
// tailwind-merge is what lets a caller's className override yours
// rather than both classes landing and specificity deciding.

// Component variants
const button = cva('rounded-lg font-medium', {
  variants: { intent: { primary: 'bg-primary text-white',
                        ghost: 'hover:bg-accent' } },
})

Dark Mode Without a Flash

The server cannot know the theme, so the first paint is a guess — unless you cheat.

A blocking inline script, or a cookie
// The problem: theme lives in localStorage or a system preference,
// neither of which the server can read. So the server renders light,
// and the client corrects to dark after hydration — a white flash on
// every page load.

// The fix: a tiny blocking script that runs BEFORE React, setting the
// class on <html> so the markup already matches.
// app/layout.tsx
<head>
  <script dangerouslySetInnerHTML={{ __html: `
    try {
      var t = localStorage.getItem('theme')
      var d = t === 'dark' || (!t && matchMedia('(prefers-color-scheme: dark)').matches)
      if (d) document.documentElement.classList.add('dark')
    } catch {}
  `}} />
</head>
// It must be blocking and inline — a deferred script runs too late.

// next-themes packages this correctly, including suppressing the
// hydration warning on <html>:
<ThemeProvider attribute="class" defaultTheme="system" enableSystem>
<html suppressHydrationWarning>

// The alternative that avoids the script entirely: store the theme in
// a COOKIE, which the server can read — at the cost of making the
// route dynamic. For a mostly-static content site, the script wins.

// Tailwind then needs nothing special:
darkMode: 'class'
<div className="bg-white dark:bg-slate-900" />

Key Points to Remember

  • 1next/font downloads and self-hosts at build time, removing a third-party request and preloading automatically
  • 2Its size-adjusted fallback stops the font swap shifting the layout, which measurably reduces CLS
  • 3Tailwind and CSS Modules work in server components; CSS-in-JS forces client boundaries and fights the model
  • 4tailwind-merge is what makes a caller-supplied className actually override a component's own classes
  • 5The server cannot know the theme, so avoid the dark-mode flash with a blocking inline script or a theme cookie

Interview Questions

Sign in to ask Aria
1

What does next/font do that a Google Fonts link tag does not?

Medium
2

Why is CSS-in-JS awkward in the App Router?

Medium
3

How do you prevent a flash of the wrong theme on first paint?

Hard

Ask Aria about Fonts and Styling

Your personal AI tutor — ask anything about this concept

Revision Status

Personal Notes

Sign in to save personal notes for this topic.

Discussion

Sign in to join the discussion.

Loading discussion…