Skip to main content

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/styles and @codeleap/web.
  • A theme.ts module that exports a theme object built with createTheme(...).
  • Style sheets written with createStyles<Composition>(...), either under a sheets/ folder convention or as a standalone file with a top-level const X = createStyles(...) declaration — both are picked up by the extractor. The standalone path has two real limits: only one top-level createStyles(...) 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. The sheets/ 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"
}
FieldDefaultUsed by
srcDirsrccodeleap-extract-styles root directory
extractedDirsrc/styles/extractedOutput directory for all build artifacts
publicExtractedDirpublic/styles/extractedMirror of extractedDir under public/
globalCsssrc/styles/global.cssPrepended into bundle.css by codeleap-capture-css
pagesDir.next/server/pagesSSG HTML directory scanned by codeleap-capture-css
themeFile(none)Theme entry imported by extract-all and gen-theme-css
sheetsDirsheetsFolder 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 fail extract:styles — the CLI logs it to stderr and falls back to the runtime injector for that one sheet, so prebuild/ predev still exits 0. Don't infer "extraction worked" from the exit code; check stderr for a [extract] N sheet(s) failed extraction line, which also prints a per-file hint.
  • [capture] ABORT: 0 cl- classes captured…. codeleap-capture-css refuses to write bundle.css when it captures zero classes, rather than silently overwriting a good bundle with just global.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-pass build script (reset → build → capture → build) instead of invoking codeleap-capture-css on its own.

Verifying it worked

  • No @emotion/* (or equivalent CSS-in-JS runtime) dependency remains, and no css= prop usage.
  • theme.css and bundle.css appear as inline <style> tags in the served HTML, not <link> tags.
  • Grep src/styles/extracted/ for the literal string extraction-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 from setup.ts) tells the runtime injector which cl-* classes are already covered by bundle.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 in bundle.css. A low hit rate (many cl-* rules appearing inside <style data-cl> that are also present in bundle.css) means the injector isn't seeing the seeded set — most likely because setup.ts isn't imported before the first render, or because the build ran without a full 3-pass build (reset → capture → rebuild), leaving manifest.json stale. Run codeleap-capture-css output — it prints the total class count captured; compare against the count in manifest.json — they must match.
  • Cumulative Layout Shift stays near zero on first load.