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.
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
.
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.
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.
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:
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:
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 aCategory
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:
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.
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!