postcss
PostCSS is not a CSS preprocessor; it is a CSS parser plus a plugin framework. It reads CSS into an abstract syntax tree, lets JavaScript plugins walk and rewrite that tree, then prints it back out with source maps. Everything people associate with it (vendor prefixes, nesting, minification, linting) is done by plugins like autoprefixer, cssnano, and stylelint that run on its AST. The 8.x line has been the API since September 2020, and most projects get PostCSS indirectly through Vite, Next.js, or their CSS tooling rather than by installing it on purpose.
The infrastructure layer of CSS tooling: if you build tools or need custom CSS transforms, there is no real substitute. As an application developer in 2026 you rarely need to install it yourself, and if raw build speed is the goal, Lightning CSS is the stronger pick.
Use it if
- You are writing a tool that needs to read or rewrite CSS programmatically: the AST, walker methods, and source map handling are the best in the JavaScript ecosystem
- You need autoprefixer or postcss-preset-env style transforms in a custom build pipeline that is not already a bundler with CSS support
- You are writing a stylelint rule or a CSS codemod; both ecosystems are built on the PostCSS node types
- You want one config (postcss.config.js) that every major bundler, framework CLI, and editor tooling already knows how to load
- You just want to write modern CSS in an app: Vite, Next.js, and friends already run PostCSS for you, so installing and configuring it yourself is usually redundant
- Build speed is the priority: Lightning CSS does prefixing, nesting, and minification in Rust, several times faster, and Tailwind v4 moved its engine off PostCSS for exactly that reason
- You want Sass semantics (mixins, functions, @use modules): PostCSS plugins imitate pieces of this but the combined result is less coherent than just using sass
- You dislike assembling behavior from plugins: core PostCSS does nothing to your CSS by itself, and picking, ordering, and version-matching plugins is on you
Setup reality
npm install postcss plus every plugin you actually want; core alone transforms nothing. Config discovery is its own little world: postcss.config.js is found by postcss-load-config, but in a "type": "module" project you may need postcss.config.cjs or a current loader version that accepts ESM, and error messages when this goes wrong are unhelpful. Plugins declare postcss as a peer dependency, so a stray duplicate postcss version in the tree can produce confusing 'plugin is not compatible' style failures. The API itself is small and types are bundled.
Patterns
Run plugins over a CSS stringprocess-css-with-plugins
import postcss from 'postcss'
import autoprefixer from 'autoprefixer'
const result = await postcss([autoprefixer]).process(css, {
from: 'src/app.css',
to: 'dist/app.css'
})
console.log(result.css)Always pass from (and to when writing output); they drive source maps and error positions. Omitting from prints a warning on every run.
Write a PostCSS 8 pluginwrite-a-plugin
const plugin = (opts = {}) => {
return {
postcssPlugin: 'postcss-px-to-rem',
Declaration (decl) {
if (decl.value.includes('px')) {
decl.value = decl.value.replace(/(\d+)px/g, (_, n) => `${n / 16}rem`)
}
}
}
}
plugin.postcss = true
export default pluginThe postcssPlugin name and the plugin.postcss = true marker are both required; the old postcss.plugin() factory from v7 is gone.
Walk and edit declarationswalk-declarations
import postcss from 'postcss'
const root = postcss.parse(css)
root.walkDecls('color', decl => {
decl.value = 'var(--brand)'
})
root.walkRules(/^\.btn/, rule => {
rule.selector += ':not([disabled])'
})
console.log(root.toString())walkDecls, walkRules, and walkAtRules accept a string or RegExp filter as the first argument, which beats checking inside the callback.
Parse CSS and inspect the ASTparse-and-inspect
import postcss from 'postcss'
const root = postcss.parse('a { color: black }', { from: 'in.css' })
const rule = root.first
rule.type // 'rule'
rule.selector // 'a'
rule.first.prop // 'color'
rule.first.value // 'black'Node types are root, atrule, rule, decl, and comment. Whitespace lives in node.raws, which is why round-tripping preserves formatting.
Build new rules and declarationscreate-nodes
import postcss from 'postcss'
const rule = postcss.rule({ selector: '.card' })
rule.append(postcss.decl({ prop: 'padding', value: '1rem' }))
const media = postcss.atRule({ name: 'media', params: '(min-width: 600px)' })
media.append(rule)
root.append(media)New nodes have no raws, so output formatting is normalized; clone an existing node instead if you need to match surrounding style.
Configure via postcss.config.jsconfig-file
// postcss.config.js
export default {
plugins: {
'postcss-preset-env': { stage: 2 },
autoprefixer: {}
}
}Plugin order in this object matters; transforms run top to bottom. In a "type": "module" package, older tooling may only read postcss.config.cjs.
Emit an external source mapgenerate-source-maps
const result = await postcss(plugins).process(css, {
from: 'src/app.css',
to: 'dist/app.css',
map: { inline: false }
})
fs.writeFileSync('dist/app.css', result.css)
fs.writeFileSync('dist/app.css.map', result.map.toString())The default is an inline base64 map appended to the CSS; inline: false gives you result.map to write separately.
Handle async plugins correctlyasync-plugin-await
const lazy = postcss([asyncPlugin]).process(css, { from: 'a.css' })
// wrong: lazy.css throws if any plugin is async
const result = await lazy // right
result.cssprocess() returns a LazyResult. Reading .css synchronously while an async plugin is in the chain throws; always await when you do not control the plugin list.
Report CSS syntax errors nicelyhandle-syntax-errors
import postcss, { CssSyntaxError } from 'postcss'
try {
postcss.parse(brokenCss, { from: 'theme.css' })
} catch (e) {
if (e instanceof CssSyntaxError) {
console.error(e.message)
console.error(e.showSourceCode(true)) // highlighted frame
} else throw e
}showSourceCode(true) prints a colored code frame with the caret position; e.file, e.line, and e.column are available for structured reporting.
Add nodes without re-triggering your visitoravoid-infinite-loops
const plugin = () => ({
postcssPlugin: 'add-fallback',
Declaration (decl) {
if (decl.prop === 'inset' && !decl.processed) {
const clone = decl.cloneBefore({ prop: 'top', value: decl.value })
clone.processed = true
decl.processed = true
}
}
})
plugin.postcss = truePostCSS 8 re-visits nodes you add or change, so a visitor that inserts nodes unconditionally loops forever; mark handled nodes or check before inserting.
Alternatives
| Package | Registry | Pick it when |
|---|---|---|
| lightningcss | npm | You want prefixing, nesting, and minification as one fast Rust binary instead of a JS plugin chain. |
| sass | npm | You want a full preprocessor language with mixins, functions, and modules rather than per-feature transforms. |
| esbuild | npm | You already bundle with esbuild and only need CSS bundling and minification, not transforms. |