SHARE

30.04.2026

Vitalii Maznyi

15 min read

How to Migrate a Rails Project from Webpack to esbuild

In the Ruby on Rails world, Webpack was the standard for a long time, thanks to webpacker. But for large projects, it became a major bottleneck. In this article, we look at a real case of migrating a Rails + React + SCSS project to esbuild, which reduced build time from 25 minutes to 10.

The Real Problem: CI Was Too Slow

The first time I worked with migration to esbuild was back in 2023. At that time, Webpack builds were becoming painfully slow and started affecting our delivery speed. We needed a faster solution.

At first, we tried Vite. It looked like a great modern alternative, but we had one serious problem: the project relied heavily on GraphQL file imports and GraphQL fragments in .graphql files. At that time, Vite did not support .graphql files properly without unstable third-party plugins. I spent a lot of time trying different workarounds, but nothing worked reliably.

Since esbuild had a plugin that supported GraphQL imports almost out of the box, we switched to it. It worked immediately, and the performance difference was huge.

Later, when I joined another project, I faced the same problem again—but this time it was much more serious. Our project used Ruby on Rails with React (JSX), SCSS, and GitHub Actions for CI. Before the migration, our CI pipeline had two separate steps:

bundle exec rake assets:precompile
bundle exec rails webpacker:compile

The first step compiled Ruby and Sprockets assets. The second one compiled JavaScript and CSS through Webpack. The problem was that webpacker:compile alone took around 8–9 minutes. The full CI pipeline usually took 22–25 minutes. Deployments took too long, tests before deployment took too long, and the whole development process became frustrating.

There was also a business problem. We used GitHub Actions with a free organization account, which gave us only 3000 CI minutes per month. Because every pipeline was so heavy, we were spending all our CI minutes in just two or three weeks. So, that was the moment when we needed to move away from Webpack and not spend money on extra minutes.

Why Webpack Became Too Heavy

Webpack works, but inside Rails, it often becomes much more complicated than it should be. In our project, Webpack came with:

  • A full Babel pipeline for transpilation (@babel/core, @babel/preset-env, @babel/preset-react, etc.)
  • A babel.config.js with environment branching logic
  • A config/webpacker.yml configuration file
  • Separate config/webpack/{development,production,test}.js entrypoints
  • react_on_rails gem as the glue between Rails and the JS bundle
  • webpack-dev-server for HMR in development

All of this existed just to bundle JavaScript and CSS, and that is too much complexity for one simple task. Esbuild replaces all of that with one fast Go binary (without Babel, huge config files, heavy plugin chains.

Migration Strategy

Stage 1: Remove Webpack and Related Code

I wanted a completely clean start, then I added esbuild and started fixing everything that broke, so I deleted everything related to Webpack. That included:

config/webpack/development.js
config/webpack/environment.js
config/webpack/production.js
config/webpack/staging.js
config/webpack/test.js
config/webpacker.yml
config/initializers/react_on_rails.rb
babel.config.js
postcss.config.js
.eslintignore
bin/webpack
bin/webpack-dev-server
Procfile.dev
Procfile.dev-hmr

Gems removed from Gemfile:

# Before
gem 'webpacker'
gem 'react_on_rails'

npm packages removed from package.json:

"@rails/webpacker": "^5.4.0",
"@babel/core": "^7.18.10",
"@babel/plugin-proposal-private-methods",
"@babel/plugin-proposal-private-property-in-object",
"@babel/plugin-transform-runtime",
"@babel/preset-env",
"@babel/preset-react",
"babel-loader",
"babel-plugin-transform-react-remove-prop-types",
"webpack-dev-server"

CI change:

# Before
- run: |
    bundle exec rake assets:precompile
    bundle exec rails webpacker:compile

# After
- run: yarn build


Stage 2: Adding esbuild

We installed:

json
"esbuild": "^0.23.1",
"esbuild-plugin-alias-path": "^2.0.2",
"esbuild-plugin-copy": "^2.1.1",
"esbuild-sass-plugin": "^3.3.1",
"dotenv": "..."

And added simple scripts to package.json:

"scripts": {
  "build": "NODE_ENV=production node esbuild.config.mjs",
  "dev": "NODE_ENV=development node esbuild.config.mjs --watch --serve"
}

It became much simpler and much easier to maintain. Instead of many Webpack files, everything now lives inside one file: esbuild.config.mjs

Stage 3: Write the esbuild Config

Esbuild is intentionally simple: it does not try to guess where your files are or how they should be processed and compiled. Instead, you define everything explicitly in

esbuild.config.mjs

how to handle each type of file (.jsx, .js, images, fonts, SCSS, icons, etc.). In return, you get full control over your setup, a clean single configuration file, and extremely fast asset compilation. A full config example is shown below.

Stage 4: Asset Hashing and Rails Integration

We also needed browser caching. Esbuild supports hashed filenames like this:

public/assets/vendor--JMBCZRRQ.css
public/assets/application--A3KF92PX.js
public/assets/pages--XC71MQBN.js

This is perfect for production because browsers can cache assets for a long time, but unlike Webpack, esbuild does not generate a manifest.json. That means Rails does not know which hashed filename is the current one.

I had to write a small custom Rails helper:

# app/helpers/application_helper.rb

# ESBuild does not support hashed assets names out of the box
# so this is a workaround to simulate this behavior
# <https://github.com/evanw/esbuild/issues/1995#issuecomment-1031464855>
def hashed_asset_path(asset_source, extension = 'js')
  extension = ".#{extension}"
  path = Dir[Rails.public_path.join('assets/', "#{asset_source}--*#{extension}")]
  hash_part = path.first&.split('/')&.last&.split('--')&.last
  return nil if hash_part.blank?

  hash = hash_part.sub(/#{Regexp.escape(extension)}$/, '')
  "/assets/#{asset_source}--#{hash}"
end

This helper scans the public/assets folder, finds the correct file, and returns the right path for Rails templates.

Here is an example of how to use in Slim layout templates:

/ admin.slim, application.slim, etc.
= stylesheet_link_tag hashed_asset_path('vendor', 'css'), media: 'all'
= stylesheet_link_tag hashed_asset_path('application', 'css'), media: 'all'
= javascript_include_tag hashed_asset_path('pages/index'), defer: true

Which renders to:

<link rel="stylesheet" href="/assets/vendor--JMBCZRRQ.css" media="all">
<script src="/assets/pages/index--A3KF92PX.js" defer="defer"></script>

Why this works for production: assets are compiled once before the app boots, so the files are already on disk when Rails starts serving requests. The glob scan happens per-request, but is cheap, it hits the local filesystem.

Stage 5: Migrate SCSS Modules (the tricky part)

Migrating SCSS Modules is the biggest source of manual work in the migration.

Why is it a problem?

Webpack (with css-loader + style-loader) supports CSS Modules natively:

// BEFORE — Webpack + CSS Modules
import styles from './Button.module.scss'

const classes = cx(styles.button, {
  [styles.fullwidth]: fullwidth,
  [styles.success]: color === Colors.SUCCESS,
  [styles.danger]: color === Colors.DANGER,
})

At build time, styles.button becomes a hashed class like Button_button__3Fq2a. This gives you locally scoped styles — no naming collisions between components.

Why doesn't esbuild support it the same way?

Esbuild can compile SCSS and even handle CSS Modules (local-css type), but it does not generate a JavaScript object of { className: hashedValue } that you can import and use in JSX.

The esbuild-sass-plugin processes .module.scss files, but the output is scoped CSS — you cannot import styles from './Button.module.scss' and expect styles.button to be a string.

The solution — BEM-style global class names

Rename .module.scss.scss and change every CSS class to use a proper naming convention (BEM or component-prefixed), then reference them as plain strings in JSX:

// AFTER — esbuild + plain SCSS
// No import of styles object!

const classes = cx('uikit-button', className, {
  'uikit-button__outline': outline,
  'uikit-button__text': text,
  'uikit-button__link': !!link,
  'uikit-button-success': color === Colors.SUCCESS,
  'uikit-button-danger': color === Colors.DANGER,
  'uikit-button-dark': color === Colors.DARK,
})
// Button.scss (was Button.module.scss)
.uikit-button {
  // ...
  &-success { background: $success-color; }
  &-danger  { background: $danger-color; }
  &__outline { background: transparent; }
}


Scale of the change

In this project, we renamed/refactored dozens of .module.scss files across the entire app/javascript/ui-kit/ directory:

Button.module.scss      → Button.scss
Tabs.module.scss        → Tabs.scss
Tooltip.module.scss     → Tooltip.scss
TimeLine.module.scss    → TimeLine.scss
Heading.module.scss     → Heading.scss
Text.module.scss        → Text.scss
Table.module.scss       → Table.scss
atomic.module.scss      → atomic.scss
...

Key rule: Every class that was styles.someClass must become a unique, predictable string. Use component-based prefixes to avoid collisions.

Stage 6: Storybook Migration (bonus)

As a bonus, we also moved Storybook away from Webpack.

Before:

@storybook/builder-webpack4

After:

framework: '@storybook/react-vite'

This also made Storybook much faster to start.

The esbuild Config: Line by Line

the core of our esbuild setup — a single configuration file that replaces multiple Webpack configs and controls how every asset in the project is built, resolved, and optimized.

Below is a line-by-line breakdown of how it works and why each part exists.

import * as esbuild from 'esbuild'
import { exec } from 'child_process'
import { glob } from 'glob'
import { aliasPath } from 'esbuild-plugin-alias-path'
import { sassPlugin } from 'esbuild-sass-plugin'
import { copy } from 'esbuild-plugin-copy'
import path from 'path'
import dotenv from 'dotenv'

Standard imports. glob is used to scan the filesystem for alias generation. dotenv loads .env variables.

dotenv.config()
const __dirname = path.resolve()
const isProduction = process.env.NODE_ENV === 'production'

Load .env, define the working directory (ESM modules don't have __dirname), and detect environment.

Custom Plugin — Font Resolution


const fontsOnResolvePlugin = {
  name: 'fonts resolve',
  setup(build) {
    build.onResolve({ filter: /^\\\\.\\\\.\\\\/webfonts|fonts\\\\// }, (args) => {
      return { path: args.path, external: true }
    })
  },
}

When SCSS files (e.g. from Font Awesome) try to import font files like ../webfonts/fa-solid-900.woff2, Esbuild would try to resolve and copy them. We mark them as external — meaning: don't process them, leave the url() reference as-is. The fonts are separately copied by the copy plugin.

Alias Generators

These three functions dynamically build import alias maps by scanning the filesystem:

const generateComponentsAliases = () => {
  const paths = glob.sync('app/javascript/components/**/*.jsx')
  let aliasPaths = {}
  paths.forEach((fullPath) => {
    const key = fullPath.replace('app/javascript/', '').replace('.jsx', '')
    aliasPaths[key] = path.resolve(__dirname, fullPath)
  })
  return aliasPaths
}

Why: The project uses short imports like import Button from 'components/button/Button'. Without aliases, esbuild can't resolve these. Webpack had resolve.alias in its config. Here, we generate aliases programmatically by scanning all .jsx files under components/.

const generateIndexAliases = () => { /* scans **/index.js */ }
const generateCommonConstantsAliases = () => { /* scans common/constants/**/*.js */ }

The same pattern allows import { MY_CONST } from 'common/constants/myFile'.

Image Aliases


const generateImagesAliases = (folder) => {
  // scans .jpg, .png, .ico, .svg under app/assets/images
  // creates aliases like: 'images/logo.png' → '/abs/path/app/assets/images/logo.png'
}

Allows JS code to import images by short path.

Asset Copying


const copyImages = () => {
  const imagesPaths = [
    './app/javascript/ui-kit/assets/images/**/*',
    './app/javascript/ui-kit/components/avatar/icons/**/*',
    './app/assets/images/encoded/**/*',
    './app/assets/images/icons/**/*',
    './app/assets/images/jquery-ui/**/*',
    './app/assets/images/**/*',
  ]
  return imagesPaths.map(imagesPath => copy({
    resolveFrom: 'cwd',
    assets: { from: imagesPath, to: ['./public/media/', './tmp/tmp-assets'] },
    watch: true,
  }))
}

Esbuild doesn't copy static assets automatically. The esbuild-plugin-copy plugin handles this. Images land in public/media/ (served by Rails) and also in tmp/tmp-assets for manifest generation.

watch: true means the copy also runs in watch mode (development), so adding a new image doesn't require restarting the build.

The same pattern applies to workers and fonts:

const copyWorkers = () => { /* copies ./app/javascript/workers/**/* → public/workers/ */ }

copy({ assets: { from: './app/assets/fonts/**/*', to: './public/fonts/' } })
copy({ assets: { from: './node_modules/font-awesome/fonts/*', to: './public/fonts/' } })


Entry Points


entryPoints: [
  'app/assets/javascripts/application.js',   // legacy jQuery/admin JS
  'app/javascript/pages/index.js',           // React app entry
  'app/assets/stylesheets/vendor.scss',      // third-party CSS
  'app/assets/stylesheets/admin.scss',       // admin panel styles
  'app/assets/stylesheets/application.scss', // main app styles
  'app/assets/stylesheets/mailer.scss',      // email styles
],

Esbuild supports multiple entry points natively. Each produces its own output file. Webpack required separate entries or MiniCssExtractPlugin for CSS, here it's built-in.

Core Build Options

entryNames: '[name]--[hash]',  // output: application--ABC123.js (cache busting)
bundle: true,                   // follow and bundle all imports
platform: 'browser',           // target environment (not Node)
sourcemap: true,                // generate .map files for debugging
outdir: 'public/assets',       // output directory
jsx: 'automatic',              // use React 17+ new JSX transform (no need to import React)
logLevel: 'info',

Environment Variables


define: {
  'process.env.PROTOCOL': JSON.stringify(process.env.PROTOCOL),
  'process.env.API_DOMAIN_NAME': JSON.stringify(process.env.API_DOMAIN_NAME),
  'process.env.AWS_CLOUDFRONT_DOMAIN': JSON.stringify(process.env.AWS_CLOUDFRONT_DOMAIN),
},

esbuild's define does compile-time text substitution. Each occurrence of process.env.API_DOMAIN_NAME in source code gets replaced with the actual string value at build time.

SCSS Plugins


sassPlugin({
  filter: /\\\\.module\\\\.scss$/,   // only *.module.scss files
  type: 'local-css',           // scoped CSS (CSS Modules mode)
  cssImports: true,            // allow @import in SCSS
  quietDeps: true,
  silenceDeprecations: ['import'],
}),
sassPlugin({
  // all other .scss files
  type: 'css',                 // global CSS
  cssImports: true,
  quietDeps: true,
  silenceDeprecations: ['import'],
}),

Two SCSS plugin instances with different filter rules — one for CSS Modules (.module.scss), one for global styles (.scss). Order matters: the more specific filter must come first.

silenceDeprecations: ['import'] — suppresses Sass warnings about @import being deprecated in favor of @use (a large-scale SCSS refactor can come later).

File Loaders


loader: {
  '.js': 'jsx',         // treat all .js files as JSX (safe for React codebases)
  '.jsx': 'jsx',
  '.css': 'css',
  '.scss': 'local-css',
  '.module.scss': 'local-css',
  '.ttf': 'file',       // fonts → emit as files, return URL
  '.otf': 'file',
  '.svg': 'file',
  '.eot': 'file',
  '.woff': 'file',
  '.woff2': 'file',
  '.jpg': 'file',
  '.png': 'file',
},

'file' loader emits the asset to the output directory and returns its URL as a string — similar to file-loader in webpack.

Asset Path Override


assetNames: '../../../../media/[name]',

esbuild by default places assets relative to outdir. This relative path redirects font/image files processed by file loader to public/media/ (navigating up from public/assets/). A bit hacky, but it works reliably.

Production vs Development Mode

let builds = {
  production: { ...buildOptions, minify: true },
  development: { ...buildOptions },
}

if (isProduction) {
  await esbuild.build(builds['production'])
} else {
  await executeCommand(`rm -rf public/assets`)   // clear stale output
  const ctx = await esbuild.context(builds['development'])

  if (process.argv.includes('--watch'))   await ctx.watch()
  if (process.argv.includes('--serve'))   await ctx.serve()
  if (process.argv.includes('--dispose')) await ctx.dispose()
}
  • Production: single esbuild.build() call, minification enabled
  • Development: esbuild.context() creates a long-running context that supports watch (rebuild on file change) and serve (built-in dev server)
  • -dispose is used in CI-like scenarios: build once, then exit cleanly

Performance Metrics

The difference in performance between Webpack and esbuild is not just noticeable — it completely changed our CI experience.

CI Build Time (Before vs After)

  • Webpack (rails webpacker:compile) → ~8–9 minutes
  • esbuild (yarn build) → ~8 seconds

Overall CI pipeline:

  • Before → ~22–25 minutes
  • After → ~9–10 minutes

GitHub Actions screenshots confirm these timings per step.

👉 This is roughly 60× faster asset compilation and about 2× faster total CI pipeline.

Why is esbuild so fast

There are a few core reasons behind this performance improvement:

  1. Written in Go Compiled binary, no Node.js overhead or V8 runtime bottlenecks.
  2. Parallel by default It fully utilizes all CPU cores without extra configuration.
  3. No Babel layer esbuild has its own fast parser for JS, JSX, and TypeScript.
  4. Minimal plugin overhead Unlike Webpack, it avoids long plugin chains like: babel-loader → css-loader → style-loader → MiniCssExtractPlugin

Bundle Size

Esbuild uses tree shaking by default, so unused code is automatically removed.

With minify: true in production:

  • output is fully minified
  • whitespace and dead code are stripped
  • final bundle size is comparable to optimized Webpack builds

Comparison with Alternatives

To make the decision more grounded, we compared esbuild with other popular bundlers. Each tool solves a slightly different problem, so the choice depends heavily on project context.

Tool Overview

Tool Core language First release Primary use case
Webpack JavaScript/Node 2012 Maximum flexibility, complex bundling setups
esbuild Go 2020 Extreme build speed, lightweight bundling
Vite JavaScript (uses esbuild + Rollup) 2020 Modern dev experience with HMR
Parcel JavaScript/Rust (SWC) 2017 Zero-config bundler
Rollup JavaScript 2015 Library bundling, tree-shaking focused

Speed Comparison

In practice, esbuild became the benchmark for build performance.

Tool Relative build time
esbuild 1× (baseline)
Vite (production build, Rollup) ~10–20× slower
Parcel 2 ~5–15× slower
Webpack 5 ~30–80× slower
Webpack 4 + Babel ~50–100× slower

Source: esbuild benchmarks (evanw.github.io/esbuild), Vite docs, community benchmarks

One important detail: Vite uses esbuild only in development for fast transforms and dependency pre-bundling. In production, it switches to Rollup, which is significantly slower. This trade-off is part of Vite’s architecture.

Feature Comparison

Feature Webpack 5 esbuild Vite Parcel 2
JS/JSX bundling
TypeScript ✅ (via loader) ✅ (native) ✅ (native)
SCSS/Sass ✅ (via plugin)
CSS Modules ⚠️ partial
Tree shaking
Code splitting
HMR ❌ (watch/serve only) ✅ (excellent)
GraphQL imports ✅ (via loader) ✅ (native) ⚠️ plugin required ⚠️ plugin required
Zero config
Rails integration ✅ (webpacker) ✅ (manual) ⚠️ via vite-ruby
Plugin ecosystem ⭐⭐⭐⭐⭐ ⭐⭐⭐ ⭐⭐⭐⭐ ⭐⭐⭐

GraphQL Support (important for our decision)

One of the key reasons we chose esbuild was GraphQL support.

At the time of migration (~2024), our project relied heavily on importing .graphql files directly into frontend code.

  • esbuild → supports GraphQL via native loader
  • Vite → required third-party plugin with limited stability at that time

This made esbuild a more predictable and reliable choice for our setup.

Community & Popularity (2024–2025)

Vite is currently the most popular choice for new frontend projects (React, Vue, Svelte).

Tool npm weekly downloads GitHub stars Trend
Webpack ~25M ~65K Stable, slowly declining
Vite ~18M ~70K Fast-growing
esbuild ~28M (indirect usage included) ~38K Stable, widely used internally
Parcel ~1M ~43K Stable, niche usage

However, esbuild is often used indirectly because many tools (including Vite itself) depend on it internally.

When to Choose What

Choose esbuild if:

  • You need raw build speed above all else
  • You have a non-standard or legacy setup (like Rails + Sprockets)
  • You need GraphQL imports or have complex custom asset pipelines
  • You're comfortable writing your own config

Choose Vite if:

  • You're starting a new React/Vue/Svelte SPA
  • You want excellent HMR and dev experience out of the box
  • You can afford slightly slower production builds in exchange for simpler config
  • Your team is familiar with modern frontend tooling

Stay on Webpack if:

  • You have a massive existing config with many custom loaders/plugins
  • You need features like Module Federation (micro-frontends)
  • You can't afford a migration effort and build times are acceptable

Known Drawbacks

Esbuild is extremely fast, but it’s not a silver bullet. During and after the migration, we ran into a few real limitations that are worth understanding before adopting it in a production Rails setup.

No built-in manifest custom Rails helper required

Webpack and Sprockets both generate a manifest.json that maps logical asset names to their hashed filenames. esbuild does not. Without a manifest, Rails has no way to know that vendor.css is now vendor--JMBCZRRQ.css.

You have to write (and maintain) a custom hashed_asset_path helper that scans the filesystem at runtime. It works, but it's boilerplate that wouldn't exist with Webpack or Vite's Rails integration (vite-ruby).

SCSS modules require a full manual rewrite

CSS Modules (import styles from './Component.module.scss') are a zero-effort feature in Webpack. In esbuild, you can't import a stylesheet as a JS object and use styles.myClass as a value in JSX.

Every component that used CSS Modules had to be refactored:

  • Rename .module.scss.scss
  • Rename all CSS classes to globally unique, BEM-style names
  • Update every JSX file to use string class names instead of styles.*

In a large codebase, this is significant manual effort, and it's easy to introduce visual regressions if classes aren't renamed consistently.

Some third-party libraries have incompatible stylesheets

Occasionally, a library ships CSS that uses old import syntax or non-standard constructs that esbuild's SCSS pipeline can't handle. In our case, Select2 had legacy styles that refused to import cleanly.

The workaround: copy the entire vendor CSS into a local file in the project and import that instead. It works, but it means you're now maintaining a snapshot of third-party style updates to the library that won't automatically reflect.

This is rare, but worth knowing before you start the migration.

No HMR in development

Esbuild has a --serve mode and a --watch mode, but no Hot Module Replacement. On file change, the bundle rebuilds (~milliseconds), but the browser does a full page reload. HMR is explicitly out of scope for the project — there's a long-standing GitHub issue #464 with no plans to implement it.

For a Rails app, this is usually fine; you're not building a pure SPA where HMR saves seconds on every edit. But if your team is used to React Fast Refresh, the DX step-down is noticeable.

No TypeScript type checking

Esbuild transpiles TypeScript by stripping types, and moving on it does zero type checking. This is intentional and is why it's so fast.

In practice, you need to run tsc --noEmit as a separate step in CI. If someone skips or misconfigures that step, type errors will silently pass through to production.

Single maintainer

Esbuild is essentially a one-person project (Evan Wallace, ex-Figma). It has been actively maintained since 2020, but there's no foundation, no corporate sponsor, and no formal contribution committee. For some teams, this is a governance risk for a core build dependency.

Intentionally limited plugin API

The plugin API does not expose the AST, so plugins can only hook into load/resolve steps — arbitrary code transforms are not possible. Many webpack use cases (custom Babel macros, codemods-as-plugins) simply cannot be replicated. And JS-written plugins run outside esbuild's Go core, which can partially negate the speed advantage if plugins do heavy work.

Summary

The migration from Webpack to esbuild in this Rails + React project took around 2 weeks of focused work and resulted in significant improvements across both performance and maintainability.

We achieved:

  • ~60× faster asset build step (8 min → 8 sec)
  • ~2× faster total CI pipeline
  • Significant reduction in configuration complexity (deleted ~500 lines of config)
  • Removed react_on_rails gem dependency
  • Cleaned up legacy Babel config and Procfiles
  • The main cost: manual SCSS module migration every .module.scss required renaming CSS classes to global BEM-style strings

For a production Rails application with legacy code, esbuild proved to be the right tool: predictable, fast, and flexible enough to handle custom asset pipelines without framework opinions.