Why Salty CSS Is Genuinely Zero-Runtime Server-First, and How Executing .css.ts Files 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 the bundler's buildStart. It kicks off a full generation, which begins by wiping saltygen/ clean and recreating its working subdirectories from scratch. Everything flows from this point on a fresh slate.
2. File discovery
A recursive walk of the project tree skips node_modules and saltygen/ entirely. A file counts as salty if its name matches *.(salty|css|styles|styled).(ts|tsx|js|…). Within that set, any file that also calls a defineX( factory 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 filesystem walk 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 — only
node_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 one goes through the same per-file compile step as everything else (see step 4), and its exports are sorted by what they declare: variables, templates, media queries, fonts, imports, and globals. salty.config.ts itself is compiled here as well, and its output is merged in.
The output of this pass is written into saltygen/: the global CSS layers (variables, reset, global, templates, fonts, imports), the generated TypeScript token/template/media-query types, and 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.
Before esbuild ever sees the source, two things happen to it. First, any styled(SomeNonSaltyComponent, …) call is rewritten to styled('div', …), so non-salty components don't drag their import chains into the bundle. Second, the cached config is made available to the file at evaluation time, so each file can resolve design tokens 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 a Node-compatible format. The result is written into saltygen/js/ under a deterministic, content-derived filename — so an unchanged file produces the same path and can be reused by the incremental HMR 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 then inspected to work out what it is — a keyframes definition, a className generator, or a styled generator — and bucketed accordingly. Exports that resolve asynchronously (a bare Promise, or a function flagged to be awaited) are settled before they're 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, the build-time context is locked in: the export name becomes the CSS filename prefix (Button → cl_button-TzHVd-0.css) and, in development, the value of the data-component-name attribute.
The generator then resolves and serialises its styles — which means it:
- 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 in your config - Serialises the result to a CSS string
The hash is computed from the style content itself — base, variants, and compound variants — not from the export name. It's content-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 runs a framework-specific transform that is loaded lazily and memoised for the entire bundler session, so the transform module loads once regardless of how many salty files exist.
2. Source transform
The per-file compile step 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 client metadata is serialised — { hash, variantKeys, propValueKeys, defaultProps, attr } — and the source is 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 client metadata, 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, a watch hook fires and recompiles only the changed file rather than re-running the whole build. This path skips discovery and the config pass entirely. It writes that file's 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 is a config or token file changing: those underpin every other file — _variables.css and the cached config snapshot in particular — so patching them incrementally isn't safe. When one changes, the dev server restarts instead.
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.
Why execution
From the moment I started building Salty CSS, I knew I wanted to execute style files rather than statically analyze them. I have nothing against AST extraction — the people maintaining those tools do genuinely hard, careful work — and I've done some AST testing for .css.ts files myself along the way. I just never had the time or experience to verify it was the right call for someone in my position. For a website developer, esbuild has honestly been all I've needed: I can reuse functions, import whatever tooling I want, and stop being bottlenecked by a fragile parser. Everything else on this page — the .css.ts contract, the funnels, the deterministic hashing, the HMR splice — is what that one choice made possible.