Serve Vue single-file components straight from Ruby, in production, with no build step.
Drop a .vue file into app/vue/, render one helper in a view, and the browser gets a native ES
module. No bundler, no watcher, no Node.js unless you want it.
<%# app/views/layouts/application.html.erb, once, in <head> %>
<%= vue_live_import_map_tag %>
<%# any view %>
<%= vue_live_mount_tag 'HelloVueLive.vue', '#app', props: { name: current_user.name }, element: true %>- Why vue_live
- Installation
- Quick start: Rails
- Quick start: Sinatra
- Quick start: plain Rack
- Writing components
- Pinia, Vue Router and other packages
- Helpers
- Compiler backends
- Development: live reload, errors, source maps
- Caching, production and deployment
- Browser support and trade-offs
- Vue 2
- Configuration reference
- Command line and rake tasks
- Security notes
- Contributing
- License
Back when Rails shipped Sprockets and Webpacker, a .vue file could be translated on the fly and
served to a running site. vue_live brings that back for modern Vue (3.x) and modern browsers,
without tying itself to Rails or to any asset pipeline.
- Pure Ruby compiler, zero runtime dependencies. A
.vuefile is parsed in Ruby, its<template>is handed to Vue's own in-browser compiler,<style scoped>selectors are rewritten the way@vue/compiler-sfcdoes, and the result is served as a native ES module. - Works anywhere Rack does. Rails, Sinatra, Roda, Hanami, plain
config.ru. - Recognises a Rails application and configures itself. A Railtie mounts the middleware,
adds view helpers, reads
config/vue_live.yml, registers rake tasks and a generator, and pinsvueinto importmap-rails when present. - Coexists with your asset manager. Components live in
app/vue/and are served from/vue/, so Sprockets, Propshaft, Webpacker, jsbundling and importmap-rails keep their own directories and URLs. Nothing is registered with them and nothing of theirs is overridden. - Optional Node backend. Point it at a project with
@vue/compiler-sfcinstalled and<script setup>, TypeScript, Pug, Sass/Less/Stylus and CSS modules work too. Theautostrategy only uses Node for components that need it. - Production ready. Compiled once per process (or persisted to disk), served with ETags, digested URLs with far-future caching for the whole import graph, and CSP-friendly style injection. Optionally precompile everything to static files for a CDN.
- Pleasant in development. Changed components recompile on the next request, the page
reloads itself, compile errors show up in the page, and inline source maps point browser
errors at the right line of the
.vuefile.
# Gemfile
gem 'vue_live'
# Until the first release is on RubyGems:
# gem 'vue_live', github: 'danielpclark/vue_live'Requires Ruby 3.0+. Node.js is not required unless you opt into the Node backend.
bin/rails generate vue_live:installThe generator writes config/vue_live.yml and an example app/vue/HelloVueLive.vue. Pass
--node to also install the Node backend, or --skip-example to leave app/vue/ empty.
Render the import map once, in your layout's <head>, and mount components from any view:
<%# app/views/layouts/application.html.erb %>
<%= vue_live_import_map_tag %> <%# omit when you use importmap-rails, see below %>
<%# app/views/pages/home.html.erb %>
<%= vue_live_mount_tag 'HelloVueLive.vue', '#app', props: { name: current_user.name }, element: true %>That is the whole setup. app/vue/HelloVueLive.vue is compiled on first request and served at
/vue/HelloVueLive.vue.js. In development it recompiles whenever the file changes and the page
reloads itself.
Settings live in config/vue_live.yml (written by the generator) or in Ruby, which takes
precedence:
# config/application.rb
config.vue_live.source_path = 'app/components' # default: app/vue
config.vue_live.prefix = '/components' # default: /vue
config.vue_live.compiler = :node # :auto (default), :ruby or :node
config.vue_live.cache = :file # :memory (default), :file or :noneBrowsers honour only one <script type="importmap"> per page. When importmap-rails is present,
vue_live_import_map_tag renders nothing and the Railtie pins vue into your import map
instead. Keep using javascript_importmap_tags. Set config.vue_live.importmap_pin = false to
opt out and manage the two separately.
Nothing to do. vue_live never touches app/assets, app/javascript, /assets or /packs,
does not register .vue with Sprockets, and vue_live:precompile is not hooked into
assets:precompile unless you set config.vue_live.hook_assets_precompile = true.
The middleware is inserted before ActionDispatch::Static and only answers requests under its
prefix; everything else passes straight through.
require 'sinatra/base'
require 'vue_live/sinatra'
class App < Sinatra::Base
set :vue_live, source_path: 'app/vue', prefix: '/vue' # optional, must come before register
register VueLive::Sinatra
get '/' do
<<~HTML
<!doctype html>
#{vue_live_import_map_tag}
#{vue_live_mount_tag 'App.vue', '#app', props: { title: 'Sinatra' }, element: true}
HTML
end
endregister reads settings.vue_live, mounts the middleware and adds the helpers, so any
set :vue_live has to come first. config/vue_live.yml is read as well, if present.
Classic-style apps (require 'sinatra') call register VueLive::Sinatra at the top level.
vue_live init scaffolds config/vue_live.yml and app/vue/HelloVueLive.vue in any project.
# config.ru
require 'vue_live'
VueLive.configure { |c| c.source_path = 'app/vue' }
VueLive.load_config_file # config/vue_live.yml, if any
use VueLive::Middleware
run MyAppInclude VueLive::Helpers wherever you render HTML to get the tag helpers.
A component is an ordinary Vue SFC. Relative imports to other components and to plain .js
files under the component root just work:
<template>
<button class="counter" @click="count++">{{ label }}: {{ count }}</button>
<Child />
</template>
<script>
import Child from './nested/Child.vue'
import { shout } from './shared/util.js'
export default {
components: { Child },
props: { label: String },
data() { return { count: 0 } },
methods: { yell() { alert(shout(this.label)) } }
}
</script>
<style scoped>
.counter { color: #42b883; }
</style>With the Ruby backend the served module is, in essence:
import Child from './nested/Child.vue.js?v=9c1e…'
import { shout } from './shared/util.js?v=1718-412'
const __sfc__ = { components: { Child }, props: { label: String }, data() { return { count: 0 } }, methods: { … } }
__sfc__.template = "<button class=\"counter\" @click=\"count++\">{{ label }}: {{ count }}</button>\n<Child />"
__sfc__.__scopeId = "data-v-5d1a9f3c"
export default __sfc__
/* + a few lines that register ".counter[data-v-5d1a9f3c] { color: #42b883 }" once */Vue compiles the template string in the browser (so the import map must point at a full build,
vue.esm-browser.js, which is the default) and applies the scope id in its renderer, so scoped
styles need no build step either.
Shared files. Anything under source_path with an allowed extension is served as-is from the
same prefix: .js and .mjs modules, .css, .json, images and fonts. So app/vue/shared/util.js
is importable from any component and app/vue/img/logo.svg is reachable at /vue/img/logo.svg.
Blocks can also point at files with src="...", e.g. <style src="./shared/theme.css">.
Several components on one page. Render vue_live_import_map_tag once, in the layout, and as
many vue_live_mount_tags as you like with different selectors. Each becomes its own small Vue
app:
<%= vue_live_mount_tag 'Search.vue', '#search', element: true %>
<%= vue_live_mount_tag 'Cart.vue', '#cart', props: { items: @cart.as_json }, element: true %>Props are passed as JSON, so anything JSON.generate accepts works. element: true renders
the mount <div> for you; leave it off when the element is already in your markup.
Components can import any bare specifier that the page's import map resolves. Add entries with
import_map in config/vue_live.yml (or config.vue_live.import_map), or per page with
vue_live_import_map_tag(imports: { ... }):
default:
import_map:
pinia: https://esm.sh/pinia@3?external=vue
vue-router: https://esm.sh/vue-router@4?external=vue?external=vue keeps the package's own import ... from 'vue' bare, so it resolves through the
import map to the same Vue instance your components use. Any CDN works as long as the build you
pick does not bundle its own copy of Vue. With importmap-rails, pin the packages there instead.
Then write the mount script yourself with vue_live_module_tag and vue_live_path:
<div id="app"></div>
<%= vue_live_module_tag "
import { createApp } from 'vue'
import { createPinia } from 'pinia'
import App from '#{vue_live_path('App.vue')}'
createApp(App).use(createPinia()).mount('#app')
" %>Inside components, import { defineStore } from 'pinia' and import { useRouter } from 'vue-router'
work exactly as they would under a bundler. Under Rails the module tag picks up the CSP nonce
automatically.
| Helper | Purpose |
|---|---|
vue_live_import_map_tag(imports: {}) |
<script type="importmap"> mapping vue plus config.import_map and imports. Once per page, before any module script. Renders nothing under importmap-rails. |
vue_live_mount_tag(component, selector = '#app', props: {}, element: false, plugins: [], nonce: nil) |
<script type="module"> that imports the component and mounts it on selector. Adds the live-reload client in development. |
vue_live_module_tag(js) |
<script type="module"> for your own code (custom mounts, routers, stores). |
vue_live_path(component) |
/vue/App.vue.js?v=<digest>, read from the manifest when precompiled. |
vue_live_tags(component, selector, **) |
Import map + mount tag in one call, for single-component pages. |
vue_live_reload_tag |
The live-reload client on its own. Renders nothing when live_reload is off. |
plugins: takes JavaScript expressions that are appended as app.use(...) calls. The generated
script imports only vue and the component, so this suits globals you have already loaded; for
anything that needs its own import, write the mount script with vue_live_module_tag as shown
above.
Under Rails the helpers return html_safe strings and pick up the CSP nonce automatically.
Outside Rails they return plain strings; pass nonce: yourself if you use a CSP.
:ruby |
:node |
|
|---|---|---|
| Dependencies | none | Node.js + @vue/compiler-sfc in the project |
<template> |
string, compiled in the browser (needs Vue's full build) | render function (runtime-only Vue build is enough) |
<script> |
yes | yes |
<script setup> |
no | yes |
<script lang="ts"> |
no | yes (types stripped by Node >= 22.13 itself, or by sucrase, esbuild, Babel or TypeScript < 7) |
<style>, <style scoped> |
yes (:deep, :slotted, :global) |
yes |
<style lang="scss">, <style module>, <template lang="pug"> |
no | yes, with the npm packages installed |
src="..." on blocks |
yes | yes |
| Speed | ~1 ms per component | ~400 ms once to start the worker, then a few ms per component |
| Source maps | script block, line for line | script and template, merged |
compiler: auto (the default) uses Ruby and falls back to Node only for components that need it,
with a clear error naming the feature when Node is unavailable.
The Node backend keeps one node compile.js --server worker per configuration, so after the
first compile (which includes Node's start-up) each component takes a few milliseconds. The
worker is restarted automatically if it dies; set node_worker: false to spawn a process per
compile instead.
Enable the Node backend with:
vue_live node-setup # npm/yarn add @vue/compiler-sfc vue
vue_live node-setup --with sass # plus preprocessors you use
bin/rails vue_live:node_setup # the same, under RailsInstalling vue locally also makes vue_live serve it from /vue/-/vue.esm-browser.js instead
of the CDN. If the webpacker_cli gem is
installed its package manager detection is reused, so projects already built with it need
nothing extra.
TypeScript. Node.js 22.13+ strips types itself. On older Node.js a transpiler package is
needed; vue_live node-setup adds sucrase automatically
in that case (pure JavaScript, keeps line numbers so source maps stay exact).
VUE_LIVE_TS_TRANSPILER=node|sucrase|esbuild|typescript|babel pins one when several are
installed, and vue_live check warns when a Node.js that cannot strip types has no transpiler.
- Live reload. With
live_reloadon (the default wheneverreloadis on)vue_live_mount_tagadds a small client that listens to a Server-Sent Events stream at/vue/-/eventsand reloads the page when any file under the component root changes. The stream is served by the middleware from the request's own thread, so it works with Puma, Falcon, WEBrick or anything else threaded, with no extra process.vue_live_reload_tagrenders the client on its own. - Errors in the page. A component that fails to compile is served as a module that logs the error, shows it in an overlay, and throws, so the failure is visible without opening the log.
- Source maps. With
source_mapson (the default outside production) every module ends with an inline source map. The Ruby backend maps the<script>block line for line; the Node backend merges@vue/compiler-sfc's script and template maps, so stack traces and breakpoints land in the.vuefile.
reload(default: on outside production) compares mtimes on every request and recompiles changed files, includingsrc="..."dependencies and imported siblings.digest_imports(default: on) rewrites the relative imports inside a module to./Child.vue.js?v=<digest>and./util.js?v=<mtime-size>, so a page's whole module graph can be served withCache-Control: immutable. A child's digest is part of its parent's code, so a change anywhere propagates up to the URL the page requests. Import cycles are handled (the back edge stays undigested and revalidates by ETag).cache: :memory(default) keeps compiled modules per process.cache: :filealso writes them totmp/cache/vue_liveso Puma workers and restarts share the work.- Responses carry an
ETag; URLs fromvue_live_pathinclude?v=<digest>and are served withCache-Control: public, max-age=31536000, immutablein production. - In production a component that fails to compile is a 500 with the details in the log.
vue_live compile/bin/rails vue_live:precompilewrites every component as a static.vue.jsfile plusmanifest.jsontopublic/vue, for a CDN ornginx. When the manifest exists in production,vue_live_pathreads URLs from it, so a web server or CDN in front ofpublic/serves the files and the app never compiles them. Without one, the middleware still answers from its cache.
Deploying. Three setups work, pick the one that fits your host:
| Setup | What to do | Good for |
|---|---|---|
| Compile at runtime, memory cache | Nothing. Each process compiles on first request. | Read-only filesystems, small apps, few processes |
| Compile at runtime, file cache | cache: file in config/vue_live.yml; tmp/ must be writable. |
Puma clusters, frequent restarts |
| Precompile | Run bin/rails vue_live:precompile in your build (Dockerfile, CI, or hook_assets_precompile: true). |
CDNs, nginx serving public/, zero compile cost at runtime |
In production the CDN URL switches to vue.esm-browser.prod.js automatically, and
vue_live check / bin/rails vue_live:check verifies that every component compiles before you ship.
vue_live relies on two browser features: native ES modules and import maps. Import maps are
supported in Chrome and Edge 89+, Firefox 108+ and Safari 16.4+ (March 2023). Older browsers can
be covered with es-module-shims if you need them.
Serving modules unbundled is a deliberate trade:
- One request per module. Fine over HTTP/2 for a page that loads a few dozen files; a component tree in the hundreds is better served precompiled behind a CDN, or bundled.
- No tree shaking or minification of your own code. Vue itself comes minified from the CDN; your components are served as written.
- The Ruby backend compiles templates in the browser. That needs Vue's full build, which is larger than the runtime-only build, and costs a little CPU on first render. The Node backend precompiles templates to render functions and removes both costs.
If you are already running Vite or esbuild and are happy with it, keep it. vue_live is for
the many apps where a build step is the only reason Node.js is installed.
The Ruby backend's output is also valid for Vue 2.7's full build (_scopeId is emitted alongside
__scopeId). Set vue_version: 2.7.16 and vue_url to a Vue 2 ESM build and
vue_live_mount_tag emits new Vue({ render: h => h(App) }).$mount(...). Vue 2 is end-of-life,
so Vue 3 is the default and the only version the Node backend supports.
Values come from built-in defaults, then config/vue_live.yml (the default section merged with
the current environment's section), then Ruby (VueLive.configure or config.vue_live).
| Key | Default | Meaning |
|---|---|---|
root |
Rails.root / Dir.pwd |
project root |
source_path |
app/vue |
component directory, relative to root |
prefix |
/vue |
URL prefix |
compiler |
auto |
ruby, node or auto |
reload |
not production | recompile on file change |
cache / cache_path |
memory / tmp/cache/vue_live |
memory, file, none |
vue_url |
auto | local copy if present, else pinned jsDelivr build (.prod.js in production) |
vue_version |
pinned 3.x | version used for the CDN URL and Vue 2 detection |
import_map |
{} |
extra import-map entries |
extensions |
.vue .js .mjs .css .json + images/fonts |
files the middleware will serve from source_path |
node_bin |
node |
Node executable |
precompile_path |
public/vue |
output of vue_live compile |
use_manifest |
auto | read public/vue/manifest.json for URLs (auto: in production when it exists) |
middleware |
true |
mount automatically (Railtie / Sinatra); set false to use it yourself |
importmap_pin |
true |
pin vue into importmap-rails |
hook_assets_precompile |
false |
run vue_live:precompile with assets:precompile |
inject_styles |
true |
emit <style> blocks into the module |
source_maps |
not production | append an inline source map to each module |
node_worker |
true |
keep one long-lived Node worker instead of a process per compile |
live_reload / live_reload_interval |
follows reload / 0.5 |
SSE live reload and its scan interval in seconds |
digest_imports |
true |
add ?v=<digest> to relative imports inside modules |
Environment variables:
| Variable | Meaning |
|---|---|
VUE_LIVE_ENV, RAILS_ENV, RACK_ENV, APP_ENV |
environment name, first one set wins (default development) |
VUE_LIVE_NODE |
Node executable, same as node_bin |
VUE_LIVE_TS_TRANSPILER |
node, sucrase, esbuild, typescript or babel |
The gem ships a vue_live executable for any project and the same operations as rake tasks
under Rails:
vue_live |
Rails | Does |
|---|---|---|
init [--force] [--node] |
bin/rails generate vue_live:install [--node] [--skip-example] |
create config/vue_live.yml and app/vue/HelloVueLive.vue |
compile [--out DIR] |
bin/rails vue_live:precompile |
write static .vue.js modules + manifest.json |
clobber |
bin/rails vue_live:clobber |
remove precompiled output and the compile cache |
check |
bin/rails vue_live:check |
compile every component and verify the Node toolchain; exits non-zero on problems |
node-setup [--with pkg,pkg] |
bin/rails vue_live:node_setup |
install @vue/compiler-sfc and vue (plus sucrase when Node.js < 22.13) |
info |
print versions and the resolved settings |
Everything under source_path with an allowed extension is public. Keep secrets out of it.
Paths are normalised and confined to that directory; dot-files and unknown extensions are refused.
bundle install
bundle exec rake test:setup # npm install in test/ (+ a Chromium) for the Node-backend and browser tests
bundle exec rake test # everything; without test:setup the Node and browser tests skip with a message
bundle exec rubocopVUE_LIVE_NODE_ROOT points the Node tests at a different node_modules; PLAYWRIGHT_CHROMIUM
names a browser when Playwright could not download one; VUE_LIVE_E2E=0 skips the browser test.
Dual-licensed under either the MIT License or the Apache License, Version 2.0, at your option.