Writing ·

Adding Vue to Legacy Rails Views Without a Rewrite

By Sovaid Shah, Software Architect · Vue.js · Rails · Legacy modernization · Testing · Cypress

TL;DR: You can modernize a legacy Rails app by mounting Vue components into specific ERB views instead of rewriting the frontend. Pass data from Rails through data attributes or JSON, migrate the highest-friction screens first, and guard each release with RSpec on the backend and Cypress on the changed flows. On MicroHealth this approach cut post-release bugs 25% in a HIPAA-conscious healthcare app.

MicroHealth LLC had a familiar problem: a healthcare app with legacy Rails screens that slowed product work down, and releases that carried too many regressions into clinical workflows.

The tempting fix is a rewrite. Freeze features, build a new SPA, cut over. It almost never goes to plan, and in healthcare, a bad cutover means clinicians fighting software instead of treating patients.

We went the other way. Vue.js was layered onto existing Rails views, one screen at a time, with test coverage added on the paths that mattered. Post-release bugs dropped 25%.

Why not rewrite?

A rewrite has to reach feature parity before it delivers anything. Every week of that time, the old app keeps changing, and the target keeps moving. You also throw away years of bug fixes that live in code nobody remembers writing.

Incremental adoption flips that. Every migrated screen ships to users straight away. If a screen goes badly, you’ve lost one screen, not the project.

How the layering works

Mount Vue on specific nodes, not the whole page

Rails keeps rendering the page: layout, navigation, authentication, everything that works. Inside a view, you render one empty element where the interactive part goes, and Vue mounts there.

<div
  data-vue="patient-schedule"
  data-props="<%= { patientId: @patient.id, slots: @slots.as_json(only: %i[id starts_at status]) }.to_json %>">
</div>
import { createApp } from 'vue';
import PatientSchedule from './PatientSchedule.vue';

const components = { 'patient-schedule': PatientSchedule };

document.querySelectorAll('[data-vue]').forEach((el) => {
  const component = components[el.dataset.vue];
  if (component) createApp(component, JSON.parse(el.dataset.props || '{}')).mount(el);
});

Each screen opts in. Nothing else on the page changes.

Let Rails own the data contract

Pass only what the component needs, and choose the fields explicitly (as_json(only: ...)), never a whole model. In a HIPAA context this matters twice: you don’t want protected health information leaking into HTML attributes, browser storage or client-side error logs just because it was on the model.

For anything the component changes, call normal Rails endpoints. Rails keeps doing validation and authorization. Vue never becomes the source of truth.

If the app uses Turbolinks (or Turbo in newer Rails), DOMContentLoaded only fires on the first visit. Mount components on turbolinks:load / turbo:load instead, and unmount them before the page is cached (turbolinks:before-cache / turbo:before-cache). Otherwise you get components that vanish after navigation, or duplicate ones that come back from cache.

Choosing what to migrate first

Start with the screens that cause the most pain, not the easiest ones. Good candidates:

  • Forms with lots of conditional fields, where server round-trips feel slow
  • Screens that generate the most support tickets or bug reports
  • Workflows users repeat many times a day

Leave static pages, admin reports and anything stable as server-rendered ERB. Some of it may never need Vue, and that’s fine.

Testing is what made it safe

The 25% drop in post-release bugs didn’t come from Vue. It came from the testing added around each change:

  • RSpec coverage expanded on the critical backend paths the migrated screens depend on.
  • Cypress end-to-end checks around release-sensitive flows: the clinical workflows where a regression actually hurts someone.

The rule: a screen isn’t migrated until the flow through it has an end-to-end test. That’s slower per screen and much faster overall.

FAQ

Can Vue and Rails ERB views coexist in one app?

Yes. Rails renders the page and Vue mounts into specific elements. Both run side by side for as long as you need, which is the whole point of incremental migration.

Should I use Webpacker, jsbundling-rails or Vite?

Webpacker is retired. For new work, use jsbundling-rails (esbuild) or vite_ruby. Both handle Vue single-file components.

How do you avoid exposing patient data in a Vue migration?

Serialize only the fields each component needs, keep authorization in Rails, and never write protected data to localStorage or client-side logs.


Full context in the MicroHealth case study. Need an architect to plan a migration like this? See software architecture.