Font and Script Optimization
Why Font Loading Is a Performance Challenge
Fonts are one of the most overlooked yet impactful factors in web performance. Here is a typical problem chain: the browser parses HTML, finds a <link> tag referencing Google Fonts, initiates an external DNS lookup (google.com โ fonts.googleapis.com โ fonts.gstatic.com), waits for the font file to download, applies the font. Throughout this process the browser does not know the font's glyph metrics, so it renders text with the system font first, then re-lays out the page when the web font arrives โ this is the source of FOUT (Flash of Unstyled Text) and CLS (Cumulative Layout Shift).
The traditional Google Fonts approach (adding a <link> tag in HTML) has another problem: every user must make a request to Google's servers, which in some regions can cause fonts to load extremely slowly or not at all.
next/font downloads font files to your server at build time and self-hosts them as static assets. When a user's browser requests a font, it comes directly from your CDN or server โ eliminating the external dependency. Simultaneously, it uses font-display: swap and injects precise fallback font metrics to minimize CLS.
next/font/google: Zero Runtime External Requests
// app/layout.tsx
import { Inter, Noto_Sans_SC } from 'next/font/google'
const inter = Inter({
subsets: ['latin'],
display: 'swap',
variable: '--font-inter',
})
const notoSansSC = Noto_Sans_SC({
subsets: ['chinese-simplified'],
weight: ['400', '500', '700'],
display: 'swap',
variable: '--font-noto-sans-sc',
})
export default function RootLayout({
children,
}: {
children: React.ReactNode
}) {
return (
<html
lang="en"
className={`${inter.variable} ${notoSansSC.variable}`}
>
<body>{children}</body>
</html>
)
}
At build time, next/font does several things:
- Downloads font files (
.woff2) from the Google Fonts API - Copies font files to
/.next/static/media/ - Generates CSS
@font-facedeclarations pointing to local files - Uses CSS properties like
size-adjustandascent-overrideto make the fallback font (system font) dimensions match the target font as closely as possible, minimizing layout shift during the font swap
Why Use CSS Variables Instead of className Directly
The code above uses the variable option and sets CSS custom properties on the HTML root element, rather than using inter.className directly. The reason is flexibility: CSS variables can be referenced anywhere in your CSS (including Tailwind), whereas className can only be applied to the element directly.
// Approach 1: Direct application (only applies Inter to body itself)
<body className={inter.className}>
// Approach 2: CSS variable (can reference anywhere in CSS/Tailwind)
<html className={inter.variable}>
// Then in CSS: font-family: var(--font-inter)
Using Custom Fonts with Tailwind CSS
Integrating CSS variables with Tailwind only requires extending the font configuration in tailwind.config.ts:
// tailwind.config.ts
import type { Config } from 'tailwindcss'
const config: Config = {
content: ['./app/**/*.{ts,tsx}', './components/**/*.{ts,tsx}'],
theme: {
extend: {
fontFamily: {
sans: ['var(--font-inter)', 'system-ui', 'sans-serif'],
'noto-sc': ['var(--font-noto-sans-sc)', 'sans-serif'],
},
},
},
}
export default config
Then use Tailwind class names directly in components:
// Using the sans font (Inter)
<p className="font-sans text-base">Body text</p>
// Using the Chinese font
<h1 className="font-noto-sc font-bold text-2xl">Heading</h1>
next/font/local: Using Custom Font Files
When you have brand fonts or fonts not available on Google Fonts, use next/font/local:
// app/layout.tsx
import localFont from 'next/font/local'
const brandFont = localFont({
src: [
{
path: '../public/fonts/BrandFont-Regular.woff2',
weight: '400',
style: 'normal',
},
{
path: '../public/fonts/BrandFont-Medium.woff2',
weight: '500',
style: 'normal',
},
{
path: '../public/fonts/BrandFont-Bold.woff2',
weight: '700',
style: 'normal',
},
],
variable: '--font-brand',
display: 'swap',
// Specify fallback font for size adjustment calculations (computed via fontpie)
adjustFontFallback: 'Arial',
})
adjustFontFallback lets Next.js automatically calculate the size-adjust, ascent-override, descent-override and related parameters needed for the fallback font, ensuring the layout barely changes when the web font swaps in.
Quantifying CLS: Before and After Font Optimization
CLS (Cumulative Layout Shift) ranges from 0 to infinity. Google's thresholds are:
- 0 to 0.1: Good (green)
- 0.1 to 0.25: Needs improvement (orange)
- Above 0.25: Poor (red)
A typical font-related CLS scenario:
Before optimization (external Google Fonts):
- Browser renders text with Arial (system font)
- Roboto loads 200โ800ms later
- Text widths change, paragraph line breaks shift, button sizes change
- Typical CLS score: 0.08โ0.15
After optimization (next/font + size-adjust):
- Browser renders text with Arial adjusted via
size-adjust - Text widths barely change when the web font loads (difference within 1โ2px)
- Typical CLS score: 0.01โ0.02
For Chinese fonts, the situation is more severe: Chinese font files easily reach 2โ5MB (containing thousands of glyphs), with load times potentially exceeding 2 seconds. next/font's subsetting (subsets: ['chinese-simplified']) downloads only the glyphs for commonly used characters, reducing file size by 80โ90%.
next/script: Precise Control Over Third-Party Scripts
Third-party scripts (Google Analytics, ad SDKs, customer support widgets) are one of the primary causes of slow pages because they typically:
- Execute on the critical rendering path
- Initiate additional network requests
- May block the main thread
The strategy prop of next/script provides four options for precise control over when a script loads:
beforeInteractive
The script loads before the page becomes interactive. This is the most aggressive strategy โ it blocks interactivity โ and should only be used for scripts that are absolutely required for the page to function at all (polyfills, consent managers):
import Script from 'next/script'
// In app/layout.tsx
<Script
src="https://cdn.cookieconsent.com/cookieconsent.js"
strategy="beforeInteractive"
/>
afterInteractive (Default)
The script loads immediately after the page becomes interactive. Suitable for analytics scripts like Google Analytics and Facebook Pixel:
<Script
src="https://www.googletagmanager.com/gtag/js?id=G-XXXXXXXXXX"
strategy="afterInteractive"
/>
lazyOnload
The script loads during browser idle time (using requestIdleCallback). Suitable for non-critical third-party features like chat widgets and survey tools:
<Script
src="https://widget.intercom.io/widget/your-app-id"
strategy="lazyOnload"
onLoad={() => {
console.log('Intercom loaded')
}}
/>
worker (Experimental)
The script runs in a Web Worker, completely off the main thread. Implemented using the Partytown library, suited for compute-intensive analytics scripts:
<Script
src="https://www.googletagmanager.com/gtag/js"
strategy="worker"
/>
Note: the worker strategy requires additional Partytown configuration, and certain scripts that need DOM access are incompatible with Web Workers.
Complete Google Analytics Implementation
Production Google Analytics needs to handle consent management (GDPR) and conditional loading:
// components/GoogleAnalytics.tsx
'use client'
import Script from 'next/script'
import { usePathname } from 'next/navigation'
import { useEffect } from 'react'
const GA_MEASUREMENT_ID = process.env.NEXT_PUBLIC_GA_ID
declare global {
interface Window {
gtag: (...args: unknown[]) => void
dataLayer: unknown[]
}
}
function sendPageView(url: string) {
if (typeof window.gtag === 'function') {
window.gtag('config', GA_MEASUREMENT_ID!, {
page_path: url,
})
}
}
export function GoogleAnalytics() {
const pathname = usePathname()
// Manually fire pageview on route changes (SPA navigation)
useEffect(() => {
sendPageView(pathname)
}, [pathname])
if (!GA_MEASUREMENT_ID) return null
return (
<>
<Script
src={`https://www.googletagmanager.com/gtag/js?id=${GA_MEASUREMENT_ID}`}
strategy="afterInteractive"
/>
<Script
id="google-analytics"
strategy="afterInteractive"
dangerouslySetInnerHTML={{
__html: `
window.dataLayer = window.dataLayer || [];
function gtag(){dataLayer.push(arguments);}
gtag('js', new Date());
gtag('config', '${GA_MEASUREMENT_ID}', {
page_path: window.location.pathname,
});
`,
}}
/>
</>
)
}
// app/layout.tsx
import { GoogleAnalytics } from '@/components/GoogleAnalytics'
export default function RootLayout({ children }: { children: React.ReactNode }) {
return (
<html lang="en">
<body>
{children}
<GoogleAnalytics />
</body>
</html>
)
}
Why App Router GA Needs Manual Route Change Tracking
In the Pages Router, every route change was a full page navigation, and the GA script would automatically fire a pageview. But App Router's client-side navigation is SPA-style โ the page never truly reloads, so the GA script never re-executes. You need to listen for route changes (using usePathname) and manually call gtag('config', ...) to send pageview events.
The id Requirement for Inline Scripts
next/script requires that inline scripts (those using dangerouslySetInnerHTML or children) provide an id prop. Next.js uses this to track whether the script has already been inserted, preventing duplicate insertion:
// Correct: provide an id
<Script id="my-inline-script">
{`console.log('hello')`}
</Script>
// Incorrect: missing id will trigger a warning
<Script dangerouslySetInnerHTML={{ __html: '...' }} />
onLoad and onReady Callbacks
<Script
src="https://maps.googleapis.com/maps/api/js?key=YOUR_KEY"
strategy="lazyOnload"
onLoad={() => {
// Runs after the script loads for the first time
initializeMap()
}}
onReady={() => {
// Runs every time the component mounts (including from cache)
// Suitable for scenarios that need re-initialization on each page show
}}
onError={(error) => {
console.error('Script failed to load:', error)
}}
/>
Combined Performance Impact: Quantified Gains
Consider a typical B2B SaaS landing page that originally used:
- Google Fonts (external
<link>tag) - Google Analytics (plain
<script>tag) - Intercom (plain
<script>tag โ the biggest offender) - Hotjar (plain
<script>tag)
Lighthouse changes after optimization (next/font + next/script with strategy assignments):
| Metric | Before | After | Improvement |
|---|---|---|---|
| Performance | 58 | 84 | +26 |
| LCP | 3.8s | 2.1s | โ45% |
| TBT (Total Blocking Time) | 680ms | 220ms | โ68% |
| CLS | 0.12 | 0.02 | โ83% |
The dramatic TBT improvement comes primarily from switching the Intercom script to lazyOnload, so the main thread is no longer blocked on the critical rendering path.
Summary
Fonts and third-party scripts are two of the biggest invisible performance drains on a page. next/font eliminates font-related CLS and external network dependencies at the root by self-hosting fonts at build time and computing precise fallback font metrics. next/script provides the ability to load third-party scripts in layers according to their importance, so non-critical scripts no longer block Time to Interactive. Correctly configuring both of these components typically delivers a 20โ30 point improvement in Lighthouse Performance score without changing any product functionality.