Why Salty CSS is Genuinely Zero-Runtime under Server Side Rendering and How esbuild execution replaces fragile AST extraction
Compiler & Zero-Runtime
The shift away from runtime CSS-in-JS wasn't a matter of taste — it was structural. React Server Components (RSC) and server-first architectures like Astro put real pressure on the model that powered the previous generation of libraries: leaning on React Context and client-side CSSOM injection creates genuine friction in a server-rendered world, where much of your tree never touches a client runtime at all.
That said, the term "zero-runtime" has become heavily diluted. Plenty of frameworks that wear the label still ship meaningful orchestration logic to the client, or ask you to work around the edges of static analysis.
Salty CSS takes a particular path through that space. This page demystifies the "magic" behind the compiler: why Salty CSS asks for specific file extensions, how its execution model differs from AST extraction, and what "zero-runtime" actually means here — including the parts where it isn't literally zero.
The Reality of "Zero-Runtime"
To understand Salty CSS, look at what actually ships to the browser.
In a server-first paradigm (like Next.js App Router without 'use client', or Astro without client:load), Salty CSS is genuinely zero-runtime. No style injection, no variant mappers, no theme providers. The HTML arrives with hashed class names, and the static CSS file handles the rest.
But what happens when you do need an interactive client component?
Unlike runtime CSS-in-JS, Salty CSS never injects <style> tags or serializes CSS during the React render cycle. Instead, the build step strips the styling logic out of your component. What ships to the client bundle is a tiny, highly optimized mapping function — not a styling engine.
Consider this source file:
// button.css.ts (Source) import { styled } from "@salty-css/react/styled"; export const Button = styled("button", { base: { padding: "{spacing.medium}", borderRadius: "6px", background: "{theme.background}", }, variants: { intent: { primary: { background: "{theme.primary}" }, danger: { background: "{theme.danger}" }, }, }, });
During compilation, Salty CSS extracts the actual CSS into a static stylesheet. The TypeScript file is then rewritten so the client bundle imports a lightweight styledClient in place of styled, with the generator's output baked in as a plain object:
// button.min.js (Compiled Client Output) import { styledClient as styled } from "@salty-css/react/styled-client"; export const Button = styled("button", "TzHVd", { hash: "TzHVd", variantKeys: ["intent"], // What prop keys are used for variants propValueKeys: [], // If you use prop-based styles (e.g., css-bg-color="{props.color}"), those keys would be listed here defaultProps: {}, // Props added directly from the styled function attr: { "data-component-name": "Button" }, // Adding some metadata for debugging in development, stripped in production });
The styledClient is just a lightweight prop-to-class mapper. It takes the intent="primary" prop and appends the pre-compiled intent-primary class alongside the base TzHVd class on the DOM element — the static stylesheet scopes the variant rule as .TzHVd.intent-primary { … } so the two classes combine at render time.
Dynamic styles: two scales
Even with everything pre-compiled, real applications need some dynamism. Salty CSS handles it at two distinct scales — and crucially, neither one ships a styling engine to the browser.
Per-value: props → CSS variables. When the shape of the styles is known at build time but a value isn't (a tenant tint, a hex color from a settings panel), Salty CSS does not generate new classes on the fly. You declare the prop reference inside your .css.ts source — e.g. backgroundColor: "{props.bgColor}" — and at usage you pass the concrete value via a matching dash-cased css-* prop: <Button css-bg-color="tomato" />. The runtime intercepts css-bg-color, writes --props-bg-color: tomato as an inline style, and the pre-compiled rule already references the variable.
Because the styling is driven entirely by native CSS variables, updating a value stays on the browser's fast path. There's no CSS serialization, no new class generation, and no <style> injection at runtime — React's job is limited to writing a single inline custom-property value rather than swapping class names across the tree, and the browser handles the change with its native custom-property update path instead of re-running a JS styling engine. (To be precise: mutating a custom property still triggers the browser's normal style recalculation for the affected elements — that's unavoidable and cheap. What you avoid is the expensive part: inserting new rules into the CSSOM and re-serializing styles on every render.)
Per-shape: defineRuntime. The harder case is when the style object itself isn't known at build time — a CMS author drops in a custom css payload, a tenant config supplies brand overrides as JSON, a database row carries a layout recipe. There's nothing for the compiler to extract, because the rule doesn't exist yet.
This is where defineRuntime earns its keep. It's a tiny request-time helper that runs the same parseStyles and the same toHash as the build-time compiler — not a parallel engine, not a second mental model. You call it from a React Server Component, an Astro frontmatter, or a route handler:
import { defineRuntime } from "@salty-css/core/runtime"; import config from "../../salty.config"; const runtime = defineRuntime(config); // Inside an RSC / Astro page / route handler: const { className, css } = await runtime.resolve(block.css); // → render <style>{css}</style> next to an element with className={className}
Because the same parser runs, JSON-shaped input gets the full Salty surface for free: {colors.brand} tokens resolve to the same CSS variables your styled components use; @media, &:hover, and nested selectors all keep working; and two blocks with structurally-identical payloads collapse to a single rule via the shared hash. A CMS block effectively ships "CSS in JSON" and slots straight into your design system — no second pipeline, no escape-hatch global CSS file, and no client styling runtime appearing on the page just because some rules were resolved per-request. The CSS is emitted as a string into the HTML response, and the browser parses it the way it parses your static stylesheet.
Reach for this when content authors want power — give them the parser, not a constrained subset. See the defineRuntime API reference for the full surface: signature, feature-support table, scoping, and worked examples.
Smart Hashing and Deduplication
The compiler uses deterministic hashing (toHash). If a HeroBlock and a TextBlock independently define identical style objects, they generate the exact same hash. The CSS rules are emitted to the final stylesheet only once, automatically deduplicating your output and keeping the CSS payload minimal.
Execution over Extraction (esbuild vs. AST)
To extract styles at build time, a compiler has to understand your code. There are two broad families for doing this, and they make different tradeoffs.
The first is Abstract Syntax Tree (AST) static analysis, used to great effect by tools like Panda CSS (and, historically, by Babel-based extractors like Linaria). These are genuinely impressive pieces of engineering — modern static extractors do identifier resolution, constant folding, and partial evaluation, and they're fast and battle-tested in large codebases.
The tradeoff is inherent to the approach: the analyzer reasons about your code without running it. When a value can only be known at runtime — a prop driven by state, a token resolved through an indirection the analyzer can't follow, a style object built by a sufficiently dynamic helper — the extractor has to either skip it or pre-generate every plausible combination ahead of time. That's why static tools offer escape hatches (Panda's staticCss, for instance) to declare the dynamic surface up front. It's a perfectly reasonable model; it just asks you to keep your styles statically analyzable, and to reach for those hatches when you can't.
Salty CSS sits in the other family: it executes rather than analyzes. Instead of parsing an AST, the Salty compiler uses esbuild to actually evaluate your .css.ts files in a Node environment during the build step. This is the same fundamental idea Vanilla Extract pioneered with its .css.ts model, and Salty owes it a clear debt.
Because the code is genuinely executed:
- You can safely import variables, functions, or design tokens from anywhere in your codebase.
- You can write complex helper functions to generate style objects.
- You can even use
async/awaitinside your style definitions (e.g., fetching a remote design token registry during the build).
If your code runs in Node, Salty CSS can compile it. The cost of this approach is the constraint it places on where styles live — which is the subject of the next section — but in exchange you don't have to keep your styling logic statically legible to a parser.
The Compilation Pipeline
Knowing why Salty CSS executes code rather than parsing ASTs is useful context. Knowing what actually happens between saving a file and seeing a styled component in the browser is what you need when something breaks — or when you're trying to understand why a particular file is slowing your build down.
There are two distinct journeys to trace. The first is the build-time funnel: what runs when you start the dev server or kick off a production build. The second is the load-time funnel: what the bundler does with a .css.ts file when it encounters it as an import, and what the browser ends up with.
Funnel 1 — Build time: .css.ts → saltygen/index.css
Plugin hook → Discovery → Config pass → esbuild (per file) → import() → CSS generation → layer assembly → index.css
1. Plugin hook fires
The bundler plugin (saltyPlugin for Vite, the webpack loader, withSaltyCss for Next.js) hooks into buildStart. It calls saltyCompiler.generateCss(), which begins by wiping saltygen/ clean and recreating its subdirectory structure: css/, js/, types/, cache/, imports/. Everything flows from this point on a fresh slate.
2. File discovery
collectFiles() walks the project tree recursively with statSync, skipping node_modules and saltygen/ entirely. A file is salty if its name matches *.(salty|css|styles|styled).(ts|tsx|js|...). Within that set, any file containing a call matching defineX( is additionally flagged as a config file and queued for processing first. Two sets leave this step: all salty files, and the config-file subset.
Perf: This is a synchronous depth-first
statSyncwalk across the whole project. In a large monorepo with thousands of directories, the syscall overhead is measurable. The compiler can't skip arbitrary output directories — onlynode_modulesandsaltygen/are excluded by name. Keep generated directories out of your source tree, and avoid nesting.css.tsfiles inside paths that share a prefix with other deeply populated directories.
Note: performance optimizations are on the roadmap
3. Config pass (parallel)
Config files run before anything else — they declare the design tokens and templates that every component file depends on. Each config file goes through compileSaltyFile() (see the next step), and its exports are bucketed by marker flag: isDefineVariables, isDefineTemplates, isMedia, isDefineFont, isDefineImport, isGlobalDefine. salty.config.ts itself is compiled here as well, and its output is merged in.
The output of this pass is written to saltygen/:
css/_variables.css— CSS custom properties fromdefineVariablesandconfig.variablescss/_global.css,css/_reset.css,css/_templates.css,css/_fonts.css,css/_imports.css— the other global layerstypes/css-tokens.d.ts— generatedVariableTokens,TemplateTokens, andMediaQueryKeystypescache/config-cache.json— a serialised snapshot of the merged config (tokens, templates, media queries) that later steps can read without re-running the compiler
4. Per-file esbuild compilation
This is the step that makes Salty CSS an execution-based compiler, and where build performance is most sensitive.
For each salty file, compileSaltyFile() does the following before esbuild ever sees the source:
replaceStyledTag()rewrites anystyled(SomeNonSaltyComponent, ...)call tostyled('div', ...), so non-salty components don't drag their import chains into the bundle.globalThis.saltyConfig = <config-cache.json contents>is prepended to the source, so each file has access to design tokens at evaluation time without re-compiling the config.
The patched source is then fed to esbuild via stdin (not as a file on disk), bundled, tree-shaken, and transpiled to Node20 format. The result is written to saltygen/js/<srcHash>-<contentHash>.js — the filename is deterministic, so an unchanged file produces the same path and can be reused by the HMR incremental path.
Perf: esbuild is written in Go and is extremely fast — but it follows every
importchain in the file.packages: 'external'excludesnode_modules, but local barrel files are a different matter. If your design tokens live in./tokens/index.tsand that file re-exports 50 individual token modules, esbuild follows all 50 chains for every.css.tsthat imports it. Each file gets its own esbuild invocation, so a barrel with 50 re-exports across 80 style files means 4 000 extra module parses. Import from the specific token file you need, not a catch-all barrel.
5. Dynamic import() + export bucketing
The compiled .js is loaded into the running Node process with a dynamic import(). This is the step that separates Salty CSS from static AST tools: the code is executed — any logic you wrote runs, any imports resolve, any async/await settles. The exports come back as a live JavaScript object.
Each named export is inspected for marker flags set by the factory functions:
isKeyframes→ keyframe definitionisClassName→ aClassNameGenerator(fromclassName())generatorpresent → aStyledGenerator(fromstyled())
Async exports — bare Promises or functions tagged with _shouldResolve — are awaited by resolveExportValue() before the export is bucketed.
Perf: You can
await fetch(...)inside a style definition to pull tokens from a remote registry at build time. Each unresolvedawaitserialises evaluation within that file's module. If 30 components each independently fetch from the same endpoint, you wait for 30 sequential requests. Batch-fetch once in a shared import and let the other files import the result instead — esbuild will inline the value, and the fetch runs once.
6. CSS generation
For each generator, _withBuildContext({ callerName, isProduction, config }) locks in the build-time context: the export name becomes the CSS filename prefix (Button → cl_button-TzHVd-0.css) and, in development, the data-component-name attribute value.
generator.css then calls parseAndJoinStyles(), which:
- Resolves
{token.path}references tovar(--token-path)CSS custom properties - Expands template call sites (
textStyle: 'heading.large'→ the class that the template layer emits) - Handles
@mediablocks, pseudo-selectors, and modifier rewrites declared indefineConfig - Serialises the result to a CSS string
The hash is computed from the style content — { base, variants, compoundVariants, anyOfVariants }. It's content-addressed, not name-addressed. Rename the export and nothing changes; edit a style value and the hash changes. Two components with structurally identical style objects produce the same hash and collapse to a single CSS rule, deduplicated automatically.
Note on cascade order: A
styled()call whose first argument is another styled component incrementspriorityby one. Higher-priority generators land in a later CSS layer (l1,l2, …). Because the@layerorder is declared at the top ofindex.css, the cascade is deterministic: a more-specific component's rules always win over its base's rules, regardless of where the files live on disk or what order they were compiled in.
7. Layer assembly + index.css
Component CSS files are grouped by priority and merged into l_0.css, l_1.css, and so on — each wrapped in @layer l0 { … }. Each per-component block is delimited by /*start:<hash>-<filename>*/ and /*end:<hash>*/ markers. These markers are how the HMR incremental path patches a single component's CSS without triggering a full rebuild.
index.css is then assembled as the single entry point: a @layer imports, reset, global, templates, fonts, l0, l1, …; declaration at the top, followed by @import rules for each global file and each layer file. This is the file the user's app imports.
Funnel 2 — Load time: saltygen/ → rendered component
The build funnel produces the CSS. This second funnel traces what happens when the bundler processes a .css.ts import in your component tree, and what the browser ultimately receives.
Bundler load hook → source transform → minified client file → browser renders
1. Bundler load hook
When the bundler encounters a .css.ts import, the plugin's load hook fires. For React projects, it calls transformSaltyFile() — a framework-specific transform that is loaded lazily and memoised for the entire bundler session (the module import promise is cached, so the transform module loads once regardless of how many salty files exist).
2. Source transform
compileSaltyFile() runs again on the file (reusing the cached .js from saltygen/js/ if the content hasn't changed). For each styled or className export, the generator's pre-computed clientProps are serialised: { hash, variantKeys, propValueKeys, defaultProps, attr }. The source is then rewritten in-place — the full style object is replaced with the baked-in client form:
// Your source export const Button = styled("button", { base: { padding: "{spacing.medium}" }, variants: { intent: { primary: { background: "{theme.primary}" } } }, });
// What the bundler serves to the browser export const Button = styled("button", "TzHVd", { hash: "TzHVd", variantKeys: ["intent"], propValueKeys: [], defaultProps: {}, attr: { "data-component-name": "Button" }, // stripped in production });
Import paths are also rewritten: { styled } from "@salty-css/react/styled" becomes { styledClient as styled } from "@salty-css/react/styled-client". All style logic — the base object, token references, variant definitions — is gone from the bundle.
3. Browser receives
The client bundle contains no CSS serialisation engine, no CSSOM injection, no theme provider. The HTML element carries hashed class names (class="TzHVd intent-primary"). The static saltygen/index.css — already linked in <head> — provides .TzHVd { … } and .TzHVd.intent-primary { … }. The browser applies them via its native cascade. Nothing computes anything.
4. React render — server-first path
In a React Server Component or an Astro component, there is no client bundle for that component at all. styledClient runs on the server, emits a string of class names baked into the HTML, and the component contributes zero JavaScript to the client payload. This is the "genuinely zero-runtime" case from the opening of this page.
5. React render — client component path
styledClient is the thin mapper that ships to the browser when you mark a component 'use client'. It receives the pre-compiled clientProps, reads variant props at render time (e.g. intent="primary"), and appends the matching class name alongside the base hash class. If any css-* props are present (like css-bg-color="tomato"), it writes the corresponding CSS custom property as an inline style. No new classes are generated, no <style> tags are inserted, no CSSOM is touched. React writes one inline style value; the browser's native custom-property update path handles the rest.
The incremental (HMR) path
When you save a .css.ts file during development, watchChange fires and calls generateFile(path) — not generateCss(). This path skips discovery and the config pass entirely. It recompiles only the changed file, writes its updated per-component CSS, and patches the result into the existing l_N.css layer bundle by locating the /*start:hash*/…/*end:hash*/ markers and splicing in the new content. The dev server picks up the change and browsers reload the stylesheet without a full page refresh.
The exception: if a config or token file changes, checkShouldRestart returns true and the dev server restarts. _variables.css and cache/config-cache.json underpin every other file — patching them incrementally isn't safe.
The .css.ts Contract: Separation as a Feature
Salty CSS asks for a file-naming convention: style definitions live in *.css.ts (or *.styles.ts, etc.) files.
If you're used to co-locating styles directly inside .tsx components, this is a real tradeoff, not a free win — you give up colocation, and for some teams that's a meaningful loss. It's worth being honest about that. But the boundary buys back three things that, in practice, tend to be worth it:
- Fast, predictable builds:
A compiler that has to scan every
.tsxfile, untangle the component logic, and hunt for embedded style calls does more work as the component tree grows. By isolating styles in.css.tsfiles, the Salty compiler only evaluates the files that actually contain CSS. This "compilation firewall" keeps CSS builds fast and their cost proportional to how much styling you have — not to the total size of your application. - Framework-Agnostic Reusability:
A
.css.tsfile contains zero React/Astro/Vue runtime logic. It's pure styling orchestration. That makes it straightforward to build portable design systems: you can publish a library of.css.tsfiles and consume them across Next.js, Vite, and Astro without worrying about JSX pragmas or framework-specific render cycles. - Optimized Output:
As the compiler processes these files, it can split the output strategically. Depending on your configuration (
importStrategy), Salty can bundle everything into one globalindex.cssto resolve design tokens globally, or split into component-level.csschunks for route-based code splitting.
By keeping the .css.ts boundary, Salty CSS protects compilation speed and keeps UI logic and styling logic decoupled.
Prior Art & Acknowledgments
Salty CSS didn't appear from nowhere, and it isn't trying to win a head-to-head fight with the libraries that shaped it. It exists because, after enough years of handcrafting websites, I wanted a tool that made a specific set of tradeoffs the way I'd make them — and the honest way to introduce it is to name the work it stands on.
- Stitches (and its death) is the reason this project exists at all. Its variant model and the developer experience it delivered on the client were, frankly, a joy to use, and Salty is in large part an attempt to carry that feeling into a server-first, zero-runtime world.
- Panda CSS taught me a lot on how to approach server-first styling and how to update the already brilliant API that Stitches pioneered. You'll find its fingerprints in the API on purpose — conventions like
baseandtextStylesastemplatesare adopted directly because they're good ideas. - Vanilla Extract is where the
.css.tsexecution model comes from. The idea that your styles can still be in their own files, like back in the old days, but with the full power of TypeScript was an architectural benchmark.
Different tools draw the lines in different places. Salty draws them where I needed them drawn. If one of the libraries above fits your project better, use it with my blessing — they're excellent, and Salty is better for existing alongside them, spicing up the mix. 🧂