Migrating from Webpack to Rspack
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.
Prerequisites
- Shakapacker 9.0+ (Rspack support was added in 9.0)
- Node.js 20+
- React on Rails 13+
Quick Start
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.
Breaking Changes in Shakapacker 9
CSS Modules: Named vs Default Exports
This is the most critical breaking change. Shakapacker 9 changed the default CSS Modules configuration from default exports to named exports (namedExport: true).
Symptoms:
- CSS modules returning
undefined - SSR errors:
Cannot read properties of undefined (reading 'className') - Build warnings:
export 'default' (imported as 'css') was not found
Affected 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.
Rspack-Specific Configuration
Bundler Auto-Detection Pattern
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:
- Single codebase for both bundlers
- Easy to compare configurations
- Clear visibility of bundler-specific differences
Server Bundle: CSS Extract Plugin Filtering
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.
CSS Modules with SSR: Stable Class Names
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.
Server Bundle: Preserve CSS Modules Configuration
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,
};
}
React Runtime Configuration
SWC React Runtime for SSR
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.
Additional Configuration
ReScript Support
If using ReScript, add .bs.js to resolve extensions:
// config/webpack/commonWebpackConfig.js
const commonOptions = {
resolve: {
extensions: ['.css', '.ts', '.tsx', '.bs.js'],
},
};
Development Hot Reloading
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());
}
Shakapacker Configuration
Enable Rspack in config/shakapacker.yml:
default: &default
assets_bundler: 'rspack' # or 'webpack'
webpack_loader: 'swc' # Rspack works best with SWC
Troubleshooting
CSS Modules Return undefined
Symptoms:
css.classNameisundefined- SSR crashes with property access errors
- Works in development but fails in SSR
Solutions:
- Configure
namedExport: false(see Breaking Changes section) - Ensure server config preserves CSS modules settings
- Filter Rspack's
cssExtractLoaderfrom server bundle
CSS Module Styles Broken in Production (SSR)
Symptoms:
- All CSS module styles missing in production
- Class names in server-rendered HTML don't match CSS selectors
- Works in development but breaks in production builds
- No errors — just silently broken styles
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.
CSS-in-JS Styled Components All Share the Same Class Name
Symptoms:
- Multiple astroturf (or similar CSS-in-JS) styled components in the same file all get identical CSS class names
- Styles look correct in development but are broken in production
- Only one component's styles survive; others are overwritten
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.
Intermittent SSR Failures
Cause: Incomplete CSS extraction filtering in server config.
Solution: Update the CSS extract loader filter to include cssExtractLoader for Rspack (see Server Bundle section).
Module Resolution Errors
Symptom: Module not found: Can't resolve './file.bs.js'
Solution: Add the file extension to webpack's resolve.extensions configuration.
UMD Libraries Behave Differently Under Rspack's Module Interop
Symptoms:
noConflict()or other methods called on an import result returnundefinedor throw- Libraries that coexist via
noConflict()(e.g., lodash + underscore) interfere with each other
Cause: 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.
Modern Entry Points Crash on Missing Globals
Symptoms:
Cannot use 'in' operator to search for 'X' in undefinedCould not find component registered with name ...(ReactOnRails registration fails)- Works in development but crashes on specific pages in production
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 Cache
Symptoms:
- Subtle compilation issues after adding a "disable cache" environment variable
- Slower rebuilds than expected even with in-memory caching supposedly enabled
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] },
},
}),
Lazy Compilation Breaks Two-Server Dev Setup
Symptoms:
- Dynamic imports fail silently in development
- Network tab shows 404s for
/lazy-compilation-using-__<N>paths hitting your Rails server - Entry points fail to load with no obvious error
Cause: 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.
Third-Party Package Issues
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"
}
}
Performance Benefits
After migration, expect:
- Build times: ~53-270ms with Rspack (vs seconds with Webpack)
- ~20x faster transpilation with SWC
- Faster CI runs and development iteration
Reference Implementation
For a complete working example, see the react-webpack-rails-tutorial Rspack migration PR.
Related Documentation
- Webpack Configuration - Rspack vs Webpack overview
- Server-Side Rendering - SSR configuration
- Troubleshooting Guide - General troubleshooting