Skip to main content

Add StyleX in Docusaurus (PostCSS method)

Learn how to implement StyleX in Docusaurus v4 (future), using the PostCSS plugin approach.

Docusaurus v3.10+ uses Rspack and SWC by default instead of Webpack and Babel. StyleX's official @stylexjs/unplugin targets bundler-level integration, but in practice this is fragile on a Docusaurus + Rspack setup: it competes with Docusaurus's own CSS extraction for the same output asset, and community SWC-based alternatives (@stylexswc/unplugin) currently lose dev-mode HMR.

The @stylexjs/postcss-plugin sidesteps all of this. It decouples the two things StyleX needs to do:

  • Compiling your JS — turning stylex.create() calls into atomic class names, done by @stylexjs/babel-plugin.
  • Generating the CSS — done independently by the PostCSS plugin, which scans your source files itself and replaces an @stylex; marker with the compiled rules.

Because these two steps don't depend on each other or on bundler-specific asset wiring, this is the more reliable option for a Rspack-based Docusaurus site.

info

This requires disabling Docusaurus's swcJsLoader flag, so your source is transpiled with Babel instead of SWC. You keep Rspack as the bundler — only the JS transform changes.

1. Install packages

npm install @stylexjs/stylex
npm install --save-dev @stylexjs/babel-plugin @stylexjs/postcss-plugin

2. Disable the SWC JS loader

StyleX's Babel plugin needs to actually run over your source files. The only flag you need to touch is swcJsLoader — leave everything else in future.faster at its default (you don't need to enumerate the other flags at all; omitting a key just leaves it on its default):

docusaurus.config.ts
const config: Config = {
future: {
v4: true,
faster: {
swcJsLoader: false, // let Babel transpile source, so @stylexjs/babel-plugin can run
// no need to list rspackBundler, swcJsMinimizer, lightningCssMinimizer, etc. —
// they stay on their defaults (rspackBundler: true included) and that's fine.
},
},
// ...
};

rspackBundler can stay true. It has no bearing on whether Babel runs — swcJsLoader alone controls that, and babel-loader shows up correctly in Rspack's compiled sourcemaps once it's off.

3. Configure Babel

Create a babel.config.js at your project root, extending Docusaurus's own preset:

babel.config.js
const path = require('path');

module.exports = {
presets: [require.resolve('@docusaurus/core/lib/babel/preset')],
plugins: [
[
'@stylexjs/babel-plugin',
{
dev: process.env.NODE_ENV !== 'production',
unstable_moduleResolution: { type: 'commonJS' },
// optional — only if you use path aliases in your source
aliases: {
'@site/*': [path.join(__dirname, '*')],
},
},
],
],
};
danger

The filename must be exactly babel.config.js Docusaurus auto-detects a project-root Babel config by this exact filename and hands babel-loader over to it. If you rename it to babel.config.cjs (e.g. to "be safe" about ESM), Docusaurus won't find it and silently falls back to its own default preset — Babel still runs, JSX still compiles, but your @stylexjs/babel-plugin is never invoked, and stylex.create() calls survive untransformed into the browser bundle. This produces the exact same runtime error as if Babel weren't running at all, which makes it a very easy dead end to get stuck in.

Only rename away from babel.config.js if your package.json has "type": "module" — and if so, use export default and process.cwd() instead of require/__dirname, not a .cjs rename.

4. Create the Docusaurus plugin

Docusaurus exposes a configurePostCss lifecycle hook, which plugs directly into the same PostCSS pass Docusaurus already runs over its own CSS (Infima included) — no separate postcss.config.js needed.

src/plugins/plugin-stylex.ts
import stylexPostcss from '@stylexjs/postcss-plugin';
import babelConfig from '../../babel.config.js';
import type { Plugin, LoadContext } from '@docusaurus/types';

export default function stylexDocusaurusPlugin(context: LoadContext): Plugin {
return {
name: 'stylex-docusaurus',

configurePostCss(postcssOptions) {
postcssOptions.plugins.push(
stylexPostcss({
include: ['src/**/*.{ts,tsx}'],
babelConfig,
}),
);
return postcssOptions;
},
};
}
danger

Don't add a root postcss.config.js configurePostCss is your PostCSS config for this site. Adding a separate postcss.config.js on top risks the StyleX transform running twice through two independent passes, producing duplicate or conflicting output.

5. Register the plugin

docusaurus.config.ts
const config: Config = {
// ...
plugins: ['./src/plugins/plugin-stylex.ts'],
};

6. Add the CSS entrypoint

Add the @stylex; marker to your site's global stylesheet — the one referenced by theme.customCss in your preset options:

src/css/custom.css
@stylex;

/* your existing styles */

This marker gets replaced with StyleX's generated rules during the build.

7. Usage

src/pages/index.tsx
import Layout from '@theme/Layout';
import * as stylex from '@stylexjs/stylex';

const styles = stylex.create({
container: {
padding: '2rem',
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
justifyContent: 'center',
backgroundColor: '#f4f4f5',
},
title: {
fontSize: '2.5rem',
color: '#333',
fontFamily: 'sans-serif',
},
});

export default function Home() {
return (
<Layout title="Hello from StyleX" description="A Docusaurus page styled with StyleX">
<main {...stylex.props(styles.container)}>
<h1 {...stylex.props(styles.title)}>Hello from StyleX!</h1>
</main>
</Layout>
);
}

Cascade layers vs. Infima

If you enable useCSSLayers: true on the PostCSS plugin, StyleX wraps its output in @layer blocks. Docusaurus's own Infima CSS is not layered, and per the CSS cascade spec, unlayered rules always beat layered ones regardless of specificity. This can mean your compiled class exists on the element, but Infima's styles still win visually. Either leave useCSSLayers off, or explicitly order Infima into the layer stack yourself.

Troubleshooting

'stylex.create' should never be called at runtime — Babel isn't actually applying @stylexjs/babel-plugin to that file. This one error has more than one possible cause, so diagnose before changing anything:

  1. Check the compiled output directly, don't guess from the error alone. Open DevTools → Sources, find the compiled version of the file (e.g. src/pages/index.tsx), and search for the literal string stylex.create.

    • Still present, untouched → Babel's plugin config isn't being applied to this file at all. Go to step 2.
    • Replaced with a generated class-name object → Babel did compile it correctly, and a second, untransformed copy is what's actually erroring at runtime. Skip to step 5.
  2. Confirm which loader is actually running, by checking the sourcemap path for this module — if you see babel-loader in the path, Babel is in the pipeline; if you don't, swcJsLoader isn't actually off (double-check the flag and do a clean docusaurus clear && docusaurus start, since Rspack's persistent cache can serve stale output after a config change).

  3. Confirm your Babel config file is named exactly babel.config.js. This is the single most common way to end up here: Babel technically runs (JSX compiles fine, sourcemaps look normal), but if the file isn't named exactly babel.config.js, Docusaurus doesn't hand your custom plugins to babel-loader at all — it silently uses its own default preset instead. Everything looks like it's working except the one plugin that matters. Don't rename it to .cjs "just in case" unless your package.json genuinely has "type": "module".

  4. Check the failing file's import path — the Babel plugin only recognizes imports from @stylexjs/stylex (or stylex) by default. A path alias needs an explicit importSources entry.

  5. Rule out duplicate installs:

    npm ls @stylexjs/stylex

    More than one resolved version can produce this same error even when Babel is compiling correctly, since the compiled output's internal marker won't match the copy of stylex doing the runtime check.

Styles compile but don't render — check the useCSSLayers note above, and confirm your include glob in plugin-stylex.ts actually matches the file's path.