Vite integration for Rails, inspired by Laravel's Vite plugin. No proxy, no config duplication, no magic.
- How It Works
- Quick Start
- Usage
- Vite Config
- Adding Frameworks
- SSR
- Auto Build
- Testing the Build
- Custom Paths
- Rake Tasks
- jsbundling Mode
- Migrating from vite_rails
- Contributing
- License
Development: The Vite plugin writes tmp/rails-vite.json with the dev server URL. The Rails helper reads it and emits <script> tags pointing directly at Vite. The browser talks to Vite — Puma never touches your assets.
Production: vite build outputs fingerprinted assets to public/vite/ with a standard Vite manifest. The Rails helper reads the manifest and emits the correct tags.
No Rack proxy. No config/vite.json. No extra binstubs.
Add to your Gemfile:
gem "rails_vite"Run the install generator:
bundle install
bin/rails generate rails_vite:installThis creates vite.config.ts, installs dependencies, and updates your layout.
Start development:
bin/devIn your layout:
<%= vite_tags "application.js" %>Short names are automatically prefixed with sourceDir (default: app/javascript). Paths containing / are used as-is.
Development output (when tmp/rails-vite.json exists):
<script src="http://localhost:5173/@vite/client" type="module"></script>
<script src="http://localhost:5173/app/javascript/application.js" type="module"></script>Production output (reads 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" />| Helper | Purpose |
|---|---|
vite_tags(*entries, **options) |
Emits script, stylesheet, and modulepreload tags |
vite_javascript_tag(*entries, **options) |
Same as vite_tags, appends .js to extensionless names |
vite_stylesheet_tag(*entries, **options) |
Same as vite_tags, appends .css to extensionless names |
vite_typescript_tag(*entries, **options) |
Same as vite_tags, appends .ts to extensionless names |
vite_asset_path(name) |
Returns the fingerprinted path from the manifest |
vite_image_tag(name, **options) |
Image tag with manifest-resolved src |
All tag helpers accept arbitrary HTML attributes:
<%= vite_tags "application.js", "application.css",
"data-turbo-track": "reload", nonce: content_security_policy_nonce %>CSS files are detected by extension and emit <link rel="stylesheet">:
<%= vite_tags "application.css" %>Pass a nonce to any tag helper; it's applied to every tag it emits:
<%= vite_tags "application.js", nonce: content_security_policy_nonce %>The dev server's @vite/client and React Fast Refresh tags pick up the request nonce automatically, so they work under strict-dynamic even when your first vite_* call is a nonce-less stylesheet.
Running a CSP in development? Allow the dev server's origin with RailsVite.dev_server_csp_source — it resolves per request, so it tracks Vite's actual port and adds nothing when the server is down:
# config/initializers/content_security_policy.rb — inside your `policy` block
if Rails.env.development?
policy.script_src(*policy.script_src, RailsVite.dev_server_csp_source)
policy.style_src(*policy.style_src, RailsVite.dev_server_csp_source)
policy.connect_src(*policy.connect_src, RailsVite.dev_server_csp_source(websocket: true)) # HMR
endSRI lets browsers verify that fetched assets haven't been tampered with by checking cryptographic hashes. Install the vite-plugin-manifest-sri plugin:
npm install -D vite-plugin-manifest-sriimport { defineConfig } from 'vite';
import rails from 'rails-vite-plugin';
import manifestSRI from 'vite-plugin-manifest-sri';
export default defineConfig({
plugins: [
rails(),
manifestSRI(),
],
});That's it — integrity and crossorigin="anonymous" attributes are automatically added to all script, stylesheet, and modulepreload tags when the manifest includes integrity hashes.
Use import.meta.glob in your entry point to include assets in the Vite manifest:
// app/javascript/application.js
import.meta.glob(['../assets/images/**'], { eager: true });Then reference them in views:
<%= vite_image_tag "app/assets/images/logo.png", alt: "Logo" %>Migrating from Propshaft? Vite resolves asset references itself, so you don't need Propshaft's RAILS_ASSET_URL(...) — swap the logical path for a path relative to the referencing file, and Vite fingerprints it:
/* Propshaft */
background: url(RAILS_ASSET_URL("logo.svg"));
/* Vite */
background: url(../assets/images/logo.svg);- In CSS: works for any stylesheet Vite processes — an entry point, or imported from one.
- In JS:
import logoUrl from '../assets/images/logo.svg'(or pull in a whole folder withimport.meta.glob). - In views:
vite_asset_path/vite_image_tag, which read the Vite manifest.
The install generator creates a minimal vite.config.ts:
import { defineConfig } from 'vite';
import rails from 'rails-vite-plugin';
export default defineConfig({
plugins: [
rails(),
],
});| Option | Default | Description |
|---|---|---|
input |
auto-detected | Entry point(s). If sourceDir/entrypoints/ exists, all files in it are used. Otherwise, detects application.{js,ts,jsx,tsx} in sourceDir |
sourceDir |
'app/javascript' |
Source directory. Short names are prefixed with this. Also sets the @ import alias |
ssr |
— | SSR entry point |
ssrOutDir |
'ssr' |
SSR output directory |
devMetaFile |
'tmp/rails-vite.json' |
Dev metadata file path |
buildDir |
'vite' |
Build output subdirectory inside public/ |
publicDir |
'public' |
Public directory |
refresh |
true |
Paths to watch for full-page reload. true watches app/views/** and app/helpers/** |
prependSourceDirToEntries |
true |
When false, entries are resolved without the sourceDir prefix. Set this when Vite's root is your sourceDir (see below) |
rails({
input: ['application.js', 'admin.js'],
})<!-- In application layout -->
<%= vite_tags "application.js" %>
<!-- In admin layout -->
<%= vite_tags "admin.js" %>rails({
input: ['entrypoints/application.ts', 'entrypoints/admin.ts'],
sourceDir: 'app/frontend',
})<%= vite_tags "entrypoints/application.ts" %>By default, root is the Rails project root, so import.meta.glob keys and manifest entries are prefixed with sourceDir (e.g. app/frontend/components/Foo.jsx). If you prefer vite_ruby-style root-relative names (components/Foo.jsx), set Vite's root to your source directory and tell the plugin not to prepend sourceDir:
import { fileURLToPath } from 'node:url'
export default defineConfig({
root: fileURLToPath(new URL('./app/frontend', import.meta.url)),
plugins: [
rails({ sourceDir: 'app/frontend', prependSourceDirToEntries: false }),
],
build: {
outDir: fileURLToPath(new URL('./public/vite', import.meta.url)),
},
})With root set to the source directory, Vite emits bare manifest keys, and prependSourceDirToEntries: false makes the Rails helpers look them up by those bare names. Set both together. (Don't point root at a symlinked path — Vite resolves symlinks and emits keys that escape the root.)
npm install -D @vitejs/plugin-reactimport { defineConfig } from 'vite';
import rails from 'rails-vite-plugin';
import react from '@vitejs/plugin-react';
export default defineConfig({
plugins: [
react(),
rails(),
],
});The React Refresh preamble is injected automatically when @vitejs/plugin-react is detected — no manual setup needed.
npm install -D @vitejs/plugin-vueimport { defineConfig } from 'vite';
import rails from 'rails-vite-plugin';
import vue from '@vitejs/plugin-vue';
export default defineConfig({
plugins: [
vue(),
rails(),
],
});Set ssr to the entry point used for server-side rendering. When you run npx vite build --ssr, the plugin uses this as the input and outputs to the ssrOutDir (default: ssr/).
rails({
ssr: 'ssr.tsx',
})Build and run:
npx vite build && npx vite build --ssr
node ssr/ssr.jsWhen the Vite dev server is not running, rails_vite automatically rebuilds assets on the first request if sources have changed. This is useful for system tests and quick checks without running bin/dev.
Freshness is determined by comparing your source files' timestamps against the build manifest's timestamp. Since the manifest lives on disk, unchanged assets are not rebuilt across process restarts — for example, on repeated local system-test runs.
Auto builds run quietly (vite build --logLevel warn), so they don't clutter your test output; warnings and errors are still shown. Run rake vite:build directly for the full build log.
Disable it:
# config/initializers/rails_vite.rb
Rails.application.config.rails_vite.auto_build = falseBy default, auto build is enabled in development and test (Rails.env.local?).
Note: for parallel test runners, disable auto build and use rake vite:build before the suite instead.
To verify your production build works in development:
rake vite:build # build assets
bin/rails s # start Rails without Vite dev serverWithout the Vite dev server running (no tmp/rails-vite.json), Rails serves built assets from public/vite/. To switch back to dev mode, start Vite again — the dev metadata takes priority.
Clean up built assets with rake vite:clobber.
If you override build.outDir in vite.config.ts, tell the gem where to find things:
# config/initializers/rails_vite.rb
Rails.application.config.rails_vite.manifest_path = Rails.root.join("public/custom/manifest.json")
Rails.application.config.rails_vite.asset_prefix = "/custom"Defaults match the plugin defaults — no config needed if you follow conventions.
| Task | Description |
|---|---|
rake vite:build |
Build assets for production |
rake vite:install |
Install JavaScript dependencies |
rake vite:clobber |
Remove public/vite/ |
vite:build hooks into assets:precompile and test:prepare automatically. Skip with SKIP_VITE_BUILD=1.
If you're using jsbundling-rails with Propshaft and want Vite as your bundler, you don't need the rails_vite gem — just the npm package:
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(),
],
});How it works: In production, Vite builds to public/assets/ and copies entry files to app/assets/builds/ so Propshaft can serve them via javascript_include_tag and stylesheet_link_tag. In development, the plugin writes stub files to app/assets/builds/ that redirect the browser to Vite's dev server for HMR.
| Option | Default | Description |
|---|---|---|
input |
auto-detected | Entry point(s). If sourceDir/entrypoints/ exists, all files in it are used. Otherwise, detects application.{js,ts,jsx,tsx} in sourceDir |
sourceDir |
'app/javascript' |
Source directory. Short names are prefixed with this. Also sets the @ import alias |
assetPipelineDir |
'app/assets/builds' |
Directory where Propshaft/Sprockets picks up entry files |
outputDir |
'public/assets' |
Public directory for the full Vite build output |
ssr |
— | SSR entry point. String or { entry, outDir } |
refresh |
— | Paths to watch for full-page reload. true watches app/views/** and app/helpers/** |
devMetaFile |
'tmp/rails-vite.json' |
Dev metadata file path. Set to false to disable |
In your Procfile.dev, replace the esbuild command:
web: bin/rails server -p 3000
-js: yarn build --watch
+vite: npx vite
CSS and JS entries in app/javascript/entrypoints/ are auto-discovered. Both javascript_include_tag and stylesheet_link_tag work unchanged — Propshaft resolves them from app/assets/builds/ as before.
React and Vue work the same as in the standard plugin — add the framework plugin before jsbundling():
import { defineConfig } from 'vite';
import jsbundling from 'rails-vite-plugin/jsbundling';
import react from '@vitejs/plugin-react';
export default defineConfig({
plugins: [
react(),
jsbundling(),
],
});To switch from jsbundling mode to the full rails_vite gem:
- Add
gem "rails_vite"to your Gemfile andbundle install - Change the import in
vite.config.tsfromrails-vite-plugin/jsbundlingtorails-vite-plugin - Replace
javascript_include_tag/stylesheet_link_tagwithvite_tagsin your layouts - Remove
jsbundling-railsfrom your Gemfile
In development, jsbundling mode writes tmp/rails-vite.json — the same file the rails_vite gem reads. You can add the gem and verify vite_tags works in dev before deploying.
# Gemfile
- gem "vite_rails"
+ gem "rails_vite"// package.json — replace vite-plugin-ruby with rails-vite-plugin
- "vite-plugin-ruby": "^5.1.1"
+ "rails-vite-plugin": "^0.2.0"import { defineConfig } from 'vite';
import rails from 'rails-vite-plugin';
export default defineConfig({
plugins: [
rails({
sourceDir: 'app/frontend',
}),
],
});If you have an entrypoints/ directory inside sourceDir, all files in it are auto-discovered — no need to list them. Otherwise, set input explicitly.
config/vite.json— settings now live invite.config.tsbin/vite— no longer needed,Procfile.devrunsnpx vitedirectly
Remove vite_client_tag and vite_react_refresh_tag — both are automatic now.
The vite_javascript_tag, vite_stylesheet_tag, and vite_typescript_tag helpers work as drop-in replacements:
Bug reports and pull requests are welcome on GitHub at https://github.com/skryukov/rails_vite.
The gem is available as open source under the terms of the MIT License.