docs/oss/migrating/migrating-from-webpack-to-rspack.md
This guide documents the process of migrating from Webpack to Rspack using Shakapacker 9+. Rspack is a high-performance bundler written in Rust that provides significantly faster builds (~20x improvement) while maintaining Webpack API compatibility.
For new projects or simple migrations, the generator handles most of the setup:
# Generate with Rspack from scratch
rails generate react_on_rails:install --rspack
# Or switch an existing app
bin/switch-bundler rspack
For complex projects with SSR, CSS Modules, or custom configurations, continue reading for important considerations.
This is the most critical breaking change. Shakapacker 9 changed the default CSS Modules configuration from default exports to named exports (namedExport: true).
Symptoms:
undefinedCannot read properties of undefined (reading 'className')export 'default' (imported as 'css') was not foundAffected code pattern:
// This pattern breaks with Shakapacker 9 defaults
import css from './Component.module.scss';
console.log(css.myClass); // undefined!
Solution: Configure CSS loader to use default exports in your webpack configuration:
// config/webpack/commonWebpackConfig.js
const { generateWebpackConfig, merge } = require('shakapacker');
const commonWebpackConfig = () => {
const baseWebpackConfig = generateWebpackConfig();
// Fix CSS modules to use default exports for backward compatibility
baseWebpackConfig.module.rules.forEach((rule) => {
if (rule.use && Array.isArray(rule.use)) {
const cssLoader = rule.use.find((loader) => {
const loaderName = typeof loader === 'string' ? loader : loader?.loader;
return loaderName?.includes('css-loader');
});
if (cssLoader?.options?.modules) {
cssLoader.options.modules.namedExport = false;
cssLoader.options.modules.exportLocalsConvention = 'camelCase';
}
}
});
return baseWebpackConfig;
};
module.exports = commonWebpackConfig;
Key insight: This configuration must be inside the function so it applies to a fresh config each time.
Use conditional logic to support both Webpack and Rspack in the same configuration files:
// config/webpack/commonWebpackConfig.js
const { config } = require('shakapacker');
// Auto-detect bundler from shakapacker config
const bundler = config.assets_bundler === 'rspack' ? require('@rspack/core') : require('webpack');
// Use for plugins that need the bundler reference
clientConfig.plugins.push(
new bundler.ProvidePlugin({
/* ... */
}),
);
serverConfig.plugins.unshift(new bundler.optimize.LimitChunkCountPlugin({ maxChunks: 1 }));
Benefits:
Rspack uses a different CSS extract loader path than Webpack. Server-side rendering configs that filter out CSS extraction must handle both:
// config/webpack/serverWebpackConfig.js
const configureServer = (serverWebpackConfig) => {
serverWebpackConfig.module.rules.forEach((rule) => {
if (rule.use && Array.isArray(rule.use)) {
// Filter out CSS extraction loaders for SSR
rule.use = rule.use.filter((item) => {
let testValue;
if (typeof item === 'string') {
testValue = item;
} else if (typeof item.loader === 'string') {
testValue = item.loader;
}
// Handle both Webpack and Rspack CSS extract loaders
return !(
testValue?.match(/mini-css-extract-plugin/) ||
testValue?.includes('cssExtractLoader') || // Rspack uses this path
testValue === 'style-loader'
);
});
}
});
};
Why this matters: Rspack uses @rspack/core/dist/cssExtractLoader.js instead of Webpack's mini-css-extract-plugin. Without this fix, CSS extraction remains in the server bundle, causing intermittent SSR failures.
If you use [contenthash] in your localIdentName for CSS modules, class names can silently diverge between client and server builds with Rspack. Pages load but all CSS module styles are broken. This only affects production builds — development with HMR works fine because style-loader injects CSS inline.
The root cause is that [contenthash] is computed from processed CSS content, which differs between client and server builds due to different loader chains (exportOnlyLocals: true on server), different plugins (CssExtractRspackPlugin on client only), and different optimization passes.
Solution: Replace [contenthash] with a custom getLocalIdent function that derives class names from stable inputs (file path, query string, and class name):
// config/webpack/getLocalIdent.js
const crypto = require('crypto');
const path = require('path');
const getLocalIdent = (context, _localIdentName, localName) => {
const resourcePath = context.resourcePath;
const resourceQuery = context.resourceQuery || ''; // needed for CSS-in-JS virtual modules; safe no-op otherwise
// Prefer process.cwd() so this stays correct across custom/monorepo layouts.
const projectRoot = process.cwd();
// Alternative if process.cwd() isn't reliable in your setup:
// const projectRoot = path.resolve(__dirname, '../..'); // adjust depth as needed
const relativePath = path.relative(projectRoot, resourcePath);
const hash = crypto
.createHash('sha256')
.update(relativePath + '\0' + resourceQuery + '\0' + localName)
.digest('base64url')
.slice(0, 8);
const basename = path.basename(resourcePath);
const name = basename
.replace(/\.(module\.)?(scss|sass|css|less|styl|tsx?|jsx?)$/, '')
.replace(/-styles$/, '');
return `${name}-${localName}_${hash}`;
};
module.exports = getLocalIdent;
For third-party CSS modules, relativePath may include node_modules/ segments in the hash input. That's expected and stable; the visible class name prefix still comes from path.basename(resourcePath).
Then use it in your commonWebpackConfig.js where you configure CSS modules:
const getLocalIdent = require('./getLocalIdent');
// In the CSS modules configuration loop:
if (cssLoader?.options?.modules) {
cssLoader.options.modules.namedExport = false;
cssLoader.options.modules.exportLocalsConvention = 'camelCase';
cssLoader.options.modules.getLocalIdent = getLocalIdent; // Stable class names across builds
}
Because getLocalIdent uses stable inputs (file path, query string, and class name) rather than processed CSS content, the same class name is produced in both client and server builds regardless of bundler internals. The same function must be used in both builds so server-rendered HTML class names match client CSS selectors.
Note — CSS-in-JS virtual modules (astroturf, etc.): The
resourceQueryline ingetLocalIdentabove is a no-op for regular.module.scssfiles. It exists for CSS-in-JS tools that create virtual CSS modules via webpack's!=!matchResource syntax. In rspack,context.resourcePathpoints to the source file rather than the virtual path, so withoutresourceQueryin the hash, all virtual modules from the same file would collide. See the troubleshooting entry for details.
When configuring SSR, merge CSS modules options instead of replacing them:
// ❌ Wrong - overwrites namedExport setting
if (cssLoader && cssLoader.options) {
cssLoader.options.modules = { exportOnlyLocals: true };
}
// ✅ Correct - preserves existing settings
if (cssLoader && cssLoader.options && cssLoader.options.modules) {
cssLoader.options.modules = {
...cssLoader.options.modules, // Preserve namedExport: false
exportOnlyLocals: true,
};
}
If using SWC (common with Rspack), you may need to use the classic React runtime for SSR compatibility:
// config/swc.config.js
const customConfig = {
options: {
jsc: {
transform: {
react: {
runtime: 'classic', // Use 'classic' instead of 'automatic' for SSR
refresh: env.isDevelopment && env.runningWebpackDevServer,
},
},
},
},
};
Symptom: SSR error about invalid renderToString call or function signature detection issues.
If using ReScript, add .bs.js to resolve extensions:
// config/webpack/commonWebpackConfig.js
const commonOptions = {
resolve: {
extensions: ['.css', '.ts', '.tsx', '.bs.js'],
},
};
Different plugins are required for hot reloading:
// config/webpack/development.js
const { config } = require('shakapacker');
if (config.assets_bundler === 'rspack') {
const ReactRefreshPlugin = require('@rspack/plugin-react-refresh');
clientWebpackConfig.plugins.push(new ReactRefreshPlugin());
} else {
const ReactRefreshWebpackPlugin = require('@pmmmwh/react-refresh-webpack-plugin');
clientWebpackConfig.plugins.push(new ReactRefreshWebpackPlugin());
}
Enable Rspack in config/shakapacker.yml:
default: &default
assets_bundler: 'rspack' # or 'webpack'
webpack_loader: 'swc' # Rspack works best with SWC
undefinedSymptoms:
css.className is undefinedSolutions:
namedExport: false (see Breaking Changes section)cssExtractLoader from server bundleSymptoms:
Cause: [contenthash] in localIdentName produces different hashes in client and server builds with Rspack.
Solution: Use a custom getLocalIdent function instead of [contenthash]. See the CSS Modules with SSR section above.
Symptoms:
Cause: Rspack sets context.resourcePath to the actual source file instead of the virtual matchResource path. All virtual CSS modules from the same source file produce the same hash.
Solution: Include context.resourceQuery in the getLocalIdent hash. See the getLocalIdent implementation above, which already includes resourceQuery for this reason.
Cause: Incomplete CSS extraction filtering in server config.
Solution: Update the CSS extract loader filter to include cssExtractLoader for Rspack (see Server Bundle section).
Symptom: Module not found: Can't resolve './file.bs.js'
Solution: Add the file extension to webpack's resolve.extensions configuration.
Symptoms:
noConflict() or other methods called on an import result return undefined or thrownoConflict() (e.g., lodash + underscore) interfere with each otherCause: Rspack's ES module interop wraps CommonJS exports differently than Webpack, so the import result may not have the methods you expect. Switching to window.* access can also be wrong if loaders like imports-loader?define=>false prevent the library from setting globals in the first place.
Solution: Audit any noConflict() calls or UMD workarounds. Check whether your loader chain already prevents the conflict, and remove the workaround if so. Verify behavior by testing in both bundlers.
Symptoms:
Cannot use 'in' operator to search for 'X' in undefinedCould not find component registered with name ... (ReactOnRails registration fails)Cause: Code in a modern entry point references a global (e.g., window._) that is only exposed via expose-loader in legacy bundles. The crash kills the script before registerForRails() executes, so ReactOnRails reports the component as missing. This bug can be latent under Webpack if legacy bundles happen to load first.
Solution: Audit all entry points for code that assumes sitewide globals exist. Remove or guard any such references, especially orphaned polyfills for removed features.
cache: false Disables Rspack's In-Memory CacheSymptoms:
Cause: Rspack's top-level cache option is boolean-only (true/false), unlike Webpack's cache: { type: 'filesystem', buildDependencies: ... }. Setting cache: false to disable disk persistence actually disables rspack's in-memory cache entirely. Persistent (disk) caching is controlled separately via experiments.cache.
Solution: Always set cache: true for rspack. Control disk persistence exclusively through experiments.cache:
cache: isRspack
? true // always enable in-memory cache; disk persistence via experiments.cache
: { type: 'filesystem', buildDependencies: { config: [__filename] } },
// Persistent cache (disk) — opt-in for rspack
...(isRspack && !process.env.DISABLE_FILESYS_CACHE && {
experiments: {
cache: { type: 'persistent', buildDependencies: [__filename] },
},
}),
Symptoms:
/lazy-compilation-using-__<N> paths hitting your Rails serverCause: Rspack's CLI auto-enables lazy compilation for web targets when using rspack serve. The lazy-compilation proxy uses relative URLs, so the browser resolves them against the page origin (your Rails server) instead of the dev server where the middleware lives. Webpack doesn't auto-enable this, so it's an invisible behavior change.
Solution: Explicitly disable lazy compilation:
...(isRspack && { lazyCompilation: false }),
Alternatively, configure lazyCompilation.backend.listen to match your dev server setup.
Some packages may not ship compiled files. Use patch-package to fix:
pnpm add --save-dev patch-package postinstall-postinstall
Add to package.json:
{
"scripts": {
"postinstall": "patch-package"
}
}
After migration, expect:
For a complete working example, see the react-webpack-rails-tutorial Rspack migration PR.