
PostCSS Explained: Plugins That Supercharge Your CSS
If you've built a front-end project in the last few years, there's a very good chance PostCSS has touched your CSS, even if you never installed it on purpose. It runs behind Autoprefixer, it's how Tailwind CSS plugs into many build setups, and frameworks like Next.js and Vite support it out of the box.
Yet PostCSS is often misunderstood. People call it a preprocessor, compare it to Sass, or assume it's one tool with one job. It's none of those things. PostCSS is a tool for transforming CSS with JavaScript plugins, and what it does depends entirely on which plugins you give it.
In this guide, I'll explain how PostCSS works under the hood, walk through the plugins that are worth knowing in 2026, show how to configure them in common setups, and finish by writing a small plugin of our own.
What PostCSS Actually Is
At its core, PostCSS does three things:
- Parses your CSS into an Abstract Syntax Tree (AST), a structured JavaScript object representing every rule, declaration, and at-rule.
- Runs plugins that read and modify that tree.
- Stringifies the tree back into CSS, with source maps if you want them.
That's it. By itself, PostCSS changes nothing. Feed it CSS with no plugins and you get the same CSS back. Every useful behavior, from adding vendor prefixes to minifying output, comes from a plugin.
This design is what makes it flexible. Sass gives you a fixed language with its own syntax. PostCSS gives you a pipeline you assemble yourself, and most of its popular plugins work on standard CSS, so your source files stay valid CSS.
PostCSS vs. Sass
They solve different problems, and many projects use both.
| Sass | PostCSS | |
|---|---|---|
| Type | A language that compiles to CSS | A plugin-based transformer |
| Input | .scss or .sass syntax | Usually plain CSS |
| Features | Fixed: mixins, functions, loops, modules | Whatever your plugins provide |
| Typical role | Authoring | Post-processing (prefixes, minification, polyfills) |
A common pipeline is Sass first (to compile your authoring language), then PostCSS (to add prefixes and optimize). With native nesting, custom properties, and cascade layers now in CSS itself, a lot of teams have dropped Sass and use PostCSS alone.
Setting Up PostCSS
With the CLI
The quickest way to try it:
npm install --save-dev postcss postcss-cli autoprefixer
Create a config file in your project root:
// postcss.config.js
module.exports = {
plugins: {
autoprefixer: {},
},
};
Then run it:
npx postcss src/styles.css -o dist/styles.css
Add --watch to rebuild on every save, or --map for source maps.
With Vite
Vite has PostCSS built in. If a postcss.config.js exists, Vite picks it up automatically for every imported CSS file. There's nothing else to configure.
With Next.js
Next.js also supports PostCSS natively. Create a postcss.config.js (or .mjs) and Next.js applies it. Plugins are referenced by package name as strings or object keys, so the config stays serializable:
// postcss.config.mjs
const config = {
plugins: {
"@tailwindcss/postcss": {},
},
};
export default config;
Note that when you provide your own config, Next.js stops applying its built-in defaults, so include anything you still want, such as Autoprefixer if your setup relies on it.
With webpack
Use postcss-loader in your CSS rule chain:
// webpack.config.js (excerpt)
module.exports = {
module: {
rules: [
{
test: /\.css$/,
use: ["style-loader", "css-loader", "postcss-loader"],
},
],
},
};
Plugins Worth Knowing
There are hundreds of PostCSS plugins. Here are the ones that consistently earn a place in real projects.
Autoprefixer
The most famous PostCSS plugin. Autoprefixer reads data from Can I Use and adds vendor prefixes only where your target browsers need them.
/* Input */
.glass {
backdrop-filter: blur(12px);
user-select: none;
}
/* Output (depending on your browser targets) */
.glass {
-webkit-backdrop-filter: blur(12px);
backdrop-filter: blur(12px);
-webkit-user-select: none;
user-select: none;
}
Your target browsers come from a Browserslist config, usually in package.json:
{
"browserslist": ["> 0.5%", "last 2 versions", "not dead"]
}
Or in a .browserslistrc file. The same config is shared by Autoprefixer, postcss-preset-env, Babel, and other tools, which keeps your whole toolchain consistent.
postcss-preset-env
postcss-preset-env lets you write modern CSS and converts features your target browsers don't support yet into something they do. It bundles Autoprefixer and dozens of feature plugins, each enabled based on your Browserslist targets.
npm install --save-dev postcss-preset-env
// postcss.config.js
module.exports = {
plugins: {
"postcss-preset-env": {
stage: 2,
features: {
"nesting-rules": true,
"custom-media-queries": true,
},
},
},
};
Now you can write things like custom media queries, which don't have broad native browser support yet:
@custom-media --tablet (width >= 768px);
@media (--tablet) {
.sidebar {
display: block;
}
}
/* Output */
@media (min-width: 768px) {
.sidebar {
display: block;
}
}
The stage option controls how experimental you're willing to go, from 0 (very early ideas) to 4 (stable standards). Stage 2 is the default and a sensible choice.
One caution: some features can only be partially polyfilled. :has(), container queries, and anchor positioning, for instance, depend on live layout and DOM state that a build step can't reproduce. For those, use @supports and a sensible fallback instead of relying on a transform.
postcss-import
postcss-import inlines @import statements at build time, so you can split CSS into many files and ship one:
/* src/main.css */
@import "./base/reset.css";
@import "./components/button.css";
@import "./components/card.css";
Without it, each @import becomes a separate network request at runtime, and those requests chain one after another. Place postcss-import first in your plugin list so later plugins see the fully combined file.
cssnano
cssnano minifies CSS for production: it removes whitespace and comments, shortens colors, merges duplicate rules, and more.
// postcss.config.js
module.exports = {
plugins: {
"postcss-import": {},
"postcss-preset-env": {},
...(process.env.NODE_ENV === "production"
? { cssnano: { preset: "default" } }
: {}),
},
};
Only run it in production so your development CSS stays readable.
PurgeCSS
PurgeCSS (via @fullhuman/postcss-purgecss) scans your HTML and templates, then removes CSS selectors that never appear. It's valuable when you ship a large framework but only use part of it.
// postcss.config.js
const purgecss = require("@fullhuman/postcss-purgecss").default;
module.exports = {
plugins: [
purgecss({
content: ["./src/**/*.html", "./src/**/*.jsx", "./src/**/*.tsx"],
safelist: ["is-active", /^modal-/],
}),
],
};
The safelist protects classes that are added dynamically by JavaScript and therefore won't show up in a static scan. Forgetting it is the most common PurgeCSS bug. Depending on the version you install, the import may be the module itself rather than .default, so check the package's README.
Tailwind CSS
Tailwind CSS v4 ships its PostCSS integration as a separate package, @tailwindcss/postcss. It also handles imports and vendor prefixing internally, so you usually don't need postcss-import or Autoprefixer alongside it. Tailwind also offers a dedicated Vite plugin, which is often the faster option in Vite projects.
Other Useful Plugins
- postcss-nesting: compiles native CSS nesting for older browsers. Current browsers support nesting natively, so you may no longer need it.
- postcss-pxtorem: converts
pxvalues torem, handy when retrofitting an older codebase for better text scaling. - postcss-sort-media-queries: groups and sorts media queries to reduce output size.
- postcss-logical: converts logical properties like
margin-inlineto physical ones for very old browsers. - Stylelint: the popular CSS linter is itself built on PostCSS's parser.
Plugin Order Matters
Plugins run in the order you list them. A typical, sensible order:
- postcss-import: combine files first.
- Transform plugins: postcss-preset-env, nesting, custom media.
- Autoprefixer: add prefixes to the final syntax (preset-env includes this).
- PurgeCSS: remove unused selectors.
- cssnano: minify last.
If you minify before purging, or prefix before transforming nesting, you'll get worse output or outright errors.
Writing Your Own Plugin
One of the best things about PostCSS is how easy it is to write a plugin. Let's build one that adds a px fallback before every rem declaration for font sizes, a small but realistic task.
// plugins/postcss-rem-fallback.js
const BASE = 16;
const plugin = (opts = {}) => {
const base = opts.base ?? BASE;
return {
postcssPlugin: "postcss-rem-fallback",
Declaration: {
"font-size"(decl) {
const match = /^([\d.]+)rem$/.exec(decl.value);
if (!match) return;
const px = parseFloat(match[1]) * base;
const prev = decl.prev();
// Avoid adding a duplicate fallback on re-runs
if (
prev &&
prev.type === "decl" &&
prev.prop === "font-size" &&
prev.value === `${px}px`
) {
return;
}
decl.cloneBefore({ value: `${px}px` });
},
},
};
};
plugin.postcss = true;
module.exports = plugin;
Register it by path:
// postcss.config.js
module.exports = {
plugins: [require("./plugins/postcss-rem-fallback")({ base: 16 })],
};
Input and output:
/* Input */
.lead {
font-size: 1.25rem;
}
/* Output */
.lead {
font-size: 20px;
font-size: 1.25rem;
}
A few things to notice:
- A plugin is a function that returns an object with a
postcssPluginname and one or more visitor methods. - Visitors like
Declaration,Rule,AtRule, andOnceare called as PostCSS walks the tree. You can scope a declaration visitor to one property, as done here. - Setting
plugin.postcss = truetells PostCSS the function is a plugin creator. - PostCSS may revisit nodes after changes, so plugins should be idempotent. That's what the duplicate check handles.
The PostCSS API also includes helpers like decl.remove(), rule.append(), and root.walkRules(). You can explore any AST visually with the AST Explorer website by selecting the PostCSS parser.
PostCSS vs. Lightning CSS
You'll also hear about Lightning CSS, a very fast CSS parser, transformer, and minifier written in Rust. It covers a lot of what people use PostCSS for, including prefixing, syntax lowering, and minification, in a single tool, and it can be used directly in Vite. It doesn't have PostCSS's plugin ecosystem, though. If your pipeline is "prefix, lower modern syntax, minify", Lightning CSS is worth a look. If you rely on specific plugins, or need custom transforms, PostCSS remains the flexible choice.
Common Pitfalls
- Assuming polyfills are perfect. Build-time transforms can't replicate features that depend on runtime layout. Always check what a preset-env feature actually outputs.
- Too many plugins. Every plugin adds build time and complexity. Remove ones that target features your browsers now support natively.
- Stale Browserslist data. Autoprefixer's decisions depend on an up-to-date browser database. Run
npx update-browserslist-db@latestperiodically. - Wrong plugin order. Import first, minify last.
- Duplicate prefixing. If a tool like Tailwind v4 or Lightning CSS already prefixes your CSS, adding Autoprefixer on top is redundant.
Conclusion
PostCSS isn't a language and it isn't a single tool. It's a pipeline that parses CSS, hands it to plugins, and writes the result back out. That simple model powers some of the most useful tools in front-end development: Autoprefixer for vendor prefixes, postcss-preset-env for modern syntax, postcss-import for bundling, PurgeCSS for trimming, and cssnano for minifying.
Start with a small, deliberate set of plugins, keep them in a sensible order, and prune them as browsers catch up. And when you hit a repetitive transformation no plugin covers, remember that writing your own takes only a few lines of JavaScript.


