Setting up the dune rules

styled-ppx needs two things from your build:

  1. The ppx on your libraries’ preprocess field — this validates your CSS and embeds the extracted rules in the compiled files.
  2. One rule running styled-ppx.generate — this collects the extracted CSS from your whole project and writes styles.css.

This page shows the full dune configuration for both Melange and native projects. If you want to understand why the build is shaped this way, read How it works first.

Melange

1. Preprocess your libraries with the ppx

Every library that uses [%css] / [%styled.<tag>] needs styled-ppx in its pps and styled-ppx.melange in its libraries:

(library
 (name ui)
 (modes melange)
 (libraries styled-ppx.melange reason-react)
 (preprocess
  (pps styled-ppx melange.ppx reason-react-ppx)))

And the same on your melange.emit stanza:

(melange.emit
 (target app)
 (libraries ui styled-ppx.melange reason-react)
 (preprocess
  (pps styled-ppx melange.ppx reason-react-ppx))
 (module_systems
  (commonjs bs.js)))

2. Generate the stylesheet

The ppx leaves the extracted CSS inside the post-ppx files (*.pp.ml) that dune produces in its build tree. Add a rule that feeds them to styled-ppx.generate:

(rule
 (mode promote)
 (target styles.css)
 (deps
  (glob_files_rec *.pp.ml))
 (action
  (run styled-ppx.generate --output %{target} --log info %{deps})))

As a reference, the demo in the repository  promotes the stylesheet into a public/ directory next to the bundled JS:

(subdir
 public
 (rule
  (mode promote)
  (target styles.css)
  (deps
   (glob_files_rec ../client.re.pp.ml))
  (action
   (run styled-ppx.generate --output %{target} --log info %{deps}))))

styles.css is a plain static asset. Reference it from your HTML:

<link rel="stylesheet" href="/styles.css" />

or import it through your bundler if that’s how you ship CSS.

Native (server-reason-react)

Native libraries use the styled-ppx.native runtime and pass the --native flag to the ppx so the generated components target server-reason-react:

(library
 (name ui_native)
 (libraries
  styled-ppx.native
  server-reason-react.react
  server-reason-react.reactDom)
 (preprocess
  (pps
   styled-ppx
   --native
   server-reason-react.ppx
   server-reason-react.melange_ppx)))

The stylesheet rule is identical — styled-ppx.generate doesn’t care which runtime produced the files:

(rule
 (mode promote)
 (target styles.css)
 (deps
  (glob_files_rec *.pp.ml))
 (action
  (run styled-ppx.generate --output %{target} --log info %{deps})))

Serve the file from your server and link it in the document <head> you render:

<head>
  <link rel="stylesheet" href="/styles.css" />
</head>

Sharing styles between client and server? If the same source files are built for both Melange and native (shared UI code), you only need one generate rule: the extracted CSS is identical for both targets.

Flags

ppx flags (in (pps styled-ppx <flags>))

FlagEffect
--nativeGenerate code for server-reason-react (native/SSR) instead of Melange
--minifyMinify the generated CSS and drop the readable -<label> suffix from class names (css-1tyndxa instead of css-1tyndxa-layout)
--devAdd greppable marker classes (cx-<binding>) to classNames in development; no effect on the extracted CSS
--debugVerbose ppx logging

styled-ppx.generate flags

FlagEffect
-o, --output <file>Write the stylesheet to <file> (default: stdout)
--log error|warning|info|debugDiagnostic verbosity (default: error). info prints the output file; debug prints the entire stylesheet
--debugShorthand for --log debug
--minifyStrip inter-rule newlines from the output

The ppx --minify and the aggregator --minify are different flags: the first shortens class names and minifies rule bodies at extraction time, the second only removes newlines between rules in the final file. For production you typically want both.

Production setup

For a release build you want three things: minified class names (ppx --minify), a minified stylesheet (aggregator --minify), and --dev off. Minification never changes the hashes — css-1tyndxa-layout in dev and css-1tyndxa in prod are the same atom, so a class seen in production can be looked up in a dev build.

Dune has no conditional pps flags (variables aren’t allowed in pps arguments), so the supported pattern is duplicating the stanza under mutually-exclusive enabled_if guards:

(library
 (name ui)
 (modes melange)
 (libraries styled-ppx.melange reason-react)
 (preprocess
  (pps styled-ppx melange.ppx reason-react-ppx))
 (enabled_if (= %{profile} dev)))
 
(library
 (name ui)
 (modes melange)
 (libraries styled-ppx.melange reason-react)
 (preprocess
  (pps styled-ppx --minify melange.ppx reason-react-ppx))
 (enabled_if (= %{profile} release)))

and the release stylesheet rule:

(action
 (run styled-ppx.generate --minify --output %{target} %{deps}))

A few more production notes:

Troubleshooting

The generated styles.css is empty. The deps glob didn’t match any post-ppx files. Check that the rule sits at (or points to) a directory that contains libraries preprocessed with styled-ppx, and that the glob matches (*.pp.ml; Reason sources produce <name>.re.pp.ml).

Unbound value M.x at compile time. You referenced $(M.x) in a selector but the binding doesn’t exist (or isn’t visible). The compiler reports this before the aggregator even runs, pointing at your source location.

styled-ppx.generate exits 1 with File "...", line N. A cross-module selector reference couldn’t be resolved. The message distinguishes the two causes, pointing at your original source location (in the OCaml compiler’s format, so editors pick it up):

Error: cross-module [%css] selector reference `A.missing` does not resolve.
The target binding is missing from module `A`, or the binding is not
a [%css] expression. Define `A.missing` with [%css "..."], or remove the
reference.

Either the binding doesn’t exist, or its file wasn’t passed to the rule — extend the deps glob. And:

Error: cross-library [%css] selector references are not supported.
The reference `Foreign.thing` resolves to module `Foreign` which is not
part of the current library. Move the [%css] binding into the current
library, or inline the class chain literally.

Selector references can’t cross dune library boundaries — co-locate the coupled styles in one library.

Styles apply in the wrong order. All atomic classes have equal specificity, so the cascade tiebreaker is stylesheet order — and the aggregator preserves your source order. If an override doesn’t stick, make sure the overriding declaration is defined after the base one in your source. Note that with atomic CSS the order of class names on an element does not matter, only the order of rules in styles.css.