ChaiBuilder Logo

Custom Fonts

The builder's Theme panel lets an editor pick a heading font and a body font, but the list it picks from is code owned. A font shows up there only after it has been registered in the font registry. Registering a font also tells the renderer how to load it, so the same call covers the builder canvas and the published page.

This page covers how to add your own font, the registry API, and the rules that decide what actually reaches the browser.

How fonts work

There are three moving parts:

  1. The registry. registerChaiFont(family, font) adds a family to an in-memory list. Anything in that list appears in the Theme panel font dropdowns.
  2. The theme. Site settings store the chosen families as theme.fontFamily.heading and theme.fontFamily.body. The value is the family name string, for example Inter.
  3. The render. On every page render, ChaiBuilder looks up the two chosen families, emits an @font-face block for each one that has a font file, preloads those files, and sets the --font-heading and --font-body CSS variables.

The starter's stylesheet binds those variables to real elements, so headings pick up --font-heading and body text picks up --font-body. In block styles the same values are available as the font-heading and font-body Tailwind classes.

What ships registered

Three system fonts are always registered and need no files, because they resolve against fonts already installed on the reader's device:

Arial, Times New Roman, Courier New

The starter registers seven self-hosted fonts on top of those, in src/fonts/index.ts:

Inter, Geist, Roboto, Open Sans, Plus Jakarta Sans, Montserrat, DM Sans

Their .woff2 files live in public/fonts/<family>/, and public/fonts/LICENSES.md records where each one came from and under which license.

Register a self-hosted font

Self-hosting is the recommended path. The file is served from your own origin, so it preloads cleanly, survives a third party outage, and adds no extra DNS lookup.

1. Add the font file

Fontsource publishes most open source families as npm packages, which keeps the file versioned with the rest of your dependencies:

pnpm add @fontsource-variable/poppins

Then copy the .woff2 you want into public/fonts/:

mkdir -p public/fonts/poppins
cp node_modules/@fontsource-variable/poppins/files/poppins-latin-wght-normal.woff2 \
  public/fonts/poppins/

A font file you bought or were handed by a designer works the same way. Drop it in public/fonts/<family>/ and skip the install. Prefer woff2. It is the smallest format and every browser ChaiBuilder supports reads it.

Add an entry to public/fonts/LICENSES.md while you are there. Font licensing is easy to lose track of once a project has a few of them.

2. Register the family

The starter keeps every project font in one list in src/fonts/index.ts. Add yours to it:

const PROJECT_FONTS: ProjectFont[] = [
  // ...existing entries
  {
    family: 'Poppins',
    fallback: 'ui-sans-serif, system-ui, sans-serif',
    path: '/fonts/poppins/poppins-latin-wght-normal.woff2',
  },
]

path is a public URL, not a filesystem path. Everything under public/ is served from the site root, so public/fonts/poppins/x.woff2 is /fonts/poppins/x.woff2.

3. Restart

The list is read when the module first loads, so a running dev server will not pick up a new entry. Stop it and run pnpm dev again. In production the change ships with the next deploy.

4. Pick it in the builder

Open the builder, go to the Theme panel, and choose the new family for Heading, Body, or both. Publish, and the font loads on the public page.

The registry API

src/fonts/index.ts is a convenience wrapper the starter provides. Underneath it calls registerChaiFont, which you can call directly from anywhere that runs before render:

import { registerChaiFont } from 'chaipro/registry'

registerChaiFont('Poppins', {
  fallback: 'ui-sans-serif, system-ui, sans-serif',
  src: [
    {
      url: '/fonts/poppins/poppins-latin-wght-normal.woff2',
      format: 'woff2',
      fontWeight: '100 900',
      fontStyle: 'normal',
      fontDisplay: 'swap',
    },
  ],
})

Font source fields

Each entry in src becomes one @font-face rule.

Field Required Notes
url yes Public URL of the file, for example /fonts/poppins/poppins.woff2
format yes CSS format string: woff2, woff, truetype, opentype
fontWeight no Single weight (700) or a variable range (100 900)
fontStyle no normal or italic
fontDisplay no Defaults to swap in the emitted CSS
fontStretch no For width axes, for example 75% 125%

fallback is the rest of the CSS font stack. It is what renders while the file downloads and if it never arrives, so it should name a same-metrics system family, not be left empty.

Export Use
registerChaiFont(family, font) Add a family to the registry
getRegisteredFont(family) Look one up by name
getAllRegisteredFonts() Every registered family, in dropdown order
useRegisteredFonts() Same list, for builder UI components
getFontStyles(headingFont, bodyFont) The @font-face CSS plus preload URLs for a theme

See API reference for the full export surface.

Where registration has to run

The registry lives in memory, per process. It has to be populated before anything renders, on both the builder side and the public side. The starter does this by calling registerProjectFonts() at module scope in four places:

  • src/app/(builder)/admin/editor/editor.tsx for the builder canvas and Theme panel
  • src/app/(frontend)/[[...slug]]/page.tsx for builder rendered pages
  • src/components/with-chai-layout.tsx for custom coded routes that use a builder layout
  • src/app/(frontend)/pro/license-overlay/_components/Document.tsx for the license screen

Adding a font to PROJECT_FONTS covers all four at once, which is the main reason to keep using that list rather than scattering registerChaiFont calls. If you do add your own call site, guard it so repeat calls are harmless. registerProjectFonts does this with a module level flag:

let didRegister = false

export function registerProjectFonts(): void {
  if (didRegister) return
  didRegister = true
  // ...register each family
}

Cold starts are the reason for the module scope call rather than a call inside the component. Any of these routes can be the first request a fresh serverless instance sees.

Multiple weights and styles

A variable font is one file covering a weight range, so one src entry with a fontWeight range is enough. Static fonts need one entry per file:

registerChaiFont('Editorial', {
  fallback: 'Georgia, serif',
  src: [
    { url: '/fonts/editorial/editorial-400.woff2', format: 'woff2', fontWeight: '400', fontStyle: 'normal' },
    { url: '/fonts/editorial/editorial-400-italic.woff2', format: 'woff2', fontWeight: '400', fontStyle: 'italic' },
    { url: '/fonts/editorial/editorial-700.woff2', format: 'woff2', fontWeight: '700', fontStyle: 'normal' },
  ],
})

All of them share one family name, so font-bold and italic in block styles resolve to the right file. Only the first entry is preloaded, so put the weight your pages use most first, usually the regular upright.

What reaches the page

For the two families the theme actually uses, the renderer emits:

  • a <link rel="preload" as="font" type="font/woff2" crossorigin> for the first src of each
  • a <style id="fonts-styles"> containing the @font-face rules
  • a <style id="theme-variables"> setting --font-heading and --font-body to "Family", fallback

Registered fonts the theme does not use cost nothing. Nothing is emitted for them, so a long registry is not a performance problem. Only the picked pair is loaded.

Gotchas

A font with no src is treated as a system font. Registering just a family and a fallback tells ChaiBuilder to assume the font is already on the reader's device. No @font-face is written and no file is fetched. That is correct for Arial, and a silent failure for anything else.

The family name is the stored value. Site settings save the string, not an id. Renaming a family in your registry orphans every theme that referenced the old name, and the builder quietly falls back to the first font in the list. Treat family names as stable once published.

Newest registered sorts first. registerChaiFont prepends, so the dropdown shows your most recently registered family at the top. Register in the reverse of the order you want.

Cross-origin files need CORS headers. The preload tag is emitted with crossorigin="anonymous". If you host font files on a separate CDN domain, that domain must return Access-Control-Allow-Origin, otherwise the browser downloads the file twice or drops the preload. Serving from public/ avoids the problem entirely.

Loading Google Fonts by stylesheet URL is not wired up. The registry types allow a URL based font, and getThemeFontsLinkMarkup and getThemeFontsCSSImport exist to build the markup, but the standard render path only emits @font-face for fonts registered with src. If you go the URL route you have to inject the stylesheet link into your layout yourself. Downloading the .woff2 and self-hosting it is fewer moving parts and one less third party in the critical render path.

Set font-display. It defaults to swap in the generated CSS, which shows fallback text immediately and swaps when the font lands. That is the right default for content sites. Override it per source only if you have a specific reason.

Verify

After adding a font, check all three layers rather than just the dropdown:

  1. The family appears in the Theme panel dropdowns in the builder.
  2. Selecting it changes the canvas immediately.
  3. On the published page, view source and confirm a @font-face for the family inside <style id="fonts-styles"> and a matching preload link in the head.
  4. In the browser network tab, the .woff2 returns 200 and is requested once.

A quick regression test is worth having if your project registers fonts in code, since a missing registration fails softly rather than throwing:

import { registerProjectFonts } from '@/fonts'
import { getAllRegisteredFonts } from 'chaipro/registry'

it('registers the project fonts', () => {
  registerProjectFonts()
  const families = getAllRegisteredFonts().map((f) => f.family)
  expect(families).toContain('Poppins')
})

© ChaiBuilder. All rights reserved.