26.3 PostCSS Pipeline: postcss.config.js and npm Integration
Right, so you’ve decided to build a Hugo site and you want to use Tailwind CSS. Smart. You’ve probably hit the first roadblock: Hugo’s built-in SASS/SCSS support is great, but it’s a completely separate universe from the Node.js-based tooling Tailwind requires. You can’t just @import "tailwindcss"; and call it a day. This is where we stop fighting the tool and start making it work for us. We’re going to set up a PostCSS pipeline. Think of it as a bouncer for your CSS: every line of your stylesheet has to get past PostCSS and its friends before it’s allowed into the final, built site.
The heart of this operation is the postcss.config.js file. This is where we tell PostCSS which plugins to use and in what order. Order is everything here. Get it wrong, and you’ll be staring at a blank page wondering why your @apply directives did nothing.
The Essential postcss.config.js
Here’s the config file that will become your new best friend. You’ll place this in the root of your Hugo project, right next to your config.toml and package.json.
// postcss.config.js
module.exports = {
plugins: [
require('postcss-import'),
require('tailwindcss'),
require('autoprefixer'),
...(process.env.HUGO_ENVIRONMENT === 'production'
? [require('cssnano')]
: [])
]
}
Let’s break down this party roster:
postcss-import: This is the bouncer’s first check. It inlines any@importstatements before Tailwind sees your CSS. This is crucial because Tailwind needs to see those@importdirectives to know what to process. Without this plugin, Tailwind will ignore them. It’s the reason you can have a main CSS file that starts with@import "tailwindcss/base";and have it actually work.tailwindcss: The main event. This plugin scans your content paths (your templates) for class names, generates the corresponding utility CSS, and merges it with your custom CSS. It’s a beast, and it needs to run after your imports are resolved but before any optimizations.autoprefixer: The polite finisher. It automatically adds vendor prefixes (-webkit-,-moz-) to CSS rules that need them. You’re not writing those by hand in 2023. Let the robot do it.cssnano(conditionally): The minimalist. We only unleash this one in production (HUGO_ENVIRONMENT === 'production'). Its job is to minify and optimize the final CSS, stripping out comments, whitespace, and redundant rules to make the file as small as humanly possible. You don’t want this running during development because it makes the generated CSS impossible to read while debugging.
The Package.json and npm Scripts
Your postcss.config.js is useless without the muscle to run it. That muscle comes from the PostCSS CLI, which we install via npm. Your package.json should look something like this:
{
"name": "my-hugo-site",
"version": "1.0.0",
"description": "",
"scripts": {
"dev": "hugo server",
"build": "hugo --minify"
},
"devDependencies": {
"autoprefixer": "^10.4.14",
"postcss": "^8.4.24",
"postcss-cli": "^10.1.0",
"postcss-import": "^15.1.0",
"tailwindcss": "^3.3.0",
"cssnano": "^5.1.15"
}
}
Now, here’s the magic trick. We need to tell Hugo to use this PostCSS pipeline instead of its built-in SASS processor. We do that by adding a couple of lines to our Hugo config.toml (or config.yaml):
# in config.toml
[module]
[[module.mounts]]
source = "assets" # This is where your source CSS lives
target = "assets"
[[module.mounts]]
source = "assets/css/dist" # This is where PostCSS will output
target = "assets/css"
Wait, what? We’re mounting a dist folder? Yep. This is the clever bit. The Hugo team, in their infinite wisdom, decided that if you have a file in your assets directory, it will always try to process it with its built-in tools. To avoid this, we use a source directory for our raw CSS and a dist directory for the processed CSS that Hugo will actually see and bundle.
Your folder structure will look like this:
assets/
└── css/
├── source/
│ └── main.css # Your source file with @imports
└── dist/
└── main.css # The processed file, generated by PostCSS
You’ll write your CSS in assets/css/source/main.css. You’ll run the PostCSS CLI command to process it into assets/css/dist/main.css. And Hugo will happily bundle the file in dist because it never even knew the source directory existed. It’s a workaround, but it’s a brilliantly effective one.
The Watch Command and Hugo Integration
The final piece is making this seamless for development. You don’t want to manually run a build command every time you change a CSS file. So we use the PostCSS CLI’s watch functionality. I usually run it in a separate terminal tab alongside hugo server.
npx postcss assets/css/source/main.css --o assets/css/dist/main.css --watch
This command tells PostCSS to take the source file, output it to the dist file, and watch for changes. Now you can edit your source CSS or your Tailwind-powered templates, and both PostCSS (regenerating your utilities) and Hugo (rebuilding the site) will do their thing almost instantly.
The rough edge here is obvious: managing two terminal commands. You can use a tool like npm-run-all to run them in parallel with a single npm run dev command, but I find just having two tabs open is simpler and makes the source of any errors blindingly obvious.
This setup is a bit more involved than hugo server, but it gives you the full, unadulterated power of the modern Tailwind CSS workflow right inside your Hugo project. It’s worth the upfront configuration, I promise.