Skip to main content

CSS pipelines

Flexweg CMS supports two CSS workflows for themes — picked per-theme based on which files exist:

  • SCSS pipeline: theme.scss exists, no tailwind.config.cjs. Vite compiles SCSS at build time. Used by the default theme.
  • Tailwind pipeline: theme.css + tailwind.config.cjs both exist. A separate prebuild step runs the Tailwind CLI before Vite. Used by magazine and corporate.

Only one is active per theme. You can mix both across themes in the same admin (the build script auto-detects).

SCSS pipeline

When to use

  • You're comfortable with SCSS (variables, nesting, mixins).
  • You want fine control over the generated CSS without a utility-class layer.
  • Your theme is mostly hand-written CSS with maybe a few semantic primitives.

Setup

src/themes/<id>/
├── manifest.ts
└── theme.scss ← your styles

In manifest.ts:

import cssText from "./theme.scss?inline";

export const manifest: ThemeManifest = {
id: "my-theme",
scssEntry: "theme.scss",
cssText,
// …
};

Vite's ?inline suffix means the SCSS gets compiled and the resulting CSS is exposed as a string on cssText. The build script's scripts/build-themes.mjs takes the same compiled output and writes it to dist/theme-assets/<id>.css.

What your theme.scss needs

A typical theme.scss starts with CSS variables (for compileCss to override) and font imports:

@import url("https://fonts.googleapis.com/css2?family=Inter:wght@400;600&display=swap");

:root {
--color-bg: #ffffff;
--color-text: #1a1a1a;
--color-primary: #3b82f6;
--font-sans: "Inter", sans-serif;
// …
}

body {
font-family: var(--font-sans);
background: var(--color-bg);
color: var(--color-text);
}

// … rest of your styles

The variables in :root are what your compileCss(config) hook will override at runtime when the user customises the theme.

Iterative dev

npm run dev watches theme.scss via Vite HMR — edits show up instantly in the editor preview without a reload.

Tailwind pipeline

When to use

  • You want to author with utility classes.
  • You like Tailwind's design tokens (colours, spacing, typography).
  • You're building component-heavy UIs where utility classes win on velocity.

Setup

src/themes/<id>/
├── manifest.ts
├── theme.css ← your input file (with @tailwind directives)
├── theme.compiled.css ← generated by the prebuild step (gitignored)
└── tailwind.config.cjs ← scoped to this theme

theme.css includes the standard Tailwind directives + any custom layers:

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

@layer components {
.btn-primary {
@apply px-4 py-2 bg-primary text-on-primary rounded;
}
}

tailwind.config.cjs MUST scope content to your theme directory only — otherwise admin-bundle utility classes leak in:

module.exports = {
content: [
"./src/themes/my-theme/**/*.{ts,tsx,html,js}",
],
theme: {
extend: {
colors: {
bg: "rgb(var(--color-bg) / <alpha-value>)",
primary: "rgb(var(--color-primary) / <alpha-value>)",
// …
},
},
},
};

In manifest.ts:

import cssText from "./theme.compiled.css?inline";

export const manifest: ThemeManifest = {
id: "my-theme",
scssEntry: "theme.css",
cssText,
// …
};

You import ?inline from the compiled file, not the source.

How the build wires up

scripts/build-theme-tailwind.mjs runs before Vite (wired into prebuild and predev in package.json). For each theme with a tailwind.config.cjs:

  1. Invoke the Tailwind CLI: tailwindcss -c tailwind.config.cjs -i theme.css -o theme.compiled.css
  2. Vite then sees theme.compiled.css and compiles it into cssText
  3. The post-build script copies the compiled CSS to dist/theme-assets/<id>.css

So the order is: prebuild (Tailwind) → vite build → post-build copy.

Iterative dev

Run the Tailwind watcher in a separate terminal:

npx tailwindcss -c src/themes/my-theme/tailwind.config.cjs \
-i src/themes/my-theme/theme.css \
-o src/themes/my-theme/theme.compiled.css \
--watch

Vite HMR picks up the rewritten file. Without the watcher, you'd need to manually run the prebuild every time you change a class.

.js files in content glob

If your theme ships runtime loaders (menu-loader.js, posts-loader.js) whose DOM has Tailwind class names, include .js in the content glob:

content: ["./src/themes/my-theme/**/*.{ts,tsx,html,js}"],

Without this, Tailwind's purge step strips the matching utility classes from your CSS, and the runtime-injected DOM renders unstyled. The magazine theme's burger menu, related-posts widget, etc. all rely on this — magazine's tailwind.config.cjs includes .js in the glob.

If it's not feasible to scan a particular file, use Tailwind's safelist for one-off classes — but it doesn't scale to a whole loader's DOM.

Comparing approaches

SCSSTailwind
Setup complexityLowMedium (extra prebuild step)
Build timeFastSlower (Tailwind CLI runs first)
Authoring styleHand-written CSS, BEMUtility classes
RefactoringHard (find all usages)Easy (rename a class, gone)
File sizeSmaller (no unused classes shipped)Tiny if purge is configured (otherwise huge)
compileCss integrationAppend :root { … } blockSame — Tailwind respects later :root declarations
Runtime customisationEasyEasy

Both approaches need compileCss

Whichever pipeline you use, if your theme has a settings page that exposes user-customisable design tokens, you need a compileCss(config) hook. Without it, the Sync theme assets button uploads the build-time cssText and erases customisations.

The hook works the same for both pipelines:

  1. Take the bundled cssText (your full compiled CSS).
  2. Make string-level edits — swap font URLs in @import lines, append a :root { … } block at the end.
  3. Return the new CSS.

See src/themes/default/style.ts (SCSS) and src/themes/magazine/style.ts (Tailwind) for reference implementations.

Continue