A glowing bridge connecting a server tower and a browser tower, with a gray-blue cat resting beside a monitor.

Inertia.js for React: The SPA That Forgot to Build an API

The Gray Cat
The Gray Cat
0 views

There is a particular kind of fatigue that sets in around the third time you write a controller, then a route, then a serializer, then a fetch hook, then a piece of client-side state, all so a list of users can appear on a screen. Half of that work is plumbing. Inertia.js looks at that plumbing and asks a blunt question: what if your server controller could just hand a React component its props directly, no API in between?

The @inertiajs/react package is the React adapter for Inertia.js, a thin protocol that glues a classic server-side framework (Laravel, Rails, Django, Phoenix) to a client-side framework. You keep your routes, controllers, middleware, and authentication exactly where they already live on the server. But instead of returning HTML or JSON for some bespoke API, your controllers return a page component name plus props. Inertia transports that across the wire and swaps the React component in without a full reload. The result feels like a single-page app, but you never built an API and you never set up client-side routing. People call this shape "the modern monolith," and it is a genuinely different way to think about full-stack work.

Why Skip the API at All

The pitch is easier to appreciate once you see what disappears. In a conventional SPA you maintain two distinct contracts: the server's API and the client's consumption of it. Every new feature touches both. You design endpoints, version them, document them, and then write the matching client code that knows their shape.

Inertia collapses that into one round trip. A controller decides which page to render and what data it needs, and that data arrives in your component as plain props. There is no endpoint to design and no fetch layer to keep in sync. Your single source of truth stays on the server, which is usually where your validation, authorization, and business rules already are.

That trade is not free of opinions. You are committing to server-driven navigation and a backend framework you actually like. But if you already have a Laravel or Rails app, or you simply prefer writing controllers over hand-rolling REST, Inertia removes an enormous amount of ceremony.

How the Wire Actually Works

It helps to know the protocol, because everything else in the library is a comfortable wrapper around it.

The very first visit to your app is an ordinary full HTML page load. The server returns a document with a root <div id="app"> and a JSON blob describing the initial page object. From that point on, Inertia's client-side router takes over. When you click an Inertia link, it issues an XHR request carrying the header X-Inertia: true. The server recognizes that header and, instead of rendering full HTML, returns just the page object as JSON.

That page object has four fields: component (the name of the page to render, like "Users/Index"), props (the data for that page), url, and version (an asset fingerprint). The router reads it, swaps in the matching React component, updates its props, and manages browser history through pushState. No full reload, real back-button behavior, snappy transitions.

The version field is a small piece of cleverness. The client sends its asset version on every request. If the server has shipped a new bundle and the versions disagree, it responds with a 409 Conflict and an X-Inertia-Location header, which tells the client to do a hard reload and pick up the fresh JavaScript and CSS. Cache-busting, automatic, no service worker required.

Getting It Installed

Inertia v3 targets React 19, so make sure your react and react-dom are on ^19.0.0 before you start.

npm install @inertiajs/react
yarn add @inertiajs/react

The client side bootstraps from a single entry file. You point createInertiaApp at your page components and let it resolve them by name from the page object.

import { createInertiaApp } from '@inertiajs/react'
import { createRoot } from 'react-dom/client'

createInertiaApp({
  resolve: (name) => {
    const pages = import.meta.glob('./Pages/**/*.tsx', { eager: true })
    return pages[`./Pages/${name}.tsx`]
  },
  setup({ el, App, props }) {
    createRoot(el).render(<App {...props} />)
  },
})

The resolve function maps a component name like "Users/Index" to an actual module. The setup function mounts the root. Your backend's Inertia adapter handles the other half, telling each controller which page to render.

Moving Between Pages

A page component is just a React component that receives its controller's props. Navigation between pages is the <Link> component, which intercepts clicks and turns them into Inertia visits instead of full reloads.

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

interface User {
  id: number
  name: string
}

export default function UsersIndex({ users }: { users: User[] }) {
  const { url } = usePage()

  return (
    <div>
      <nav>
        <Link href="/dashboard" className={url === '/dashboard' ? 'active' : ''}>
          Dashboard
        </Link>
        <Link href="/logout" method="post" as="button">
          Log out
        </Link>
      </nav>

      <ul>
        {users.map((user) => (
          <li key={user.id}>
            <Link href={`/users/${user.id}`}>{user.name}</Link>
          </li>
        ))}
      </ul>
    </div>
  )
}

A few things are quietly doing a lot of work here. The method="post" prop turns a link into a non-GET request, and as="button" renders it as a real button for accessibility, which matters for actions like logging out. The usePage() hook reads the current page object, which is the standard way to do active-link styling. While a request is in flight, Inertia adds a data-loading attribute to the link, so you can style loading states with pure CSS and never touch component state.

Forms Without the Wiring

If there is one feature that converts people, it is useForm. It handles dirty tracking, validation errors, submission state, and file-upload progress, and it does so while letting your server be the source of truth for validation.

import { useForm } from '@inertiajs/react'

export default function Login() {
  const { data, setData, post, processing, errors } = useForm({
    email: '',
    password: '',
  })

  function submit(e: React.FormEvent) {
    e.preventDefault()
    post('/login')
  }

  return (
    <form onSubmit={submit}>
      <input
        type="email"
        value={data.email}
        onChange={(e) => setData('email', e.target.value)}
      />
      {errors.email && <span>{errors.email}</span>}

      <input
        type="password"
        value={data.password}
        onChange={(e) => setData('password', e.target.value)}
      />
      {errors.password && <span>{errors.password}</span>}

      <button type="submit" disabled={processing}>
        {processing ? 'Signing in...' : 'Sign in'}
      </button>
    </form>
  )
}

The magic is in what you did not write. When your server validation fails, it sends errors back through the page object's props, and Inertia routes them straight into the errors object keyed by field name. There is no mapping layer translating an API error shape into form state. The server says email is taken, and errors.email becomes "email is taken". The processing flag flips automatically during the request, so disabling the button is a one-liner.

The full helper is generous. Beyond what is shown you get reset() and reset(field), transform(fn) to reshape data before sending, isDirty for unsaved-changes guards, wasSuccessful and recentlySuccessful for success feedback, and a progress object for file uploads. Pass a string key as the first argument (useForm('LoginForm', { ... })) and Inertia will persist the form's state across history navigation, so a user who hits back finds their typing intact. If you would rather not wire state at all, the declarative <Form> component collects values straight from named inputs.

Asking for Less Data

Once your app grows, you will not always want a full page payload. Partial reloads let you request only the props you actually need. Tell a visit which props to fetch with only, and the server skips computing everything else.

import { router, Link } from '@inertiajs/react'

function RefreshButton() {
  return (
    <>
      <Link href="/dashboard" only={['notifications']} preserveScroll>
        Refresh notifications
      </Link>

      <button
        onClick={() =>
          router.reload({ only: ['stats'] })
        }
      >
        Refresh stats
      </button>
    </>
  )
}

The only array means the server returns just notifications and the standard page metadata, leaving the rest of the page's props untouched in your component. preserveScroll keeps the viewport where it is, which is exactly what you want for an in-place refresh. The router object is the imperative counterpart to <Link>, with router.visit, router.reload, and method shortcuts, all accepting the same options plus onSuccess and onError callbacks.

This pairs naturally with deferred props, where slow-to-compute data loads after the initial render so the page paints fast, and with prefetching, where a link can preload its target on hover so the navigation feels instant. Polling helpers refresh data on an interval, and merge helpers smooth out infinite scrolling and pagination. None of these require an API; they are all just smarter ways of asking the same controller for data.

Sharing State Across the Whole App

Some data belongs on every page: the current user, flash messages, an unread-count badge. Inertia calls this shared data. Your backend defines it once as global props, and it shows up in usePage().props everywhere.

import { usePage } from '@inertiajs/react'

interface SharedProps {
  auth: { user: { name: string } | null }
  flash: { message: string | null }
}

function Layout({ children }: { children: React.ReactNode }) {
  const { auth, flash } = usePage<SharedProps>().props

  return (
    <div>
      {flash.message && <div className="toast">{flash.message}</div>}
      <header>{auth.user ? `Hi, ${auth.user.name}` : 'Guest'}</header>
      {children}
    </div>
  )
}

Typing usePage<SharedProps>() gives you full IntelliSense on shared data, which removes the usual guesswork about what a global prop contains. Flash messages are the canonical example: your controller flashes a message after a successful save, and your layout renders it on the very next page without any client-side notification library. The new useLayoutProps hook in v3 makes it even cleaner to share dynamic data specifically between pages and their layouts.

What Changed in v3

The current major line is v3, and 3.4.0 is the latest release. It is largely a modernization pass, and a welcome one.

The biggest internal change is that Axios is gone, replaced by a smaller built-in XHR client that trims the bundle. If you relied on Axios interceptors, you migrate them to the new client. The qs and lodash-es dependencies were dropped too, with es-toolkit quietly handling the utility work. SSR got simpler: it now works automatically in Vite's dev mode, so you no longer babysit a separate Node SSR server while developing.

There are real new capabilities as well. Optimistic updates apply a change to the UI instantly and roll it back automatically if the server rejects it. Instant visits swap to the target component before the server even responds. A new useHttp hook lets you make standalone HTTP calls that do not trigger a page visit, which is handy for the occasional fire-and-forget request.

The breaking changes are worth noting before you upgrade. React 19 is now required. Two events were renamed, with invalid becoming httpException and exception becoming networkError. The router.cancel() method is now router.cancelAll(), and the <Head> component's attribute syntax moved from inertia to data-inertia. On the backend, Laravel's Inertia::lazy() gives way to Inertia::optional(). The features that landed during the v2 cycle, prefetching, polling, deferred props, infinite scroll, and history encryption, are all mature now and carry forward.

When to Reach for It

Inertia is not trying to be Next.js, and knowing the difference is the whole decision. Next.js, Remix, and TanStack Start are full-stack JavaScript frameworks; they are the backend. Hotwire/Turbo keeps rendering on the server and never gives you a React component tree. Livewire and LiveView offer server-driven reactivity with minimal JavaScript and no React at all.

Inertia occupies a specific and useful spot: you want a real React frontend with genuine client-side state, and you want to keep your existing server framework, especially Laravel, as the backend. You do not want to build, version, and document an API just to feed your own UI. If that describes you, the @inertiajs/react adapter is one of the most pleasant ways to write a full-stack app right now. It is dominant in the Laravel ecosystem, where it ships in the official starter kits, and it works happily with Rails, Django, and Phoenix too.

The library asks you to give up one thing, the freedom of a fully decoupled API, and hands you back a great deal of deleted boilerplate in return. For a lot of teams, that is a trade worth making, and your future self, the one who would otherwise be writing the seventeenth serializer of the week, will quietly thank you.