Typography and Fonts

This content was generated with the assistance of AI and should be verified against the official documentation before being relied on in production.

This sub-section’s WordPress bibliography lists the reference material consulted while preparing these pages.

A block theme declares its fonts and its type scale in theme.json, and WordPress turns those declarations into @font-face rules, CSS custom properties and, when fluid typography is on, clamp() expressions. Site owners add more fonts through the Font Library without touching the theme. This page covers both sides for theme developers: bundling self-hosted WOFF2 files in the Lumen Press lumen theme, what WordPress prints from them, how a fluid size is calculated, how the Font Library stores fonts and exposes them over REST, and how to keep fonts private and fast.

This page was written against WordPress 7.1.3 (released 2026-10-06; 7.1.4, a security release of 2026-10-28, is the version to run in production), PHP 7.4 minimum and 8.3+ recommended, theme.json version 3 and WP-CLI 2.12.0, and checked on 2026-10-28. WordPress 7.2 is in beta and due in December 2026; nothing announced for it so far changes the font APIs described here. Every output on this page comes from a 7.1.3 site (the wordpress:7.1.3-php8.4-apache image with MariaDB 11.8) running the lumen theme below.

The general theme.json settings (appearanceTools, layout, colour, spacing) are covered in theme.json Settings, and how the settings become styles in theme.json Styles. The site owner’s view of the Fonts screen and of Styles > Typography is in Site Editor and Global Styles.

How fonts reach a page

Three layers can declare fonts, merged in the usual theme.json order (core < parent theme < child theme < user):

Layer Where it is declared Where the files live

Theme

settings.typography.fontFamilies in the theme’s theme.json (or in a style variation under styles/*.json)

Inside the theme, referenced with file:./...

Font Library (installed)

wp_font_family and wp_font_face posts, created on Appearance > Fonts or through /wp/v2/font-families

wp-content/uploads/fonts (filterable with font_dir)

User (activated)

settings.typography.fontFamilies.custom in the user’s wp_global_styles post

Points at the Font Library files

The Font Face API (wp_print_font_faces(), WordPress 6.4) reads the merged theme.json data, collects every fontFace entry, and prints one <style class="wp-fonts-local"> block. It is hooked to wp_head at priority 50 and is also called for the editor canvas, so the editor and the front end use the same files. The fontFamily value of each family becomes a preset custom property, --wp--preset--font-family--{slug}, which styles reference as var:preset|font-family|<slug>.

A font that is installed in the Font Library but not activated is not part of the merged data, so no @font-face rule is printed for it and no visitor downloads it.

Self-hosted fonts in theme.json

The Lumen Press theme uses Source Serif 4 (Adobe, SIL Open Font License 1.1) for body text and a system sans-serif stack for headings. The variable WOFF2 files come from the font’s official release on GitHub (source-serif-4.005_WOFF2.zip, folder VAR), renamed to source-serif-4-roman.woff2 and source-serif-4-italic.woff2 and stored in lumen/assets/fonts/source-serif-4/ together with OFL.txt, because the OFL requires the licence to travel with the font files. lumen/theme.json keeps the palette and layout of Themes Overview and adds the typography settings and styles; the styles section below shows only the typography styles:

{
	"$schema": "https://schemas.wp.org/wp/7.1/theme.json",
	"version": 3,
	"settings": {
		"appearanceTools": true,
		"color": {
			"palette": [
				{ "slug": "paper", "name": "Paper", "color": "#fbf8f1" },
				{ "slug": "ink", "name": "Ink", "color": "#1f1d1a" },
				{ "slug": "lumen-red", "name": "Lumen red", "color": "#a3271f" }
			]
		},
		"layout": { "contentSize": "680px", "wideSize": "1140px" },
		"typography": {
			"defaultFontSizes": false,
			"fluid": {
				"minViewportWidth": "360px",
				"maxViewportWidth": "1200px",
				"minFontSize": "15px"
			},
			"fontSizes": [
				{ "slug": "small", "name": "Small", "size": "0.875rem", "fluid": false },
				{ "slug": "medium", "name": "Medium", "size": "1.125rem", "fluid": { "min": "1rem", "max": "1.125rem" } },
				{ "slug": "large", "name": "Large", "size": "1.75rem", "fluid": { "min": "1.375rem", "max": "1.75rem" } },
				{ "slug": "x-large", "name": "Extra large", "size": "3rem" }
			],
			"fontFamilies": [
				{
					"slug": "source-serif",
					"name": "Source Serif 4",
					"fontFamily": "\"Source Serif 4\", Georgia, serif",
					"fontFace": [
						{
							"fontFamily": "Source Serif 4",
							"fontStyle": "normal",
							"fontWeight": "200 900",
							"fontDisplay": "swap",
							"src": [ "file:./assets/fonts/source-serif-4/source-serif-4-roman.woff2" ]
						},
						{
							"fontFamily": "Source Serif 4",
							"fontStyle": "italic",
							"fontWeight": "200 900",
							"fontDisplay": "swap",
							"src": [ "file:./assets/fonts/source-serif-4/source-serif-4-italic.woff2" ]
						}
					]
				},
				{
					"slug": "system-sans",
					"name": "System sans-serif",
					"fontFamily": "-apple-system, BlinkMacSystemFont, \"Segoe UI\", Roboto, \"Helvetica Neue\", sans-serif"
				}
			]
		}
	},
	"styles": {
		"typography": {
			"fontFamily": "var:preset|font-family|source-serif",
			"fontSize": "var:preset|font-size|medium",
			"lineHeight": "1.6"
		},
		"elements": {
			"heading": {
				"typography": { "fontFamily": "var:preset|font-family|system-sans", "fontWeight": "700" }
			},
			"h1": { "typography": { "fontSize": "var:preset|font-size|x-large" } }
		}
	}
}

The keys of a font family and a font face

Key Meaning

name

Label shown in Styles > Typography and on the Fonts screen.

slug

Identifier used in the preset custom property and in var:preset|font-family|<slug> references. Keep it stable: renaming it breaks every saved block that referenced the old slug.

fontFamily

The CSS font-family value, with its fallbacks. Names with spaces must be quoted (escaped \" in JSON).

fontFace

Optional array of faces; each becomes one @font-face rule. A family without faces (such as a system stack) prints no rule.

fontFace[].fontFamily

The family name the rule declares. It must match the first name in the family’s fontFamily, or the browser never uses the file.

fontFace[].src

A string or an array of URLs. file:./ resolves relative to the theme folder through get_theme_file_uri(), which looks in the child theme first, then the parent. Absolute URLs are also accepted. The format() hint is derived from the extension.

fontWeight, fontStyle, fontStretch

Descriptors of the face. A range ("200 900") declares a variable font’s axis range.

fontDisplay

auto, block, swap, fallback or optional; WordPress uses fallback when the key is absent.

unicodeRange, fontFeatureSettings, fontVariationSettings, ascentOverride, descentOverride, lineGapOverride, sizeAdjust, fontVariant

The remaining @font-face descriptors, passed through to the CSS. The metric overrides let a fallback font match the web font’s metrics and reduce layout shift while the file loads.

settings.typography also holds the editor switches covered in theme.json Settings: customFontSize, dropCap, fontStyle, fontWeight, letterSpacing, lineHeight, textColumns, textDecoration, textTransform and writingMode (appearanceTools turns on the ones that default to off).

The generated @font-face CSS

With the theme active, wp_print_font_faces() prints two rules, and the family values become preset custom properties in the global stylesheet:

docker compose exec cli wp eval 'wp_print_font_faces();'
docker compose exec cli sh -c 'wp eval "echo wp_get_global_stylesheet( array( \"variables\" ) );" | tr ";" "\n" | grep font-family'
<style class="wp-fonts-local">
@font-face{font-family:"Source Serif 4";font-style:normal;font-weight:200 900;font-display:swap;src:url('http://lumenpress.test/wp-content/themes/lumen/assets/fonts/source-serif-4/source-serif-4-roman.woff2') format('woff2');}
@font-face{font-family:"Source Serif 4";font-style:italic;font-weight:200 900;font-display:swap;src:url('http://lumenpress.test/wp-content/themes/lumen/assets/fonts/source-serif-4/source-serif-4-italic.woff2') format('woff2');}
</style>
--wp--preset--font-family--source-serif: "Source Serif 4", Georgia, serif
--wp--preset--font-family--system-sans: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", sans-serif

The URLs are http:// because WP-CLI has no HTTPS request to detect; in a page served over HTTPS, is_ssl() is true and the same rules use https://lumenpress.test/.... The system-stack family prints no rule.

Classic and hybrid themes

wp_print_font_faces() reads theme.json, so a classic theme that ships one (such as a hybrid lumen-classic) gets the same output by declaring fontFamilies there. Without theme.json, a classic theme enqueues a stylesheet with its own @font-face rules (see functions.php, Theme Supports and Assets). Either way the files ship inside the theme.

Fluid typography

A fluid font size grows linearly with the viewport width between a minimum and a maximum. WordPress writes it as a CSS clamp(min, preferred, max) whose middle term mixes rem and vw. Fluid typography arrived in WordPress 6.1, and 6.4 made the viewport bounds (minViewportWidth, maxViewportWidth) configurable.

Turning it on

Setting Effect

settings.typography.fluid: true

Every preset size and every fixed size chosen in the editor becomes fluid, with the default bounds.

settings.typography.fluid: { ... }

Same, with minViewportWidth (default 320px), maxViewportWidth (default settings.layout.wideSize when it is set, otherwise 1600px) and minFontSize (default 14px).

fontSizes[].fluid: false

That preset stays static (Lumen’s small).

fontSizes[].fluid: { "min": ..., "max": ... }

Explicit bounds for that preset. A preset with its own fluid object is fluid even when the global switch is off.

fontSizes[].fluid absent

The preset size is the maximum and WordPress computes the minimum.

defaultFontSizes (theme.json version 3 default true) controls whether core’s own size presets (small, medium, large, x-large, xx-large) are output. Since version 3 a theme preset with a core slug no longer replaces core’s when defaultFontSizes is true; Lumen sets it to false so that its four sizes are the only ones and keep core’s slugs, which keeps content portable between themes.

The values WordPress computes

wp_get_typography_font_size_value() turns a preset (or any array( 'size' => ... )) into the value WordPress outputs. A short wp eval prints it for Lumen’s presets and for three ad hoc sizes, as if an editor had typed them in a block’s typography panel:

docker compose exec cli wp eval '
foreach ( wp_get_global_settings( array( "typography", "fontSizes", "theme" ) ) as $preset ) {
	printf( "%-8s %s\n", $preset["slug"], wp_get_typography_font_size_value( $preset ) );
}
foreach ( array( "16px", "14px", "1.5rem" ) as $size ) {
	printf( "%-8s %s\n", $size, wp_get_typography_font_size_value( array( "size" => $size ) ) );
}'
small    0.875rem
medium   clamp(1rem, 1rem + ((1vw - 0.225rem) * 0.238), 1.125rem)
large    clamp(1.375rem, 1.375rem + ((1vw - 0.225rem) * 0.714), 1.75rem)
x-large  clamp(1.743rem, 1.743rem + ((1vw - 0.225rem) * 2.394), 3rem)
16px     clamp(15px, 0.938rem + ((1vw - 3.6px) * 0.119), 16px)
14px     14px
1.5rem   clamp(0.984rem, 0.984rem + ((1vw - 0.225rem) * 0.983), 1.5rem)

The same strings appear as --wp--preset--font-size--{slug} custom properties in the global stylesheet. These functions have no Code Reference page; the source file above is the authoritative description.

The maths

The function wp_get_computed_fluid_typography_value() builds:

clamp( min, min_rem + ((1vw - vw_min / 100) * factor), max )

factor = 100 × (max − min) / (vw_max − vw_min)

All lengths are first converted to the unit of the minimum size (1rem = 16px for the conversion), and min_rem is the minimum expressed in rem. Since 1vw is one hundredth of the viewport width W, the middle term equals min + (W − vw_min) × (max − min) / (vw_max − vw_min): a straight line that passes through min at vw_min and through max at vw_max. clamp() holds the value at the bounds outside that range. Starting the line from a rem value keeps the text responsive to browser zoom and to the user’s default font size, which a pure vw size would ignore (WCAG 1.4.4, Resize Text).

For Lumen’s x-large preset (3rem, no explicit minimum), step by step:

  1. Minimum size. When no min is given, WordPress scales the size by clamp(1 − 0.075 × log2(size in px), 0.25, 0.75). For 48px: log2(48) = 5.585, so the factor is 1 − 0.419 = 0.581, and 3rem × 0.581 = 1.743rem (27.9px). Larger sizes shrink more, smaller ones less.

  2. Minimum limit. The computed minimum may not fall below minFontSize (15px here); 27.9px is above it.

  3. Viewport bounds in the font’s unit. 360px = 22.5rem and 1200px = 75rem, so the offset vw_min / 100 is 0.225rem.

  4. Factor. 100 × (3 − 1.743) / (75 − 22.5) = 125.7 / 52.5 = 2.394.

  5. Check. At a 768px viewport, 1vw = 7.68px = 0.48rem: 1.743 + (0.48 − 0.225) × 2.394 = 2.354rem, about 37.7px. At 1200px, 1vw = 0.75rem and the result is exactly 3rem.

The other rows follow the same rules:

  • medium has explicit bounds, so the factor is 100 × (1.125 − 1) / 52.5 = 0.238.

  • 16px gets a computed minimum of 16 × (1 − 0.075 × 4) = 11.2px, below the 15px limit, so the minimum is 15px. Because the minimum is in pixels, the offset is 3.6px; the middle term still starts at 15px written as 0.938rem.

  • 14px is not larger than minFontSize and has no explicit bounds, so it stays static: shrinking it would make it unreadable.

  • small is static because of "fluid": false.

Pitfalls: units other than px, rem and em (such as pt, % or a calc() value) are returned unchanged; minViewportWidth equal to maxViewportWidth disables the calculation; and a wideSize in % or vw cannot be used as the maximum viewport, so the 1600px default applies. Setting maxViewportWidth explicitly avoids depending on the layout width.

The Font Library

The Font Library (WordPress 6.5) lets users install fonts without editing the theme. In 6.5 it was a modal in Styles > Typography of the Site Editor; since 7.0 it is also an admin page, Appearance > Fonts (wp-admin/font-library.php), available with every theme, classic ones included. The page requires the edit_theme_options capability (Administrators; Editors do not have it). Its tabs are Library, Upload (.woff2, .woff, .ttf, .otf) and Install Fonts (font collections); the screens themselves are described in Site Editor and Global Styles.

Storage

Item Storage

Font family

A wp_font_family post: post_title is the name, post_name the slug, post_content the JSON settings.

Font face

A wp_font_face post whose parent is the family. The title encodes family, style, weight, stretch and range (inter;normal;100 900;100%;U+0-10FFFF). The file name is in the _wp_font_face_file meta.

Files

wp-content/uploads/fonts/, as returned by wp_get_font_dir(). The 6.5 betas used wp-content/fonts; the final 6.5 release moved the directory into uploads so that hosts that only allow writes there are supported, and older 6.5-era articles still show the earlier path. The font_dir filter changes it.

Activation

An entry in settings.typography.fontFamilies.custom of the user’s wp_global_styles post, with the faces that are switched on.

Deleting a family deletes its faces, and deleting a face deletes its files (_wp_before_delete_font_face()). Font posts belong to the site, not to the theme: they survive a theme switch and are not part of a theme export.

The REST endpoints

Route Methods Purpose

/wp/v2/font-collections

GET

Registered collections, each with its font families and categories.

/wp/v2/font-collections/<slug>

GET

One collection. For google-fonts the server fetches the catalogue JSON from s.w.org on demand.

/wp/v2/font-families

GET, POST

Installed families. POST takes a font_family_settings field holding a JSON string.

/wp/v2/font-families/<id>

GET, POST/PUT/PATCH, DELETE

One family; DELETE needs force=true (font posts have no trash).

/wp/v2/font-families/<id>/font-faces

GET, POST

Faces of a family. POST takes font_face_settings (JSON) and, for an upload, the file in a multipart field whose name is the face’s src value.

/wp/v2/font-families/<id>/font-faces/<id>

GET, DELETE

One face; DELETE needs force=true.

Every route requires edit_theme_options; an anonymous request gets 401 rest_cannot_read. The following session installs Inter (rsms/inter 4.1, OFL, InterVariable.woff2 from its GitHub release) as a user font, authenticated with an application password stored in WP_APP_PASSWORD (application passwords need HTTPS, or WP_ENVIRONMENT_TYPE set to local, as explained in REST API: Using It):

SITE=https://lumenpress.test/wp-json
AUTH="admin:$WP_APP_PASSWORD"

# 1. Create the family.
curl -s -u "$AUTH" -X POST "$SITE/wp/v2/font-families" \
  -F 'font_family_settings={"name":"Inter","slug":"inter","fontFamily":"Inter, sans-serif"}'

# 2. Upload one variable face to family 4 (the id returned by step 1).
curl -s -u "$AUTH" -X POST "$SITE/wp/v2/font-families/4/font-faces" \
  -F 'font_face_settings={"fontFamily":"Inter","fontStyle":"normal","fontWeight":"100 900","src":"inter-file"}' \
  -F 'inter-file=@inter-variable.woff2;type=font/woff2'

# 3. List the installed families.
curl -s -u "$AUTH" "$SITE/wp/v2/font-families?_fields=id,font_family_settings,font_faces"

The responses, without their _links:

{"id":4,"theme_json_version":3,"font_faces":[],"font_family_settings":{"name":"Inter","slug":"inter","fontFamily":"Inter, sans-serif","preview":""}}
{"id":5,"theme_json_version":3,"parent":4,"font_face_settings":{"fontFamily":"Inter","fontStyle":"normal","fontWeight":"100 900","src":"https:\/\/lumenpress.test\/wp-content\/uploads\/fonts\/inter-variable.woff2"}}
[{"id":4,"font_faces":[5],"font_family_settings":{"name":"Inter","slug":"inter","fontFamily":"Inter, sans-serif","preview":""}}]

The stored rows and file:

docker compose exec cli wp post list --post_type=wp_font_family,wp_font_face --fields=ID,post_type,post_title,post_parent
docker compose exec cli wp post meta list 5
docker compose exec cli ls wp-content/uploads/fonts
ID	post_type	post_title	post_parent
5	wp_font_face	inter;normal;100 900;100%;U+0-10FFFF	4
4	wp_font_family	Inter	0
post_id	meta_key	meta_value
5	_wp_font_face_file	inter-variable.woff2
inter-variable.woff2

At this point wp_print_font_faces() still prints only the two Source Serif rules: Inter is installed, not active. The Fonts screen activates it by writing the family into the user’s global styles, which the same REST API does with POST /wp/v2/global-styles/<id> (the id of the theme’s wp_global_styles post) and this body, which also uses Inter for headings:

{
	"settings": { "typography": { "fontFamilies": { "custom": [ {
		"name": "Inter", "slug": "inter", "fontFamily": "Inter, sans-serif",
		"fontFace": [ { "fontFamily": "Inter", "fontStyle": "normal", "fontWeight": "100 900",
			"src": "https://lumenpress.test/wp-content/uploads/fonts/inter-variable.woff2" } ]
	} ] } } },
	"styles": { "elements": { "heading": { "typography": { "fontFamily": "var:preset|font-family|inter" } } } }
}

Afterwards the front page’s <head> carries a third rule, with the default font-display: fallback because the face has no fontDisplay:

@font-face{font-family:Inter;font-style:normal;font-weight:100 900;font-display:fallback;src:url('https://lumenpress.test/wp-content/uploads/fonts/inter-variable.woff2') format('woff2');}

Errors met on the way, as returned by 7.1.3:

Response Cause

401 rest_cannot_read

No authentication, or a user without edit_theme_options.

400 rest_duplicate_font_family

An installed family already uses that slug. A user family may share a slug with a theme family; the user one wins when activated.

400 rest_font_upload_invalid_file_type

The file is not otf, ttf, woff or woff2 (checked by extension and MIME type through WP_Font_Utils::get_allowed_font_mime_types()).

501 rest_trash_not_supported

DELETE without force=true.

Font names are sanitized on the way in: a fontFamily of Source Serif 4 is stored as "Source Serif 4", quoted because of the spaces.

Font collections and Google Fonts

A font collection is a catalogue shown on the Install Fonts tab. Core registers one, google-fonts, whose catalogue is a JSON file on s.w.org (google-fonts-with-preview.json for 7.1). Plugins add their own with wp_register_font_collection() and remove one with wp_unregister_font_collection(), both on init.

Installing from Google Fonts is opt-in. The tab first asks for permission to connect to Google’s servers (stored in the browser, revocable from the tab’s menu). After that, the administrator’s browser downloads the chosen files from Google and uploads them to /wp/v2/font-families/<id>/font-faces, exactly like the Inter upload above. Visitors receive the files from the site’s own domain and never contact Google.

That is the correction to older books, Pro WordPress in particular, which load Google Fonts by enqueueing a fonts.googleapis.com stylesheet. Loading from the CDN sends every visitor’s IP address and user agent to a third party, which under the GDPR needs a legal basis and usually a consent banner; it also adds a DNS lookup and a connection to another origin before text can render. Self-hosting (bundled fontFace files in the theme, or the Font Library) removes both problems. Google Fonts files are under the OFL or the Apache License, so redistributing them inside a theme is allowed; for the Lumen theme they are taken from the font project’s own release rather than from the Google Fonts API, which serves subsets tuned per browser.

Restricting the Font Library

On production, Lumen Press lets only the theme decide which fonts exist. A must-use plugin removes the Google Fonts collection and hides the Font Library UI in the editors:

<?php
/**
 * Plugin Name: Lumen Fonts Policy
 * Description: Removes the Google Fonts collection and hides the Font Library UI on the Lumen Press production site.
 * Version: 0.1.0
 * Requires at least: 6.9
 * Requires PHP: 7.4
 */

add_action(
	'init',
	static function () {
		wp_unregister_font_collection( 'google-fonts' );
	}
);

add_filter(
	'block_editor_settings_all',
	static function ( $settings ) {
		$settings['fontLibraryEnabled'] = false;
		return $settings;
	}
);

Saved as wp-content/mu-plugins/lumen-fonts-policy.php, GET /wp/v2/font-collections returns [] and the editor settings carry fontLibraryEnabled: false. The filter hides the Font Library inside the editors only; the REST routes and Appearance > Fonts remain for users with edit_theme_options. To close the API as well, the 6.5 dev note sets show_in_rest to false for wp_font_family and wp_font_face through register_post_type_args, and removes the /wp/v2/font-collections routes with rest_endpoints.

Fonts are writable even when DISALLOW_FILE_MODS is true, because the files go to the uploads directory like media. Uploaded font files are binary data that the browser parses, so only trusted administrators should hold edit_theme_options; see Security Hardening.

Variable fonts

A variable font holds a continuous range of one or more axes in a single file. Read from the fonts' fvar tables, the roman Source Serif 4 file (427 KB) has wght 200–900 and opsz 8–60, and InterVariable.woff2 (352 KB) has opsz 14–32 and wght 100–900.

In theme.json a variable face declares its range in the descriptor ("fontWeight": "200 900", or "fontStretch": "75% 125%" for a width axis), so every weight from the editor’s weight picker resolves to the same file and the browser renders the exact weight instead of synthesizing bold. The optical-size axis needs nothing: font-optical-sizing: auto is the browser default and picks the opsz value from the font size. Custom axes or fixed axis values go in fontVariationSettings on the face, or in font-variation-settings in block styles.

Two variable files (roman and italic) replace the eight or more static files an older theme would ship for regular, medium, semibold and bold in both styles. A static file is still smaller when a site truly needs one weight.

Font performance

  • WOFF2 only. Every browser WordPress supports reads WOFF2, which is Brotli-compressed. The "bulletproof" @font-face stacks with EOT, SVG, TTF and WOFF fallbacks in older tutorials are unnecessary.

  • Subset. The full Source Serif 4 roman covers many scripts. A site that publishes only in Western European languages can subset the file (for example with pyftsubset from fontTools) and declare the covered range in unicodeRange, so the browser skips the file for pages without those characters. Keep the OFL notice; the licence allows modified versions under a different name when the Reserved Font Name applies.

  • font-display. swap shows fallback text at once and swaps when the file arrives (good for body text); fallback, WordPress’s default, swaps only if the file arrives within about three seconds; optional never swaps after first paint. Pair swap with a fallback whose metrics are close (sizeAdjust, ascentOverride) to limit layout shift.

  • Preload the critical face. Only the upright body face is used above the fold on every Lumen page; preloading it lets the browser fetch it in parallel with the CSS. WordPress does not preload fonts itself, so the theme does:

<?php
/**
 * Lumen theme functions.
 *
 * @package lumen
 */

/**
 * Preloads the upright body font, which every page uses above the fold.
 */
function lumen_preload_body_font() {
	printf(
		'<link rel="preload" href="%s" as="font" type="font/woff2" crossorigin>' . "\n",
		esc_url( get_theme_file_uri( 'assets/fonts/source-serif-4/source-serif-4-roman.woff2' ) )
	);
}
add_action( 'wp_head', 'lumen_preload_body_font', 1 );

The crossorigin attribute is required even for same-origin fonts (fonts are always fetched in CORS mode); without it the preloaded file is not reused and is downloaded twice. Preload one or two files at most: each preload competes with the CSS and the largest image for bandwidth.

  • Cache. Font files never change under the same name, so the web server can send them with a long Cache-Control lifetime and immutable; when a font is updated, rename the file. Server configuration is in Web Servers: Apache and nginx and caching in general in Performance and Caching.

  • Count the families. Each activated family with faces adds files to every page. A typography style variation that swaps the font pair (see Style Variations and Section Styles) loads only the fonts of the active variation on the front end.

What older books and tutorials get wrong

Older advice Current practice (WordPress 7.1)

Load Google Fonts by enqueueing a fonts.googleapis.com stylesheet in functions.php (Pro WordPress)

Self-host: fontFace files in the theme, or the Font Library, which downloads the files once and serves them from the site. No visitor request goes to Google.

Install a fonts plugin to add custom or Google fonts (older site-owner guides)

The Font Library is core since 6.5, and since 7.0 Appearance > Fonts works with classic themes as well.

Set heading sizes in px and override them in media queries

Font size presets with fluid typography produce one clamp() value per preset, with rem-based minimums that respect zoom.

@import a font stylesheet at the top of style.css

Declare fonts in theme.json (or enqueue a stylesheet in a classic theme); @import serializes downloads.

Ship EOT, SVG, TTF and WOFF for each weight

One variable WOFF2 per style, with a weight range.

Custom fonts are stored in wp-content/fonts (6.5 beta articles)

They are stored in wp-content/uploads/fonts, and the font_dir filter can move them.