Now you see it: Vite on Rails without the proxy

Vite is the build tool every frontend developer reaches for. But you won’t find it in rails new. Vite runs a dev server. Propshaft—Rails’ asset pipeline—expects files on disk. For years, these two have refused to share a stage. rails_vite makes this incompatibility …disappear.
Why Vite?
If your current setup works and you’re not thinking about bundlers, that’s fine. Propshaft and importmaps are a perfectly good default. But there are things a dev server gives you that file-based tooling can’t. For example:
Hot module replacement for JS. React and Vue components update without losing state. Edit a component, see the change, keep working. Stimulus controllers can hot-reload too with vite-plugin-stimulus-hmr.
Instant CSS feedback. Change a color, see it immediately. No page reload, no flash of unstyled content. Vite injects updated styles while preserving the page state—your modals stay open, your scroll position stays put.
One process instead of two (or three). If you’re on jsbundling-rails with Tailwind, you have a JS watcher and a CSS watcher in your Procfile. Vite handles both (JavaScript, CSS, PostCSS, Tailwind) in a single dev server.
Code splitting for free. Use dynamic import() and Vite splits your bundles automatically. Users download what each page needs, nothing more.
The ecosystem. First-class plugins for React, Vue, Svelte. Built-in TypeScript and JSX support. Vite 8 ships with Rolldown, a Rust-based bundler that makes production builds dramatically faster.
None of this requires abandoning Rails conventions. Ta da.
The pledge
Maybe you’re on importmaps and have never needed a bundler. Maybe you’re on jsbundling-rails with esbuild, a Procfile with two watchers, and a full page reload every time you change anything. Either way, you’ve seen Vite’s instant feedback and thought, ”I want that“.
But swapping your whole frontend pipeline again? If you’ve been around Rails long enough, that has the ick of a magician who keeps asking you to pick a card: Sprockets, pick a card, Webpacker, pick a card, jsbundling, pick a card.
We used vite_rails (Vite Ruby) here at Evil Martians, we even wrote a whole post about it, and it works great for thousands of apps.
But I kept thinking about how much of it I could remove and still have everything work. The Rack proxy, the shared config file, the extra moving parts; it worked, but it felt like more ceremony than the problem deserved.

Irina Nazarova CEO at Evil Martians
Then, while working on Inertia Rails I kept looking at how Laravel handles Vite, and their whole integration is almost embarrassingly simple: a Vite plugin writes the dev server address to a file, a server-side helper reads it, that’s it. No proxy, shared config, or binstub. I thought, why can’t it be this simple on Rails?
So, I ported it. That gave me a vite_tags helper, manifest-based production output, the browser talking to Vite directly. Less machinery than vite_ruby and cleaner architecture. But you’re still picking a different card—stepping outside javascript_include_tag, not something rails new could offer. What if this time you don’t have to?
What if Rails doesn’t need to know?
What if Propshaft keeps serving assets the way it always has? Same helpers, same deploy pipeline, and Vite quietly does its thing on the other side of the wall? You install Vite, swap your build script and your Procfile command. Everything else stays exactly where it was.
Can we actually do this? (Spoiler: there’d be no reason to write this post if we couldn’t.) But to see how, let’s take a quick look at what we’re working with.
The two sides
Propshaft doesn’t care how files get into app/assets/builds/. It just fingerprints whatever it finds and serves it. esbuild writes files there. Rollup writes files there. That’s the whole contract: put a file where Propshaft expects it, and javascript_include_tag does the rest.
Vite has two modes. In production, it hashes files and puts them in an output directory; this sounds fine, we can work with that. However, in development, there are no files. Just an HTTP server compiling modules on the fly, pushing updates over a WebSocket.
Propshaft needs a file. Vite doesn’t have one. That’s our gap. But here’s the thing: Propshaft doesn’t read the file. It just needs it to exist so it can resolve the name and serve it. The browser is the one that actually executes the content. So what if the content points somewhere else?
The stub
We plant a file in app/assets/builds/. It’s a tiny script that redirects the browser from Propshaft’s world to Vite’s:
// rails-vite dev stub – DO NOT EDIT
import "http://localhost:5173/@vite/client";
import "http://localhost:5173/app/javascript/application.css";
import "http://localhost:5173/app/javascript/application.js";Propshaft finds application.js in app/assets/builds/ and serves it. The browser loads it as a module script, follows the import statements to Vite’s dev server, and from that point on, Vite owns the experience. HMR, fast refresh, the works. Propshaft thinks it served a real asset. The browser knows better. Neither side had to compromise.
A stub file, hiding in plain sight, is the entire surface between two systems that know nothing about each other.
When Vite stops, the stubs get cleaned up. If Rails is still running, you get a MissingAssetError; a loud signal, not a silent failure.
The CSS trick
Look at the stub again. That second line (import "http://localhost:5173/app/javascript/application.css") imports a CSS file from JavaScript. When Vite receives this import, it doesn’t return raw CSS, rather, it returns a tiny JavaScript module that injects a <style> tag into the DOM. The CSS stub file on disk (application.css in app/assets/builds/) is empty. It exists so stylesheet_link_tag doesn’t raise an error. The real styles come through the JS import.
This is why CSS HMR works for free. The CSS is in Vite’s module graph via the JS import, so @vite/client swaps the <style> tag whenever you edit a stylesheet. No page reload. Change a color, see it immediately. In production, none of this applies; Vite builds real CSS files and Propshaft serves them through normal <link> tags.
If you’ve used vite_ruby, you know the layout ceremony: vite_client_tag for HMR, vite_react_refresh_tag for React, vite_javascript_tag for your code. Three helpers in the right order. The stub handles all of this: @vite/client, the React Refresh preamble, your entry points. A little “sleight of import”, and the three-helper ceremony disappears. You never touch it.
The production double
In production, Vite outputs content-hashed chunks to public/assets/. The plugin writes a double (app/assets/builds/application.js) that re-exports the hashed bundle. Propshaft sees the name it expects, the browser follows the import to the real file. Over HTTP/2, that’s one extra request on the same connection (negligible for most apps).
The double also solves a subtler problem: module identity. Propshaft fingerprints filenames for caching (application-abc123.js), but if the same module gets loaded from two different paths, the browser treats them as separate modules with separate state. The double ensures both the fingerprinted path and the original name chain to the same content-hashed chunk.
The migration
Every frontend setup is different, so let’s walk through the simplest case: migrating from esbuild to Vite. The core is three changes: an npm package, a config file, and a Procfile update.
npm install -D rails-vite-plugin vite// vite.config.ts
import { defineConfig } from "vite";
import jsbundling from "rails-vite-plugin/jsbundling";
export default defineConfig({
plugins: [
jsbundling(),
],
}); web: bin/rails server -p 3000
- js: yarn build --watch
+ vite: npx viteAnd that’s it. You continue using the Rails asset pipeline as you always were.
Using Tailwind with cssbundling-rails? Swap @tailwindcss/cli for @tailwindcss/vite, add it to your Vite config, and drop cssbundling-rails from your Gemfile:
// vite.config.ts
import { defineConfig } from "vite";
import jsbundling from "rails-vite-plugin/jsbundling";
import tailwindcss from "@tailwindcss/vite";
export default defineConfig({
plugins: [
jsbundling(),
tailwindcss(),
],
});The separate CSS watcher goes away. Thanks to Propshaft staying in charge, all we’re really doing is replacing dependencies.
A couple of things that might trip you up. Adding "type": "module" to your package.json (required for Vite 8) makes Node treat .js files as ES modules. If your postcss.config.js uses module.exports, rename it to .cjs (same for tailwind.config.js if you’re still on Tailwind v3).
And if you’re loading Stimulus controllers through esbuild’s glob imports, you’ll need to switch to Vite’s import.meta.glob with stimulus-vite-helpers, which also gets you HMR for controllers for free.
The plugin also watches your app/views/ and app/helpers/ directories by default; edit an ERB template and the browser reloads automatically. Vite serves source maps too, so your breakpoints in browser DevTools map back to your original files.
Deployment
Your deploy doesn’t change. Nothing up the sleeve. jsbundling-rails hooks yarn build into assets:precompile. Change your package.json build script:
"build": "vite build"Your CI, Docker build, Kamal/Capistrano/Heroku setup: none of it changes.
(Still on importmaps? The migration is a bigger step, you’ll need a JavaScript runtime! bundlebun can bundle Bun into your app without a system install. Check the vanishing-importmap demo for a complete importmaps → Vite walkthrough. Starting fresh with Inertia? The beta version of the new generator sets up rails_vite out of the box.)
What about the gem mode?
Right now this is street magic. Where’d Vite come from? It’s just… in your Procfile. It acts exactly like esbuild or rollup, which makes it a natural -j option for jsbundling-rails. It could be a rails new -j vite away.
But the jsbundling mode does have tradeoffs. The stub adds one extra hop in development. The production double adds one extra import. You can’t use vite_image_tag or vite_asset_path for manifest-resolved images. And CSS in development bypasses stylesheet_link_tag entirely, so CSP nonces won’t apply to injected styles.
If any of that matters, the gem mode is there to get things to “Las Vegas” level. Simply say the magic words:
bundle add rails_vite
bin/rails generate rails_vite:installAnd in your layout:
<%= vite_tags "application.js" %>In this mode, instead of stubs in app/assets/builds/, the Vite plugin writes its dev server URL, source directory, and entry points to tmp/rails-vite.json (the same idea Laravel uses). The gem reads that file and vite_tags emits script tags pointing straight at Vite:
<script src="http://localhost:5173/@vite/client" type="module"></script>
<script src="http://localhost:5173/app/javascript/application.js" type="module"></script>In production, it reads the Vite manifest:
<link rel="modulepreload" href="/vite/assets/vendor-b3c4d5e6.js" />
<script src="/vite/assets/application-a1b2c3d4.js" type="module"></script>
<link rel="stylesheet" href="/vite/assets/application-x9y8z7w6.css" />No stubs or translation layer since the browser talks to Vite directly. The gem also gives you vite_image_tag and vite_asset_path for manifest-resolved images, modulepreload hints for chunks, and CSP nonce support.
If you’re migrating from vite_ruby, the gem gives you familiar helpers (vite_tags, vite_javascript_tag, vite_image_tag) without the Rack proxy. But vite_ruby works fine for thousands of apps. If you’re happy with it, there’s no reason to switch. And if the proxy is your only friction point, check out vite_ruby’s skipProxy option first.
Now you know how it’s done
Penn & Teller have this bit where they perform a trick, then do it again in a clear box so you can see the mechanics. You see everything, yet the trick is still impressive. Hopefully this is much the same.
The jsbundling mode is a stub that redirects Propshaft’s expectations to Vite’s reality. A double in production that re-exports the real bundle. The gem mode is a Ruby helper that reads a JSON file and emits the right tags. No proxy between your browser and Vite. Just a file on disk, present or absent.
The trick was making Vite invisible to Rails. Both systems got what they wanted, and neither had to compromise.
We’ve been running rails_vite in production on client projects at Evil Martians, and it ships as the default in the Inertia Rails starter kits. If you try it on an existing app and something breaks, open an issue.
Want to see it before committing? We migrated the AnyCable Rails Demo (Stimulus, Turbo, Tailwind, AnyCable) from esbuild to Vite using the jsbundling mode. Clone it, run bin/dev, edit a Stimulus controller or change a color—and watch it update without a reload.

