[{"data":1,"prerenderedAt":1251},["ShallowReactive",2],{"$navigation":3,"$settings":40,"articles/how-to-connect-react-and-vue-in-a-single-app-the-island-architecture":73,"$latestArticles":560},{"id":4,"uid":5,"url":5,"type":6,"href":7,"tags":8,"first_publication_date":9,"last_publication_date":10,"slugs":11,"linked_documents":12,"lang":13,"alternate_languages":14,"data":15},"Z60ahxIAACUARzT5",null,"navigation","https://antoninpleskac.cdn.prismic.io/api/v2/documents/search?ref=ak4HWhEAACoA7dJQ&q=%5B%5B%3Ad+%3D+at%28document.id%2C+%22Z60ahxIAACUARzT5%22%29+%5D%5D",[],"2025-02-12T22:02:49+0000","2025-02-12T22:03:20+0000",[6],[],"en-us",[],{"homepageLabel":16,"links":22},[17],{"type":18,"text":19,"spans":20,"direction":21},"heading3","Articles",[],"ltr",[23],{"label":24,"link":28},[25],{"type":18,"text":26,"spans":27,"direction":21},"About ",[],{"id":29,"type":30,"tags":31,"lang":13,"slug":32,"first_publication_date":33,"last_publication_date":34,"uid":35,"url":36,"link_type":37,"key":38,"isBroken":39},"Z60aXhIAACUARzS6","page",[],"about-antonin","2025-02-12T22:02:08+0000","2025-02-12T22:09:51+0000","about-me","/about-me","Document","bf201bad-6e98-4261-ac1c-d25ae140dea0",false,{"id":41,"uid":5,"url":5,"type":42,"href":43,"tags":44,"first_publication_date":45,"last_publication_date":46,"slugs":47,"linked_documents":48,"lang":13,"alternate_languages":49,"data":50},"Z60ZTRIAACgARzM5","settings","https://antoninpleskac.cdn.prismic.io/api/v2/documents/search?ref=ak4HWhEAACoA7dJQ&q=%5B%5B%3Ad+%3D+at%28document.id%2C+%22Z60ZTRIAACgARzM5%22%29+%5D%5D",[],"2025-02-12T21:57:50+0000","2025-02-12T22:00:22+0000",[42],[],[],{"name":51,"description":56,"profilePicture":61,"newsletterDescription":71,"newsletterDisclaimer":72},[52],{"type":53,"text":54,"spans":55,"direction":21},"heading1","Antoín Pleskač",[],[57],{"type":58,"text":59,"spans":60,"direction":21},"paragraph","Programmer exploring Vue, Rails, and PostgreSQL development, along with insights into 3D printing....",[],{"dimensions":62,"alt":64,"copyright":5,"url":65,"id":66,"edit":67},{"width":63,"height":63},2000,"Antonin","https://images.prismic.io/antoninpleskac/Z60Z6ZbqstJ9-jF8_DALL%C2%B7E2025-02-1222.59.40-Createabrightandpositivepixelartavatarbasedonagivenphoto%2Cfeaturingaprogrammeraesthetic.Theavatarshouldhavearetrogamingstylewi.webp?auto=format,compress&rect=0,0,1024,1024&w=2000&h=2000","Z60Z6ZbqstJ9-jF8",{"x":68,"y":68,"zoom":69,"background":70},0,1,"transparent",[],[],{"id":74,"uid":75,"url":76,"type":77,"href":78,"tags":79,"first_publication_date":80,"last_publication_date":80,"slugs":81,"linked_documents":82,"lang":13,"alternate_languages":83,"data":84},"ak4E1xEAACkA7c0T","how-to-connect-react-and-vue-in-a-single-app-the-island-architecture","/articles/how-to-connect-react-and-vue-in-a-single-app-the-island-architecture","article","https://antoninpleskac.cdn.prismic.io/api/v2/documents/search?ref=ak4HWhEAACoA7dJQ&q=%5B%5B%3Ad+%3D+at%28document.id%2C+%22ak4E1xEAACkA7c0T%22%29+%5D%5D",[],"2026-07-08T08:16:26+0000",[75],[],[],{"title":85,"publishDate":5,"featuredImage":89,"slices":96},[86],{"type":53,"text":87,"spans":88,"direction":21},"How to connect React and Vue in a single app: the \"island\" architecture",[],{"dimensions":90,"alt":87,"copyright":5,"url":92,"id":93,"edit":94},{"width":63,"height":91},1500,"https://images.prismic.io/antoninpleskac/yIPWYod2GTSqlRiU_ChatGPTImageJul8%2C2026%2C10_15_59AM.png?auto=format,compress&rect=85,0,1365,1024&w=2000&h=1500","yIPWYod2GTSqlRiU",{"x":95,"y":68,"zoom":69,"background":70},85,[97,113],{"variation":98,"version":99,"items":100,"primary":101,"id":111,"slice_type":112,"slice_label":5},"wide","sktwi1xtmkfgx8626",[],{"image":102,"caption":108},{"dimensions":103,"alt":87,"copyright":5,"url":106,"id":93,"edit":107},{"width":104,"height":105},1536,1024,"https://images.prismic.io/antoninpleskac/yIPWYod2GTSqlRiU_ChatGPTImageJul8%2C2026%2C10_15_59AM.png?auto=format,compress",{"x":68,"y":68,"zoom":69,"background":70},[109],{"type":58,"text":87,"spans":110,"direction":21},[],"image$e19df188-6fec-4d23-8b3c-f8a0172ed05a","image",{"variation":114,"version":99,"items":115,"primary":116,"id":558,"slice_type":559,"slice_label":5},"default",[],{"text":117},[118,122,129,132,136,139,145,150,153,159,164,169,174,185,188,191,197,200,203,206,209,216,219,225,234,237,241,244,250,253,256,263,268,271,281,284,287,295,298,306,311,316,323,328,333,341,344,350,353,356,362,365,371,378,381,390,393,403,406,415,418,424,430,433,442,452,455,460,466,469,475,479,482,485,490,495,498,501,504,508,513,518,523,528,533,538,543,547,552],{"type":119,"text":120,"spans":121,"direction":21},"heading2","The problem: two generations of frontend in one codebase",[],{"type":58,"text":123,"spans":124,"direction":21},"Most long-lived web apps eventually end up with two generations of frontend living side by side. The older part runs on the framework and build tool that made sense five years ago. New work wants a different stack — a more modern framework, a faster bundler, a different design system.",[125],{"start":126,"end":127,"type":128},48,95,"strong",{"type":58,"text":130,"spans":131,"direction":21},"Here's the concrete starting point I'll work with:",[],{"type":133,"text":134,"spans":135,"direction":21},"preformatted","  +-----------+---------------------------+---------------------------+\n  |           | Old world                 | New world                 |\n  +-----------+---------------------------+---------------------------+\n  | Framework | Vue 2                     | React 19                  |\n  | Bundler   | webpack 4 (Webpacker)     | Vite 5                    |\n  | Router    | vue-router 3              | react-router 6            |\n  | State     | Vuex + apollo-client 2    | @apollo/client 3          |\n  | UI        | Element UI + SCSS         | shadcn + Tailwind         |\n  | i18n      | vue-i18n                  | react-i18next             |\n  +-----------+---------------------------+---------------------------+",[],{"type":58,"text":137,"spans":138,"direction":21},"",[],{"type":58,"text":140,"spans":141,"direction":21},"The key fact: today these two environments share nothing at all. Each has its own router, its own GraphQL client, its own i18n, its own styles. And yet we need the user to click from a Vue section into a React section and back without anything flickering or the page reloading.",[142],{"start":143,"end":144,"type":128},14,64,{"type":58,"text":146,"spans":147,"direction":21},"So the question isn't \"how do I rewrite the app in React.\" It's \"how do I let a React section live inside the existing Vue app as a full-fledged, seamless part — and do it so that further sections can be ported by the same mechanism at a fraction of the effort.\"",[148],{"start":144,"end":149,"type":128},262,{"type":119,"text":151,"spans":152,"direction":21},"Why \"island\" and not the alternatives",[],{"type":58,"text":154,"spans":155,"direction":21},"Before you dive into your own implementation, it's worth knowing what you rejected and why — because every rejected path has its advocate.",[156],{"start":157,"end":158,"type":128},74,82,{"type":58,"text":160,"spans":161,"direction":21},"Micro-frontends / Module Federation. The classic answer to \"two frameworks in one app.\" Except Module Federation is a webpack 5 feature. It doesn't work on webpack 4 (or a webpack + Vite mix). If you're tied to an older bundler, this path is closed — and that's a more common situation than it might seem.",[162],{"start":68,"end":163,"type":128},36,{"type":58,"text":165,"spans":166,"direction":21},"Iframe. Isolates perfectly, but breaks the shared session, shared styles, browser history, navigation, and analytics. The seamlessness we want is something an iframe fundamentally can't deliver.",[167],{"start":68,"end":168,"type":128},7,{"type":58,"text":170,"spans":171,"direction":21},"Big-bang rewrite. Rewrite the whole app at once. Months of work with no delivered value, a huge risky merge, and frozen feature development the entire time. Unacceptable for a product with real users.",[172],{"start":68,"end":173,"type":128},17,{"type":58,"text":175,"spans":176,"direction":21},"Island architecture. Vue stays the permanent host shell (navbar, layout, router core) and individual sections are embedded into it as React islands. Migration proceeds section by section, each behind a feature flag, each shippable on its own. This is the chosen path.",[177,179,182],{"start":68,"end":178,"type":128},20,{"start":180,"end":181,"type":128},35,55,{"start":183,"end":184,"type":128},134,147,{"type":58,"text":186,"spans":187,"direction":21},"The name \"island\" is precise: it's a self-contained, bounded piece of React that carries its own router, its own data layer, and its own styles, set into an ocean of Vue that surrounds and hosts it.",[],{"type":119,"text":189,"spans":190,"direction":21},"Anatomy of the bridge: five independent mechanisms",[],{"type":58,"text":192,"spans":193,"direction":21},"Here's the key lesson of the whole project: \"connect React and Vue\" isn't one problem, it's five independent problems. If you don't untangle them, you drown. If you separate them, each has a clean and surprisingly small solution.",[194],{"start":195,"end":196,"type":128},44,118,{"type":133,"text":198,"spans":199,"direction":21},"┌─ Rails layout ───────────────────────────────────────────────┐\n│  window.vueAuth / vueConfig / \u003Cmeta csrf>   (context from server)│\n│  window.foFlags = { reactExpenses: \u003Cserver-eval bool> }        │\n│  window.foIslandManifest = { expenses: \"\u003Casset URL>\" }         │\n│                                                                │\n│  #navbar  → Vue Navbar (HOST shell, always alive)             │\n│  #app     → Vue Layout + \u003Crouter-view :key=\"route.name\">       │\n│      ├─ /invoices, /contacts → Vue component                  │\n│      └─ /expenses/:rest*  →  ReactIslandHost.vue (passthrough) │\n│             mounted:  inject \u003Cscript type=module>              │\n│                       → window.FoIslands.expenses.mount(el,ctx)│\n│             beforeDestroy: …unmount()                          │\n│                                                                │\n│             React tree inside .react-island:                   │\n│               react-router · own Apollo · i18next · shadcn    │\n└────────────────────────────────────────────────────────────────┘",[],{"type":18,"text":201,"spans":202,"direction":21},"Mechanism 1 — Delivery: how to get the React code onto the page at all",[],{"type":58,"text":204,"spans":205,"direction":21},"Two bundlers can't share a module graph. React is built by Vite, Vue by webpack — you can't simply import React from Vue code.",[],{"type":58,"text":207,"spans":208,"direction":21},"The solution has two parts:",[],{"type":58,"text":210,"spans":211,"direction":21},"A runtime global mount API. The React island is a standalone Vite entrypoint that does not auto-mount. Instead, as a side effect it registers a small API onto a global object on window:",[212,214],{"start":68,"end":213,"type":128},27,{"start":158,"end":215,"type":128},101,{"type":133,"text":217,"spans":218,"direction":21},"window.FoIslands = window.FoIslands || {};\nwindow.FoIslands.expenses = {\n  mount(el, ctx)  { const root = createRoot(el); roots.set(el, root);\n                    root.render(\u003CExpensesIslandApp ctx={ctx} />); },\n  unmount(el)     { roots.get(el)?.unmount(); roots.delete(el); },\n};",[],{"type":58,"text":220,"spans":221,"direction":21},"window is the only shared language of the two worlds. Vue doesn't import React — it just calls a function React placed on window. The boundary between bundlers is thereby reduced to two functions and one JavaScript object.",[222],{"start":223,"end":224,"type":128},182,221,{"type":58,"text":226,"spans":227,"direction":21},"Injection via \u003Cscript type=\"module\">. How do you load that script? The first instinct is a dynamic import(). But the old bundler treats it as its own chunk and takes it over — exactly what we're trying to avoid. The solution is to bypass the bundler entirely: the host component creates a native \u003Cscript type=\"module\" src=\"...\"> and inserts it into the DOM. The browser's native ESM loader evaluates the module outside any bundler, the side effect registers window.FoIslands.expenses, the component waits for onload and calls mount().",[228,229,231],{"start":68,"end":143,"type":128},{"start":163,"end":230,"type":128},37,{"start":232,"end":233,"type":128},411,430,{"type":58,"text":235,"spans":236,"direction":21},"The URL of that script is supplied by the server (via an asset helper) into window.foIslandManifest. In dev it points to the dev server with HMR; in production to a hashed asset.",[],{"type":58,"text":238,"spans":239,"direction":21},"Lesson: don't solve the boundary between two incompatible build systems at the build level. Move it into runtime and make it the thinnest possible contract — ideally a single function on window.",[240],{"start":68,"end":168,"type":128},{"type":18,"text":242,"spans":243,"direction":21},"Mechanism 2 — Context: what the island knows about the rest of the app",[],{"type":58,"text":245,"spans":246,"direction":21},"The React island needs to know who is logged in, what the configuration is, which language, the CSRF token. All of that is already exposed by the Rails layout on window (window.vueAuth, window.vueConfig, \u003Cmeta name=\"csrf-token\">). At mount time, the host assembles a snapshot of it and passes it as the second argument to mount(el, ctx):",[247],{"start":248,"end":249,"type":128},267,275,{"type":133,"text":251,"spans":252,"direction":21},"interface IslandContext {\n  auth;                                   // from window.vueAuth\n  config; subscription;                   // from window.vueConfig\n  locale;                                 // document.documentElement.lang\n  csrfToken;                              // from \u003Cmeta>\n  basename;                               // where the island \"starts\" in the URL\n  navigate(path): void;                   // callback → vue-router (leaving the island)\n  notify({ type, message }): void;        // callback → Vue flash messages\n}",[],{"type":58,"text":254,"spans":255,"direction":21},"Two crucial design moves:",[],{"type":58,"text":257,"spans":258,"direction":21},"A snapshot, not a live shared store. Auth, config, and locale are read once at mount and never change afterward. No reactive bridge to Vuex. Why? Because changing the logged-in user or the language does a full reload anyway → the snapshot is always fresh afterward. You spare yourself an entire class of synchronization bugs in exchange for state that is unshared but frozen once.",[259,260],{"start":68,"end":163,"type":128},{"start":261,"end":262,"type":128},71,75,{"type":58,"text":264,"spans":265,"direction":21},"Callbacks for the things that must reach out of the island. The island has no navbar or flash-message system of its own — those belong to the host. So it's handed two functions: navigate (delegates to vue-router when the user leaves the section) and notify (sends a message into the Vue notification system). Notice the mapping at the boundary — the island emits { type, message }, the Vue store expects { kind, text }, and the host translates it:",[266],{"start":68,"end":267,"type":128},59,{"type":133,"text":269,"spans":270,"direction":21},"notify: flash => this.$store.dispatch('flashMessages/addMessage', {\n  kind: flash.type, text: flash.message,\n}),",[],{"type":58,"text":272,"spans":273,"direction":21},"Lesson: distinguish the data the island only reads (give it a snapshot) from the actions that must reach out into the host (give it a callback). Don't try to share a live store across frameworks — it's a source of endless race conditions.",[274,275,278],{"start":68,"end":168,"type":128},{"start":276,"end":277,"type":128},45,50,{"start":279,"end":280,"type":128},99,108,{"type":18,"text":282,"spans":283,"direction":21},"Mechanism 3 — Routing and history: the trickiest part",[],{"type":58,"text":285,"spans":286,"direction":21},"This is where most of the mines lie. Two routers (vue-router and react-router) have to share one address bar without stepping on each other.",[],{"type":58,"text":288,"spans":289,"direction":21},"One passthrough route instead of many. The server originally emits four separate Vue routes for the section (list, new, detail, clone). When the flag is on, the host collapses them into a single catch-all route:",[290,292],{"start":68,"end":291,"type":128},38,{"start":293,"end":294,"type":128},166,210,{"type":133,"text":296,"spans":297,"direction":21},"{ path: `${base}/:rest*`, name: 'expenses', component: ReactIslandHost,\n  props: { island: 'expenses', basename: base } }",[],{"type":58,"text":299,"spans":300,"direction":21},"Why one route and why a single name? Because the layout renders \u003Crouter-view :key=\"route.name\">. As long as name stays the same for the entire domain, the Vue island doesn't remount on internal navigation — the React tree lives on, only the screens inside it switch. If name changed, every in-section transition would kill and rebuild the whole React tree.",[301,304],{"start":302,"end":303,"type":128},136,142,{"start":293,"end":305,"type":128},181,{"type":58,"text":307,"spans":308,"direction":21},"Splitting URL ownership. vue-router matches anything under /expenses/* onto a single passthrough route and stops caring. React inside has its own \u003CBrowserRouter basename=\"/expenses\"> and handles the subpaths /new, /:id, /:id/clone.",[309],{"start":68,"end":310,"type":128},24,{"type":58,"text":312,"spans":313,"direction":21},"Coordinating window.history is the heart of the whole dance:",[314],{"start":68,"end":315,"type":128},13,{"type":317,"text":318,"spans":319,"direction":21},"list-item","React internal navigation calls history.pushState. vue-router only listens to popstate, so it doesn't see this change → no conflict.",[320],{"start":321,"end":322,"type":128},94,105,{"type":317,"text":324,"spans":325,"direction":21},"Back/forward inside the section → both routers receive popstate. vue-router still matches the same passthrough route → no remount. React switches the screen.",[326],{"start":315,"end":327,"type":128},19,{"type":317,"text":329,"spans":330,"direction":21},"Back/forward across the boundary of the section → vue-router matches a different route → remount → the host calls unmount() and the island disappears cleanly.",[331],{"start":315,"end":332,"type":128},32,{"type":58,"text":334,"spans":335,"direction":21},"A mine that only a walk-through of the code reveals. A layout typically does something like body.classList.add(to.name) and remove(from.name) in the route watcher. But on a popstate inside the section, to.name === from.name === 'expenses' → the class is first added, then immediately removed → body loses the class the CSS hangs on. The fix is one line, but you only find it by walking the whole transition in your head:",[336,338],{"start":68,"end":337,"type":128},52,{"start":339,"end":340,"type":128},298,332,{"type":133,"text":342,"spans":343,"direction":21},"if (to.name !== from.name) { /* ...original watcher body... */ }",[],{"type":58,"text":345,"spans":346,"direction":21},"Lesson: two routers on one URL are not two independent problems. You have to explicitly decide who owns which part of the path, and walk through all four combinations (in/out × push/pop). The mines are always at the boundary where to and from look identical.",[347,348],{"start":68,"end":168,"type":128},{"start":349,"end":293,"type":128},132,{"type":18,"text":351,"spans":352,"direction":21},"Mechanism 4 — CSS isolation: so styles don't leak",[],{"type":58,"text":354,"spans":355,"direction":21},"Tailwind (the new world) has a global reset (preflight). Element UI (the old world) has its own global styles. On a single page they clash.",[],{"type":58,"text":357,"spans":358,"direction":21},"The instinctive choices fail. @scope is too fresh a CSS feature — it would raise the minimum supported browser above Tailwind itself, so some users would see the island unstyled. Shadow DOM breaks both the portals of libraries like Radix and the inheritance of CSS custom properties (design tokens) from the host.",[359],{"start":360,"end":361,"type":128},179,189,{"type":58,"text":363,"spans":364,"direction":21},"The chosen solution has two parts:",[],{"type":58,"text":366,"spans":367,"direction":21},"A build-time prefix. A PostCSS step wraps all of the island's CSS under a single class — button { ... } becomes .react-island button { ... }. The whole React tree renders inside a \u003Cdiv class=\"react-island\">, so the island's styles apply only there and leak nowhere. It works on every browser that can handle Tailwind itself — no new browser feature.",[368,369],{"start":68,"end":178,"type":128},{"start":163,"end":370,"type":128},86,{"type":58,"text":372,"spans":373,"direction":21},"Portals back into scope. Modern UI libraries (Radix et al.) render dialogs, selects, and tooltips via a portal into document.body — that is, outside .react-island, where they'd lose their styles. The solution: set these components' container to the island's element so they portal inside the scope. The island hands its container down through React context:",[374,375],{"start":68,"end":310,"type":128},{"start":376,"end":377,"type":128},98,116,{"type":133,"text":379,"spans":380,"direction":21},"\u003Cdiv className=\"react-island\" ref={setContainer}>\n  \u003CPortalContainerContext.Provider value={container}>\n    {/* dialogs, popovers and selects now portal HERE, not into document.body */}\n  \u003C/PortalContainerContext.Provider>\n\u003C/div>",[],{"type":58,"text":382,"spans":383,"direction":21},"Lesson: style isolation always has two fronts — the static tree (solved by a prefix/scope) and the things libraries portal out (dialogs, dropdowns, tooltips). The second front is the easiest to forget and it only shows up at the first open modal. Test isolation with an open dialog, not just a static page.",[384,385,387],{"start":68,"end":168,"type":128},{"start":127,"end":386,"type":128},126,{"start":388,"end":389,"type":128},270,281,{"type":18,"text":391,"spans":392,"direction":21},"Mechanism 5 — Data layer and shared domain logic",[],{"type":58,"text":394,"spans":395,"direction":21},"The island has its own GraphQL client (it doesn't share a cache with Vue). Authentication runs purely through the session cookie — the island sends the same same-origin /graphql request as Vue, and the server derives the tenant from the session. The client never asserts whose data it wants — that's a security property, not a detail: multi-tenant scoping stays on the server, and the island only displays the tenant's identity, it doesn't select by it.",[396,397,400],{"start":327,"end":230,"type":128},{"start":398,"end":399,"type":128},114,128,{"start":401,"end":402,"type":128},257,290,{"type":58,"text":404,"spans":405,"direction":21},"Two separate caches (Vue Apollo 2 × React Apollo 3) mean that after leaving the island and coming back, data is refetched (cold cache). For a section that doesn't share critical data with the rest, this is an acceptable price for zero cache synchronization across frameworks.",[],{"type":58,"text":407,"spans":408,"direction":21},"A shared domain core — protection against drift. During migration, both the old (Vue) and the new (React) version of a section live side by side behind the flag. If both hold their own copy of the business rules (tax thresholds, conditional field visibility, validations), they will diverge — someone fixes a bug in one and forgets the other. The solution: extract a pure, framework-agnostic core (no vue-i18n, no Element, no React) into a neutral folder reachable from both bundlers:",[409,410,412],{"start":68,"end":126,"type":128},{"start":411,"end":402,"type":128},278,{"start":413,"end":414,"type":128},367,396,{"type":133,"text":416,"spans":417,"direction":21},"app/shared/expenses/taxRules.js   ← pure functions, `locale` as a parameter\n        ↑ imported by Vue                  ↑ imported by React",[],{"type":58,"text":419,"spans":420,"direction":21},"Vue keeps its Element validators on top of this core, React writes its zod refinements — but the thresholds and decision logic are physically one file. Drift is thereby structurally impossible, not just \"watched over by code review.\"",[421],{"start":422,"end":423,"type":128},93,151,{"type":58,"text":425,"spans":426,"direction":21},"Lesson: whenever you rewrite a piece of the app and let the old version live temporarily, separate the framework-agnostic business logic from the framework-specific glue and have both versions import the same source of truth. Duplicating rules during the coexistence period is a time bomb.",[427,428],{"start":68,"end":168,"type":128},{"start":429,"end":302,"type":128},103,{"type":119,"text":431,"spans":432,"direction":21},"Feature flag: a switch at the server level, not the client",[],{"type":58,"text":434,"spans":435,"direction":21},"The whole section is toggled by a single boolean flag. A non-obvious detail: the flag has to be server-eval (Rails evaluates it at render time and injects it into window.foFlags), not client-side.",[436,439],{"start":437,"end":438,"type":128},96,107,{"start":440,"end":441,"type":128},180,183,{"type":58,"text":443,"spans":444,"direction":21},"Why? The router is built synchronously from window when the app boots. A client-side feature-flag system (GrowthBook et al.) initializes asynchronously → at the moment the router is built, the flag wouldn't be available yet → a silent fallback to the old version. A flag evaluated on the server is on window before the frontend even runs, so the router sees it reliably.",[445,447,449],{"start":446,"end":291,"type":128},25,{"start":448,"end":423,"type":128},137,{"start":450,"end":451,"type":128},308,337,{"type":133,"text":453,"spans":454,"direction":21},"export const buildExpenseRoutes = (routes, flags) => {\n  if (!flags || !flags.reactExpenses) return routes;   // flag off → old Vue routes unchanged\n  // flag on → collapse 4 routes into one passthrough to ReactIslandHost\n  ...\n};",[],{"type":58,"text":456,"spans":457,"direction":21},"The result is an instant kill switch without a deploy — if something goes wrong with the island in production, you flip the flag and you're back on the proven Vue version within seconds.",[458],{"start":173,"end":459,"type":128},53,{"type":58,"text":461,"spans":462,"direction":21},"Lesson: a feature flag that synchronous boot code (routing) depends on must be evaluated before the JS runs. An asynchronous client-side flag silently fails at this job.",[463,464],{"start":68,"end":168,"type":128},{"start":465,"end":127,"type":128},89,{"type":119,"text":467,"spans":468,"direction":21},"How it scales: from pilot to pattern",[],{"type":58,"text":470,"spans":471,"direction":21},"The whole point of this work isn't \"rewrite one section.\" It's to build the bridge once and carry further sections over it cheaply. Once the first (pilot) section is done, adding another looks like this:",[472],{"start":473,"end":474,"type":128},66,131,{"type":476,"text":477,"spans":478,"direction":21},"o-list-item","A new Vite entrypoint that registers window.FoIslands.\u003Cname>.",[],{"type":476,"text":480,"spans":481,"direction":21},"One line in the build config + a path in the manifest.",[],{"type":476,"text":483,"spans":484,"direction":21},"In the router, switch the domain to ReactIslandHost with island: '\u003Cname>' behind a flag.",[],{"type":58,"text":486,"spans":487,"direction":21},"Generic, done once and for all: the host component ReactIslandHost, the context builder, CSS isolation (.react-island + portal container), history coordination and the layout guard, the server-eval flag switch, the form stack, shared app primitives (date picker, combobox, file upload), the shared-domain-core pattern.",[488],{"start":68,"end":489,"type":128},31,{"type":58,"text":491,"spans":492,"direction":21},"Per section, all that remains: the React tree of that specific domain, its translations, and any domain core.",[493],{"start":68,"end":494,"type":128},30,{"type":58,"text":496,"spans":497,"direction":21},"You build the first section expensively — because you're building the bridge. Every subsequent one at a fraction of the cost, because the bridge already stands.",[],{"type":119,"text":499,"spans":500,"direction":21},"Checklist and takeaways",[],{"type":58,"text":502,"spans":503,"direction":21},"If you're facing the same problem, here's the distillation:",[],{"type":476,"text":505,"spans":506,"direction":21},"Don't blend five problems into one. Delivery, context, routing, CSS isolation, and data are five independent mechanisms. Solve them separately.",[507],{"start":68,"end":180,"type":128},{"type":476,"text":509,"spans":510,"direction":21},"Make the boundary between bundlers as thin as possible — a runtime global API on window, not a shared module graph.",[511],{"start":68,"end":512,"type":128},54,{"type":476,"text":514,"spans":515,"direction":21},"Give context as a snapshot, actions as callbacks. Don't share a live store across frameworks.",[516],{"start":68,"end":517,"type":128},49,{"type":476,"text":519,"spans":520,"direction":21},"For routing, walk through every combination of push/pop × in/out. The mines are where to === from.",[521],{"start":68,"end":522,"type":128},43,{"type":476,"text":524,"spans":525,"direction":21},"CSS isolation has two fronts — the static tree and portals. Test with an open dialog.",[526],{"start":68,"end":527,"type":128},28,{"type":476,"text":529,"spans":530,"direction":21},"Framework-agnostic business logic goes into a shared core — otherwise the old and new versions drift.",[531],{"start":68,"end":532,"type":128},57,{"type":476,"text":534,"spans":535,"direction":21},"Evaluate a feature flag for boot-time code on the server, not the client.",[536],{"start":68,"end":537,"type":128},56,{"type":476,"text":539,"spans":540,"direction":21},"Keep multi-tenancy scoping on the server — the client only displays the tenant's identity, it doesn't select by it.",[541],{"start":68,"end":542,"type":128},40,{"type":476,"text":544,"spans":545,"direction":21},"Build the bridge as a reusable layer, not a one-off. First section expensive, the rest cheap.",[546],{"start":68,"end":163,"type":128},{"type":476,"text":548,"spans":549,"direction":21},"Spike \"dry\" first. Before you write a single line of business code, verify that a bare \"Hello from React\" island appears without a reload, the navbar stays alive, and CSS doesn't leak — and that on a production build, not just in dev.",[550],{"start":68,"end":551,"type":128},18,{"type":58,"text":553,"spans":554,"direction":21},"Migrating between frontend frameworks doesn't have to be an all-or-nothing bet. The island architecture turns it into a gradual, reversible, section-by-section process — with the app live in production the entire time.",[555],{"start":556,"end":557,"type":128},120,167,"text$101cdd22-dec1-4858-aa53-8c26b81a6eda","text",[561,842,1021],{"id":74,"uid":75,"url":76,"type":77,"href":78,"tags":562,"first_publication_date":80,"last_publication_date":80,"slugs":563,"linked_documents":564,"lang":13,"alternate_languages":565,"data":566},[],[75],[],[],{"title":567,"publishDate":5,"featuredImage":570,"slices":573},[568],{"type":53,"text":87,"spans":569,"direction":21},[],{"dimensions":571,"alt":87,"copyright":5,"url":92,"id":93,"edit":572},{"width":63,"height":91},{"x":95,"y":68,"zoom":69,"background":70},[574,583],{"variation":98,"version":99,"items":575,"primary":576,"id":111,"slice_type":112,"slice_label":5},[],{"image":577,"caption":580},{"dimensions":578,"alt":87,"copyright":5,"url":106,"id":93,"edit":579},{"width":104,"height":105},{"x":68,"y":68,"zoom":69,"background":70},[581],{"type":58,"text":87,"spans":582,"direction":21},[],{"variation":114,"version":99,"items":584,"primary":585,"id":558,"slice_type":559,"slice_label":5},[],{"text":586},[587,589,592,594,596,598,601,604,606,609,612,615,618,623,625,627,630,632,634,636,638,642,644,647,652,654,657,659,662,664,666,670,673,675,680,682,684,688,690,694,697,700,703,706,709,713,715,719,721,723,726,728,732,736,738,743,745,750,752,757,759,762,766,768,772,777,779,782,786,788,791,793,795,797,800,803,805,807,809,812,815,818,821,824,827,830,833,836,839],{"type":119,"text":120,"spans":588,"direction":21},[],{"type":58,"text":123,"spans":590,"direction":21},[591],{"start":126,"end":127,"type":128},{"type":58,"text":130,"spans":593,"direction":21},[],{"type":133,"text":134,"spans":595,"direction":21},[],{"type":58,"text":137,"spans":597,"direction":21},[],{"type":58,"text":140,"spans":599,"direction":21},[600],{"start":143,"end":144,"type":128},{"type":58,"text":146,"spans":602,"direction":21},[603],{"start":144,"end":149,"type":128},{"type":119,"text":151,"spans":605,"direction":21},[],{"type":58,"text":154,"spans":607,"direction":21},[608],{"start":157,"end":158,"type":128},{"type":58,"text":160,"spans":610,"direction":21},[611],{"start":68,"end":163,"type":128},{"type":58,"text":165,"spans":613,"direction":21},[614],{"start":68,"end":168,"type":128},{"type":58,"text":170,"spans":616,"direction":21},[617],{"start":68,"end":173,"type":128},{"type":58,"text":175,"spans":619,"direction":21},[620,621,622],{"start":68,"end":178,"type":128},{"start":180,"end":181,"type":128},{"start":183,"end":184,"type":128},{"type":58,"text":186,"spans":624,"direction":21},[],{"type":119,"text":189,"spans":626,"direction":21},[],{"type":58,"text":192,"spans":628,"direction":21},[629],{"start":195,"end":196,"type":128},{"type":133,"text":198,"spans":631,"direction":21},[],{"type":18,"text":201,"spans":633,"direction":21},[],{"type":58,"text":204,"spans":635,"direction":21},[],{"type":58,"text":207,"spans":637,"direction":21},[],{"type":58,"text":210,"spans":639,"direction":21},[640,641],{"start":68,"end":213,"type":128},{"start":158,"end":215,"type":128},{"type":133,"text":217,"spans":643,"direction":21},[],{"type":58,"text":220,"spans":645,"direction":21},[646],{"start":223,"end":224,"type":128},{"type":58,"text":226,"spans":648,"direction":21},[649,650,651],{"start":68,"end":143,"type":128},{"start":163,"end":230,"type":128},{"start":232,"end":233,"type":128},{"type":58,"text":235,"spans":653,"direction":21},[],{"type":58,"text":238,"spans":655,"direction":21},[656],{"start":68,"end":168,"type":128},{"type":18,"text":242,"spans":658,"direction":21},[],{"type":58,"text":245,"spans":660,"direction":21},[661],{"start":248,"end":249,"type":128},{"type":133,"text":251,"spans":663,"direction":21},[],{"type":58,"text":254,"spans":665,"direction":21},[],{"type":58,"text":257,"spans":667,"direction":21},[668,669],{"start":68,"end":163,"type":128},{"start":261,"end":262,"type":128},{"type":58,"text":264,"spans":671,"direction":21},[672],{"start":68,"end":267,"type":128},{"type":133,"text":269,"spans":674,"direction":21},[],{"type":58,"text":272,"spans":676,"direction":21},[677,678,679],{"start":68,"end":168,"type":128},{"start":276,"end":277,"type":128},{"start":279,"end":280,"type":128},{"type":18,"text":282,"spans":681,"direction":21},[],{"type":58,"text":285,"spans":683,"direction":21},[],{"type":58,"text":288,"spans":685,"direction":21},[686,687],{"start":68,"end":291,"type":128},{"start":293,"end":294,"type":128},{"type":133,"text":296,"spans":689,"direction":21},[],{"type":58,"text":299,"spans":691,"direction":21},[692,693],{"start":302,"end":303,"type":128},{"start":293,"end":305,"type":128},{"type":58,"text":307,"spans":695,"direction":21},[696],{"start":68,"end":310,"type":128},{"type":58,"text":312,"spans":698,"direction":21},[699],{"start":68,"end":315,"type":128},{"type":317,"text":318,"spans":701,"direction":21},[702],{"start":321,"end":322,"type":128},{"type":317,"text":324,"spans":704,"direction":21},[705],{"start":315,"end":327,"type":128},{"type":317,"text":329,"spans":707,"direction":21},[708],{"start":315,"end":332,"type":128},{"type":58,"text":334,"spans":710,"direction":21},[711,712],{"start":68,"end":337,"type":128},{"start":339,"end":340,"type":128},{"type":133,"text":342,"spans":714,"direction":21},[],{"type":58,"text":345,"spans":716,"direction":21},[717,718],{"start":68,"end":168,"type":128},{"start":349,"end":293,"type":128},{"type":18,"text":351,"spans":720,"direction":21},[],{"type":58,"text":354,"spans":722,"direction":21},[],{"type":58,"text":357,"spans":724,"direction":21},[725],{"start":360,"end":361,"type":128},{"type":58,"text":363,"spans":727,"direction":21},[],{"type":58,"text":366,"spans":729,"direction":21},[730,731],{"start":68,"end":178,"type":128},{"start":163,"end":370,"type":128},{"type":58,"text":372,"spans":733,"direction":21},[734,735],{"start":68,"end":310,"type":128},{"start":376,"end":377,"type":128},{"type":133,"text":379,"spans":737,"direction":21},[],{"type":58,"text":382,"spans":739,"direction":21},[740,741,742],{"start":68,"end":168,"type":128},{"start":127,"end":386,"type":128},{"start":388,"end":389,"type":128},{"type":18,"text":391,"spans":744,"direction":21},[],{"type":58,"text":394,"spans":746,"direction":21},[747,748,749],{"start":327,"end":230,"type":128},{"start":398,"end":399,"type":128},{"start":401,"end":402,"type":128},{"type":58,"text":404,"spans":751,"direction":21},[],{"type":58,"text":407,"spans":753,"direction":21},[754,755,756],{"start":68,"end":126,"type":128},{"start":411,"end":402,"type":128},{"start":413,"end":414,"type":128},{"type":133,"text":416,"spans":758,"direction":21},[],{"type":58,"text":419,"spans":760,"direction":21},[761],{"start":422,"end":423,"type":128},{"type":58,"text":425,"spans":763,"direction":21},[764,765],{"start":68,"end":168,"type":128},{"start":429,"end":302,"type":128},{"type":119,"text":431,"spans":767,"direction":21},[],{"type":58,"text":434,"spans":769,"direction":21},[770,771],{"start":437,"end":438,"type":128},{"start":440,"end":441,"type":128},{"type":58,"text":443,"spans":773,"direction":21},[774,775,776],{"start":446,"end":291,"type":128},{"start":448,"end":423,"type":128},{"start":450,"end":451,"type":128},{"type":133,"text":453,"spans":778,"direction":21},[],{"type":58,"text":456,"spans":780,"direction":21},[781],{"start":173,"end":459,"type":128},{"type":58,"text":461,"spans":783,"direction":21},[784,785],{"start":68,"end":168,"type":128},{"start":465,"end":127,"type":128},{"type":119,"text":467,"spans":787,"direction":21},[],{"type":58,"text":470,"spans":789,"direction":21},[790],{"start":473,"end":474,"type":128},{"type":476,"text":477,"spans":792,"direction":21},[],{"type":476,"text":480,"spans":794,"direction":21},[],{"type":476,"text":483,"spans":796,"direction":21},[],{"type":58,"text":486,"spans":798,"direction":21},[799],{"start":68,"end":489,"type":128},{"type":58,"text":491,"spans":801,"direction":21},[802],{"start":68,"end":494,"type":128},{"type":58,"text":496,"spans":804,"direction":21},[],{"type":119,"text":499,"spans":806,"direction":21},[],{"type":58,"text":502,"spans":808,"direction":21},[],{"type":476,"text":505,"spans":810,"direction":21},[811],{"start":68,"end":180,"type":128},{"type":476,"text":509,"spans":813,"direction":21},[814],{"start":68,"end":512,"type":128},{"type":476,"text":514,"spans":816,"direction":21},[817],{"start":68,"end":517,"type":128},{"type":476,"text":519,"spans":819,"direction":21},[820],{"start":68,"end":522,"type":128},{"type":476,"text":524,"spans":822,"direction":21},[823],{"start":68,"end":527,"type":128},{"type":476,"text":529,"spans":825,"direction":21},[826],{"start":68,"end":532,"type":128},{"type":476,"text":534,"spans":828,"direction":21},[829],{"start":68,"end":537,"type":128},{"type":476,"text":539,"spans":831,"direction":21},[832],{"start":68,"end":542,"type":128},{"type":476,"text":544,"spans":834,"direction":21},[835],{"start":68,"end":163,"type":128},{"type":476,"text":548,"spans":837,"direction":21},[838],{"start":68,"end":551,"type":128},{"type":58,"text":553,"spans":840,"direction":21},[841],{"start":556,"end":557,"type":128},{"id":843,"uid":844,"url":845,"type":77,"href":846,"tags":847,"first_publication_date":848,"last_publication_date":848,"slugs":849,"linked_documents":851,"lang":13,"alternate_languages":852,"data":853},"ajDYvhEAAHypkVKW","how-i-replaced-the-deprecated-git-up-gem-with-a-na","/articles/how-i-replaced-the-deprecated-git-up-gem-with-a-na","https://antoninpleskac.cdn.prismic.io/api/v2/documents/search?ref=ak4HWhEAACoA7dJQ&q=%5B%5B%3Ad+%3D+at%28document.id%2C+%22ajDYvhEAAHypkVKW%22%29+%5D%5D",[],"2026-06-16T05:55:35+0000",[850],"how-i-replaced-the-deprecated-git-up-gem-with-a-native-git-alias",[],[],{"title":854,"publishDate":5,"featuredImage":858,"slices":863},[855],{"type":53,"text":856,"spans":857,"direction":21},"How I Replaced the Deprecated git-up Gem With a Native Git Alias",[],{"dimensions":859,"alt":856,"copyright":5,"url":860,"id":861,"edit":862},{"width":63,"height":91},"https://images.prismic.io/antoninpleskac/ajDlJY1P9HI4UjH7_ChatGPTImageJun16%2C2026%2C07_54_30AM.png?auto=format,compress&rect=85,0,1365,1024&w=2000&h=1500","ajDlJY1P9HI4UjH7",{"x":95,"y":68,"zoom":69,"background":70},[864,873],{"variation":98,"version":99,"items":865,"primary":866,"id":872,"slice_type":112,"slice_label":5},[],{"image":867,"caption":871},{"dimensions":868,"alt":856,"copyright":5,"url":869,"id":861,"edit":870},{"width":104,"height":105},"https://images.prismic.io/antoninpleskac/ajDlJY1P9HI4UjH7_ChatGPTImageJun16%2C2026%2C07_54_30AM.png?auto=format,compress",{"x":68,"y":68,"zoom":69,"background":70},[],"image$7b2f5f6d-91ac-4766-b2c3-467d01cd5195",{"variation":114,"version":99,"items":874,"primary":875,"id":1020,"slice_type":559,"slice_label":5},[],{"text":876},[877,882,892,897,900,906,912,915,921,925,928,931,934,938,942,946,950,955,963,967,972,975,978,981,984,987,990,995,999,1003,1007,1012,1017],{"type":18,"text":878,"spans":879,"direction":21},"Introduction",[880],{"start":68,"end":881,"type":128},12,{"type":58,"text":883,"spans":884,"direction":21},"When you work across several feature branches at once, keeping them all in sync with the remote is a small but constant chore. For years my answer was git-up — a tidy little Ruby gem that fetches and rebases every locally-tracked branch in one command. Then I went to set it up on a fresh machine and remembered: the gem is deprecated, and has been for a while. So what do you reach for when the tool you relied on is gone, but the problem it solved is still there?",[885],{"start":423,"end":886,"type":887,"data":888},157,"hyperlink",{"link_type":889,"url":890,"target":891},"Web","https://github.com/aanand/git-up","_blank",{"type":18,"text":893,"spans":894,"direction":21},"The Problem",[895],{"start":68,"end":896,"type":128},11,{"type":58,"text":898,"spans":899,"direction":21},"git pull has two long-standing annoyances that git-up originally existed to fix:",[],{"type":317,"text":901,"spans":902,"direction":21},"It merges upstream changes by default, leaving your history full of noisy merge commits instead of a clean rebase.",[903],{"start":904,"end":905,"type":128},3,9,{"type":317,"text":907,"spans":908,"direction":21},"It only updates the branch you're currently on. So the moment you switch back to master, it's stale — and git push starts complaining about branches you weren't even thinking about.",[909],{"start":910,"end":911,"type":128},34,46,{"type":58,"text":913,"spans":914,"direction":21},"The gem's own author says it best in the README: as of Git 2.0 and 2.9, the core of what git-up did is now built into git itself. git pull --rebase --autostash covers the single-branch case completely. Installing a Ruby gem (and carrying a Ruby dependency) just for this no longer makes sense.",[],{"type":58,"text":916,"spans":917,"direction":21},"But there was one part I genuinely missed: updating the branches I'm not on. Specifically, I want my local master to be current the instant I switch to it — without leaving my feature branch to go fetch it.",[918],{"start":919,"end":920,"type":128},69,72,{"type":18,"text":922,"spans":923,"direction":21},"The Solution: One Alias That Does It All",[924],{"start":68,"end":542,"type":128},{"type":58,"text":926,"spans":927,"direction":21},"Instead of a gem, I now define git up as a git alias. It fetches once, rebases the branch I'm on, and fast-forwards every other branch that can be fast-forwarded — master included — all without a single checkout.",[],{"type":133,"text":929,"spans":930,"direction":21},"git config --global alias.up '!f() {\n  git fetch origin --prune\n  cur=$(git symbolic-ref --short HEAD)\n  git for-each-ref --format=\"%(refname:short) %(upstream:short)\" refs/heads |\n  while read b u; do\n    [ -z \"$u\" ] && continue                                      # no upstream → skip\n    git rev-parse --verify --quiet \"$u\" >/dev/null || continue   # upstream gone on origin → skip\n    lb=$(git rev-parse \"$b\"); lu=$(git rev-parse \"$u\")\n    [ \"$lb\" = \"$lu\" ] && continue                                # up to date → silent\n    base=$(git merge-base \"$b\" \"$u\")\n    [ \"$base\" = \"$lu\" ] && continue                              # ahead of upstream → silent\n    if [ \"$b\" = \"$cur\" ]; then\n      git pull --rebase --autostash >/dev/null 2>&1 && echo \"↻ $b (rebased)\"\n    elif [ \"$base\" = \"$lb\" ]; then\n      git fetch . \"$u:$b\" >/dev/null 2>&1 && echo \"✓ $b\"          # fast-forward, no checkout\n    else\n      echo \"⚠ $b (diverged, skipped)\"\n    fi\n  done\n}; f'\n",[],{"type":58,"text":932,"spans":933,"direction":21},"In ~/.gitconfig this lands on a single line — that's just how git config stores it. The wrapped version above is only for reading.",[],{"type":58,"text":935,"spans":936,"direction":21},"What This Alias Does",[937],{"start":68,"end":178,"type":128},{"type":476,"text":939,"spans":940,"direction":21},"Fetches origin once with --prune, so deleted remote branches stop lingering as stale tracking refs.",[941],{"start":68,"end":327,"type":128},{"type":476,"text":943,"spans":944,"direction":21},"Walks every local branch and looks up its upstream.",[945],{"start":68,"end":310,"type":128},{"type":476,"text":947,"spans":948,"direction":21},"Skips the noise silently — branches with no upstream, branches whose remote was deleted, branches already up to date, and branches ahead of their upstream.",[949],{"start":68,"end":310,"type":128},{"type":476,"text":951,"spans":952,"direction":21},"Rebases the current branch with pull --rebase --autostash, exactly like the old single-branch git up.",[953],{"start":68,"end":954,"type":128},26,{"type":476,"text":956,"spans":957,"direction":21},"Fast-forwards the rest using git fetch . origin/\u003Cbranch>:\u003Cbranch> — a neat trick that moves a local branch up to its upstream without checking it out. This is what keeps master fresh while you stay on your feature branch.",[958,960],{"start":68,"end":959,"type":128},22,{"start":386,"end":961,"type":962},149,"em",{"type":476,"text":964,"spans":965,"direction":21},"Reports diverged branches as ⚠ and leaves them untouched, because automatically rebasing a branch you're not watching is asking for trouble.",[966],{"start":68,"end":446,"type":128},{"type":18,"text":968,"spans":969,"direction":21},"Important Notes",[970],{"start":68,"end":971,"type":128},15,{"type":317,"text":973,"spans":974,"direction":21},"The single most useful trick here is git fetch . origin/master:master. It only succeeds as a fast-forward — if the histories have diverged, git refuses the refspec. That refusal is a feature: it means the alias can never silently rewrite or clobber a branch.",[],{"type":317,"text":976,"spans":977,"direction":21},"The reason the output stays short on a repo with hundreds of branches is one line: git rev-parse --verify --quiet \"$u\" || continue. It mirrors what the gem did internally — only act on branches whose origin/\u003Cbranch> still exists. Without it, every old merged branch prints a \"can't fast-forward\" line and the result is unreadable.",[],{"type":317,"text":979,"spans":980,"direction":21},"If you only ever care about the current branch, you don't need any of this. The minimal replacement is genuinely a one-liner:",[],{"type":317,"text":982,"spans":983,"direction":21},"git config --global alias.up 'pull --rebase --autostash'",[],{"type":317,"text":985,"spans":986,"direction":21},"Or make it the default for every pull:",[],{"type":317,"text":988,"spans":989,"direction":21},"git config --global pull.rebase true git config --global rebase.autoStash true",[],{"type":18,"text":991,"spans":992,"direction":21},"Safety Considerations",[993],{"start":68,"end":994,"type":128},21,{"type":317,"text":996,"spans":997,"direction":21},"Diverged branches are never touched. They're reported and skipped — you decide when and how to rebase them.",[998],{"start":68,"end":163,"type":128},{"type":317,"text":1000,"spans":1001,"direction":21},"Fast-forward only for non-current branches. Because git fetch . src:dst rejects non-fast-forward updates, there's no way for the alias to lose commits on a branch you're not on.",[1002],{"start":68,"end":173,"type":128},{"type":317,"text":1004,"spans":1005,"direction":21},"Autostash protects your work tree. If you run it with uncommitted changes, --autostash stashes them before the rebase and restores them after.",[1006],{"start":68,"end":910,"type":128},{"type":317,"text":1008,"spans":1009,"direction":21},"It still only rebases the branch you have checked out, so the one operation that does rewrite history happens where you can see it.",[1010],{"start":1011,"end":95,"type":962},81,{"type":18,"text":1013,"spans":1014,"direction":21},"Conclusion",[1015],{"start":68,"end":1016,"type":128},10,{"type":58,"text":1018,"spans":1019,"direction":21},"Sometimes a deprecated tool is a gift: it nudges you to find out the problem was already solved by something you already have. git-up was great, but everything it did for me now lives in three lines of git config — no Ruby, no gem, no dependency to keep alive. If you've been keeping the gem around out of habit, drop it, paste the alias, and let git up keep your whole local repo — master and all — fresh from a single command.",[],"text$092eda5c-7757-4cdb-bb84-fc219560455f",{"id":1022,"uid":1023,"url":1024,"type":77,"href":1025,"tags":1026,"first_publication_date":1027,"last_publication_date":1027,"slugs":1028,"linked_documents":1029,"lang":13,"alternate_languages":1030,"data":1031},"aUPCvBEAACkAHz8u","implementing-react-server-side-rendering-with-vite-in-ruby-on-rails-heroku-deployment","/articles/implementing-react-server-side-rendering-with-vite-in-ruby-on-rails-heroku-deployment","https://antoninpleskac.cdn.prismic.io/api/v2/documents/search?ref=ak4HWhEAACoA7dJQ&q=%5B%5B%3Ad+%3D+at%28document.id%2C+%22aUPCvBEAACkAHz8u%22%29+%5D%5D",[],"2025-12-18T09:19:16+0000",[1023],[],[],{"title":1032,"publishDate":5,"featuredImage":1036,"slices":1041},[1033],{"type":53,"text":1034,"spans":1035,"direction":21},"Implementing React Server-Side Rendering with Vite in Ruby on Rails (Heroku Deployment)",[],{"dimensions":1037,"alt":1034,"copyright":5,"url":1038,"id":1039,"edit":1040},{"width":63,"height":91},"https://images.prismic.io/antoninpleskac/aUPGx3NYClf9oYhL_ChatGPTImageDec18%2C2025%2C10_17_43AM.png?auto=format,compress&rect=45,0,711,533&w=2000&h=1500","aUPGx3NYClf9oYhL",{"x":276,"y":68,"zoom":69,"background":70},[1042,1055],{"variation":98,"version":99,"items":1043,"primary":1044,"id":1054,"slice_type":112,"slice_label":5},[],{"image":1045,"caption":1051},{"dimensions":1046,"alt":1034,"copyright":5,"url":1049,"id":1039,"edit":1050},{"width":1047,"height":1048},800,533,"https://images.prismic.io/antoninpleskac/aUPGx3NYClf9oYhL_ChatGPTImageDec18%2C2025%2C10_17_43AM.png?auto=format,compress",{"x":68,"y":68,"zoom":69,"background":70},[1052],{"type":58,"text":1034,"spans":1053,"direction":21},[],"image$1ee0626f-f793-4883-8b18-745aca73f284",{"variation":114,"version":99,"items":1056,"primary":1057,"id":1250,"slice_type":559,"slice_label":5},[],{"text":1058},[1059,1075,1078,1081,1085,1089,1094,1097,1100,1103,1106,1109,1112,1115,1118,1121,1124,1129,1132,1135,1138,1141,1144,1147,1150,1153,1156,1159,1162,1165,1169,1172,1175,1178,1181,1184,1187,1190,1193,1196,1199,1202,1205,1208,1211,1214,1217,1220,1223,1226,1229,1232,1235,1238,1241],{"type":58,"text":1060,"spans":1061,"direction":21},"This article walks through a full, production-ready setup for Server-Side Rendering (SSR) of a React application embedded in a Ruby on Rails project. The solution uses Vite as the build tool, Apollo Client for GraphQL data handling, and a dedicated Node.js SSR server, with deployment targeting Heroku.",[1062,1064,1067,1070,1072],{"start":1063,"end":465,"type":128},62,{"start":1065,"end":1066,"type":128},168,172,{"start":1068,"end":1069,"type":128},192,205,{"start":1071,"end":248,"type":128},249,{"start":1073,"end":1074,"type":128},295,301,{"type":119,"text":1076,"spans":1077,"direction":21},"Overall Architecture",[],{"type":58,"text":1079,"spans":1080,"direction":21},"The SSR implementation is composed of three clearly separated layers:",[],{"type":476,"text":1082,"spans":1083,"direction":21},"Rails (Backend)\nHandles incoming HTTP requests, forwards rendering requests to the SSR server, and returns the generated HTML.",[1084],{"start":68,"end":971,"type":128},{"type":476,"text":1086,"spans":1087,"direction":21},"Node.js (SSR Server)\nRuns an Express server responsible for executing React code and producing static HTML output.",[1088],{"start":68,"end":178,"type":128},{"type":476,"text":1090,"spans":1091,"direction":21},"React (Frontend)\nThe actual UI application, including a dedicated entrypoint designed specifically for server-side rendering.",[1092],{"start":68,"end":1093,"type":128},16,{"type":119,"text":1095,"spans":1096,"direction":21},"1. Rails Layer: SsrRenderer Concern",[],{"type":58,"text":1098,"spans":1099,"direction":21},"On the Rails side, we encapsulate all SSR-related logic inside a controller concern. This keeps controllers clean and makes SSR optional and configurable.",[],{"type":18,"text":1101,"spans":1102,"direction":21},"app/controllers/concerns/ssr_renderer.rb",[],{"type":133,"text":1104,"spans":1105,"direction":21},"module SsrRenderer\n  extend ActiveSupport::Concern\n\n  private\n\n  def render_ssr(url)\n    # SSR can be disabled (e.g., in development)\n    return [nil, nil, nil] unless ENV.fetch('SSR_ENABLED', 'true') == 'true'\n\n    ssr_host = ENV.fetch('SSR_HOST', '127.0.0.1')\n    ssr_port = ENV.fetch('SSR_PORT', '4000')\n\n    # Determine protocol and host for GraphQL endpoint\n    protocol = request.headers['X-Forwarded-Proto']&.split(',')&.first&.strip || request.protocol\n    host = request.headers['X-Forwarded-Host']&.split(',')&.first&.strip || request.host\n    graphql_uri = \"#{protocol}://#{host}/graphql\"\n\n    uri = URI.parse(\n      \"http://#{ssr_host}:#{ssr_port}/render\" \\\n      \"?url=#{CGI.escape(url)}\" \\\n      \"&graphql=#{CGI.escape(graphql_uri)}\" \\\n      \"&locale=#{I18n.locale}\"\n    )\n\n    http = Net::HTTP.new(uri.host, uri.port)\n    http.read_timeout = 6\n\n    req = Net::HTTP::Get.new(uri.request_uri)\n    req['Cookie'] = request.headers['Cookie'] if request.headers['Cookie'].present?\n    req['X-CSRF-Token'] = form_authenticity_token\n\n    response = http.request(req)\n    return [nil, nil, nil] unless response.is_a?(Net::HTTPSuccess)\n\n    json = JSON.parse(response.body)\n    [json['html'], json['state'], json['head']]\n  rescue StandardError => e\n    Rails.logger.error(\"[SSR HTTP] Failed: #{e.class}: #{e.message}\")\n    [nil, nil, nil]\n  end\nend\n",[],{"type":58,"text":1107,"spans":1108,"direction":21},"This design ensures that if SSR fails for any reason, the application gracefully falls back to client-side rendering.",[],{"type":119,"text":1110,"spans":1111,"direction":21},"2. Node.js Layer: SSR Server (Express + Vite)",[],{"type":58,"text":1113,"spans":1114,"direction":21},"The SSR server is a lightweight Express application that renders React either via Vite (development) or via a prebuilt bundle (production).",[],{"type":18,"text":1116,"spans":1117,"direction":21},"server/ssr-server.mjs",[],{"type":133,"text":1119,"spans":1120,"direction":21},"import express from 'express';\nimport { createServer } from 'vite';\nimport path from 'node:path';\n\nconst isProd = process.env.NODE_ENV === 'production';\nconst rootDir = path.resolve(path.dirname(new URL(import.meta.url).pathname), '..');\nconst app = express();\n\nlet vite;\nif (!isProd) {\n  vite = await createServer({\n    root: rootDir,\n    server: { middlewareMode: true, hmr: false },\n    appType: 'custom'\n  });\n  app.use(vite.middlewares);\n}\n\napp.get('/render', async (req, res) => {\n  try {\n    const { url, graphql, locale } = req.query;\n    let render;\n\n    if (isProd) {\n      const bundlePath = path.resolve(\n        rootDir,\n        'server/ssr-build/application-server.cjs'\n      );\n      render = (await import(bundlePath)).render;\n    } else {\n      render = (\n        await vite.ssrLoadModule(\n          'app/frontend/entrypoints/application-server.tsx'\n        )\n      ).render;\n    }\n\n    const ssrContext = {\n      headers: {\n        cookie: req.headers.cookie,\n        'x-csrf-token': req.headers['x-csrf-token'],\n        'x-locale': locale\n      },\n      graphqlUri: graphql\n    };\n\n    const result = await render(url, {}, ssrContext);\n    res.json(result);\n  } catch (e) {\n    console.error('[SSR Error]', e);\n    res.status(500).json({ error: e.message });\n  }\n});\n\napp.listen(4000, () => {\n  console.log('SSR Server running on port 4000');\n});\n",[],{"type":119,"text":1122,"spans":1123,"direction":21},"3. React Layer: Server-Side Entrypoint",[],{"type":58,"text":1125,"spans":1126,"direction":21},"To support GraphQL-based SSR, data must be fetched before rendering the component tree.",[1127],{"start":1128,"end":532,"type":128},51,{"type":18,"text":1130,"spans":1131,"direction":21},"app/frontend/entrypoints/application-server.tsx",[],{"type":133,"text":1133,"spans":1134,"direction":21},"import React from 'react';\nimport { renderToString } from 'react-dom/server';\nimport { ApolloProvider } from '@apollo/client';\nimport { getDataFromTree } from '@apollo/client/react/ssr';\nimport { StaticRouter } from 'react-router-dom/server';\nimport { HelmetProvider } from 'react-helmet-async';\nimport AppRoutes from '../app/AppRoutes';\nimport { createApolloClient } from '../config/apolloClient';\n\nexport async function render(url: string, _initialData: any, ssrContext: any) {\n  const client = createApolloClient({\n    ssrHeaders: ssrContext.headers,\n    ssrUri: ssrContext.graphqlUri\n  });\n\n  const helmetContext: any = {};\n\n  const app = (\n    \u003CApolloProvider client={client}>\n      \u003CHelmetProvider context={helmetContext}>\n        \u003CStaticRouter location={url}>\n          \u003CAppRoutes />\n        \u003C/StaticRouter>\n      \u003C/HelmetProvider>\n    \u003C/ApolloProvider>\n  );\n\n  await getDataFromTree(app);\n\n  const html = renderToString(app);\n  const initialApolloState = client.extract();\n\n  const { helmet } = helmetContext;\n  const head =\n    `${helmet.title.toString()}` +\n    `${helmet.meta.toString()}` +\n    `${helmet.link?.toString() || ''}`;\n\n  return {\n    html,\n    head,\n    state: `\u003Cscript>window.__APOLLO_STATE__=${JSON.stringify(initialApolloState)}\u003C/script>`\n  };\n}\n",[],{"type":119,"text":1136,"spans":1137,"direction":21},"4. Vite Configuration for SSR",[],{"type":58,"text":1139,"spans":1140,"direction":21},"Vite must be explicitly configured to produce a Node-compatible SSR bundle.",[],{"type":18,"text":1142,"spans":1143,"direction":21},"vite.ssr.config.mjs",[],{"type":133,"text":1145,"spans":1146,"direction":21},"import { defineConfig } from 'vite';\nimport react from '@vitejs/plugin-react';\n\nexport default defineConfig({\n  plugins: [react()],\n  ssr: {\n    noExternal: true\n  },\n  build: {\n    ssr: true,\n    copyPublicDir: false,\n    rollupOptions: {\n      input: 'app/frontend/entrypoints/application-server.tsx',\n      output: {\n        format: 'cjs',\n        entryFileNames: 'application-server.cjs',\n        inlineDynamicImports: true\n      }\n    }\n  }\n});\n",[],{"type":119,"text":1148,"spans":1149,"direction":21},"5. Client-Side Hydration",[],{"type":58,"text":1151,"spans":1152,"direction":21},"Once HTML is rendered on the server, the browser must “take over” using hydration.",[],{"type":18,"text":1154,"spans":1155,"direction":21},"app/frontend/entrypoints/application.tsx",[],{"type":133,"text":1157,"spans":1158,"direction":21},"import { hydrateRoot, createRoot } from 'react-dom/client';\nimport { createApolloClient } from '../config/apolloClient';\n\nconst client = createApolloClient({\n  initialApolloState: window.__APOLLO_STATE__\n});\n\nconst container = document.getElementById('react_app');\n\nif (container && container.hasChildNodes()) {\n  hydrateRoot(container, (\n    \u003CApolloProvider client={client}>\n      \u003CBrowserRouter>\n        \u003CAppRoutes />\n      \u003C/BrowserRouter>\n    \u003C/ApolloProvider>\n  ));\n} else if (container) {\n  const root = createRoot(container);\n  root.render(\u003CApp />);\n}\n",[],{"type":119,"text":1160,"spans":1161,"direction":21},"6. Localization (i18n) and SSR",[],{"type":58,"text":1163,"spans":1164,"direction":21},"Hydration mismatches often occur when the server and client disagree on language.",[],{"type":58,"text":1166,"spans":1167,"direction":21},"Solution overview:",[1168],{"start":68,"end":551,"type":128},{"type":476,"text":1170,"spans":1171,"direction":21},"Rails determines I18n.locale.",[],{"type":476,"text":1173,"spans":1174,"direction":21},"The locale is sent to the SSR server.",[],{"type":476,"text":1176,"spans":1177,"direction":21},"React initializes i18n with this locale before rendering.",[],{"type":476,"text":1179,"spans":1180,"direction":21},"The client reads the same locale during hydration.",[],{"type":58,"text":1182,"spans":1183,"direction":21},"This guarantees consistent language output.",[],{"type":119,"text":1185,"spans":1186,"direction":21},"7. Best Practices and Common Pitfalls",[],{"type":18,"text":1188,"spans":1189,"direction":21},"Always use timeouts",[],{"type":58,"text":1191,"spans":1192,"direction":21},"Rails should never wait indefinitely for SSR. A short timeout ensures graceful degradation to SPA rendering.",[],{"type":18,"text":1194,"spans":1195,"direction":21},"Avoid browser-only globals",[],{"type":58,"text":1197,"spans":1198,"direction":21},"Objects like window or document do not exist on the server:",[],{"type":133,"text":1200,"spans":1201,"direction":21},"if (typeof window !== 'undefined') {\n  // browser-only logic\n}\n",[],{"type":18,"text":1203,"spans":1204,"direction":21},"Prevent state leakage",[],{"type":58,"text":1206,"spans":1207,"direction":21},"Never reuse Apollo or i18n instances between requests. Each SSR request must create fresh instances to avoid leaking user data.",[],{"type":119,"text":1209,"spans":1210,"direction":21},"8. Deployment and Operations (Heroku)",[],{"type":58,"text":1212,"spans":1213,"direction":21},"In production, the SSR server runs alongside Rails as a parallel process.",[],{"type":18,"text":1215,"spans":1216,"direction":21},"Procfile",[],{"type":133,"text":1218,"spans":1219,"direction":21},"web: ./bin/web-start\n",[],{"type":18,"text":1221,"spans":1222,"direction":21},"bin/web-start",[],{"type":133,"text":1224,"spans":1225,"direction":21},"#!/usr/bin/env bash\n\nexport SSR_PORT=${SSR_PORT:-4000}\n\nnode server/ssr-server.mjs & SSR_PID=$!\nsleep 2\nbundle exec puma -C config/puma.rb & PUMA_PID=$!\n\nwait -n $PUMA_PID $SSR_PID\n",[],{"type":119,"text":1227,"spans":1228,"direction":21},"Environment Variables",[],{"type":58,"text":1230,"spans":1231,"direction":21},"SSR_ENABLED = true #Enables or disables SSRSSR_PORT4000SSR server port\nSSR_HOST = 127.0.0.1 #SSR server host\nSSR_DEBUG = 0 #Verbose logging\nSSR_CACHE_ENABLED = true #Enables HTML caching\nNODE_ENV = production #Production mode",[],{"type":119,"text":1233,"spans":1234,"direction":21},"Build Step on Heroku",[],{"type":58,"text":1236,"spans":1237,"direction":21},"Make sure the SSR bundle is built during deploy:",[],{"type":133,"text":1239,"spans":1240,"direction":21},"yarn frontend:build:ssr\n",[],{"type":58,"text":1242,"spans":1243,"direction":21},"This architecture delivers fast initial page loads, excellent SEO, and a modern React developer experience, all while staying deeply integrated with Ruby on Rails.",[1244,1245,1247],{"start":213,"end":277,"type":128},{"start":337,"end":1246,"type":128},65,{"start":1248,"end":1249,"type":128},73,106,"text$81d5e1de-43df-4c57-9ed9-79c04c5dbd4c",1783498628029]