# Now you see it: Vite on Rails without the proxy

> Introducing rails_vite—a new Vite integration for Rails that works with Propshaft, not against it. Drop it into an existing jsbundling app for instant CSS HMR, or use the full gem for manifest-based asset resolution.

- Date: 2026-04-14T00:00:00.000Z
- Authors: Svyatoslav Kryukov, Travis Turner
- Categories: Rails, DX, Open Source
- URL: https://evilmartians.com/chronicles/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`](https://github.com/skryukov/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`](https://github.com/ElMassimo/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](https://vite.dev/guide/build#chunking-strategy) 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](https://vite.dev/blog/announcing-vite8).

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`](https://vite-ruby.netlify.app) (Vite Ruby) here at Evil Martians, we even [wrote a whole post about it](/chronicles/vite-lizing-rails-get-live-reload-and-hot-replacement-with-vite-ruby), 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.

Then, while working on [Inertia Rails](https://inertia-rails.dev) 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:

```javascript
// 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";
```

The URLs in the stub aren't hardcoded. The plugin writes placeholder stubs at boot, then rewrites them with the real dev server address once Vite is listening. If port 5173 is busy, Vite picks another one, and the stubs follow. No config to update.

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.

```bash
npm install -D rails-vite-plugin vite
```

```typescript
// vite.config.ts
import { defineConfig } from "vite";
import jsbundling from "rails-vite-plugin/jsbundling";

export default defineConfig({
  plugins: [
    jsbundling(),
  ],
});
```

```diff
  web: bin/rails server -p 3000
- js: yarn build --watch
+ vite: npx vite
```

By default, the plugin picks up `application.js` from `app/javascript/`. esbuild globs `app/javascript/*.*` instead, so if you have multiple entry points, pass `input: "*.js"` to `jsbundling()` to match that behavior. You can also create an `app/javascript/entrypoints/` directory and the plugin auto-discovers everything in it.

And 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:

```typescript
// 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`](https://github.com/ElMassimo/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:

```json
"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`](https://github.com/yaroslav/bundlebun) can bundle Bun into your app without a system install. Check the [vanishing-importmap](https://github.com/skryukov/vanishing-importmap) demo for a complete importmaps → Vite walkthrough. Starting fresh with Inertia? The beta version of the new [generator](https://github.com/inertia-rails/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:

```bash
bundle add rails_vite
bin/rails generate rails_vite:install
```

And in your layout:

```erb
<%= 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:

```html
<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:

```html
<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](https://vite-ruby.netlify.app/config/#skip-proxy) first.

Vladimir's 2022 post on Vite Ruby, the predecessor that brought Vite to the Rails ecosystem.

## 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](https://github.com/skryukov/rails_vite/issues).

Want to see it before committing? We migrated the [AnyCable Rails Demo](https://github.com/anycable/anycable_rails_demo/pull/41) (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.
