PostCSS Pipeline Decisions: preset-env vs. Hand-Picked Plugins, Plus a Custom Plugin
PostCSS transforms nothing by itself — the real decision is which plugins run and in what order. Compare postcss-preset-env against hand-picked plugins, write a custom plugin with the current API, and validate the pipeline in a unit test.
26 Aug 2026, 13:16 UTC

The actual decision
PostCSS itself transforms nothing. It parses your CSS into an AST, runs whatever plugins you hand it, and stringifies the result. So the only real engineering decision is which plugins run, in what order, and who maintains that list over time. Get this wrong and you get duplicated transformations, prefixes for browsers you dropped two years ago, or a minifier mangling output that a later plugin still expected to rewrite.
There are two supported ways to assemble the pipeline, and a third task most teams eventually face: writing a small custom plugin. This guide compares the two assembly options, then shows a concrete custom plugin and how to validate the whole pipeline in a unit test.
Option A vs. Option B
| postcss-preset-env | Hand-picked plugins | |
|---|---|---|
| What you get | A curated bundle of spec-stage polyfill plugins (custom properties, nesting, autoprefixing via browserslist) | Exactly the plugins you list: autoprefixer, postcss-nested, cssnano, etc. |
| Configuration | stage number plus a features map to toggle individual transforms | Per-plugin options, full control |
| Maintenance | Low; one dependency tracks the CSS spec landscape | Higher; you track versions and changelogs yourself |
| Order control | Internal order is fixed by the preset | Explicit; array order is execution order |
| Risk | Stage defaults shift across major versions; pin and read changelogs | Accidentally running two plugins that handle the same feature (e.g., nesting twice) produces broken or duplicated output |
| Best fit | Teams wanting forward-compatible CSS with minimal upkeep | Teams with unusual transforms, strict ordering needs, or nonstandard syntax |
Whichever you pick, declare your browser support policy exactly once, in the browserslist field of package.json or a .browserslistrc file. Both options read it; duplicating targets inside individual plugin configs is how stale prefixes sneak in.
One rule applies in both worlds: minification (cssnano or similar) is a separate, final step. Optimizers merge rules and drop declarations, which breaks any feature transpilation that still expects the original structure.
Writing a custom plugin with the supported API
The legacy postcss.plugin('name', ...) factory is deprecated. The current form is a function returning an object with a postcssPlugin property and visitor methods. Here is a plugin that rewrites a custom brand color function into a hex value — the kind of small, team-specific transform preset-env will never ship:
// postcss-brand-color.js
module.exports = (opts = {}) => {
const value = opts.value || '#0057b8';
return {
postcssPlugin: 'postcss-brand-color',
Declaration(decl) {
if (decl.value.includes('brand()')) {
decl.value = decl.value.replace(/brand\(\)/g, value);
}
},
};
};
module.exports.postcss = true;Visitor methods exist for Declaration, Rule, AtRule, and others; visiting nodes this way is the stable public API. Two pitfalls to avoid: mutating or removing nodes while iterating can cause the walker to skip siblings — collect nodes first, then modify — and forgetting the postcssPlugin key, which makes PostCSS treat your export as the old API and warn.
Assembling and validating the pipeline
Plugin order is the array order passed to postcss([...]). A typical build script (run with Node, no special permissions needed):
const postcss = require('postcss');
const presetEnv = require('postcss-preset-env');
const brandColor = require('./postcss-brand-color');
const plugins = [
brandColor({ value: '#0057b8' }), // custom transforms first
presetEnv({ stage: 2 }), // feature polyfills
// cssnano goes last, in a separate production-only step
];
postcss(plugins)
.process(css, { from: 'src/app.css', to: 'dist/app.css', map: { inline: false } })
.then(result => { /* write result.css and result.map */ });Validate with a unit test rather than eyeballing build output. Run the pipeline against a fixture and assert on the emitted string:
const result = await postcss(plugins).process(
'.card { color: brand(); & .title { color: red; } }',
{ from: undefined }
);
expect(result.css).toContain('#0057b8');
expect(result.css).toContain('.card .title'); // nesting flattenedBeyond the unit test, three cheap checks catch most pipeline regressions: log the resolved plugin list at build time and confirm ordering (minifier last, nothing duplicated); inspect output for a property known to need a prefix in one of your browserslist targets and confirm the prefix appears; and build with source maps enabled, then open the result in browser devtools and confirm mappings resolve to original source files.
Limitations
preset-env's stage defaults and bundled feature set change across major versions, so pin the version and review the changelog on upgrade — a stage bump can silently start transforming (or stop transforming) a feature you rely on. The nesting example above assumes the current preset-env behavior; verify against your installed version, since nesting syntax itself shipped in browsers and tooling behavior has shifted. If you hand-pick plugins, audit for overlap once: two plugins handling the same feature is the most common source of doubled or corrupted output, and nothing warns you about it.
0 replies
A thoughtful contribution can make all the difference. Be the first to share one.