The Visual Website Builder
You Actually Own. React + Next.js, low-code, self-hosted. Your data, your infrastructure, every feature included.
© ChaiBuilder. All rights reserved.

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.
There are three moving parts:
registerChaiFont(family, font) adds a family to an in-memory list.
Anything in that list appears in the Theme panel font dropdowns.theme.fontFamily.heading and
theme.fontFamily.body. The value is the family name string, for example Inter.@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.
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.
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.
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.
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.
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.
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.
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',
},
],
})
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.
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 panelsrc/app/(frontend)/[[...slug]]/page.tsx for builder rendered pagessrc/components/with-chai-layout.tsx for custom coded routes that use a builder layoutsrc/app/(frontend)/pro/license-overlay/_components/Document.tsx for the license screenAdding 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.
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.
For the two families the theme actually uses, the renderer emits:
<link rel="preload" as="font" type="font/woff2" crossorigin> for the first src of each<style id="fonts-styles"> containing the @font-face rules<style id="theme-variables"> setting --font-heading and --font-body to
"Family", fallbackRegistered 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.
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.
After adding a font, check all three layers rather than just the dropdown:
@font-face for the family inside
<style id="fonts-styles"> and a matching preload link in the head..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')
})
