Seamless navigation between SPA and LiveView

Feb 5, 2024 · 1626 words · 8 minutes read


Navigating between SPA and LiveView mostly works!

Recently, many websites have become single-page applications (SPAs), written in React/Vue/Svelte/ w/e else is trendy now, sometimes needlessly, but what’s done is done. I was wondering if it would be possible to break out from this path, where everything is rendered by SPA, and extend it with Phoenix LiveView, without radically affecting the user experience or requiring a months-long rewrite.

I believe the approach and problems presented in the post should work for different tools working similarly to LiveView, like LiveWire, or Turbo, but I’ve not tested that. Similarly, here in this blog post, I’ll use React, but I’ll refer to the frontend app as SPA because it’s mostly independent of the library.

The goal of the exercise is simple, can we replace a page with LiveView?

The setup

At the very beginning, the out-of-the-box setup for many SPAs configs is that they have their own dev server, with its own index.html that’s rendered on /*. Later on deployment, if there’s no server-side rendering, everything is served as static files.

Our server is nowhere to be found on the request’s path. That’s a bummer. We need to move to the good old way, where the app’s server is doing the routing and rendering HTML on the server. Starting with index.html. Otherwise, there’s no place for our Phoenix app.

This will be different for every SPA setup (there are dozens, or maybe hundreds, ?!). For brevity and for the purposes of the exercise, I created two apps. The first one is the most typical and boring Phoenix app. The second is the Vite React template for SPA. They’re living in their own worlds, without knowing about one another, at least at this stage.

$ mix sample
$ mix phx.server
[info] Migrations already up
[info] Running SampleWeb.Endpoint with cowboy 2.10.0 at (http)
[info] Access SampleWeb.Endpoint at http://localhost:4000
[watch] build finished, watching for changes...

Done in 550ms.
$ npm create vite@latest sample -- --template react
$ cd sample
$ npm install
$ npm run dev

Extending SPA with LiveView

Vite docs include a guide for integration with backend and we’ll follow it. It’s mostly correct; it’s just a matter of putting the things in the right place with additional LiveView specific glue.

We start with defining LiveView page where we can dump all requests.

  scope "/", SampleWeb do
    pipe_through :browser

+   live "/app/*path", SpaLive

SpaLive is going to have a single responsibility - show SPA. This means the SPA in DOM will be rendered inside LiveView.

defmodule SampleWeb.SpaLive do
  use SampleWeb, :live_view

  def mount(params, session, socket) do
    {:ok, socket, layout: {SampleWeb.Layouts, :spa}}

  # When URLs changes, do nothing, SPA is handling the routing
  def handle_params(uri, _, socket) do
    {:noreply, socket}

  def render(assigns) do
    # It's critical that div where the SPA is going to be rendered
    # is ignored with phx-update
    <div id="root" phx-update="ignore" phx-hook="LoadSPA"></div>

Starting from the top, we have mount/3 with a small change, we set a layout dedicated to SPA. It simply delegates all the content to inner content and keeps a place for flash messages from server.

  <.flash_group flash={@flash} />
  <%= @inner_content %>

We do it in such a way as to leave a place for any future HTML that may be part of the layout for the page. This is useful in case we’d like to move more of the layout to server, some boring stuff like a footer, a navigation bar or a cookie prompt. We don’t change anything in root.html.heex.

Next, we have handle_params/3. It’s a no-op method because SPA is handling the routing, we don’t need to do anything here. It’s important for links using navigate, in cases where they point to SPA and only this method will be called, not mount.

Finally, render/1. We cannot simply put <script> tags in ~H block, as instructed by Vite’s backend integration guide. This would work on the initial render, but if there’s navigation between different LiveView controlled pages, these scripts won’t be executed! LiveView under the hood is using morphdom and DOM is set via innerHTML.

HTML specifies that a <script> tag inserted with innerHTML should not execute.

This is a reason why we need to take a different approach, phx-hook. We’ll use # JavaScript interoperability to load SPA.

// required by Vite
// error handling of import is left as another exercise
const setupReactPatching = (callback) => {
  if (window.$RefreshReg$) {

  import("http://localhost:5173/@react-refresh").then(RefreshRuntime => {
    window.$RefreshReg$ = () => {}
    window.$RefreshSig$ = () => (type) => type
    window.__vite_plugin_react_preamble_installed__ = true

const LoadSPA = {
  mounted() {
    setupReactPatching(() => {
      import("http://localhost:5173/src/main.jsx").then(mount => {
        this.unmount = mount.default(this.el)

  destroyed() {
    if (this.unmount) {

export default LoadSPA;

In this hook, we follow Vite’s guide as before for backend integration and we load JS files from its own dev server.

We don’t set up any additional communication between LiveView and SPA, frontend will still send requests to server as before (if it did). If you’re interested in communication between SPA and LiveView, make sure to read React in LiveView: How and Why? blog post.

Back to LiveView hook. This isn’t fully working yet, notice how import of main.jsx exported a function to render SPA, we need to modify the most typical SPA set up to wait with rendering until we actually need it! Not on initial load.

// sample/src/main.jsx
const mount = (element) => {
  const root = ReactDOM.createRoot(element);
      <App />

  return () => {

export default mount;

…and that’s it, or is it? What about links?

We must be able to create links handled by LiveView on the client side. In a typical Phoenix heex template we’d use something like <.link navigate={~p"/about"}>About</.link>. LiveView then captures clicks (or fallbacks gracefully) and does client-side routing to seamlessly switch between pages. We need to do this for links on frontend. This can be done by adding phx annotations, and exactly we mimic the behavior of this LiveView component.

Sample component wrapping React Router link.

const LiveLink = ({ navigate, href, patch, replace, ...props }) => {
  const phxDataProps = {}

  if (navigate) {
    phxDataProps["data-phx-link"] = "redirect";
    phxDataProps["data-phx-link-state"] = replace ? "replace" : "push";
  } else if (patch) {
    phxDataProps["data-phx-link"] = "patch";
    phxDataProps["data-phx-link-state"] = replace ? "replace" : "push";

  return <Link {...props} {...phxDataProps} to={navigate || patch || href || "#"}  />;

For example, /app/login is a LiveView page and everything else is still on SPA.

Phoenix routing:

    live "/app/login", LoginLive
    live "/app/*path", SpaLive

A sample menu on SPA then may look like this:

// Full client rounting. LiveView won't see it at all
<Link to="/app/about">About</Link>

// LiveView captures click, handle_params/3 is called but SPA still renderd the page
<LiveLink patch="/app/contact">Contact</LiveLink>

// LiveView captures the click and navigates to a different LiveView dedicated to login page
<LiveLink navigate="/app/login">Login</LiveLink>

When it comes to navigation to SPA views, on backend it works as with any other link. Sample menu:

  <.link navigate={~p"/app/about"}>About</.link>
  <.link navigate={~p"/app/contact"}>Contact</.link>
  <.link navigate={~p"/app/login"}>Login</.link>

That’s all there is to navigate between SPA and LiveView, for a demo.

It wouldn’t work on deployment, for deployment, we need to load files from the manifest instead of running dev server, so the LiveView hook presented earlier would be a bit more complex.

Side note: if you’re using react-router, ensure you initialize the routes when SPA is mounting, not outside of it on initial load! Otherwise, when LiveView navigates browser’s history, react-router state will be out of date and it won’t render the correct page!

- const router = createBrowserRouter([...]);

const mount = (element) => {
  const root = ReactDOM.createRoot(element);
+  const router = createBrowserRouter([...]);
      <RouterProvider router={router} />


I actually thought it would be much more difficult to connect both worlds and was pleasantly surprised how little glue was needed in very specific places.

Such integration might be a practical option to explore for older applications, where LiveView looks very interesting to the team, but at the same time we need to maintain and develop our frontend too. We can still write SPA as before, we can create backend views like there’s no SPA and LiveView navigates between the two separate worlds.

And this separation is concerning because there are some areas where the two will overlap.

UI Components

It’s unlikely that client UI toolkit uses Web Components. This means that we either have to re-implement UI components, using the same styling on backend, or migrate needed frontend components to web components.

Currently on LiveView side, while basic web components would work, slots and styling for shadow DOM needs special consideration, see this thread on elixirforum.

Switch between two modes

If you follow the demo at the beginning closely, there’s noticable rerender when page switches between the two modes. This is because almost the whole DOM is replaced when JS/LiveView takes over.

This is very noticeable on elements that would be shared and must look the same in both SPA and LiveView. If it’s make-or-break, it might be worth considering moving these elements to server side and letting SPA just render the “inside” of the page, not the whole layout.

There’s more

There is more that needs to be done for great UX before you go ahead and deploy it for production. All invalid links now point to SPA, always. Maybe previously any user with the page left open, didn’t keep the WebSocket connection open and everything was cached. Now you need to handle this load on server side. And many more things, unique to each setup. If you would like to check the full code from the demo, see this repository. Thanks for reading!