Rspack joins the Next.js ecosystem so I guess we should add support for rspack
To understand the options we have for rspack, it helps to know how the other bundlers solve the same problem. The core challenge is always the same: the yak SWC plugin transforms a .tsx file and embeds CSS as comments in the JS output. We then need to extract that CSS into a separate module. The tricky part is that the class name hashes must match exactly between the JS runtime and the generated CSS, so ideally both sides come from the same SWC transformation.
Webpack solves this with this.loadModule(). The CSS loader calls this.loadModule(this.resourcePath) which re-processes the file through webpack's loader pipeline (including SWC). It returns the transformed source as a string, we extract the CSS from it. One SWC pass, hashes always match.
Turbopack takes a different route. The SWC plugin is configured to emit CSS directly as base64 data URL imports (import "data:text/css;base64,...") in the transformed JS. No separate CSS loader needed, turbopack picks up the data URLs natively.
Vite has neither loadModule nor a loader pipeline to hook into. So the vite plugin reads the source file and calls swcTransform() directly a second time with the same config, then extracts CSS from that. It works, but SWC runs twice per file and hash consistency depends on both calls using identical config.
The problem with rspack
Rspack is mostly webpack-compatible but is missing the two APIs we rely on:
this.loadModule() is not implemented. Calling it throws TypeError: this.loadModule is not a function
- Data URL imports (
import "data:text/css;base64,...") are not supported out of the box
Rspack does have this.importModule(), but that one compiles and evaluates the module. Since we're dealing with React component files, that would try to execute React code at build time which obviously doesn't work. You could work around that with an inline raw loader (see Option A), but importModule also recompiles the module in Node.js mode to make it evaluatable, so it's a separate compilation from rspack's normal browser-targeted build anyway
Also Source maps are passed to loaders as JSON strings instead of objects, which breaks SWC. Fixable by parsing/serializing at the boundaries, but something to be aware of
So we need a different approach. We explored two options:
Option A: importModule + inline raw loader
The idea here is to work around the importModule evaluation problem. If we pipe the file through a tiny raw loader first, the module's export becomes just a string instead of React code.
The raw loader is literally 3 lines:
export default function rawLoader(source: string) {
return `export default ${JSON.stringify(source)}`;
}
Then in the CSS extraction loader:
const { default: source } = await this.importModule(
`${pathToRawLoader}!${this.resourcePath}`
);
const css = extractCss(source, transpilationMode);
What happens under the hood:
importModule triggers a separate compilation of the .tsx file in Node.js mode
- Rspack's loaders (including SWC with the yak plugin) run as part of that compilation
- The inline raw loader wraps the output as
export default "<source>"
importModule evaluates the module and returns the string
Important caveat: since importModule compiles in Node.js mode, this is a separate SWC pass from the browser-targeted one. The yak plugin config should be the same, so hashes should match, but it's not guaranteed by the pipeline itself. This is the same situation as Vite
Option B: Reuse the turbo-loader + experiments.css
This approach was implemented and tested in yak-worktree-2. Instead of replicating the webpack flow, it reuses the existing turbo-loader (originally built for turbopack) and enables rspack's native CSS support to handle the data URL imports.
How it works:
- Detect rspack via the
NEXT_RSPACK env var (set by withRspack())
- Register the turbo-loader as an
enforce: "pre" rule for source files
- The turbo-loader runs SWC directly with the yak plugin, extracts CSS, and outputs JS with
import "data:text/css;base64,..."
- Enable
experiments.css = true and add a rule for { scheme: "data", mimetype: "text/css", type: "css/auto" } so rspack handles those data URL imports natively
source.tsx -> turbo-loader (runs SWC + extracts CSS)
|
JS with data:text/css;base64 import
|
rspack's built-in SWC (handles remaining JSX)
|
rspack's native CSS pipeline (experiments.css)
A few things to note:
- Config wrapper order matters:
withYak(withRspack(nextConfig)) because withRspack must run first to clean up the TURBOPACK env var before withYak checks it
- The turbo-loader preserves JSX (
react.runtime: "preserve"), so rspack's built-in SWC still handles JSX->JS compilation after
- Source maps needed some fixing because rspack passes string source maps where SWC expects objects
Comparison
Both options run SWC twice per file and both rely on keeping the yak plugin config in sync between the two passes for hash consistency. Neither can reuse rspack's browser-targeted SWC pass directly (unlike webpack's loadModule which does exactly that).
|
Option A: importModule + raw loader |
Option B: turbo-loader + experiments.css |
| SWC runs per file |
Twice (Node.js recompilation) |
Twice (turbo-loader + rspack's built-in) |
| Hash consistency |
Depends on keeping SWC config in sync |
Depends on keeping SWC config in sync |
| New code needed |
~3-line raw loader + rspack CSS loader |
Only addYakRspack() in withYak |
| CSS pipeline |
Next.js's CSS pipeline |
rspack's native CSS pipeline |
Rspack joins the Next.js ecosystem so I guess we should add support for rspack
To understand the options we have for rspack, it helps to know how the other bundlers solve the same problem. The core challenge is always the same: the yak SWC plugin transforms a
.tsxfile and embeds CSS as comments in the JS output. We then need to extract that CSS into a separate module. The tricky part is that the class name hashes must match exactly between the JS runtime and the generated CSS, so ideally both sides come from the same SWC transformation.Webpack solves this with
this.loadModule(). The CSS loader callsthis.loadModule(this.resourcePath)which re-processes the file through webpack's loader pipeline (including SWC). It returns the transformed source as a string, we extract the CSS from it. One SWC pass, hashes always match.Turbopack takes a different route. The SWC plugin is configured to emit CSS directly as base64 data URL imports (
import "data:text/css;base64,...") in the transformed JS. No separate CSS loader needed, turbopack picks up the data URLs natively.Vite has neither
loadModulenor a loader pipeline to hook into. So the vite plugin reads the source file and callsswcTransform()directly a second time with the same config, then extracts CSS from that. It works, but SWC runs twice per file and hash consistency depends on both calls using identical config.The problem with rspack
Rspack is mostly webpack-compatible but is missing the two APIs we rely on:
this.loadModule()is not implemented. Calling it throwsTypeError: this.loadModule is not a functionimport "data:text/css;base64,...") are not supported out of the boxRspack does have
this.importModule(), but that one compiles and evaluates the module. Since we're dealing with React component files, that would try to execute React code at build time which obviously doesn't work. You could work around that with an inline raw loader (see Option A), butimportModulealso recompiles the module in Node.js mode to make it evaluatable, so it's a separate compilation from rspack's normal browser-targeted build anywayAlso Source maps are passed to loaders as JSON strings instead of objects, which breaks SWC. Fixable by parsing/serializing at the boundaries, but something to be aware of
So we need a different approach. We explored two options:
Option A:
importModule+ inline raw loaderThe idea here is to work around the
importModuleevaluation problem. If we pipe the file through a tiny raw loader first, the module's export becomes just a string instead of React code.The raw loader is literally 3 lines:
Then in the CSS extraction loader:
What happens under the hood:
importModuletriggers a separate compilation of the.tsxfile in Node.js modeexport default "<source>"importModuleevaluates the module and returns the stringImportant caveat: since
importModulecompiles in Node.js mode, this is a separate SWC pass from the browser-targeted one. The yak plugin config should be the same, so hashes should match, but it's not guaranteed by the pipeline itself. This is the same situation as ViteOption B: Reuse the turbo-loader +
experiments.cssThis approach was implemented and tested in
yak-worktree-2. Instead of replicating the webpack flow, it reuses the existing turbo-loader (originally built for turbopack) and enables rspack's native CSS support to handle the data URL imports.How it works:
NEXT_RSPACKenv var (set bywithRspack())enforce: "pre"rule for source filesimport "data:text/css;base64,..."experiments.css = trueand add a rule for{ scheme: "data", mimetype: "text/css", type: "css/auto" }so rspack handles those data URL imports nativelyA few things to note:
withYak(withRspack(nextConfig))becausewithRspackmust run first to clean up theTURBOPACKenv var beforewithYakchecks itreact.runtime: "preserve"), so rspack's built-in SWC still handles JSX->JS compilation afterComparison
Both options run SWC twice per file and both rely on keeping the yak plugin config in sync between the two passes for hash consistency. Neither can reuse rspack's browser-targeted SWC pass directly (unlike webpack's
loadModulewhich does exactly that).importModule+ raw loaderexperiments.cssaddYakRspack()in withYak