Building ASP.NET apps with Tailwind CSS

Date: 2024-01-05 | create | tech | dotnet | tailwind | fsharp | docker | containers |

I like Tailwind for styling my frontends. I find the atomic styles to be easy to compose and I think composability is a great attribute for building Simple Scalable Systems (3S).

I also mostly build my fullstack projects with F# on ASP.NET.

The problem is that Tailwind is really JS / NPM native. So combining Tailwind with other technologies is not as straightforward as you might hope. Yes you could always just get it via CDN but it's usually a large payload that requires a JS load so not recommended for production.

In this post we'll explore another option for using Tailwind with your ASP.NET app that's production-ready.

Q: How can we combine Tailwind with an ASP.NET app?

Answer

In this post we'll be exploring an example ASP.NET app that builds and serves an optimized Tailwind bundle. We'll be using Docker containers to handle builds and orchestration and output a container ready to be run in production anywhere.

Tailwind + ASP.NET - Build Pipeline

Essentially our approach will be:

  • ASP.NET app - our app we want to use Tailwind with
  • Tailwind CLI - a minimal npm project for building Tailwind
  • Docker container - To configure, build, and optimize our Tailwind output, build our ASP.NET app, then place the Tailwind output in our ASP.NET app's static folder

Tailwind + ASP.NET - Example App

To demonstrate this and prove it works, I'll be using an example app I built. This app is very similar to how I'm using Tailwind / ASP.NET in production and is how we bundle Tailwind / ASP.NET in CloudSeed - my F# project boilerplate.

This example app is a fullstack monolith serving server-side rendered HTML. It is a very simple markdown blog displaying post lists and content on the frontend with Tailwind styles.

Yes this is F# but it uses standard options / services from ASP.NET so will work for C# as well (they both target dotnet / ASP.NET).

Obviously this is overkill to show you Tailwind + ASP.NET bundled but I like to build realish apps to prove this stuff works.

In this post we will be focusing mostly on:

  • Setting up ASP.NET to serve static files
  • Setting up Tailwind CLI
  • Bundling Tailwind + ASP.NET with Docker

We may touch on other parts but it's mostly out of scope. I've linked relevant articles if you'd like to learn more and the full project source is available in the HAMY LABS Example Project Repo for HAMINIONs subscribers.

Alright, now let's build this thing.

Serving static files with ASP.NET

The first thing we need to do is set up our ASP.NET app to be able to serve static files. Here we'll be using that to serve our static Tailwind css output in a known location so we can pull it into our frontend.

Tailwind + ASP.NET - Example App

When we build our ASP.NET app in Docker, we'll be building this like a normal app. It will simply assume that the Tailwind output is in the correct place at build time which we'll orchestrate with Docker. This is one of the benefits of containerization - we can explicitly declare and orchestrate environmental dependencies without needing to fight the native build tools to do stuff like this (cough cough msbuild).

Here's the folder structure for my ASP.NET App:

  • Source - Contains my app source code
  • wwwroot - Where the static files will go (this is the ASP.NET default location but you could configure it elsewhere if you want)
    • We'll be using a folder wwwroot/css to hold our css files. This will be available from our app at url /css/FILE
  • Tailwind - An empty folder for now which will contain the project needed to run Tailwind CLI

The important thing to call out here is that we're using the IApplicationBuilder.UseStaticFiles option to setup wwwroot as a static file host.

Yes this is in F# but the same options will apply for C# on ASP.NET

app
    .UseStaticFiles()
    .UseRouting()
    .UseCors(
        Action<_>
            (fun (b: Infrastructure.CorsPolicyBuilder) ->
                // Put real allowed origins in here
                b.AllowAnyHeader() |> ignore
                b.AllowAnyMethod() |> ignore
                b.AllowAnyOrigin() |> ignore)
    )
    .UseEndpoints(
        fun e ->
            e.MapGiraffeEndpoints(endpointsList)
    )
|> ignore

We're going to assume that the orchestrated Tailwind file is located at wwwroot/css/tailwind.css and thus that we can access it via url at /css/tailwind.css. This means we can include it in our html like this:

<!DOCTYPE html">
<html lang="en" data-theme="light>
<head>
    <meta charset="UTF-8">
    <link rel="stylesheet" href="/css/tailwind.css">
    { head }
</head>
<body>
    { body }
</body>
<footer>
    { footer }
</footer>
</html>

Building Tailwind for ASP.NET

Now that we have a folder in our ASP.NET app that will serve static files, we just gotta get Tailwind built and in that folder.

For the build part we're going to be using Tailwind CLI which is the recommended way to build Tailwind outside of the JS ecosystem. Again you could always go buildless and use the CDN but that will leave you with a very large payload filled with a bunch of CSS you don't actually use. By using Tailwind CLI we get all the CSS culling / optimization that allows it to only output the CSS you actually use and ends with a payload that's actually reasonable for production.

To setup Tailwind CLI, we're basically following the official Tailwind CLI setup docs.

  • Navigate to the empty Tailwind folder inside our ASP.NET project
  • Use the npx command to generate a base Tailwind CLI project

After doing this we should have a baseline version of Tailwind ready for building via npx command. This means that we'll be able to build Tailwind as normal (with configuration, custom css, plugins, etc) and output it to a specific file we can use in our ASP.NET app.

But there's still a few things we need to do to get this working.

Create a base css file

The way Tailwind works is it:

  • Looks at the classes you want to build (based on a base css file)
  • Looks at the code that will be using the classes to see what's used / unused
  • Outputs a new css file containing only the classes you've used

Here we just create a basic CSS file to basically tell Tailwind we want it to include all the classes we use:

/Tailwind/Styles/tailwind.css

@tailwind base;
@tailwind components;
@tailwind utilities;

Configure Tailwind with tailwind.config.js

We can use tailwind.config.js to configure our Tailwind build. This is useful for things like theming, plugins, etc. Those are optional but what's necessary is to update the content field to target the types of files we will be writing Tailwind CSS classes in.

This is important. If you get this wrong, Tailwind will act funky:

  • Include too little files (i.e. leave out files where you wrote Tailwind) - Tailwind can't know that you used certain classes and therefore won't output those classes and thus those classes will not work and make your frontend look broken
  • Include too many files (i.e. include everything including build directories) - Tailwind will take a long time to run cause it's parsing a ton of unnecessary files and will feel like more of a pain than a utility

In my app, I'm building frontend with HTML string templates in F# so all of my markup and css classes will be declared in .fs files so that's all I need. But I expect a lot of people reading this will be building with C# and / or HTML templates so here we're targeting:

  • F# files - .fs
  • HTML files - .html
  • CSHTML files - .cshtml

If you are writing markup / declaring Tailwind css classes in other files you will need to add them.

Okay - here's my tailwind.config.js - it says look for all files in the ./Source directory that end with .fs, .html, or .cshtml

/** @type {import('tailwindcss').Config} */
module.exports = {
  // hamy: all css in .fs
  content: ["./Source/**/*.{fs,html,cshtml}"],
  theme: {
    extend: {},
  },
  // hamy: these plugins are just nice-to-haves for my markdown blog
  plugins: [
    require('@tailwindcss/typography'),
    require("daisyui")
  ],
}

Combining Tailwind and ASP.NET with Docker

Okay so now we've got:

  • Basic ASP.NET app setup with the ability to serve static files from wwwroot/css
  • Simple Tailwind project setup to include all Tailwind classes we use in files ending with .fs, .html, and .cshtml

Tailwind + ASP.NET - Build Pipeline

Now we're ready to orchestrate these together with Docker. This docker file will:

Build Tailwind

  • Create a layer with Node
  • Copy the Tailwind project into it
  • npm install so it has its dependencies ready
  • Copy the App Source code (./Source) into the container
  • Run npx on our base css and output it at ./out/tailwind.css

Build ASP.NET

  • Create a layer with dotnet sdk
  • Copy the fsproj in and dotnet restore to get dependencies
  • Copy the rest of the code in and publish it

Orchestrate ASP.NET / Tailwind

  • Create a layer with dotnet aspnet
  • Copy ASP.NET build in here
  • Copy the ./out/tailwind.css file we made into the ASP.NET static folder at ./wwwroot/css/tailwind.css
  • Run the ASP.NET app

Dockerfile

# Set up Tailwind

FROM node:20-slim as styles

COPY ./Tailwind ./
RUN npm install

COPY ./Source ./Source
RUN npx tailwindcss -i ./Styles/tailwind.css -o ./out/tailwind.css

# Build Dotnet App

FROM mcr.microsoft.com/dotnet/sdk:7.0-bullseye-slim AS build
# EXPOSE 8080
EXPOSE 80
# ENV ASPNETCORE_URLS=http://+:8080

WORKDIR /source

# copy csproj and restore as distinct layers
COPY ./*.fsproj ./
RUN dotnet restore

# copy and publish app and libraries
COPY . .
RUN dotnet publish -c release -o /app
# --no-restore

# Copy build / styles into final location

FROM mcr.microsoft.com/dotnet/aspnet:7.0
WORKDIR /app
COPY --from=build /app .
COPY --from=styles ./out/tailwind.css ./wwwroot/css/tailwind.css
ENTRYPOINT ["dotnet", "App.dll"]

Next

That's it - you should now be able to build an ASP.NET app using Tailwind for styling all orchestrated via Docker containers.

If you liked this post, you might also like:

Want more like this?

The best / easiest way to support my work is by subscribing for future updates and sharing with your network.