Ruby on Rails and Vue
"Ruby on Rails and Vue" is three different decisions wearing one phrase, and the one you pick decides
whether you ever write a .vue file, whether you run a second process in development, and whether
Rails keeps owning the URL. I built all three on this laptop today: Vue through importmap with no
build step, Vue through Vite with real single file components, and Vue through Inertia with no JSON
API. Everything below came out of those three applications, and the fenced blocks are copied from
what ran.
Three scratch apps, all generated by rails new on Rails 8.1.4, Ruby 4.0.5, PostgreSQL on port
15432, macOS on an Apple M2 Max. Vue is 3.5.43 in all three. Browser measurements are headless
Chrome 153.0.8010.53 over loopback. The test counts at the bottom are real runs.
Route one: importmap, and it does work
The first thing worth knowing is that the no-build route is not a hack. importmap-rails 2.2.3 pins Vue like anything else:
$ bin/importmap pin vue
Pinning "vue" to vendor/javascript/vue.js via download from https://ga.jspm.io/npm:vue@3.5.43/dist/vue.esm-browser.prod.js
Read the filename. vue.esm-browser.prod.js is the browser build that includes the template
compiler, it arrives as one file with no imports of its own, and it is 173,347 bytes on disk. That
choice was made for you by the JSPM resolution behind bin/importmap, and it is the right one,
because without a build step there is nothing to precompile your templates.
A component then looks like this, in app/javascript/vue/counter.js, pinned with
pin_all_from "app/javascript/vue", under: "vue":
import { createApp, ref, onMounted, onUnmounted } from "vue"
export const Counter = {
setup() {
const count = ref(0)
onMounted(() => window.vueAppsAlive++)
onUnmounted(() => window.vueAppsAlive--)
return { count, bump: () => count.value++ }
},
template: `<p>count is {{ count }}</p><button id="bump" @click="bump">+1</button>`
}
That renders, counts and responds to clicks, with no npm, no package.json and no second process.
For an island or two on an otherwise server-rendered application, that is the whole story.
The cost is the compiler. Vue publishes a runtime-only build for exactly this reason, and I pinned it beside the full one to see what happens:
pin "vue" # @3.5.43
pin "vue-runtime", to: "vue-runtime.js" # @3.5.43 runtime-only
vue.runtime.esm-browser.prod.js is 111,539 bytes, 41,894 gzipped, against 173,347 and 62,928 for
the full one. So the template compiler is 61,808 bytes of the payload, sent to every visitor, to do
at page load what a build step would have done once.
Every gzipped figure on this page is gzip -c at its default level, which is -6. At -9 those
same two files are 62,828 and 41,847, so compare like with like if you check them.
The dead end: the runtime build fails silently
I expected the swap to fail loudly. The warning exists: vue.runtime.esm-browser.js in
node_modules carries the string Component provided template option but runtime compilation is not
supported in this build of Vue.. The .prod.js next to it, which is the one bin/importmap pin
downloads, contains that string zero times.
window.__runtimeOnlyThrew = null
try {
createApp({ template: `<p>compiled in the browser</p>` }).mount(el)
} catch (e) {
window.__runtimeOnlyThrew = e.message
}
test "the runtime-only build cannot compile a template string and leaves an empty comment" do
visit "/runtime-only"
assert_selector "h1", text: "Runtime only"
assert_equal "<!---->", page.evaluate_script("document.querySelector('#runtime-only').innerHTML")
assert_nil page.evaluate_script("window.__runtimeOnlyThrew")
end
Green. createApp returned, mount ran, the placeholder text was cleared, and what it left behind
was an empty HTML comment node. Nothing threw, nothing was logged, and the browser showed a blank
space where a component used to be. If you ever try to trim the importmap payload by switching to the
runtime build, this is what you will be debugging, and the only visible symptom is <!----> in the
inspector.
Which settles route one: on importmap you ship the full build, or you ship nothing.
Route two: Vite, and what the build step actually buys
vite_rails 3.11.1 on Vite 8.3.1 with @vitejs/plugin-vue 6.0.9. Three lines in vite.config.ts
and the same counter becomes a real single file component:
<script setup>
import { ref, onMounted } from "vue"
const props = defineProps({ start: { type: Number, default: 0 } })
const count = ref(props.start)
</script>
<template>
<p class="readout">count is {{ count }}</p>
<button id="bump" @click="count++">+1</button>
</template>
<style scoped>
.readout { font-weight: 700; }
</style>
$ bundle exec vite build
vite v8.3.1 building client environment for production...
✓ 10 modules transformed.
public/vite/assets/application-DXO2rdIx.css 0.04 kB │ gzip: 0.06 kB
public/vite/assets/application-BAGadyV4.js 61.18 kB │ gzip: 24.16 kB │ map: 561.70 kB
✓ built in 61ms
61,189 bytes for the entire application, Vue included, against 173,347 for the importmap copy of Vue alone. The difference is not Vite being clever. It is that the templates were compiled at build time, so the compiler is not in the bundle and the parts of Vue nothing imports are gone.
<style scoped> is the other thing you cannot have without a build. It compiled to an attribute
selector:
.readout[data-v-1563882d]{font-weight:700}
and the element carries the matching data-v-1563882d attribute, which a system test can assert on.
The cost is two processes in development, a node_modules you now maintain, and a package.json in
a Rails repository. That is a real tax and it is the reason importmap exists. Pay it when you have
enough Vue to want components in files.
Vite has opinions about your test environment, and they are wrong twice
The first surprise is that your system tests run against production Vue. vite build defaults to
production mode, autoBuild is on for the test environment, and so the bundle your tests exercise
has had every development warning compiled out of it.
I proved this on a prop type error that is almost impossible to avoid, because el.dataset values
are always strings and Vue props are typed. Under bin/vite dev, Chrome logged:
WARNING [Vue warn]: Invalid prop: type check failed for prop "start". Expected Number with value 41, got String with value "41".
The dev server's copy of Vue is 1,201,938 bytes and contains the string Invalid prop three times.
The built test bundle contains it zero times, and the system test that renders the same broken
component sees an empty console:
test "the test-environment bundle is production Vue, so no prop warning is reachable" do
visit root_path
assert_text "count is 41"
warnings = page.driver.browser.logs.get(:browser).map(&:message).grep(/Vue warn/)
assert_equal [], warnings
bundle = Dir["#{Rails.root}/public/vite-test/assets/*.js"].sole
assert_equal 0, File.read(bundle).scan("Invalid prop").length
end
So a browser-log assertion in a system test is not a safety net for Vue warnings. It cannot be. The warnings are not in the code under test.
The second surprise cost me twenty minutes. My three passing Vite tests started failing all at once,
every one of them with expected to find text "count is 41" in "Home", which is the page with no
JavaScript on it at all. The only thing that had changed was that I had left bin/vite dev running
in another terminal.
bin/vite dev writes tmp/vite-ruby.json:
{"url":"http://localhost:3036","host":"localhost","port":3036,"https":false,"pid":29264}
One file, at root.join("tmp", DEV_SERVER_META_FILENAME), for every environment. dev_server_running?
in vite_ruby 3.11.0 returns !dev_server_meta.nil?, so the test process reads the development
server's file and concludes a dev server is up. manifest.rb:124 then reads
config.auto_build && !dev_server_running?, skips the build, and emits a tag pointing at
/vite-test/entrypoints/application.js, which proxies to port 3037 where nothing is listening. No
JavaScript, no error, three failing tests and a passing dev server.
The fix is one key in config/vite.json, and with it the tests pass with the dev server still
running:
"test": {
"autoBuild": true,
"publicOutputDir": "vite-test",
"port": 3037,
"devServerConnectionCheck": true
}
Turbo is what actually breaks Vue in a Rails app
Both routes above sit inside a default Rails application, which means Turbo Drive is intercepting every link. This is the part that has nothing to do with which bundler you chose and everything to do with why your component stops appearing.
The first version everybody writes is this one, and it is wrong:
document.addEventListener("DOMContentLoaded", mountCounter)
DOMContentLoaded fires once per document load. Turbo replaces the <body> without one. Navigate
away and back and the server's empty <div id="counter"></div> is all that is left, which Capybara
will not even call visible:
Capybara::ElementNotFound: Unable to find visible css "#counter"
turbo:load fires after every Turbo render, and swapping the listener fixes the mount. It does not
fix the other two.
Turbo caches what Vue rendered, not what Rails sent. I clicked the button three times and
navigated away, reading #counter inside turbo:before-cache:
test "Turbo caches the Vue-rendered DOM, not the empty div the server sent" do
visit root_path
assert_text "count is 0"
3.times { find("#bump").click }
assert_text "count is 3"
click_link "Other"
assert_selector "h1", text: "Other"
assert_equal '<p>count is 3</p><button id="bump">+1</button>',
page.evaluate_script("window.cachedSnapshots[0]")
end
That string is what Turbo will paint as the preview the next time the user goes back. A counter at 3
is harmless. A cart total, a form in a half-filled state, or a component showing another account's
data is not, and the reader sees it before your turbo:load handler has run.
Mounting without unmounting leaks a whole application per visit. Vue's onMounted and
onUnmounted make this countable. Three round trips between two pages, with turbo:load mounting and
nothing tearing down:
test "without a turbo:before-cache unmount, every visit leaves a live app behind" do
visit other_path
assert_selector "h1", text: "Other"
3.times do
click_link "Home"
assert_text "count is 0"
click_link "Other"
assert_selector "h1", text: "Other"
end
assert_equal 3, page.evaluate_script("window.vueAppsAlive")
end
Three live applications, each with its own reactive effects, watchers, timers and event listeners,
none of them attached to anything on screen. The same test against a page that calls app.unmount()
on turbo:before-cache asserts 0 and passes.
So the whole of the Rails-and-Vue integration, for any bundler, is these six lines:
document.addEventListener("turbo:load", mount)
document.addEventListener("turbo:before-cache", unmount)
with mount holding the app instance in a module-level variable and unmount nulling it. Everything
else is packaging.
CSRF, and the test that will not tell you
The moment the Vue component posts something, Rails wants a token. A bare fetch:
$ curl -s -o /dev/null -w "%{http_code}\n" -X POST -H "Content-Type: application/json" \
-d '{"body":"hello"}' http://localhost:3210/notes
422
and in the development log:
ActionController::InvalidAuthenticityToken (Can't verify CSRF token authenticity.):
The fix is the meta tag csrf_meta_tags already puts in the layout:
const token = document.querySelector("meta[name=csrf-token]").content
await fetch("/notes", { method: "POST", headers: { "X-CSRF-Token": token, ... } })
Here is the part worth the section. The system test for the broken version passes:
test "with the generated test config, a token-less fetch from Vue is accepted" do
visit "/csrf"
find("#bare").click
assert_selector "#bare-status", text: "200"
end
200, not 422, because config/environments/test.rb:29 in every generated Rails application says
config.action_controller.allow_forgery_protection = false. Your browser test drives the real
component, makes the real request, and confirms a behaviour that will not happen in production. The
only way to test it is to turn protection back on for the example:
test "with forgery protection on, a bare fetch is 422 and the meta token makes it 200" do
was = ActionController::Base.allow_forgery_protection
ActionController::Base.allow_forgery_protection = true
visit "/csrf"
find("#bare").click
assert_selector "#bare-status", text: "422"
find("#tokened").click
assert_selector "#tokened-status", text: "200"
ensure
ActionController::Base.allow_forgery_protection = was
end
Both of those pass, which is the point: the suite is green either way, and only one of the two tests knows anything.
Route three: Inertia, where the API disappears
inertia_rails 3.22.0 is the answer to the question most people are really asking, which is how to use Vue without building and versioning a JSON API for your own frontend. The controller renders a page component by name and hands it props:
class CounterController < InertiaController
def index
render inertia: "counter/index", props: { start: 41 }
end
end
The first response is an ordinary Rails HTML document with the props inlined. Note where they are:
inertia_rails/helper.rb:38 branches on config.use_script_element_for_initial_page, and in 3.22.0
the default puts the page object in a script element rather than in a data-page attribute on the
div, so a selector written against the attribute finds nothing.
<script data-page="app" type="application/json">{"component":"counter/index","props":{"errors":{},"start":41},"url":"/counter","version":"b9d600f5e4ef1ece9ebfd748bde78563fcf7c458","encryptHistory":true,"clearHistory":false,"sharedProps":["errors"]}</script>
<div id="app"></div>
No fetch, no CORS, no serializer, no route table in two places. Every subsequent navigation through
an Inertia <Link> is a request to the same Rails route with X-Inertia: true, and the response is
the page object as JSON:
$ curl -s -H "X-Inertia: true" -H "X-Inertia-Version: b9d600f5e4ef1ece9ebfd748bde78563fcf7c458" \
-D- http://localhost:3212/other | tail -3
content-length: 185
{"component":"other/index","props":{"errors":{}},"url":"/other","version":"b9d600f5e4ef1ece9ebfd748bde78563fcf7c458","encryptHistory":true,"clearHistory":false,"sharedProps":["errors"]}
185 bytes for a page change. Send a stale version, or none, and the protocol forces a full reload rather than letting a new frontend run against old assets:
HTTP/1.1 409 Conflict
x-inertia-location: http://localhost:3212/other
content-length: 0
The price is the bundle and the state. @inertiajs/vue3 plus Vue built to 174,269 bytes, 58,870
gzipped, which is more than either other route, though page components are code split and cost almost
nothing each: the counter page is a 437 byte chunk and the other page is 305. And component state does
not survive a visit. This test passes:
test "component state is not preserved across an Inertia visit" do
visit "/counter"
3.times { find("#bump").click }
assert_text "count is 44"
find("#to-other").click
assert_selector "h1", text: "Other"
find("#to-counter").click
assert_text "count is 41"
end
Back to 41, the value the controller sent. Inertia is not a single page application with a client store that happens to talk to Rails. It is Rails navigation with Vue doing the rendering, and the server remains the source of every prop.
What it costs at load, which is less than you would guess
From performance.now() inside onMounted, 15 loads each, headless Chrome on loopback with a warm
cache: the importmap page reached its mounted hook at a median of 11.3 ms (min 10.0, max 50.2) and
the Vite page at 8.2 ms (min 7.3, max 13.7). The importmap figure is also carrying Turbo and Stimulus,
which the Vite app does not load at all.
Three milliseconds. On a loopback with nothing to download, the bundle size difference does not show up, and anyone quoting first-render numbers taken this way is measuring their own laptop. The 112,158 byte difference between the importmap payload and the Vite bundle is a download cost on a real connection, not a parse cost here, and that is the honest shape of it.
The verdict
If Vue is going to render your screens, use Vite and Inertia together. You get single file components, you get the 61 kB class of bundle instead of the 173 kB one, and Inertia removes the API layer that is the actual expensive part of a Rails-plus-Vue application: the second route table, the serializers, the auth story on two sides, and the deploy that has to ship them in step.
If you need Vue for one screen in an otherwise Hotwire application, use importmap, accept the
173,347 bytes, write the turbo:load and turbo:before-cache pair on day one, and do not try to
save the compiler.
What would change this: if your screens are forms, tables and lists, none of the above is worth
starting. Turbo does those with no JavaScript written by you, and
Ruby on Rails and Hotwire is what that costs instead. The point at which Vue
starts paying is when a screen holds state the server should not be told about on every keystroke.
Count those screens before you add a package.json.
What this page does not cover
Server-side rendering. Inertia supports it, inertia_ssr_head is already in the generated layout, and
I did not run a Node SSR process, so nothing here says what a crawler sees on an Inertia page. Vue
Router, Pinia and TypeScript, none of which are installed in any of the three apps. Forms: Inertia's
useForm and its error handling against Rails validations is the next thing anybody building this
will hit, and it is not measured here. Turbo Frames and Turbo Streams containing Vue components,
which is a different and worse lifecycle problem than the Drive one above. Asset fingerprinting and
assets:precompile in a real deploy, since all three applications ran in development. And no
production-mode benchmark of any kind: the only browser timings above are loopback with a warm cache,
stated with their conditions because they are not transferable.
Every figure on this page came out of the three scratch applications described at the top. The full run behind it is 17 system examples with 51 assertions across those three apps, all passing.
Comments
No comments yet. Be the first.