RailsVite
Vite integration for Rails, inspired by Laravel's Vite plugin. No proxy, no config duplication, no magic.
Table of Contents
- 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
How It Works
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.
Quick Start
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 replaces javascript_include_tag "application" and stylesheet_link_tag "application" in your layout with vite_tags. Other layouts, such as the Rails 8 default, need it by hand: replace javascript_importmap_tags with <%= vite_tags "application" %>.
Start development:
bin/devUsage
In 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" />Helpers
| 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, with asset_host applied like Rails' asset_path
|
vite_asset_url(name) |
Same as vite_asset_path, as a full URL like Rails' asset_url. In mailers, set asset_host to get a full URL |
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 Entry Points
CSS files are detected by extension and emit <link rel="stylesheet">:
<%= vite_tags "application.css" %>Content Security Policy
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
endSubresource Integrity (SRI)
SRI 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.
Asset Discovery (Images, Fonts)
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.
Vite Config
The install generator creates a minimal vite.config.ts:
import { defineConfig } from 'vite';
import rails from 'rails-vite-plugin';
export default defineConfig({
plugins: [
rails(),
],
});Custom Vite Executable
The gem runs Vite through the detected package manager (npx vite, yarn vite, pnpm vite, bun run vite or aube exec vite). To use a Vite-compatible executable with a different name, set vite_executable:
# config/initializers/rails_vite.rb
Rails.application.config.rails_vite.vite_executable = "vp"rake vite:build, assets:precompile, test builds and auto builds then run vp build (for example, npx vp build). A package.json build script runs as written, so use vp build in it too. In your Procfile.dev, run the dev server with vp dev:
js: npx vp dev
Vite+ installs vp, not vite, so set vite_executable = "vp". Without it, the package manager may still find a plain Vite (installed by another package, or downloaded by npx) and build without Vite+. Package managers may also warn that the plugin's vite peer dependency is not met, because Vite+ replaces vite with its own package and version.
Plugin Options
| 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 |
$RAILS_VITE_BUILD_DIR, else 'vite-test' in test mode, else 'vite'
|
Build output subdirectory inside public/. The gem sets RAILS_VITE_BUILD_DIR from config.rails_vite.build_dir when it runs a build |
publicDir |
'public' |
Public directory |
refresh |
true |
Paths to watch for full-page reload. true watches app/views/** and app/helpers/**
|
refreshDelay |
0 |
Milliseconds to wait after the last refresh change before the full-page reload. Changes within the delay send one reload. Set it (e.g. 300) when Rails sees template changes late, as with config.file_watcher = ActiveSupport::EventedFileUpdateChecker, so the reload does not get the old HTML |
prependSourceDirToEntries |
true |
When false, entries are resolved without the sourceDir prefix. Set this when Vite's root is your sourceDir (see below) |
Multiple Entry Points
rails({
input: ['application.js', 'admin.js'],
})<!-- In application layout -->
<%= vite_tags "application.js" %>
<!-- In admin layout -->
<%= vite_tags "admin.js" %>Custom Source Directory
rails({
input: ['entrypoints/application.ts', 'entrypoints/admin.ts'],
sourceDir: 'app/frontend',
})<%= vite_tags "entrypoints/application.ts" %>Setting Vite's root to your source directory
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.)
Adding Frameworks
React
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.
Vue
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(),
],
});SSR
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.jsTo build the SSR bundle during assets:precompile, define a build script — vite:build runs it when present:
"scripts": {
"build": "vite build && vite build --ssr"
}Auto Build
When 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.
Besides sourceDir, auto build checks these files at the app root by default: vite.config.*, postcss.config.*, tailwind.config.*, tsconfig*.json, package.json, and the package manager lockfile. Use auto_build_paths to add other build inputs. Each entry is a path or glob relative to Rails.root. Directories are checked recursively, and paths that do not exist are ignored. For example, Tailwind generates CSS from class names in your views, so add the directories it scans:
# config/initializers/rails_vite.rb
Rails.application.config.rails_vite.auto_build_paths += %w[
app/views
app/components
app/helpers
app/assets/stylesheets
]Assign the option (=) instead of appending to replace the defaults.
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.
CDN
Tags follow config.action_controller.asset_host. rake vite:build and auto builds also pass a string asset_host to the plugin in RAILS_VITE_ASSET_HOST, and the plugin prepends it to Vite's base, so chunks Vite loads at runtime (dynamic import() and their preloads) come from the same host. With a proc or %d asset_host, set RAILS_VITE_ASSET_HOST in the build's environment yourself (.env files are not read for it).
The host is fixed at build time, so build with the same asset_host you deploy with. jsbundling mode doesn't use this.
Build Mode
rake vite:build and auto builds pass --mode test to vite build in the test environment, and no --mode elsewhere. This sets import.meta.env.MODE and chooses which .env.[mode] files Vite loads. If MODE === 'test' means "running under Vitest" in your app, a Rails system-test bundle then includes Vitest-only code. Choose another mode, or no mode:
# config/initializers/rails_vite.rb
if Rails.env.test?
Rails.application.config.rails_vite.build_mode = nil # no --mode, so Vite uses "production"
# or
Rails.application.config.rails_vite.build_mode = "e2e" # --mode e2e
endThe build mode does not change the build directory: the test environment still builds to public/vite-test/. The gem passes its build_dir to the plugin in the RAILS_VITE_BUILD_DIR environment variable, so the gem and the plugin always use the same directory. If you run vite build yourself, set RAILS_VITE_BUILD_DIR or the plugin's buildDir option to match.
Upgrade rails-vite-plugin together with the gem: older plugin versions ignore RAILS_VITE_BUILD_DIR and build every mode other than test into public/vite/.
With build_mode set, vite:build runs vite build --mode directly instead of your package.json build script, because --mode would only reach the last command of a compound script. Build other bundles, such as SSR, in a separate step.
Testing the Build
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, or its Vite process is gone), 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.
Custom Paths
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.
Rake Tasks
| 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.
vite:build prefers your package.json build script when one exists (like jsbundling-rails), falling back to a bare vite build. Test builds, and builds with build_mode set, skip the script and run vite build directly.
Package Manager
The rake tasks, auto build and the install generator run your JS package manager, picked by lockfile in this order: aube-lock.yaml, bun.lockb or bun.lock, yarn.lock, pnpm-lock.yaml, package-lock.json. Without a lockfile, the first of bun, yarn, pnpm, npm and aube found on PATH is used.
aube can also use an existing pnpm, yarn, npm or bun lockfile in place, without writing aube-lock.yaml. Such a project is detected as the tool that owns the lockfile, so keep that tool installed, or run aube import to create aube-lock.yaml.
jsbundling Mode
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.
jsbundling Options
| 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/**
|
refreshDelay |
0 |
Milliseconds to wait after the last refresh change before the full-page reload. Changes within the delay send one reload. Set it (e.g. 300) when Rails sees template changes late, as with config.file_watcher = ActiveSupport::EventedFileUpdateChecker, so the reload does not get the old HTML |
devMetaFile |
'tmp/rails-vite.json' |
Dev metadata file path. Set to false to disable |
Replacing esbuild
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.
Frameworks
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(),
],
});Upgrading to rails_vite
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.
Migrating from vite_rails
1. Swap dependencies
# 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.3.0"2. Replace vite.config.ts
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.
3. Delete files
-
config/vite.json— settings now live invite.config.ts -
bin/vite— no longer needed,Procfile.devrunsnpx vitedirectly
4. Update layouts
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:
Contributing
Bug reports and pull requests are welcome on GitHub at https://github.com/skryukov/rails_vite.
License
The gem is available as open source under the terms of the MIT License.