mrkeyoor.com_
Wed 05 Aug 19:51 UTC
npmWeb Frontendupdated 05 Aug 2026

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.

Verdict

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.

API stability5/58.0 shipped in September 2020 and the line is still current at 8.5.x six years later; the plugin API and node types have not broken since the painful 7-to-8 transition.
Docs4/5postcss.org has a full API reference and the writing-a-plugin guide in the repo is genuinely good; what is missing is guidance for app developers on which plugins to combine, which lives in scattered blog posts.
Maintenance5/5Pushed August 2026, only 21 open issues and PRs, steady patch releases (8.5.25 in July 2026), and stewardship by Evil Martians with Open Collective funding.
Ecosystem5/5About 274M weekly downloads, 200+ plugins, and autoprefixer plus stylelint built on top; the caveat is that Tailwind v4 and other Rust-based tools are slowly moving the app-facing workload elsewhere.

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
Skip it if

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 plugin

The 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.css

process() 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 = true

PostCSS 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

PackageRegistryPick it when
lightningcssnpmYou want prefixing, nesting, and minification as one fast Rust binary instead of a JS plugin chain.
sassnpmYou want a full preprocessor language with mixins, functions, and modules rather than per-feature transforms.
esbuildnpmYou already bundle with esbuild and only need CSS bundling and minification, not transforms.