Build-Time CSS Extraction
This is a project-implementation guide: how to wire @codeleap/styles so that CSS is computed
once at build time instead of by a runtime CSS-in-JS engine, for a Next.js (pages router)
project. It assumes you're already using createStyles(...), StyleRegistry, and the style
prop as covered in Getting Started — this page is specifically about the build
pipeline, not the styling API itself.
Why
@codeleap/styles' runtime is a merged-object model: every styled element resolves its
variants and default style into one merged object, hashed into a single cl-<hash> class, which
is one CSS rule. That merge can happen purely at runtime (a CSS-in-JS engine, computing and
injecting styles as components render) or it can happen once at build time, with the runtime only
seeding a lookup table from the result. The second option removes an entire engine from your
boot bundle and replaces render-blocking or client-computed CSS with one static, pre-built
stylesheet — the win is Total Blocking Time (from removing the engine) and Largest Contentful
Paint (from removing a render-blocking round trip for CSS delivery).
Prerequisites
- A Next.js project (pages router) already using
@codeleap/stylesand@codeleap/web. - A
theme.tsmodule that exports athemeobject built withcreateTheme(...). - Style sheets written with
createStyles<Composition>(...), either under asheets/folder convention or as a standalone file with a top-levelconst X = createStyles(...)declaration — both are picked up by the extractor. The standalone path has two real limits: only one top-levelcreateStyles(...)call is supported per file, and a locally-referenced helper constant is inlined only one level deep — a helper that itself depends on another local helper falls through to the runtime injector path instead of being extracted. Thesheets/folder convention has neither limit and is the safer default for anything non-trivial.
The three build-time CLIs
@codeleap/styles ships three binaries. Each does one job; together they produce everything the
runtime needs.
1. codeleap-extract-styles — compute the variant registry
codeleap-extract-styles [<root>] [<output-dir>] [--theme <theme-file>] [--sheets-dir <name>] [--config <path>]
Walks <root> for folder-convention directories and standalone createStyles(...) declarations,
imports each one (so its variants register into an in-process StyleRegistry), and writes the
result as registry.json in <output-dir> — a plain JSON snapshot of every registered variant,
ready to be loaded back into the runtime without ever importing the sheet source files themselves.
The folder match is a literal directory-name check at any depth under <root> —
src/styles/sheets/, src/components/Button/sheets/, and similar all match by default. If your
project doesn't use the sheets/ name (e.g. stylesheets), pass --sheets-dir to
match on a different name instead:
codeleap-extract-styles --config config/codeleap-styles.config.json --sheets-dir stylesheets
--sheets-dir takes a single value and replaces the default sheets name — only folders
with that exact name are walked. A folder name that matches nothing under <root> fails silently
(0 sheets found, no error) — if codeleap-extract-styles's stderr output reports fewer sheets
than expected after adding --sheets-dir, double check the exact name against the real directory,
including case.
--theme <file> imports your theme initializer once before extraction, so themeStore is
populated when each sheet factory runs (a sheet factory receives theme as its argument — it
needs a real theme object to compute against). Sheets under the sheets/ convention usually
import theme.ts themselves and don't strictly need this flag; it matters most for the standalone
createStyles(...)-in-a-.tsx convention, whose extracted copy has no theme import chain of its
own — omit --theme there and extraction breaks silently instead of failing loudly.
2. codeleap-gen-theme-css — render theme CSS variables
codeleap-gen-theme-css [<output-dir>] [--theme <theme-file>] [--config <path>]
Imports your theme and server-renders @codeleap/web's ThemeVariables component to static
markup, extracting the resulting <style> content into <output-dir>/theme.css — the :root
--cl-* custom properties every themed value ultimately resolves through. Each non-color token
category is namespaced in its variable name (--cl-radius-*, --cl-stroke-*, --cl-size-*) so
that two tokens sharing a key across categories — radius.medium and size.medium, say — produce
distinct variables instead of one silently clobbering the other.
3. codeleap-capture-css — capture the rendered CSS bundle
codeleap-capture-css --reset # empty bundle.css + manifest.json, before the first build pass
codeleap-capture-css # capture, after the first build pass
This is a render capture, not a static extractor. Because the merged-object model only ever
produces one hash per fully-resolved element, a static analysis of the registry alone can't
reconstruct the exact runtime output — variants and defaults have to actually be merged the way a
real render would merge them. So instead, codeleap-capture-css reads the already-rendered HTML
output of a Next.js SSG build (.next/server/pages), extracts every <style data-cl> tag it
finds, and unions all the rules into bundle.css and manifest.json. Both files are written to
the extractedDir and publicExtractedDir paths from the config (see
Configuration file below).
Configuration file
All three CLIs read from codeleap-styles.config.json in the project root (or the path given via
--config). CLI flags and positional arguments override whatever the file supplies, so existing
scripts that don't use --config keep working unchanged.
Drop a config/codeleap-styles.config.json at the project root:
{
"srcDir": "src",
"extractedDir": "src/styles/extracted",
"publicExtractedDir": "public/styles/extracted",
"globalCss": "src/styles/global.css",
"pagesDir": ".next/server/pages",
"themeFile": "src/styles/theme.ts",
"sheetsDir": "sheets"
}
| Field | Default | Used by |
|---|---|---|
srcDir | src | codeleap-extract-styles root directory |
extractedDir | src/styles/extracted | Output directory for all build artifacts |
publicExtractedDir | public/styles/extracted | Mirror of extractedDir under public/ |
globalCss | src/styles/global.css | Prepended into bundle.css by codeleap-capture-css |
pagesDir | .next/server/pages | SSG HTML directory scanned by codeleap-capture-css |
themeFile | (none) | Theme entry imported by extract-all and gen-theme-css |
sheetsDir | sheets | Folder name that identifies stylesheet directories |
Wiring package.json
{
"scripts": {
"extract:styles": "codeleap-extract-styles --config config/codeleap-styles.config.json && codeleap-gen-theme-css --config config/codeleap-styles.config.json && mkdir -p public/styles/extracted && cp src/styles/extracted/theme.css public/styles/extracted/theme.css",
"predev": "bun run extract:styles",
"prebuild": "bun run extract:styles",
"build:styles": "codeleap-capture-css --config config/codeleap-styles.config.json --reset && next build && codeleap-capture-css --config config/codeleap-styles.config.json",
"build": "rm -rf .next && bun run build:styles && next build"
}
}
bun (and npm/yarn) runs pre<script> automatically before <script> — predev/prebuild
don't need to be called explicitly, they fire before dev/build on their own. build is
therefore a two-pass next build: pass 1 renders with an empty seed, so every cl-* rule
gets injected inline where codeleap-capture-css can read it; pass 2 re-renders with the captured
bundle seeded, so the runtime injector's per-page tail collapses to near-zero bytes on any
statically-renderable page.
Always rm -rf .next as part of build (shown above) — a stale .next/ can retain a previous
build's manifest and chunks alongside the freshly captured CSS artifacts, which produces a real
regression, not just wasted cache.
Seeding the runtime
Create one module, imported once from your app root (e.g. _app.tsx), that loads the build
artifacts into the runtime registry and injector instead of ever importing sheet source files at
boot:
// styles/setup.ts
import { StyleRegistry } from './registry'
import registry from './extracted/registry.json'
import { styleInjector } from '@codeleap/web'
import manifest from './extracted/manifest.json'
StyleRegistry.seed(registry)
styleInjector.seedExtractedClasses(manifest.classNames)
// _app.tsx
import '@/styles/setup'
StyleRegistry.seed(...) restores every registered variant from registry.json — the runtime
never needs to import a single sheet file to know what 'primary' or 'row' compute to.
styleInjector.seedExtractedClasses(...) tells the runtime CSS injector which class names are
already covered by the captured bundle.css, so it skips re-injecting them and only ever emits
CSS for genuinely dynamic, client-only states the SSG build couldn't have rendered.
Variant-to-element maps aren't covered by seed(). If a component's variant needs to resolve
to a specific HTML tag per variant (a Text component rendering h1 for a display variant and
p for a body variant, for example), that mapping lives on a separate, purely in-memory registry
field that registry.json doesn't serialize. Register it explicitly in the same setup.ts
module:
StyleRegistry.registerVariantElements('Text', { display: 'h1', p1: 'p', /* … */ })
Wiring the document
Put the document-level style plumbing in its own module — styles/DocumentStyles.tsx — rather
than inlining it in _document.tsx. _document.tsx runs once per Next.js project and small
mistakes there (wrong style order, a swallowed exception) break every page at once, so keeping the
file-read + JSX in a separately named, separately reviewable module is worth the indirection; it
also keeps _document.tsx itself readable as "assemble the document," not "compute styles and
assemble the document." Read both CSS artifacts once at module load, and inline them directly as
<style> tags — not a <link>:
// styles/DocumentStyles.tsx
import { ColorSchemeFoucScript, flushServerStyles } from '@codeleap/web'
import { readFileSync } from 'fs'
import { join } from 'path'
// Read once at module load (server-only — this file never reaches the client bundle). Both
// files are static build artifacts that don't change while the server process is alive.
const themeCss = readFileSync(join(process.cwd(), 'public/styles/extracted/theme.css'), 'utf8')
const bundleCss = readFileSync(join(process.cwd(), 'public/styles/extracted/bundle.css'), 'utf8')
// SSR payload for `getInitialProps`: only the dynamic tail NOT already in bundle.css.
export function collectDocumentStyles() {
return { clCss: flushServerStyles() }
}
export function DocumentStyleTags() {
return (
<>
{/* Sets data-color-scheme from localStorage before paint, so the CSS below picks the
right variable block on first render — see "Runtime color-scheme switching" below. */}
<ColorSchemeFoucScript />
<style dangerouslySetInnerHTML={{ __html: themeCss }} />
<style dangerouslySetInnerHTML={{ __html: bundleCss }} />
</>
)
}
Do not reach for the classic media="print" → media="all" (or rel=preload + onload)
non-blocking-CSS swap for these two files, even though it's the standard technique for
non-critical CSS elsewhere. In this merged-object model, a cl-<hash> class defines actual
layout (flex, grid, spacing), not just cosmetic styling — so an async stylesheet swap risks the
page painting with zero layout before the rules apply, a real Cumulative Layout Shift regression.
Separately, React strips onLoad from a <link> rendered inside _document, so a
preload-then-swap approach relying on it silently never fires at all — styles simply never apply
on client-side navigation. Inlining trades "no longer a separately cacheable asset across page
loads" for "zero render-blocking round trip and no CLS risk," which is the right trade for this
architecture.
You'll also need an SSR-time flush for any genuinely dynamic styles the injector produces per
request (client-only states the static build couldn't render). The Pages Router only exposes this
through _document's class-based getInitialProps API — there's no functional-component
equivalent — and you must merge into initialProps.styles rather than replace it, or you silently
drop whatever Next (or another library) already injected there:
// pages/_document.tsx
import Document, { Html, Head, Main, NextScript, DocumentContext, DocumentInitialProps } from 'next/document'
import { collectDocumentStyles, DocumentStyleTags } from '@/styles/DocumentStyles'
import { fontClassNames } from '@/styles/theme'
export default class MyDocument extends Document<DocumentInitialProps> {
static async getInitialProps(ctx: DocumentContext): Promise<DocumentInitialProps> {
const initialProps = await Document.getInitialProps(ctx)
const { clCss } = collectDocumentStyles()
return {
...initialProps,
styles: [
// Spread, don't replace — initialProps.styles may already hold entries from Next
// itself or another library; dropping them silently breaks those styles.
...(Array.isArray(initialProps.styles) ? initialProps.styles : [initialProps.styles]),
<style key="cl" data-cl dangerouslySetInnerHTML={{ __html: clCss }} />,
],
}
}
render() {
return (
<Html lang='en'>
<Head>
<link rel='icon' sizes='any' href='/favicon.ico' />
<DocumentStyleTags />
</Head>
<body className={fontClassNames}>
<Main />
<NextScript />
</body>
</Html>
)
}
}
Runtime color-scheme switching doesn't touch theme.css
theme.css is genuinely static once generated — nothing re-renders ThemeVariables at runtime,
and no runtime wiring beyond the CLI step is needed for it. ThemeVariables (the component
codeleap-gen-theme-css server-renders) emits every color scheme's variables up front: the
default scheme on :root, and each alternate scheme as a delta block scoped to either
[data-color-scheme="<name>"] or an @media (prefers-color-scheme: dark) guard (for dark
specifically). Switching schemes at runtime is a plain DOM attribute write —
@codeleap/styles' applyColorSchemeToDOM sets or removes
document.documentElement.dataset.colorScheme — which just flips which CSS block the cascade
picks; no React re-render, no re-fetch, no regeneration of theme.css involved.
ColorSchemeFoucScript (rendered above, in DocumentStyleTags) exists only to avoid a
flash-of-wrong-scheme: it reads the persisted choice from localStorage and sets
data-color-scheme on <html> before hydration, synchronously, ahead of any React code running.
Fonts
If your theme references a next/font object, the extraction pipeline needs one adjustment:
reference only the font object's .variable (the CSS class name that sets a --font-* custom
property on <body>), and write your typography tokens as var(--font-name), sans-serif. Never
read someFont.style.fontFamily inside a theme file.
codeleap-gen-theme-css runs theme.ts in an isolated Node process — no real React render ever
happens, so next/font's bundler transform never applies, and .style.fontFamily reads back a
literal placeholder string ("extraction-stub") instead of the real font family, which gets baked
silently into your production theme.css. The CSS variable, by contrast, is only ever resolved by
the browser at runtime and is never read during extraction, so it's safe:
// fonts.ts
import { Inter } from 'next/font/google'
export const inter = Inter({ subsets: ['latin'], variable: '--font-inter' })
// theme.ts
import { inter } from './fonts'
export const fontClassNames = inter.variable // apply on <body> in _document
const theme = createTheme({
typography: {
defaults: { fontFamily: 'var(--font-inter), sans-serif' },
},
// ...
})
Multiple fonts: combine every font's .variable into one fontClassNames string exported from
theme.ts (not fonts.ts) and apply the whole string on <body>, even if only one font is used
in typography.defaults — a font referenced only inside typography.fonts.<Name> for selective
use still needs its .variable applied on <body>, or its CSS variable is never defined and the
var(--font-*) reference falls back silently:
// theme.ts
import { dmSans, inter } from './fonts'
// Referencing both font objects here keeps fonts.ts in theme.ts's module graph, so Next emits
// their @font-face + --font-* var definitions into a loaded CSS chunk. Apply the whole string on
// <body> (see _document) — dropping either one drops that font's CSS var, and every fontFamily
// value referencing it falls back to the next entry in its stack.
export const fontClassNames = `${dmSans.variable} ${inter.variable}`
const theme = createTheme({
typography: {
fonts: {
Inter: 'var(--font-inter), sans-serif',
DMSans: 'var(--font-dm-sans), sans-serif',
},
defaults: { fontFamily: 'var(--font-dm-sans), sans-serif' },
},
// ...
})
The same isolated-process problem extends beyond fonts: .svg and .css imports are stubbed the
same way for anything in a sheet's transitive import graph, not just the theme. A sheet that
imports an SVG or CSS file directly (e.g. to read a value off it) gets an empty/no-op stub at
extraction time with no error — if you hit an extraction failure or wrong output tracing back to
an asset import, that stub sandbox is why; extending it is a source-level change to the extraction
pipeline itself, not something configurable from a project.
Writing sheets that extract correctly
A sheet factory is imported and executed with a real theme object at build time — so it can't contain anything whose real value only exists at component-render time.
No computed theme-key access. theme.foo[someRuntimeVariable] can't be extracted, because the
extractor has no way to know what value that variable will hold once a real component renders.
@codeleap's ESLint plugin ships a no-dynamic-theme-access rule that catches this at lint time.
If it fires:
// ❌ can't be extracted — `size` is only known at render time
createStyles<Composition>((theme) => ({
wrapper: { width: theme.sizes[size] },
}))
// ✅ finite set of keys — refactor to a named variant per key
createStyles<Composition>((theme) => ({
wrapper: {},
'wrapper:small': { width: theme.sizes.small },
'wrapper:large': { width: theme.sizes.large },
}))
// ✅ genuinely runtime-driven — compute where the prop is in scope, pass through `style`
const width = theme.sizes[size] // in the component body, not the sheet
<View style={[styles.wrapper, { width }]} />
No arithmetic on a resolved theme value. theme.colors, theme.radius, theme.stroke,
theme.size, theme.typography, and theme.effects values are CSS-variable strings (e.g.
"var(--cl-radius-medium)"), not numbers — theme.radius.medium + 4 doesn't throw, it silently
produces NaN or a concatenated string, and no-dynamic-theme-access doesn't catch it (it only
flags computed-key access, not arithmetic on an already-resolved value). Build a real CSS calc()
expression instead, interpolating the token directly:
createStyles<Composition>((theme) => ({
// ❌ theme.radius.medium is "var(--cl-radius-medium)" — this is NaN, not a number
wrapper: { width: theme.radius.medium + 4 },
// ✅ interpolate the token into a calc() string
wrapper: { width: `calc(${theme.radius.medium} + 4px)` },
}))
theme.spacing is the one category still numeric — it isn't run through the CSS-variable proxy,
so arithmetic on it is safe. For a calc() expression with only plain numeric operands (no themed
token involved), @codeleap/styles also exports a chainable calc(...) builder
(calc(100, '%').sub(16).build() → 'calc((100%) - (16px))') — it takes numeric literals, not
theme values, so it doesn't replace the interpolation above when a token is one of the operands.
createStylesWithContext sheets aren't captured by build-time extraction. The factory's
context-driven variants (isDisabled, isSelected, etc.) only resolve with a real
ComponentContext passed in at render time; the extractor can only call the factory with no
context, so it captures the default/no-context styles only. These sheets still need a live
StyleRegistry.registerVariants(...) call at runtime regardless of extraction — seed() restores
extracted sheets but explicitly skips context factories, so skipping registerVariants for one
leaves its context-driven states unstyled in production.
No module-level registration side effects in individual sheets. Centralize every
StyleRegistry.registerVariants(name, sheet) call into one module, imported once, rather than
each sheet registering itself on import. A sheet that registers itself pulls the whole registry
(and its transitive import graph) onto whatever imports that one sheet — centralizing keeps
individual sheets inert and safe to import from anywhere without side effects.
Migrating animations off Emotion's keyframes()
@emotion/react's keyframes() helper returns a generated class name backed by a runtime style
injection — it doesn't survive build-time extraction, because there's no way for the extractor to
statically capture what Emotion injects at render time. Move the @keyframes rule into
global.css as plain CSS, and reference it from the sheet by its literal name string instead of
importing keyframes at all:
// ❌ before — Emotion keyframes(), only resolves at runtime
import { keyframes } from '@emotion/react'
const fadeIn = keyframes`
from { opacity: 0; }
to { opacity: 1; }
`
export const ModalStyles = {
default: createModalVariant((theme) => ({
wrapper: { animation: `${fadeIn} 0.2s ease-in-out` },
})),
}
/* ✅ after — src/styles/global.css: prepended into bundle.css unconditionally */
@keyframes cl-fadeIn {
from { opacity: 0; }
to { opacity: 1; }
}
// ✅ after — src/styles/sheets/Modal.ts: reference the animation by name
// Keyframes live in src/styles/global.css and are concatenated into bundle.css by extract:styles.
const fadeIn = 'cl-fadeIn'
export const ModalStyles = {
default: createModalVariant((theme) => ({
wrapper: { animation: `${fadeIn} 0.2s ease-in-out` },
})),
}
A cl- prefix on the keyframe name isn't required by the tooling, but it's worth adopting as a
convention — global.css is a flat, ungoverned namespace (no module scoping, no extraction-time
collision check), so a plain name like fadeIn is one accidental duplicate away from silently
overriding another sheet's animation.
Directory layout (reference)
src/styles/
├── theme.ts # createTheme(...), imports fonts.ts if using next/font
├── fonts.ts # next/font objects, referenced only via .variable
├── global.css # resets + @keyframes — prepended into bundle.css unconditionally
├── registry.ts # `export const StyleRegistry = new WebStyleRegistry()`
├── setup.ts # seeds the registry + injector from build artifacts (see above)
├── DocumentStyles.tsx # reads theme.css/bundle.css, exports DocumentStyleTags + SSR flush
│ # (also renders ColorSchemeFoucScript — see "Wiring the document")
├── sheets/
│ ├── register.ts # ONE loop calling registerVariants for every sheet
│ ├── Button.ts
│ ├── Text.ts
│ └── ...
└── extracted/ # gitignored — regenerated every build
├── .gitignore
├── registry.json
├── theme.css
├── bundle.css
└── manifest.json
Ignore the four generated files with a .gitignore placed inside extracted/ itself, rather
than a pattern in the project's root .gitignore:
# src/styles/extracted/.gitignore
bundle.css
manifest.json
registry.json
theme.css
This keeps the ignore rule colocated with what it ignores instead of one more line in a
project-wide file, but it has one sharp edge: an rm -rf of the whole extracted/ directory
(e.g. a stale-build cleanup script, or a manual rm -rf while debugging) deletes this file too,
since it lives inside the directory it's describing. Nothing regenerates it — the extraction
pipeline only writes the four CSS/JSON artifacts, never this file — so a wiped extracted/
silently comes back untracked-and-visible-to-git until someone notices and restores it from
history.
Troubleshooting
- A sheet silently missing from
registry.json. A single sheet failing to extract (a bad import, a serialization error, a sandbox-resolution issue) does not failextract:styles— the CLI logs it to stderr and falls back to the runtime injector for that one sheet, soprebuild/predevstill exits 0. Don't infer "extraction worked" from the exit code; check stderr for a[extract] N sheet(s) failed extractionline, which also prints a per-file hint. [capture] ABORT: 0 cl- classes captured….codeleap-capture-cssrefuses to writebundle.csswhen it captures zero classes, rather than silently overwriting a good bundle with justglobal.css. This means it ran against the wrong build state — most commonly, running it standalone against an already-built (pass-2) tree, where the inline<style data-cl>tail has already collapsed to near-empty. Run the full two-passbuildscript (reset → build → capture → build) instead of invokingcodeleap-capture-csson its own.
Verifying it worked
- No
@emotion/*(or equivalent CSS-in-JS runtime) dependency remains, and nocss=prop usage. theme.cssandbundle.cssappear as inline<style>tags in the served HTML, not<link>tags.- Grep
src/styles/extracted/for the literal stringextraction-stub— must be zero hits. - Curl a route and check the trailing
<style data-cl>tag's content — it should be empty (or very close to it) on any statically-rendered page, confirming the captured bundle covers everything the runtime needs. - Seed hit rate ≥ 95%.
styleInjector.seedExtractedClasses(manifest.classNames)(called fromsetup.ts) tells the runtime injector whichcl-*classes are already covered bybundle.css, so it skips re-injecting them. If the seed is working correctly, at least 95% of the class names the app would normally inject at runtime should already be present inbundle.css. A low hit rate (manycl-*rules appearing inside<style data-cl>that are also present inbundle.css) means the injector isn't seeing the seeded set — most likely becausesetup.tsisn't imported before the first render, or because the build ran without a full 3-passbuild(reset → capture → rebuild), leavingmanifest.jsonstale. Runcodeleap-capture-cssoutput — it prints the total class count captured; compare against the count inmanifest.json— they must match. - Cumulative Layout Shift stays near zero on first load.