Using Tailwind CSS with ASP.NET Core and Hot Reload
Tailwind CSS was released in 2017 and has gained a lot of adoption by web developers. For many, it has become an indispensable power-up for writing CSS. Since Tailwind is not tighly coupled with any particular web framework, it can also be used to write styles in ASP.NET project. There is, however, some fricting in doing so. These issues can be overcome though, resulting in a very fluid development experience.
Khalid Abuhakmeh wrote about the same topic in February 2021, but some details have changed since then.
Setting up Tailwind
Since Tailwind itself is build in JavaScript, installing Tailwind requires a JavaScript runtime. Since .NET doesn’t have one built in, one needs to install it separately.
Once e.g. NodeJS is installed, Tailwind can be configured:
- Install Tailwind with
npm install -D tailwindcss - Create a Tailwind config with
npx tailwindcss init - Inside the generated
tailwind.config.js, update thecontentproperty to match all*.razoror*.cshtmlfiles, depending on whether you use Blazor, MVC, or Razor Pages - Create a file
wwwroot/css/tailwind-input.csswith a boilerplate config:
@tailwind base;
@tailwind components;
@tailwind utilities;
Loading Tailwind output in ASP.NET Core
- Inside
package.json, add commands to build and watch source files:
"scripts": {
"build:css": "tailwindcss --input ./wwwroot/css/tailwind-input.css --output ./wwwroot/css/site.css",
"watch:css": "tailwindcss --input ./wwwroot/css/tailwind-input.css --output ./wwwroot/css/site.css --watch"
}
- Check that
site.cssis loaded inside your layout:
<link rel="stylesheet" href="~/css/site.css" asp-append-version="true" />
When you run npm run build:css, a file wwwroot/css/site.css should be created.
Then make sure that changes to site.css and tailwind.config.js are always considered when determining whether a rebuild is needed.
<Project Sdk="Microsoft.NET.Sdk.Web">
[...]
<ItemGroup>
<UpToDateCheckBuilt Include="./wwwroot/css/site.css" Set="Css" />
<UpToDateCheckBuilt Include="./tailwind.config.js" Set="Css" />
</ItemGroup>
[...]
</Project>
UpToDateCheckBuilt tells the build system to compare timestamps to determine whether a rebuild is needed for a particular file.
An issue with Hot Reload
When writing frontend code, developers want to be able to see and test the changes as quickly as possible. For any development stack, this requires being able to reload code changes, compile them if required, and load them into the browser as quick and as smoothly as possible.
ASP.NET Core has a built-in mechanism to facilitate this kind of fast-feedback workflow: Hot Reload. This features has been included in the stack since version 6, and has been refined and extended over the more recent releases. In my experience, it has become more stable and reliable recently, making it more attractive for daily use.
There is, however, an assumption built into Hot Reload that causes issues when combined with Tailwind: Hot Reload is triggered when a file (that is considered part of the source code by .NET tooling) has changed. Then
- the app gets built
- the build output gets loaded into the running process (unless a “rude edit” is detected, which then requires to restart the process)
- the browser receives a notification through a websocket to reload a certain part of the website
- the browser refreshed that part of the website
While these steps are ongoing, other changes to the source don’t trigger another Hot Reload.
Now, changes in Tailwind always cause two files to change:
- the html template of the component containing the Tailwind CSS class attribute (i.e. the
.razoror.cshtmlfile) - the
wwwroot/css/site.cssfile generated by Tailwind
When saving the former after making a change, Hot Reload may trigger the rebuild without picking up the (re-)generated latter, causing the browser to not reload site.css, resulting in an incomplete preview in the browser. This can be particularly an issue in larger projects.
dotnet watch ⌚ File changed: ./Features/Shared/_Layout.cshtml.
Giving Tailwind 1 seconds to settle...
Reloading...
dotnet watch 🔥 Hot reload of changes succeeded.
Fixing Hot Reload
Making Hot Reload pick up changes by Tailwind requires customizing it. There is an official API for this task:
[assembly: System.Reflection.Metadata.MetadataUpdateHandler(typeof(HotReloadDelayer))]
public static class HotReloadDelayer
{
private static readonly TimeSpan DelayHotReload = TimeSpan.FromSeconds(1);
public static void UpdateApplication(Type[]? updatedTypes)
{
Console.WriteLine($"Giving Tailwind {DelayHotReload.Seconds} seconds to settle...");
Thread.Sleep(DelayHotReload);
Console.WriteLine("Reloading...");
}
}
This will delay Hot Reload for the changed template file. The delay will then cause Hot Reload to pick up on the change to site.css, triggering another Hot Reload.
dotnet watch ⌚ File changed: ./Features/Shared/_Layout.cshtml.
Giving Tailwind 1 seconds to settle...
Reloading...
dotnet watch 🔥 Hot reload of changes succeeded.
dotnet watch ⌚ File changed: ./wwwroot/css/site.css.
dotnet watch 🔥 Hot reload of static file succeeded.
As one can see, the change to site.css is now picked up correctly.
Integrating Tailwind into Docker builds
A both powerful and convenient way to build production-ready Docker images for ASP.NET Core applications is using the Docker images provided by Microsoft. A boilerplate Dockerfile using these images might look like the following:
FROM mcr.microsoft.com/dotnet/sdk:8.0 AS build-env
WORKDIR /App
COPY . ./
RUN dotnet restore
RUN dotnet publish -c Release -o out ./src/PROJECT_NAME/PROJECT_NAME.csproj
FROM mcr.microsoft.com/dotnet/aspnet:8.0
WORKDIR /App
COPY --from=build-env /App/out .
ENTRYPOINT ["dotnet", "PROJECT_NAME.dll"]
This Dockerfile uses multi-stage builds with two stages:
- use the .NET sdk image to restore dependencies and build the app
- use the .NET runtime image to start the app
Now, simply adding the Tailwind build command here would not work: NodeJS is not installed inside these sdk images.
We can, however, use a NodeJS Docker image to build Tailwind, and then integrate the result:
FROM node:lts-alpine as node-build-env
WORKDIR /App
COPY ./src/PROJECT_NAME ./
RUN npm ci
RUN npm run build:css
FROM mcr.microsoft.com/dotnet/sdk:8.0 AS build-env
WORKDIR /App
COPY . ./
COPY --from=node-build-env /App/wwwroot/css/site.css /App/src/PROJECT_NAME/wwwroot/css/site.css
RUN dotnet restore
RUN dotnet publish -c Release -o out ./src/PROJECT_NAME/PROJECT_NAME.csproj
FROM mcr.microsoft.com/dotnet/aspnet:8.0
WORKDIR /App
COPY --from=build-env /App/out .
ENTRYPOINT ["dotnet", "PROJECT_NAME.dll"]
This Dockerfile adds another stage to the :
- use the nodejs image to build Tailwind
- copy the generated site.css into the source folder
- continue with the stages like above
Optional: Integrating Tailwind into msbuild
Since there are now two separate build systems for frontend code (msbuild and npm), they need to always be run both in order to build the project. It is possible to automate this integration further by integrating npm into msbuild.
<Project Sdk="Microsoft.NET.Sdk.Web">
<Target Name="Tailwind" BeforeTargets="Build">
<Exec Command="npm run build:css"/>
</Target>
</Project>
This tells msbuild to run npm run build:css right before other build steps, so that site.css is up to date.
Note that this integration is not compatible with the Dockerfile from the previous section: npm is not installed in the .NET sdk Docker image.
Conclusion
Tailwind CSS has become a powerful tool for speeding up frontend development. Since it has so many capabilities now, it is imperative to get the integration into the rest of the development stack right.
While the experience isn’t perfect, it is definitely possible to integrate Tailwind into ASP.NET Core and profit from the fast feedback loop that Hot Reload provides.
Going forward, I wish the ASP.NET Core team would make the experience even more frictionless. For example, the option --non-interactive should be enabled by default.