Building an InertiaJS app with Rails

By Exequiel Rozas

- February 24, 2025

Around the 10s, SPAs were everywhere: they promised to solve the increasingly challenging requirements for the front-end that SSR frameworks like Rails weren't designed to solve.

The feeling in the air was that every new app needed to be an API-backed Single Page Application.

The paradigm shift didn't come without a cost: building an application became much more difficult, the front-end became more complex, and some non-issues, like SEO, became a problem.

However, there are some parts of our applications that might be highly interactive where using a framework like React or Vue is a good thing.

But we would rather not throw everything that we love about Rails for a few parts of our apps: that's where Inertia comes to play: it allows us to have the benefits of an SPA without having to leave our beloved Rails monolith or building an API.

Let's start by seeing what we will build:

The result

To demonstrate how Inertia integrates with Rails, I built a budget tracking application using Rails and Inertia.

The app doesn't have many features, it just allows us to record transactions—think expenses or income—and categorize them to better keep track of our finances.

This is what it looks like:

Because of format constraints I cannot show every part of the application in this article, but you are welcome to check the repository.

Please note: I chose React as the front-end framework because that's what I know best. Every JS code example will be using that. Consider that equivalent alternatives are also available for Vue and Svelte and that the concepts are equivalent for any alternative you pick.

How does Inertia work?

Inertia defines itself as a way to build single-page applications without the need for an API.

Its main purpose is to connect our backend with a frontend built using a JS framework as seamlessly as possible.

It allows us to replace traditional Rails views with React, Vue or Svelte components without throwing everything that Rails provides for us out of the window.

At its core, it's a client-side routing library that allows us to make page visits without producing a full-page reload, but it also includes many features that can make our lives easier like: form and redirection handling, CSRF, validations, etc.

For example, we can render a post collection page using a Posts/Index.jsx component by simply passing the inertia option to the render method in our PostsController:

class PostsController < ApplicationController
  def index
    posts = Post.all
    render inertia: 'Posts/Index', props: {posts: posts}
  end
end

Now, the Posts/Index component is responsible for displaying our post collection, and it receives the posts prop to make the collection available to the client.

If we explore what's actually rendered in the initial request, we can see that the Inertia app is mounted using an auto-generated div that looks like this:

<div id="app" data-page='{"component": "Sessions/New", "props": {posts: []}, url: "/posts", version: "7b04c941b5a5480943a86fb95ba55c94b1e423aa", "encryptHistory: false, "clearHistory": false}'>
  <!-- The component gets rendered here -->
</div>

The data-page attribute is the way that Inertia uses to pass data from the server to the client.

But data sharing is just one of the several things that are needed to properly build an app of this nature: form submits, server-side validations, redirects, link behavior, routing for components that don't necessarily need a controller action, scroll management, and more.

Before moving further, let's explore some vital concepts to better understand how it works:

Pages and layouts

They are JavaScript components that are rendered in place of the traditional Rails views.

The props that they receive come from the controller and can be used within the component or passed down to child components that may use them.

Meanwhile, layouts are JS components too, but they are usually rendered wrapping page components using the {children} prop.

Layouts can be used wrapping every page component, which forces them to be destroyed and recreated on every visit, which implies no persistent layout state between pages:

import Layout from "../Layout";

export default function Home({user}) {
  return (
    <Layout>
      <h1>Hello World, {user.username}</h1>
    </Layout>
  )
}

Or, we can use persistent layouts by setting the .layout attribute:

import Layout from "../Layout";

export default function Home({user}) {
  return (
    <div>
      <h1>Hello World, {user.username}</h1>
    </div>
  )
}

Home.layout = (page) => <Layout children={page} title="Custom finance tracking app" />

The second option is a must if we're building something that requires persistent layout state, like a podcast or video player that shouldn't be thrown out when navigating to other pages.

We can also define default layouts to avoid repeating the configuration too much.

The page object

It's the way Inertia shares data between the server and the client. It's passed on every request, and it includes the following attributes: component, props, url, version, encryptHistory and clearHistory.

On full-page visits—a.k.a. standard Rails visits—it includes the attributes on a <div> just like we showed above. On Inertia visits, it gets returned as a JSON payload without us having to do anything.

We can access the page object implicitly via component props:

export default function CategoryIndex = ({categories, user}) => {}

Or directly using the usePage hook:

import {usePage} from "@inertiajs/react"

export default function CategoryIndex = () => {
  const user = usePage().props.user;
  const categories = usePage().props.categories;
}

If we want to access properties like the current url or the currentComponent we need to access them through usePage.

Besides passing data through the props option in a controller action, we can also use the inertia_share helper, which merges the data passed to it with the props object. It's especially useful for data that needs to be used in multiple pages like flash messages, for example.

The Link component

Much like link_to, the <Link/> component is a wrapper around an anchor <a> tag but it's also capable of intercepting the request and prevent a full-page reload.

The way to use them is by importing it and declaring it passing the href attribute:

import {Link} from "@inertiajs/react"

export default () => <Link href="/categories">Categories</Link>

We can configure the type of tag that's rendered using the as attribute and the request method in case we need to:

export default () => {
  <Link href="/sign-out" method="delete" as="button" />
}

We can also pass data, custom headers, cause partial reloads, among other things, using this component. You are welcome to consult the link guide to see everything that can be done with it.

Inertia provides a frontend folder, which is the place where we put the code that we usually put in the views folder. Most of your application frontend code will live here. By default, this folder has assets, entrypoints and pages folders. However, we can add as many folders as we need. I added folders for components, layouts and utils. You are welcome to do the same and design your frontend as you wish.

Forms

Unlike in traditional SPA apps, handling forms in Inertia requires us to define them, intercepting the event to prevent default behavior and submitting them to the backend.

There, we run validations and return a success response or the errors that can be processed in the front-end to provide feedback to users.

Unless we have some complex front-end validation needs, there's no need to use form libraries.

We can handle the form submission using local state and Inertia's router for maximum control or we can use the useForm hook to handle almost everything for us:

import { useForm } from "@inertiajs/react"

export default NewTagForm = () => {
  const {data, setData, post, processing, errors} => useForm({value: ""});

  function handleSubmit(evt) {
    evt.preventDefault()
    post("/tags")
  }

  return(
    <form onSubmit={handleSubmit}>
      <input type="text" value={data.value} onChange={(e) => setData("value", e.target.value)} />
      {errors.value && <div>{errors.value}</div>}

       <button type="submit" disabled={processing}>
         {processing ? "Submitting" : "Submit"}
       </button>
    </form>
  )
}

This is everything we need to manage form submissions with the helper.

When to use Inertia

The long answer is: whenever we want to access the good parts of a frontend framework without the headaches that come from building a separate API and having two separate repositories.

The short answer is: it depends, and it's up to us.

One might wonder what the benefits of using a library like Inertia over just using Hotwire and Stimulus to build the frontend of your application.

After all, they can achieve basically the same thing, but Hotwire and Stimulus can make us more productive, right?

Well, not exactly, at least not for every use case.

The following are reasons we might choose Inertia over omakase Rails:

  • The JS ecosystem: the JavaScript ecosystem is huge, and some libraries are only available for specific frameworks like React or Vue.
  • High interactivity: there are some applications where using a frontend framework will improve the user experience. Especially if we're dealing with user interfaces very dynamic in nature. A few examples that come to mind are apps like YouTube or Spotify.
  • Integration with other frontend heavy apps: even if solutions like Hotwire Native exist, we might need to build a mobile app using a tool like React Native. In that case, sharing components between your web and mobile apps is desirable. In this case, you might need to build an API for the mobile app, but you will still get to enjoy the good ol' Rails goodies to build your web app.

Having mentioned those, and if we're more experienced with Hotwire/Stimulus than we're with React, Vue, or Svelte, there are many scenarios where not using Inertia is the smart choice.

All things considered, using Inertia depends on our requirements and the affinity we have with front-end frameworks.

Lastly, the decision to use Inertia doesn't mean we have to go all in with it for our application. We can always use it for parts we deem worthy and use the traditional stack for other parts of our app.

If you're just seeking for a perceived performance improvement and nothing more, Inertia is probably not your solution. Just like Turbo, Inertia doesn't produce a CSS/JSS re-evaluation between requests, so you can produce a similar improvement without the need to write any framework-specific JS.

Application set up

The first step is to create a new Rails app, skipping Javascript:

$ rails new finance_tracker --skip-javascript --css=tailwind

After it successfully runs, we install the Inertia Rails gem with the following command:

$ bundle add inertia_rails && bundle install

Now, we proceed to install inertia_rails:

$ bin/rails generate inertia:install

The installation command will:

  • Check if our app has Vite Rails installed and install it if not.
  • Ask whether we want to use TypeScript or not.
  • Ask us to choose our preferred frontend framework among: React, Vue, Svelte 4 and Svelte 5.
  • Ask us if we wish to use Tailwind.
  • Install dependencies.
  • Set our application to work with Inertia.
  • Copy an example view, with its corresponding controller, that can be accessed in the /inertia-example route. We can skip this example page with the --skip-example flag when calling the generator.

In my case, I went with Vite, React— without Typescript for simplicity —and Tailwind, but you can, of course, pick the combination you consider best.

Then, if everything's ok, we can run bin/dev, navigate to the example page at /inertia-example and it should work.

However, we might encounter the following error:

It looks like you're trying to use tailwindcss directly as a PostCSS plugin. The PostCSS plugin has moved to a separate package, so to continue using Tailwind CSS with PostCSS you'll need to install @tailwindcss/postcss and update your PostCSS configuration.

This is related to the recently released Tailwind v4. To solve this issue, we just have to install @tailwindcss/postcss:

$ npm install @tailwindcss/postcss

And then, replace the tailwindcss plugin with @tailwindcss/postcss in the postcss.config.js file:

export default {
  plugins: {
    "@tailwindcss/postcss": {},
    autoprefixer: {},
  },
}

The next change we have to make so Tailwind v4 can work correctly is change the imports in frontend/entrypoints/application.css:

/* From this */
@tailwind base;
@tailwind components;
@tailwind utilities;

/* To this */
@import tailwindcss;

Now we can navigate back to the example page, and we should see something like this:

View of the example Inertia page in Rails

This page is rendered by the inertia_example_controller.rb which uses the inertia helper method, we can remove it safely and add the models:

Authentication with the Rails generator

Later on, we will show how Inertia interacts with authentication in our app. So let's add it with the new generator:

$ bin/rails generate authentication && bin/rails db:migrate

These will give us User and Session models that we will use later to authenticate our users from an Inertia view.

Check our Rails 8 authentication if you want to better understand how the generator works.

Application models

Besides the User model, we will add two models to keep track of transactions:

  • Transaction: represents a transaction which can be an expense or income.
  • Category: to better track how we spend our money, every Transaction will belong to a Category

Database tables for our finance tracking app

Let's start by creating the Category model:

$ bin/rails generate model Category user:references name description color_code

We then proceed with the Transaction:

$ bin/rails generate model Transaction user:references category:references transaction_type amount_cents:integer amount_currency date:datetime notes:text

Then we add the associations:

class User < ApplicationRecord
  has_secure_password
  has_many :sessions, dependent: :destroy
  has_many :categories, dependent: :destroy
  has_many :transactions, dependent: :destroy

  normalizes :email_address, with: ->(e) { e.strip.downcase }

  monetize :balance_cents
end
class Category < ApplicationRecord
  belongs_to :user
  has_many :transactions

  extend FriendlyId
  friendly_id :name, use: :slugged

  validates :name, presence: true, uniqueness: { scope: :user_id }
  validates :description, presence: true
  validates :color_code, presence: true
end
class Transaction < ApplicationRecord
  TRANSACTION_TYPES = ['expense', 'income']

  belongs_to :user
  belongs_to :category

  validates :amount, presence: true
  validates :transaction_type, presence: true, inclusion: { in: TRANSACTION_TYPES }
  validates :date, presence: true

  monetize :amount_cents

  scope :descending_by_date, -> { order(date: :desc) }
  scope :income, -> { where(transaction_type: 'income') }
  scope :expense, -> { where(transaction_type: 'expense') }
end

Next, we will add the following seeds to create some categories and transactions so we can produce the views:

# config/db/seeds.rb
puts "Clearing existing data..."
Transaction.destroy_all
Category.destroy_all

user = User.create!(email_address: "example@example.com", password: "password", username: "example", balance: 8950)
puts "Creating data for #{user.email_address}..."

# Create categories first
categories = {
  income: Category.create!(
    user: user,
    name: 'Income',
    description: 'Money earned from salary, freelancing, investments, and other sources',
    color_code: '#28a745'
  ),
  bills: Category.create!(
    user: user,
    name: 'Bills',
    description: 'Regular monthly expenses like rent, utilities, and subscriptions',
    color_code: '#dc3545'
  ),
  transport: Category.create!(
    user: user,
    name: 'Transportation',
    description: 'Costs related to commuting, travel, and vehicle maintenance',
    color_code: '#fd7e14'
  ),
  food: Category.create!(
    user: user,
    name: 'Food',
    description: 'Groceries, restaurants, coffee shops, and food delivery',
    color_code: '#ffc107'
  ),
  shopping: Category.create!(
    user: user,
    name: 'Shopping',
    description: 'Clothing, electronics, home goods, and other retail purchases',
    color_code: '#e83e8c'
  ),
  pets: Category.create!(
    user: user,
    name: 'Pets',
    description: 'Pet food, veterinary care, supplies, and grooming',
    color_code: '#6f42c1'
  ),
  leisure: Category.create!(
    user: user,
    name: 'Leisure',
    description: 'Entertainment, hobbies, sports, and recreational activities',
    color_code: '#17a2b8'
  ),
  education: Category.create!(
    user: user,
    name: 'Education',
    description: 'Courses, books, online learning, and educational materials',
    color_code: '#20c997'
  )
}

# Create transactions with direct category references
transactions = [
  # Income
  { category: categories[:income], amount_cents: 500000, transaction_type: 'income', date: 1.month.ago, notes: 'Monthly Salary' },
  { category: categories[:income], amount_cents: 85000, transaction_type: 'income', date: 10.days.ago, notes: 'Freelance Project' },

  # Bills
  { category: categories[:bills], amount_cents: 165000, transaction_type: 'expense', date: 28.days.ago, notes: 'Rent' },
  { category: categories[:bills], amount_cents: 13500, transaction_type: 'expense', date: 25.days.ago, notes: 'Electricity' },
  { category: categories[:bills], amount_cents: 7500, transaction_type: 'expense', date: 24.days.ago, notes: 'Internet' },
  { category: categories[:bills], amount_cents: 4500, transaction_type: 'expense', date: 23.days.ago, notes: 'Phone' },

  # Transportation
  { category: categories[:transport], amount_cents: 6500, transaction_type: 'expense', date: 29.days.ago, notes: 'Transit Pass' },
  { category: categories[:transport], amount_cents: 3200, transaction_type: 'expense', date: 20.days.ago, notes: 'Uber to Airport' },
  { category: categories[:transport], amount_cents: 2800, transaction_type: 'expense', date: 15.days.ago, notes: 'Uber from Restaurant' },
  { category: categories[:transport], amount_cents: 3500, transaction_type: 'expense', date: 5.days.ago, notes: 'Uber Late Night' },

  # Food
  { category: categories[:food], amount_cents: 8500, transaction_type: 'expense', date: 27.days.ago, notes: 'Whole Foods' },
  { category: categories[:food], amount_cents: 6500, transaction_type: 'expense', date: 20.days.ago, notes: 'Trader Joes' },
  { category: categories[:food], amount_cents: 7200, transaction_type: 'expense', date: 13.days.ago, notes: 'Local Market' },
  { category: categories[:food], amount_cents: 7800, transaction_type: 'expense', date: 6.days.ago, notes: 'Weekly Groceries' },
  { category: categories[:food], amount_cents: 1500, transaction_type: 'expense', date: 25.days.ago, notes: 'Starbucks' },
  { category: categories[:food], amount_cents: 2200, transaction_type: 'expense', date: 18.days.ago, notes: 'Coffee Shop' },
  { category: categories[:food], amount_cents: 8500, transaction_type: 'expense', date: 12.days.ago, notes: 'Italian Restaurant' },
  { category: categories[:food], amount_cents: 3500, transaction_type: 'expense', date: 8.days.ago, notes: 'Lunch' },

  # Shopping
  { category: categories[:shopping], amount_cents: 12500, transaction_type: 'expense', date: 17.days.ago, notes: 'Winter Boots' },
  { category: categories[:shopping], amount_cents: 8500, transaction_type: 'expense', date: 14.days.ago, notes: 'H&M' },
  { category: categories[:shopping], amount_cents: 4500, transaction_type: 'expense', date: 7.days.ago, notes: 'Home Decor' },

  # Pets
  { category: categories[:pets], amount_cents: 7500, transaction_type: 'expense', date: 21.days.ago, notes: 'Dog Food' },
  { category: categories[:pets], amount_cents: 12500, transaction_type: 'expense', date: 16.days.ago, notes: 'Vet Visit' },
  { category: categories[:pets], amount_cents: 3500, transaction_type: 'expense', date: 4.days.ago, notes: 'Dog Toys' },

  # Leisure
  { category: categories[:leisure], amount_cents: 3200, transaction_type: 'expense', date: 22.days.ago, notes: 'Movies' },
  { category: categories[:leisure], amount_cents: 8500, transaction_type: 'expense', date: 11.days.ago, notes: 'Concert' },
  { category: categories[:leisure], amount_cents: 4500, transaction_type: 'expense', date: 3.days.ago, notes: 'Bowling' },

  # Education
  { category: categories[:education], amount_cents: 12500, transaction_type: 'expense', date: 19.days.ago, notes: 'Udemy Courses' },
  { category: categories[:education], amount_cents: 4500, transaction_type: 'expense', date: 9.days.ago, notes: 'Programming Books' },
  { category: categories[:education], amount_cents: 1500, transaction_type: 'expense', date: 2.days.ago, notes: 'Learning Platform' }
]

# Set default currency and create transactions
transactions.each do |t|
  Transaction.create!(user: user, amount_currency: 'USD', **t)
end

puts "Created #{Category.count} categories and #{Transaction.count} transactions"

Once we have the seeds in place, we now add routes for the endpoints we need:

# config/routes.rb
  get "dashboard" => "pages#dashboard"
  resources :categories, only: [:index, :create, :destroy]
  resources :transactions, only: [:index, :create, :destroy]

  root "pages#home"

These routes will be the foundation for our app. Let's start building it:

Building the application

As explained above, the app will have two layouts: one for the site itself and another for the parts that require authentication.

We will call the first one SiteLayout and the second one DashboardLayout.

Site layout

The SiteLayout file looks like this:

import { Head } from "@inertiajs/react"
import NavBar from "~/components/NavBar"
import FlashMessages from "~/components/FlashMessages"

const SiteLayout = ({ children }) => {

  return (
    <main>
      <Head title="Track your finances - Avonomy" />
      <NavBar />
      <FlashMessages />
      <div>{children}</div>
    </main>
  )
}

export default SiteLayout

It's mostly a standard React component that receives a children prop, which is the page component it wraps.

The Head component, provided by the framework, is used to set the title and meta tags for every page.

It can be called in any page component, and it will override what we've declared here, but we provide a default in case we forget to set it elsewhere.

The other is the FlashMessages component, which we use to display the flash messages we receive from the server. To define it, we first create a components folder inside app/frontend:

import { usePage } from '@inertiajs/react'
import { CircleAlert, TriangleAlert } from 'lucide-react'

const FlashMessages = () => {
  const { flash } = usePage().props

  if (flash.alert) {
    return <div className="bg-red-100 text-red-800 p-4 rounded-md">
      <div className="max-w-xl mx-auto flex justify-center items-center gap-2">
        <TriangleAlert className="w-6 h-6" />
        <p>{flash.alert}</p>
      </div>
    </div>
  }

  if (flash.notice) {
    return <div className="bg-blue-100 text-blue-800 p-4 rounded-md flex items-center gap-2">
      <div className="max-w-xl mx-auto flex justify-center items-center gap-2">
        <CircleAlert className="w-6 h-6" />
        <p>{flash.notice}</p>
      </div>
    </div>
  }

  return null
}

export default FlashMessages

Without paying much attention to the flash code itself, notice that we're getting the flash object from the props of the usePage hook.

That hook allows us to access the page object without the need to pass props to every child in the component tree.

However, notice that the flash prop is not populated by default, we have to add it to our Rails app like so:

class ApplicationController < ActionController::Base
  inertia_share flash: -> { flash.to_hash }
end

After adding this, anytime the flash object is not empty, any page that uses this layout will display the flash message:

View of the example Inertia page in Rails

Next, we can see what the DashboardLayout is all about:

Dashboard layout

This layout is pretty similar to the site layout except it includes a bit more code, which I'm excluding here to avoid too much clutter:

import React, {useState} from 'react';
import {usePage, Link} from "@inertiajs/react"

import FlashMessages from "../components/FlashMessages"

export default function DashboardLayout({children}) {
  const url = usePage().url;
  const currentUser = usePage().props.current_user;

  const navigation = [
    { name: 'Dashboard', href: '/dashboard', icon: Home, current: url.startsWith('/dashboard') },
    { name: 'Transactions', href: '/transactions', icon: Tickets, current: url.startsWith('/transactions') },
    { name: 'Categories', href: '/categories', icon: Tag, current: url.startsWith('/categories') },
  ]

  return (
    <>
      <div>
        {/* Sidebar */}
        <nav className="flex flex-1 flex-col">
          <ul role="list" className="flex flex-1 flex-col gap-y-7">
            <li>
              <ul role="list" className="-mx-2 space-y-1">
                {navigation.map((item) => (
                  <li key={item.name}>
                    <Link
                      href={item.href}
                      className={classNames(
                        item.current
                          ? 'bg-emerald-700 text-white'
                          : 'text-emerald-200 hover:bg-emerald-700 hover:text-white',
                        'group flex gap-x-3 rounded-md p-2 text-xs/6 font-semibold',
                      )}
>
                      <item.icon
                        strokeWidth={2}
                        size={16}
                      />
                      {item.name}
                    </Link>
                  </li>
                ))}
              </ul>
            </li>
          </ul>
        </nav>
        {/* Main Content Area */}
        <div>
          {/* Top Navigation Bar */}
          {/* Main Content */}
          <main>
            <FlashMessages />
            {children}
          </main>
        </div>
      </div>
    </>
  };

Besides using the usePage hook to retrieve the current user, this component also includes the Link component for the first time

Forms

The application has several forms including the authentication one. We will implement the form to add new transaction categories here.

The first step is to define the component and import everything we need, keep in mind I used @headlessui/react and I also defined some custom components:

import { Dialog, DialogBackdrop, DialogPanel, DialogTitle } from '@headlessui/react'
import { useForm, usePage } from '@inertiajs/react'
import TextInput from '../../components/TextInput'
import ColorInput from '../../components/ColorInput'
import FormGroup from '../../components/FormGroup'
import Button from '../../components/Button'

export default function FormDialog({open, setOpen}) {
  const {errors} = usePage().props;

  const {post, data, transform, setData, processing, reset} = useForm({
    name: '',
    description: '',
    color_code: '#3687e3',
    icon: '',
  });
}

As you can imagine, this form is rendered within a modal dialog. It receives the open and setOpen props from the parent component, which is actually the Categories/Index page, and it handles the form submission from within.

We access the errors prop using the usePage hook to provide feedback in case something goes wrong.

We set the default form values by passing them as an object to the useForm hook and accessing them in the fields.

The actual form code is the following:

<form onSubmit={handleSubmit} className="mt-4">
    <TextInput label="Name" name="name" value={data.name} onChange={(ev) => setData('name', ev.target.value)} errors={errors && errors.name} />
    <div className="flex items-center justify-between space-x-4 mt-3">
      <FormGroup>
        <TextInput label="Description" name="description" value={data.description} onChange={(ev) => setData('description', ev.target.value)} required={false} errors={errors && errors.description} />
      </FormGroup>
      <FormGroup>
        <ColorInput label="Color code" name="color_code" value={data.color_code} onChange={(ev) => setData('color_code', ev.target.value)} errors={errors} />
      </FormGroup>
    </div>
    <Button type="submit" variant="primary" rounded="md" size="sm" fullWidth className="mt-6" isLoading={processing}>
      Create
    </Button>
</form>

I'm using custom reusable components, but there's nothing magical about them, they're just wrapping the inputs to abstract some presentation logic.

The handleSubmit method looks like this:

const handleSubmit = (e) => {
  e.preventDefault();

  post('/categories', {
    data: transform((data) => {
      return data;
    }),
    onSuccess: () => {
      setOpen(false);
      reset();
    }
  });
}

You might have noticed the use of the transform method that's currently not doing anything. It's not doing anything here, but I wanted to show that you can use it to transform the data before sending it to the server.

The onSuccess is also optional, but I'm using it to close the form dialog and reset the form after a successful submission.

Now, for the Rails part of the equation:

def create
  category = Current.user.categories.new(category_params)

  if category.save
    redirect_to categories_url, notice: 'Category created successfully'
  else
    redirect_to categories_url, inertia: { errors: category.errors }
  end
end

It's very similar to everyday Rails controllers except for the fact we are passing the errors using inertia.

In the FE, using the errors prop, we can process the errors and provide feedback to the user. In this case, besides the FlashMessages I also added a FormErrors component that looks like this:

import { humanize } from '../utils/string';

const FormErrors = ({errors}) => {
  if (!errors) return null;

  const renderErrors = (errors) => {
    if (typeof errors === 'object') {
      return Object.keys(errors).map((key, index) => {
        return <li className="text-sm" key={index}>{humanize(key)} {errors[key]}</li>
      })
    } else {
      return <li className="text-sm">{errors}</li>
    }
  }

  return(
    <div className="bg-rose-100 p-4 rounded-md my-2">
      <ul className="list-inside text-rose-500">
        {renderErrors(errors)}
      </ul>
    </div>
  )
}

export default FormErrors;

There's nothing too fancy, but if the error is a string it renders it directly, otherwise it iterates over the keys to display a list with the errors.

You might have noticed that using the useForm hook, we didn't shape the request that's sent to the server. This is because Inertia does it for us, inferring the name of the parameter from the controller name. If your controller doesn't match the params hash require or expect you will have to handle that for yourself.

Authentication

One of the worst parts of building an SPA is losing everything that a framework like Rails provides when it comes to authentication or authorization.

The good thing with Inertia is that we don't need to add much to the front-end to handle authentication, we handle most of it from the backend.

Assuming we receive an initial request to a route that requires authentication, our controller should redirect the user to the login page and this would render something like a Sessions/New page component.

On the other hand, if we try to access a protected page using the <Link/> component, the controller will produce a redirect that will trigger the same Sessions/New rendering.

Furthermore, we can pass the current user using the inertia_share helper:

class ApplicationController < ActionController::Base
  include Authentication
  allow_browser versions: :modern
  inertia_share flash: -> { flash.to_hash }, current_user: -> { inertia_user }

  private

  def current_user
    Current.user
  end

  def inertia_user
    return nil unless current_user.present?
    {
      id: current_user.try(:id),
      email: current_user.try(:email_address),
      username: current_user.try(:username),
      avatar_url: current_user.try(:avatar_url),
    }
  end
end

Doing that, we can access the current user using usePage().props.current_user and use it in our views as we deem necessary.

To implement an actual sign-in flow this is the code we should add:

# app/controllers/sessions_controller.rb
class SessionsController < ApplicationController
  allow_unauthenticated_access only: %i[ new create ]
  before_action :resume_session, only: [:new]
  rate_limit to: 10, within: 3.minutes, only: :create, with: -> { redirect_to new_session_url, alert: "Try again later." }

  def new
    return redirect_to dashboard_path if current_user.present?
    render inertia: "Auth/Sessions/New"
  end

  def create
    if user = User.authenticate_by(params.permit(:email_address, :password))
      start_new_session_for user
      redirect_to dashboard_url, notice: "Successfully signed in."
    else
      redirect_to new_session_path, alert: "Invalid email address or password."
    end
  end

  def destroy
    terminate_session
    redirect_to root_path, notice: "Successfully signed out."
  end
end

The JS component:

import {useForm, usePage} from '@inertiajs/react'

import FormErrors from '~/components/FormErrors'
import SiteLayout from "~/layouts/SiteLayout"

const New = () => {
  const {errors} = usePage().props;
  const {post, data, setData, processing, reset} = useForm({
    email_address: '',
    password: '',
  })

  const handleSubmit = (ev) => {
    ev.preventDefault()

    post('/session')
  }

  return(
    <div className="flex min-h-full flex-1 flex-col justify-center px-6 py-12 lg:px-8">
      <div className="sm:mx-auto sm:w-full sm:max-w-sm">
        <h2 className="mt-10 text-center text-3xl font-bold tracking-tight text-gray-900">
          Sign in to your account
        </h2>
      </div>

    <div className="mt-10 sm:mx-auto sm:w-full sm:max-w-sm">
      <form onSubmit={handleSubmit} className="space-y-6">
        <FormErrors errors={errors} />
        <div>
          <label htmlFor="email" className="block text-sm/6 font-medium text-gray-900">
            Email address
          </label>
          <div className="mt-2">
            <input
              id="email"
              name="email"
              type="email"
              required
              value={data.email_address}
              onChange={(ev) => setData('email_address', ev.target.value)}
              autoComplete="email"
              className="block w-full rounded-md bg-white px-3 py-1.5 text-base text-gray-900 outline-1 -outline-offset-1 outline-gray-300 placeholder:text-gray-400 focus:outline-2 focus:-outline-offset-2 focus:outline-emerald-600 sm:text-sm/6"
            />
          </div>
        </div>

        <div>
          <div className="flex items-center justify-between">
            <label htmlFor="password" className="block text-sm/6 font-medium text-gray-900">
              Password
            </label>
            <div className="text-sm">
              <a href="#" className="font-semibold text-emerald-900 hover:text-emerald-800">
                Forgot password?
              </a>
            </div>
          </div>
          <div className="mt-2">
            <input
              id="password"
              name="password"
              type="password"
              required
              value={data.password}
              onChange={(ev) => setData('password', ev.target.value)}
              autoComplete="current-password"
              className="block w-full rounded-md bg-white px-3 py-1.5 text-base text-gray-900 outline-1 -outline-offset-1 outline-gray-300 placeholder:text-gray-400 focus:outline-2 focus:-outline-offset-2 focus:outline-emerald-600 sm:text-sm/6"
            />
          </div>
        </div>

        <div>
          <button
            type="submit"
            className="flex w-full justify-center rounded-md bg-emerald-700 px-3 py-1.5 text-sm/6 font-semibold text-white shadow-xs hover:bg-emerald-800 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-emerald-600"
          >
            {processing ? 'Signing in...' : 'Sign in'}
          </button>
        </div>
      </form>

      <p className="mt-10 text-center text-sm text-gray-500">
        Don't have an account?{' '}
        <a href="#" className="font-semibold text-emerald-900 hover:text-emerald-800">
          Sign up for free
        </a>
        </p>
      </div>
    </div>
  )
}
New.layout = (page) => <SiteLayout>{page}</SiteLayout>

export default New

About data sharing

Consider that the data you share with Inertia is entirely available client-side, make sure to think about what you're sharing and try to share as little as possible: there's no need to include record timestamps unless really needed.

Be vigilant about shared app-wide data to avoid sharing it when you don't intend to. Even if it seems like an unlikely mistake, make sure to double-check as frequently as you can.

At first, sharing data is usually done via the props object in the controller and, as long as that doesn't get too big to handle, that's totally fine.

Yet, at some point, your data sharing needs might be more complex, that's where you might want to reach for a serialization library to remove serialization from the controller and better separate concerns.

Takeaways

Inertia lets us build SPA-like applications without having to resort to a dedicated API, thus giving us the interactivity gains of front-end frameworks without all the productivity losses.

It's a front-end router and a light proxy between the backend and the frontend that provides data sharing options, among other goodies.

If we're productive with Rails plus any front-end framework, and you need to build a frontend-heavy application, Inertia is a solid option because it lets us keep most of what makes Rails great while giving us the option to replace views with React, Vue or Svelte components.

Once we familiarize with how it works and interacts with Rails, we can be very productive using it.

If you enjoyed the article, don't hesitate to share it or tell us what you think.

Have a great one and stay tuned for more content like this!

Build your next rails app 10x faster with Avo

Avo dashboard showcasing data visualizations through area charts, scatterplot, bar chart, pie charts, custom cards, and others.

Find out how Avo can help you build admin experiences with Rails faster, easier and better.