Making Tailwind JIT work with Hugo, the Version 3 edition

A Hugo fix for Tailwind CSS v.3 — with a surprise bonus.


Update from the future: In 2023, the release of Hugo 0.112.0 finally (?) resolved this issue.

General note: This site’s appearance, configuration, hosting, and other basic considerations will change over time. As a result, certain content on this page could be at variance with what you’re currently seeing on the site, but the two were consistent when this post originally appeared.

This is a follow-up to my post from a few months back, “Making Tailwind JIT work with Hugo.” The code I suggested in that post worked fine when Tailwind CSS was still in Version 2.x, but things got a little more complicated when Tailwind 3.0 appeared just a few weeks later.

So, as Daffy Duck would say, “let’s try that again.” And, spoiler alert: if you prefer to style your Hugo site with Sass/SCSS, I’ll have some good and surprising news for you, too.

A recap of JIT-on-Hugo woes

Perhaps you’ve already read the earlier post and thus don’t need a total recap of the problem that occasioned it; but, just in case, here’s a TL;DR version:

  • The release of Tailwind 2.1 added just-in-time (JIT) functionality to the framework. This eliminated one of its biggest drawbacks up to that point. Before then, Tailwind would generate gigantic CSS files which required an occasionally problematic purging process to become suitable for distribution. With JIT — at that time, an opt-in feature — Tailwind created only enough CSS to handle whichever files you were having it “watch.” In short, its CSS now was starting small and building up, rather than starting elephantine and trying to shrink.
  • This was a great improvement, but some apps didn’t play so cheerfully with Tailwind JIT, and one of them was Hugo. Tailwind-with-JIT either would lock up Hugo or cause it to crash, in each case because Tailwind couldn’t find a stdin file. This would later turn out to be an issue with PostCSS, on which Tailwind typically depends.
  • A few months later, Hugo user Praveen Juge cooked up an ingenious workaround, about which he wrote in “Use Tailwind JIT with Hugo.” It made use of a Tailwind 2.x capability that allowed not using PostCSS, thus avoiding the problem.
  • However, Juge’s approach, which he demonstrated in a deliberately bare-bones Hugo project, presented some issues when I tried adding it to an existing Hugo repo.
  • Then, as I explained in that aforementioned earlier post, I poked around with Hugo, Juge’s approach, and Tailwind 2.x until I managed to make everything work together. I didn’t care at all for the way this method forced me to restructure my CSS1 but, hey, it produced the final result I wanted.

That’s where things stood as of November 1, 2021. Then, a little over a month later, Tailwind Labs released Tailwind 3.0, with JIT now an opt-out feature; but JIT still wasn’t truly Hugo-okay without the Juges-inspired workaround. Testing revealed that the solution I’d described, slightly adjusted, was still workable. Yet, I remained convinced there should be a better way, one that didn’t require what I considered to be mangling of my CSS file structure.2


In January, the releases of Tailwind CSS 3.0.10 and its tweak in 3.0.11 finally gave Tailwind users the ability to get past the stdin issue. That is, it did if they were using Node.js v.16.x.

Why that last caveat? As my earlier post on the subject recounted, it turned out that the stdin glitch with some apps stemmed from Tailwind’s use of Node’s fs.statSync() method. While Node v.14.x was supposed to have been better in this respect, it’s since turned out that, at least for the whole Tailwind-JIT-on-Hugo mess, Node 16+ allows everything to be sweetness and light.

Well, almost. You knew that was coming, didn’t you?

With all of these adjustments in place, Hugo and Tailwind JIT now worked together fine in production. However, Juges soon reported that, if using this setup in development, Hugo “doesn’t watch for [CSS] file changes.” This indicated that the Hugo Pipes assets pipeline wasn’t “seeing” what Tailwind was doing whenever there were edits to the site’s CSS.

Fortunately, Jonas Duri had been following the whole saga and, soon thereafter, wrote on to offer a solution and an accompanying sample repo. I encourage you to read his article and view the repo for a fuller picture, but here is a quick summary of how his extremely clever method works:

  • Use Tailwind as a PostCSS plugin, after all. This is a friendlier method for Hugo Pipes’ purposes than going a non-PostCSS route because it lets your project manage the CSS through resources.PostCSS, as the Hugo gods intended.3
  • Then, to force Hugo Pipes to trigger a site rebuild when your CSS changes, make Hugo do the following:
    • Interact with your CSS input file (e.g., assets/css/index.css) as if it were a template, via Hugo’s resources.ExecuteAsTemplate pipe.
    • Generate a random string with each Tailwind generation of the CSS file and pass it into the resulting template as part of the CSS file’s name. Duri’s method uses the now.UnixMilli function (present since Hugo 0.88.0), to inject a time-based string.

Brilliant. Bravo.

I finally got around to trying this method a few days ago. While production builds went lickity-split as always, I noticed that attempting it on a large Hugo project in development mode resulted in a seeming “hang” and, most alarmingly, extremely high activity (and resulting high temperatures) for my computer’s CPU. Fortunately, I soon found an explanation and remedy, in the form of a GitHub comment by Ingo Struck:

When using . . . [this method], I would recommend that you put this into a separate partial that is included using partialCached. Otherwise the build times grow for larger sites due to excessive rebuilds. With a separate partial this could be reduced to one rebuild per cycle (or per cycle and language). [Emphasis and links added.]4

Struck’s comment proved to be the final bit of information I needed to put together what follows.

The code

I’ll give you two versions of the css.html partial; they vary according to the production-side CSS output. One produces external CSS, while the other produces internal CSS — i.e., head-based <style></style> stuff. In development, each works off Duri’s method and produces an external CSS file with a name that changes every time you change any of the CSS that Tailwind is processing.

First, the version for external CSS in production:


{{ $styles := resources.Get "css/index.css" }}
{{ $styles = $styles | resources.PostCSS }}
{{ if hugo.IsProduction }}
	{{ $styles = $styles | minify | fingerprint | resources.PostProcess }}
{{ else }}
	{{ $styles = $styles | resources.ExecuteAsTemplate (printf "css/" now.UnixMilli) .}}
{{ end }}
<link href="{{ $styles.RelPermalink }}" rel="stylesheet" />

And then, the one for internal CSS in production:


{{ $styles := resources.Get "css/index.css" }}
{{ $styles = $styles | resources.PostCSS }}
{{ if hugo.IsProduction }}
	{{- with $styles -}}
		<style>{{- .Content | safeCSS -}}</style>
	{{- end -}}
{{ else }}
	{{ $styles = $styles | resources.ExecuteAsTemplate (printf "css/" now.UnixMilli) .}}
	<link href="{{ $styles.RelPermalink }}" rel="stylesheet" />
{{ end }}

To wrap up, regardless of which css.html partial you’re using, you now just put the following in your site’s head and you’re home free:

{{ partialCached "css.html" . }}

As before, the postcss.config.js file for the project looks like this (with the postcss-import package installed, of course):


module.exports = {
	plugins: {
		'postcss-import': {},
		'tailwindcss/nesting': {},
		tailwindcss: {},

And, for me, one of the biggest benefits of doing Tailwind-on-Hugo this way is the freedom to ditch the uglified CSS conflagration I described in my earlier post in favor of something similar to this simplified5 example:


/* the contents of index.css */
@import '/assets/css/codeblocks.css';
@import 'tailwindcss/base';
@import '/assets/css/global.css';
@import '/assets/css/myutils.css';
@import '/assets/css/nav.css';
@import 'tailwindcss/components';
@import 'tailwindcss/utilities';

. . . with the various CSS components all in their own separate files as I prefer.

A surprise for Sass supporters

Finally, here’s that extra I promised you Sass-on-Hugo fans: this approach gave me an idea for how to enable the use of Dart Sass on Hugo, and with any Hugo-supporting hosting vendor! That’s a biggie6 — as I’ll explain in a future post, after I finish running a few more tests to make sure my eyes aren’t deceiving me.

Stay tuned.

  1. Please understand that this unwanted result was not due to Juge’s method, but rather because of the pecularities of how postcss-import and Tailwind worked together. And, yes, even though we were going around PostCSS in this endeavor, postcss-import was still in play. ↩︎

  2. This is in the absence of a truly official fix on Hugo’s end, which continues to be pushed further out into the project’s milestones↩︎

  3. I might add that, at least apparently, it also makes the whole Tailwind-on-Hugo dev process a little faster than with Juge’s original “Tailwind-without-PostCSS” approach. Perhaps that’s because it is, indeed, going through the preternaturally fast Hugo Pipes. ↩︎

  4. Incidentally: partialCached is an especially valuable Hugo tool for any project that’s gotten big over time, dramatically reducing the development time on both Hugo and your device. Let’s say you’re working on your site with hugo server running, causing lots of rebuilds as you change various files. If you have a partial template (e.g., for your site’s navigation menu) that never changes from one build to the next, there’s no point in forcing Hugo to rebuild it every single time. And, on a large site, you can only imagine how non-cached Tailwind stuff, with all those thousands of entries across God knows how many template (and resulting HTML) files, will tax your computer. Struck’s use of partialCached, here, was superbly apt. ↩︎

  5. I still have to include the full project path to each bespoke CSS file for the sake of postcss-import (at least it “knows” where the tailwindcss files are). I don’t know if that would be different if I didn’t have postcss.config.js in the project root. ↩︎

  6. In the meantime: in case you haven’t been following the long-running discussion about using Dart Sass with Hugo instead of the deprecated LibSass, check this Hugo Discourse thread for starters. ↩︎