→ Work out which stage failed, not which line — ordered by likelihood.
Troubleshooting
Nearly everything on this page is a small mistake in a codebase rather than anything broken in the compiler — a filename, a missing word, a file the build never saw. Salty catches a good number of those and tells you in the terminal, and strict catches a good number more.
The ones that send people here are the residual category: mistakes that are indistinguishable from valid code, so there's nothing for the compiler to object to. Those show up as an absence rather than an error — an element that renders perfectly and looks like a browser default, or a value that appears in DevTools as the literal text you typed.
That's the shape of debugging a build-time tool. By the time the browser sees anything the decisions have already been made, so guessing at the code is usually the slow way round and working out which stage it failed at is the fast one. This page is ordered accordingly — the filename suffix and a missing export take the top two spots by a wide margin, and they'll keep taking them long after you know better.
Start here: two minutes in DevTools
Inspect the element that's wrong before you open the source file. Salty gives every styled component a hashed class like eyjPN, and whether that class is there — and whether it has a rule behind it — splits the problem three ways immediately:
| What you see on the element | What actually failed | Where to go |
|---|---|---|
| No hashed class at all | The file never reached the compiler | First-time setup, or one component |
| Hashed class, but no matching rule in the Styles panel | The CSS never reached the page | The whole app |
| Rule is there, but crossed out or holding a value you didn't write | Something else is winning, or a value never resolved | The styles are there but something's off |
In development builds every styled component also carries a data-component-name attribute taken from its export name, so [data-component-name="Card"] in the elements panel takes you straight to the right element without hunting through hashes. It's stripped in production builds.
Setting it up for the first time
The file that type-checks perfectly and emits nothing
You wrote the component, imported it, the page renders, the button is right there on screen — and it looks like a browser default button. No red squiggle, no terminal output, nothing in the browser console.
The file is called button.ts.
Salty finds files by suffix, not by import. Only these compile:
button.css.ts ✓ the default
button.css.tsx ✓ same, when the file needs JSX
button.salty.ts ✓
button.styled.ts ✓
button.styles.ts ✓
button.ts ✗ valid TypeScript, valid React, zero CSSNothing in the type system can see this problem, because the problem is the filename. Rename it and the styles appear on the next build. If you'd rather not think about it again, npx salty-css generate src/components/button --name Button scaffolds the file with the suffix already correct.
The definition nothing exports
The same silence, one word different:
const Badge = styled("span", { base: { borderRadius: "999px" } }); // ✗
export const Badge = styled("span", { base: { borderRadius: "999px" } }); // ✓The compiler collects exported calls. An unexported styled, className, keyframes, or define* call is dead code as far as the build is concerned — it compiles, it renders, it emits nothing, and nothing anywhere tells you why.
This and the filename are the two mistakes TypeScript structurally cannot catch, which is the entire reason the ESLint plugin exists. Its must-be-exported rule is autofixable, so with fix-on-save the missing export gets added while you're still looking at the file rather than three hours later while you're inspecting an unstyled element.
salty.config.ts isn't where the plugin looks
The config file goes next to your bundler config — beside next.config.ts, vite.config.ts, astro.config.mjs, or webpack.config.js. That isn't a filing preference; it's the path the plugin resolves.
In a monorepo, that means the package's own root, not the workspace root. Running npx salty-css init from the top of the repo is the single most common way to end up with a project that looks completely set up and compiles nothing.
The plugin never got wired in — or got un-wired later
init writes the plugin into your bundler config. Nothing keeps it there. The usual story is that someone adds another Next plugin three weeks later, rewrites the export line, and the wrapper quietly goes with it:
import { withSaltyCss } from "@salty-css/next";
const nextConfig = { /* ... */ };
export default nextConfig; // ✗ compiles, never runs Salty
export default withSaltyCss(nextConfig); // ✓On standalone Webpack the shape is different, because saltyPlugin mutates the config you hand it rather than returning a new one — so what you export is the same object you passed in:
const config = { /* your existing config */ };
saltyPlugin(config, __dirname);
module.exports = config;Worth saying, since it's a reasonable thing to worry about: your own webpack function isn't a conflict. withSaltyCss wraps around it and leaves it intact, so a Next config that already customizes Webpack keeps doing exactly what it did:
import { withSaltyCss } from "@salty-css/next";
import type { NextConfig } from "next";
const nextConfig: NextConfig = {
webpack: (config) => {
// your own loaders and rules, untouched
config.module.rules.push({ /* ... */ });
return config;
},
};
export default withSaltyCss(nextConfig);Salty logs a build-time warning when the plugin didn't load, so before assuming it's your code, search the terminal output for salty-css. It's the cheapest check on this page and it rules out a whole branch.
Nobody imports the generated stylesheet
The tell here is specific: every element has its hashed class, and there's no Salty CSS anywhere in the Styles panel.
With the default importStrategy: 'root', the generated stylesheet has to be imported once, somewhere that loads on every page. On Next.js, withSaltyCss does this for you. Everywhere else it's a line you add yourself:
/* your global stylesheet */
@import "../saltygen/index.css";init picked the wrong framework
It reads package.json and infers from what it finds, which goes wrong most often in monorepos and in projects carrying dependencies they no longer use. Delete salty.config.ts and re-run init from the package's own root — editing the generated file by hand tends to leave you with a config that half-matches your bundler.
What a working install actually looks like
Worth knowing so you can compare rather than guess. After running the dev server once, or npx salty-css build:
saltygen/exists at the root of the package you initialised.saltygen/index.cssis non-empty — layer declarations and the reset are in there before you write a single component.- A test component renders with a hashed class and a matching rule in DevTools.
- No
salty-csswarnings in the terminal.
If step 2 passes and step 3 doesn't, the compiler is running fine and the problem is in one file. That's the next section.
It worked yesterday
One component is unstyled and the rest of the app is fine
Scope is the diagnosis. If everything else still has its styles, the compiler is running, the plugin is wired, and the stylesheet is on the page — so nothing about your setup is broken, and the problem is inside one file.
Which brings you back to the same two rules, and a third that only shows up once a project is big enough to have a barrel file:
- The suffix, again — usually because the file was renamed or moved.
button.css.ts→button.styles.tsis fine.button.css.ts→button.tsis a silent deletion of every rule in it. - The
export, again — usually lost in a refactor. - Nothing that renders imports it. Unused exports are tree-shaken, so a component exported from a barrel that nothing pulls into a rendered tree emits no CSS. The component is fine; it just isn't reachable.
The whole app is unstyled after a clone, a pull, or a deploy
saltygen/ is a build artifact. It's regenerated from scratch on every compile, it belongs in .gitignore, and it holds both your stylesheet and the declaration file your editor reads token names from. So a fresh clone starts with neither — and the failure is confusing in exactly the wrong direction, because the repo is obviously committed correctly and the page is obviously unstyled.
A prepare script covers the local case:
{
"scripts": {
"prepare": "npx salty-css build"
}
}prepare runs after npm install and npm ci, so cloning and installing is enough to produce the CSS and the types before anyone starts a dev server.
The deploy case needs one more thing. CI and hosting platforms that install with --ignore-scripts skip prepare entirely — which is how you get a project that's perfect locally and unstyled on the preview URL. Add npx salty-css build to the build command explicitly there.
The styles are there but something's off
The class is present, the rule exists, and the result still isn't what you wrote. Usually one of these:
A token prints as literal text. You see padding: {spacing.smal} in DevTools and the browser has dropped the declaration. Either the path is typo'd, or the file defining those variables never reached the build graph — a defineVariables call only takes effect if something in the build imports it, while variables passed inside defineConfig are picked up automatically. Both cases are worth making loud rather than hunting: see strict below.
Something else is winning the cascade. Salty's output lives in @layers rather than fighting on specificity, and the order runs imports, reset, global, templates, fonts, l0…l8. Two consequences catch people in opposite directions: anything pulled in with defineImport sits in the earliest layer and can never beat your own rules, and global styles sit below component styles, so a broad global selector can't out-muscle a styled component no matter how specific it looks. When you genuinely need a Salty rule to win a tie, raise its priority rather than reaching for !important.
anyOfVariants is losing to a regular variant. By design — it's emitted with :where() and carries zero specificity. If the rule has to win, move it into compoundVariants or base.
A variant prop styles the element without setting the attribute. Variant props are consumed by Salty and don't reach the DOM. That's what stops loading landing on the element as a stray attribute, but it bites when a variant is named after a real HTML attribute: a disabled variant makes the button look disabled while it stays perfectly clickable. Either keep the native attribute and style &:disabled in base, or forward it with passProps: ["disabled"].
A wrapped third-party component lost its styles. Salty applies its class through the className prop, so the wrapped component has to accept one. And because Salty treats every non-native prop as its own, a wrapped next/link never receives href unless you say so:
export const Link = styled(NextLink, {
passProps: ["href", "prefetch"],
base: { color: "{colors.brand.main}" },
});A variant prop stopped existing entirely. Check whether variants ended up nested inside base. It's easy to do in a large style object where the indentation stops being a reliable guide, it's still valid CSS-in-JS so nothing complains, and what you get is a rule targeting a child element literally named <variants> — plus a prop that was never declared. The no-variants-in-base lint rule catches it and autofixes it.
The build itself got slow, or started failing
A heavy module made it into a .css.ts file. These files are evaluated in Node at build time, so a big runtime library — especially one that touches window — either slows compilation noticeably or throws outright. Derive the value once in a lightweight file and import the result, or add the package to externalModules in defineConfig.
Two .css.ts files import each other. Cycles don't compile well. Keep the dependency graph one-directional.
Package versions drifted apart. @salty-css/core, @salty-css/react, and your bundler plugin are versioned in lockstep, and mixing them produces confusing errors rather than clear ones. npx salty-css up bumps them together.
Your editor insists a token doesn't exist
Not a build problem. Your token suggestions come from a declaration file the compiler writes into saltygen/ on each build, and your editor can be holding a stale copy of it. Restart the TypeScript server — in VS Code, Command Palette → TypeScript: Restart TS Server. While you're there, make sure the editor is using the workspace TypeScript rather than its own bundled copy, since Salty needs 5.x.
Errors and warnings
Look in the terminal, not the browser console
Salty does its work before the browser is involved, so a styling problem almost never surfaces as a browser console error. If a style is missing and you're reading the console, you're reading the wrong output — the messages worth having are in the terminal running your dev server or build.
Two specific things to look for there: a salty-css warning that the plugin didn't load, and anything a modifier or helper of your own threw during compilation.
Turn the silent failures loud
This is the highest-value setting on the page, so here's the honest framing first: Salty's style values are permissive strings on purpose. Every CSS property accepts an arbitrary string, which is what makes modifiers, custom syntax, and one-off escape hatches possible at all. The cost is that "{spacing.smal}" is a perfectly valid string as far as TypeScript is concerned.
strict is where you buy that back:
import { defineConfig } from "@salty-css/core/config";
export const config = defineConfig({
strict: true,
});With it on, an unresolvable token path fails the build instead of quietly emitting a declaration the browser drops. init writes it in for you, but a hand-written or older config may not have it — worth opening the file and checking rather than assuming. 'warn' is the middle setting if you're mid-migration and can't have the build going red yet.
The errors you wrote yourself
Some build failures are your own code running at compile time, which is easy to forget when the message arrives from a stylesheet:
- A modifier
transformthat throws fails the build with your message attached. That's the intended design — it's how "please stay on the spacing scale" becomes a compiler error instead of a code-review comment. color()throws at build time when it can't parse its input — a missing token path, a malformed string. Understrict: 'warn'it warns instead and passes the original value through.- A themed value handed to
color()isn't an error at all — it passes through unchanged, because that value doesn't exist yet when the compiler runs. Derive shades from static tokens and store the result as a themed one. Theming has the worked version.
The failures that never print anything
Worth stating plainly, because it's the category that sends most people to this page: a missing suffix, a missing export, and a tree-shaken component all produce no output whatsoever. There is no message to search for, and no amount of reading the terminal will help. The ESLint plugin covers the first two; the DevTools split at the top of this page covers the third.
The full list
Every message Salty can emit, with its cause and its fix, will live in Errors & warnings. Search it for the text of what you're looking at.
Still stuck
The Discord is the fastest way to get untangled, and GitHub issues is the right place once something looks like a confirmed bug.
Four things make the answer arrive much faster, and the first one is a single command:
npx salty-css --version # CLI version plus every @salty-css/* package in your package.jsonThen: your framework and bundler, the terminal output from a clean build rather than a summary of it, and the exact filename of the file that isn't working. That last one resolves a surprising share of questions before anyone has to read the code.