The go-to resource for upgrading Ruby, Rails, and your dependencies.

Replacing Heavy React SPAs with Inertia.js and Svelte in Rails Apps


In the mid-19th century, Great Britain had a problem with its expanding railway network: different railway companies used different track gauges. When freight moved from a line built by the Great Western Railway to one built by a rival, the train could not continue on its way. It had to stop, and every single item had to be manually unloaded, carried across the platform, and reloaded onto a different train. This “break of gauge” introduced massive delays, increased costs, and created a logistical nightmare where there should have been a seamless journey.

A solution, of course, eventually came in the form of standardizing the gauge — unifying the network so that a single train could travel from end to end without interruption.

Around 2015, web development experienced its own “break of gauge” when applications moved away from server-rendered HTML toward a separated architecture. We built monolithic backends serving JSON APIs to thick Single Page Applications (SPAs), often built with React. This approach solved real problems regarding user experience, but it introduced a new set of challenges.

We now had to maintain two separate applications, synchronize state across the network, manage complex client-side routing, and duplicate business logic. We introduced a “JSON API tax” — the overhead of serializing data on the server, transmitting it, parsing it on the client, and mapping it into complex state management libraries. Much like the railway workers unloading and reloading freight, our engineering teams were spending an inordinate amount of time translating data across the boundary between backend and frontend.

Comparing Frontend Modernization Options

Before we look at the solution, let’s discuss a few possibilities for modernizing a Rails application’s frontend. There are three major approaches to delivering modern web experiences in Rails today.

The first is the separated SPA approach we already discussed, using React or Vue. This is useful, in my experience, when you have multiple frontends (like a web app and a native mobile app) consuming the exact same API.

The second is the Hotwire ecosystem, which includes Turbo and Stimulus. This approach sends HTML over the wire instead of JSON. Hotwire is the default in modern Rails and provides a fantastic developer experience for applications that need interactivity without the overhead of a JavaScript framework.

The third option is Inertia.js, which acts as a bridge between your server-side framework and a modern JavaScript frontend. Inertia allows you to build a classic server-driven web application while using a modern JavaScript framework for the view layer.

Generally speaking, the Hotwire approach is an excellent choice if you want to stay entirely within the Ruby ecosystem. The Inertia.js option, though, will often make more sense if your application requires highly complex, stateful user interfaces where a dedicated component framework shines, but you still want the routing and controller logic to live in Rails.

For this guide, we will focus on Inertia.js paired with Svelte. Svelte, strictly speaking, is a compiler rather than a traditional runtime framework like React. It shifts much of the work to compile-time, resulting in smaller, faster, and highly reactive components.

Understanding Inertia.js Mechanics

Before we get into the code, though, we need to understand the mechanics of the migration and how Inertia actually works.

When a user clicks a link in an Inertia application, the frontend intercepts the click and makes an XHR request to the server. The Rails controller processes the request exactly as it normally would, but instead of rendering an HTML view, it returns a JSON response containing the name of the Svelte component to render and the data (props) to pass to it.

You can think of Inertia as the missing glue that allows you to use Svelte components as if they were standard ERB templates, without building a dedicated API.

Practical Implementation

Let’s see how this works in practice. First, we need to set up the backend.

Configuring the Backend

Assuming you have a Rails application with Vite already configured, our first step is to add the Inertia.js Rails adapter. Let’s do that with the bundle add command:

$ bundle add inertia_rails

We also need to install the frontend dependencies via npm or yarn:

$ npm install @inertiajs/svelte svelte

Next, we configure Inertia in an initializer so it understands how to integrate with Vite.

# config/initializers/inertia_rails.rb
InertiaRails.configure do |config|
  config.ssr_enabled = ViteRuby.config.ssr_build_enabled
  config.version = ViteRuby.digest
end

Controller Design

In a traditional SPA architecture, your Rails controller would return a JSON response, and your frontend React component would use useEffect and fetch (or a library like axios) to asynchronously retrieve that data. This means the client must download the JavaScript, mount the component, and then make a second network request for the data, resulting in a loading spinner state.

With Inertia, however, we bypass that extra network hop completely. The controller action responds to the initial request by returning a specialized response that renders a Svelte component and passes data directly into it as props.

class CompaniesController < ApplicationController
  def index
    @companies = Company.includes(:industries).order(company_name: :asc)

    render inertia: 'companies/index', props: {
      companies: @companies.as_json(
        only: [:id, :company_name, :annual_revenue],
        include: { industries: { only: [:id, :name] } }
      )
    }
  end
end

Notice the explicit JSON serialization. We use as_json to strictly control what data is exposed to the frontend. This prevents accidentally leaking sensitive attributes to the client.

Svelte Component Structure

On the frontend, the component receives the data exported by the controller. Let’s look at the corresponding Svelte page.

<!-- app/frontend/pages/companies/index.svelte -->
<script>
  import { router } from '@inertiajs/svelte';
  
  export let companies;

  function viewCompany(id) {
    router.get(`/companies/${id}`);
  }
</script>

<div class="container mx-auto px-4">
  <h1 class="text-3xl font-bold mb-6">Companies</h1>

  <table class="min-w-full">
    <thead>
      <tr>
        <th>Name</th>
        <th>Revenue</th>
        <th>Actions</th>
      </tr>
    </thead>
    <tbody>
      {#each companies as company}
        <tr>
          <td>{company.company_name}</td>
          <td>${company.annual_revenue}</td>
          <td>
            <button on:click={() => viewCompany(company.id)} class="text-blue-600">
              View Details
            </button>
          </td>
        </tr>
      {/each}
    </tbody>
  </table>
</div>

The component receives the companies prop directly from Rails. Contrast this with the traditional React approach:

// A typical React fetch approach
function Companies() {
  const [companies, setCompanies] = useState([]);
  const [loading, setLoading] = useState(true);

  useEffect(() => {
    fetch('/api/companies')
      .then(res => res.json())
      .then(data => {
        setCompanies(data);
        setLoading(false);
      });
  }, []);

  if (loading) return <p>Loading...</p>;
  return <div>{/* render companies */}</div>;
}

By eliminating the need for useEffect fetch calls, manual loading states, and complex global state management to load initial data, we radically simplify our view layer.

Handling Forms and State

One of the most complex parts of a React SPA is form handling and validation. In a typical SPA, a developer has to intercept the submit event, serialize the form data, make a network request via fetch, parse the JSON response to extract validation errors, and map those errors back to individual input fields.

Inertia, on the other hand, reduces this complexity by providing a useForm helper that handles this boilerplate for us, allowing the form to behave more like a traditional HTML form submission from the developer’s perspective.

<!-- app/frontend/pages/companies/edit.svelte -->
<script>
  import { useForm } from '@inertiajs/svelte';

  export let company;

  let form = useForm({
    company_name: company.company_name,
    annual_revenue: company.annual_revenue
  });

  function handleSubmit() {
    $form.put(`/companies/${company.id}`, {
      preserveState: true,
      preserveScroll: true
    });
  }
</script>

<form on:submit|preventDefault={handleSubmit}>
  <div class="mb-4">
    <label for="company_name">Name:</label>
    <input type="text" id="company_name" bind:value={$form.company_name} class="border p-2" />
    {#if $form.errors.company_name}
      <p class="text-red-600">{$form.errors.company_name}</p>
    {/if}
  </div>

  <button type="submit" disabled={$form.processing} class="bg-blue-600 text-white px-4 py-2">
    {$form.processing ? 'Saving...' : 'Save Company'}
  </button>
</form>

When this form is submitted, Inertia sends a PUT request to the Rails controller. If the model validation fails, the controller returns the validation errors, which the $form.errors object automatically receives.

Shared Data

Often, you need to share data across all pages — such as the currently logged-in user or flash messages. We can do this using inertia_share in the ApplicationController.

class ApplicationController < ActionController::Base
  inertia_share do
    {
      auth: {
        user: current_user&.as_json(only: [:id, :email, :name])
      },
      flash: {
        success: flash[:success],
        error: flash[:error]
      }
    }
  end
end

Optimizing Shared Data with Lazy Evaluation

While inertia_share is convenient, it can introduce a performance bottleneck if the shared data is computationally expensive to generate. For example, imagine sharing the number of unread notifications:

class ApplicationController < ActionController::Base
  inertia_share do
    {
      auth: {
        user: current_user&.as_json(only: [:id, :email, :name]),
        unread_notifications: current_user&.notifications&.unread&.count
      }
    }
  end
end

In this scenario, the unread_notifications query will execute on every single page load, even on pages that do not display the notification count. This is because shared props are included in all standard Inertia responses.

To mitigate this, the inertia_rails gem provides a lazy evaluation feature. By wrapping the expensive data in an InertiaRails.lazy block, we instruct the backend to only evaluate and send the data when the frontend explicitly requests it as part of a “partial reload.”

class ApplicationController < ActionController::Base
  inertia_share do
    {
      auth: {
        user: current_user&.as_json(only: [:id, :email, :name]),
        unread_notifications: InertiaRails.lazy { current_user&.notifications&.unread&.count }
      }
    }
  end
end

It is critical to understand that lazy props are never evaluated on the initial page load. They are only evaluated during subsequent client-side navigations that explicitly request them using the only option in an Inertia link or programmatic visit. This makes them suitable for data that is expensive to calculate and only needed on specific pages or in response to specific user interactions.

Performance and Long-Term Maintainability

Of course, this approach is not without its trade-offs. The initial page load requires downloading the JavaScript bundle, which makes it slightly slower than pure HTML for the very first request. However, subsequent navigations are incredibly fast because only the JSON data is transferred over the network.

When replacing a heavy React SPA, the most significant gain is in maintainability. By moving the routing, data fetching, and authorization back to Rails, we eliminate thousands of lines of client-side JavaScript. Svelte’s compiler-driven approach further reduces the bundle size compared to a traditional React setup, resulting in a snappier experience for the end user.

Before you begin a migration, it is wise to ensure your Rails backend is fully prepared. You should audit your controllers for N+1 queries, as sending deeply nested JSON can trigger performance issues if relationships are not eager-loaded correctly.

Conclusion

The architectural shift from a separated SPA back to a monolith using Inertia.js and Svelte represents a pragmatic approach to building modern web applications. By treating Svelte components as the view layer of a standard Rails application, we retain the interactivity users expect while keeping the development process grounded in the proven conventions of Ruby on Rails.

This leads naturally to a more durable codebase — one that is easier to maintain, faster to develop, and free from the inherent complexities of maintaining two entirely separate applications.

If you are interested in exploring this architecture further, you may want to examine the official Inertia.js documentation. It is an excellent resource for deeper topics like manual visits, progress indicators, and partial reloads. Similarly, the official Svelte documentation offers a comprehensive guide to its compiler-based reactivity model.

Sponsored by Durable Programming

Need help maintaining or upgrading your Ruby on Rails application? Durable Programming specializes in keeping Rails apps secure, performant, and up-to-date.

Hire Durable Programming