Integrating Tailwind CSS with dune

A practical guide to integrating Tailwind CSS into your Melange project with dune rules

DEC 2025

4 MINUTES

DAVESNX

Tailwind is an extremely popular choice for styling. I'm not a great fan, but integrating it into web applications built with Melange or any other dune-based system is surprisingly easy.

This tutorial shows you how to set up dune build to handle both JavaScript compilation and CSS generation in one go, with dune running a single watch process.

Prerequisites

You'll need:

  1. A Melange project with dune. If you're starting from scratch, the easiest option is create-melange-app:

    npx create-melange-app my-app
    cd my-app
  2. Tailwind CSS installed via npm

    npm install -D @tailwindcss/cli
  3. A CSS entrypoint file (styles.css) with your Tailwind directives

    @import "tailwindcss";
     
    @theme {
      --font-display: "Satoshi", "sans-serif";
    }

    Check the Tailwind documentation for complete customization details.

Setting up the dune rules

Dune's install and rule stanzas make Tailwind work seamlessly with Melange. The rule stanza defines custom build steps that use dune's dependency tracking and caching.

Add this to your dune file:

(install
 ; This tells `dune` where to install the tailwind executable
 ; "sections" are predefined installation locations within a package (like `bin` for executables, `lib` for libraries, `share` for data files and `docs`)
 (section bin)
 
 ; Replace this with your package name
 (package my-app)
 
 ; Make the tailwind CLI available as an executable called "tailwind"
 ; by copying it from node_modules to the install directory
 (files
  ("../node_modules/@tailwindcss/cli/dist/index.mjs" as tailwind)))
 
(rule
 ; The output file that will be generated
 (target output.css)
 
 ; Dependencies that this rule needs to run
 (deps
  ; :input is your source CSS file with Tailwind directives
  (:input ./styles.css)
 
  ; Watch the source_tree so Tailwind runs on each change on your source files
  (source_tree .))
 
 ; The command to execute: runs the tailwind CLI
 (action
  (run tailwind -i %{input} -o %{target})))

The dune documentation on rules covers them in more detail.

Generate your CSS with:

dune build output.css

To rebuild when files change:

dune build --watch output.css

The generated output.css will be in _build/default/output.css, relative to the location of your dune file. It will contain only the Tailwind utilities you're using in your project.

Dune rebuilds output.css only when one of its dependencies changes, which keeps builds fast and efficient.

dune build also runs the rule.

Specifying dependencies

Explicitly declaring what your rule depends on is a crucial part of dune rules. When any of these dependencies change, dune automatically reruns the rule.

In our Tailwind setup, we use (source_tree .) to watch the entire source tree because Tailwind scans your source files for class names to determine which CSS to generate. When you add a new class to your code, Tailwind needs to regenerate the CSS.

Dune supports many types of dependencies beyond source_tree:

  • (file <filename>) - depend on a specific file
  • (glob_files *.ml) - depend on all files matching a pattern
  • (alias <name>) - depend on an alias being built
  • (universe) - depend on everything (use when you can't specify exact dependencies)

For the complete list of dependency types, see the dune dependency specification documentation.

If you're unsure what dependencies to declare, you can use (universe) as a catch-all, though this prevents dune from caching the result effectively. It's better to be specific when possible.

Using the generated CSS

With the rule above, your output.css will be in _build/default/output.css. You can reference it in your HTML:

<!DOCTYPE html>
<html>
<head>
  <link rel="stylesheet" href="_build/default/output.css">
</head>
<body> <!-- ... --> </body>
</html>

This works, but knowing the file structure under _build can feel a bit awkward. That's where promotion comes in.

Promotion

By default, dune generates files in the _build directory by replicating your folder structure and storing build metadata.

In this case, you want the generated output.css to appear in your source tree so you can reference it directly in your HTML. Dune calls this "promotion," which promotes the file to your source code. Add (mode promote) to the rule:

(rule
 (target output.css)
 (deps
  (:input ./styles.css)
  (source_tree .))
 (action
  (run tailwind -i %{input} -o %{target}))
 ; Promote the generated file to the source tree
 (mode promote))

When you run dune build, dune copies output.css to your source directory alongside your dune file. You can then reference it directly in your HTML:

<!DOCTYPE html>
<html>
<head>
  <link rel="stylesheet" href="output.css">
</head>
<body> <!-- ... --> </body>
</html>

Dune regenerates the promoted output.css file whenever your Tailwind config or input styles change, so your styling stays in sync with your code.

Production builds

For production, add the --minify flag to reduce file size. You can use dune aliases to create separate development and production builds:

(rule
 (target output.min.css)
 (alias prod)
 (deps
  (:input ./styles.css)
  (source_tree .))
 (action
  (run tailwind -i %{input} -o %{target} --minify))
 (mode promote))

During development, run dune build --watch. For production, run dune build @prod, which generates a minified output.min.css.

Aliases are one way to handle this. You could also use enabled_if to conditionally enable rules based on environment variables or other conditions.


With this setup, dune build handles your Melange compilation and Tailwind CSS generation together, with proper dependency tracking and caching.

Happy hacking with Melange!

Thanks for reading!
Any feedback is appreciated.

@davesnx