Project

rails_vite

0.02
The project is in a healthy, maintained state
Simple Vite integration for Rails, inspired by Laravel. No proxy, no config duplication.
2005
2006
2007
2008
2009
2010
2011
2012
2013
2014
2015
2016
2017
2018
2019
2020
2021
2022
2023
2024
2025
2026
 Dependencies

Runtime

>= 7.0
 Project Readme

RailsVite

Gem Version

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
Built by Evil Martians

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

This 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/dev

Usage

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
end

Subresource 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-sri
import { 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 with import.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-react
import { 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-vue
import { 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.js

To 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 = false

By 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
end

The 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 server

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

  1. Add gem "rails_vite" to your Gemfile and bundle install
  2. Change the import in vite.config.ts from rails-vite-plugin/jsbundling to rails-vite-plugin
  3. Replace javascript_include_tag / stylesheet_link_tag with vite_tags in your layouts
  4. Remove jsbundling-rails from 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 in vite.config.ts
  • bin/vite — no longer needed, Procfile.dev runs npx vite directly

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.