Simplicity, vanished?! Solving the mystery with Inertia.js + Rails

Cover for Simplicity, vanished?! Solving the mystery with Inertia.js + Rails

The front-end development racket has been running the same con for years now. Walk into any dev shop and you’ll see the same sorry picture: smart developers drowning in tooling they don’t need, solving problems that didn’t exist until someone sold them the solution.

I’ve been working this beat long enough to know how the game works. First, they fragment your ecosystem by making you choose between React, Vue, Angular, Svelte. Then they multiply your decisions until you’re paralyzed by choice. Finally, they swoop in with “expert solutions” that cost more than the original problem.

Book a call

Hire Evil Martians

Use Inertia.js to build modern JavaScript components and client-side interactivity without the typical SPA complexity.

The Rails community illustrates this perfectly. DHH has spent years fighting complexity, preaching “No Build” and rolling out Hotwire. But many Rails developers have already been convinced otherwise.

The Rails Community Survey reveals the scope of this problem: only 31% of Rails developers use Stimulus. The majority have been convinced that Rails API + SPA architecture is “modern” and “necessary.”

This isn’t progress, rather, it’s developers abandoning Rails’ core strength (server-side simplicity) for client-side complexity that serves no user value.

The cost of this shift is measurable and significant.

The real cost of “modern” frontend architecture

When Rails developers adopt the API + SPA pattern, they’re not just choosing different tools. They’re accepting fundamental complexity:

  • Dual codebases - different conventions, testing approaches, and deployment pipelines
  • Serialization overhead - converting Rails objects to JSON, then back to JavaScript objects
  • Duplicated logic - routing, validation, and business rules exist in both places
  • Authentication complexity - token management, refresh logic, and security boundaries
  • State synchronization - keeping client and server data consistent

Each of these creates ongoing maintenance burden that serves no user value.

But here’s the thing that doesn’t add up: if modern frontend development requires all this complexity, why has Inertia.js been quietly solving these problems for over five years with almost zero fanfare in the Rails community?

Inertia.js: a different approach

Inertia.js eliminates the API layer entirely. Instead of building separate frontend and backend applications, you build a traditional server-side app that happens to use JavaScript components for the UI.

Your Rails controllers return data directly to JavaScript components: no JSON APIs, no client-side routing, no state management libraries required.

Inertia systematically eliminates unnecessary complexity:

  1. Eliminates API layer - Your Rails controllers return data directly to components
  2. Removes client-side routing - Rails routes handle everything
  3. Destroys state management complexity - No Redux, Zustand, or Context APIs needed
  4. Maintains Rails conventions - Server-side logic stays where it belongs

But that’s just the surface. The real evidence lies in how these patterns scale…

Advanced techniques

We’ve already covered the basic Inertia.js setup, where we built a React application integrated with Rails using Inertia.js. Now it’s time to explore more advanced techniques and dive deeper into the philosophy behind this approach.

The deeper you dig into Inertia.js, the more you realize it’s not just about “easier SPAs.” It’s about questioning whether you needed that complexity in the first place.

Here’s where it gets interesting: you get SPA-style navigation without learning a single client-side router. Unlike Hotwire’s magic, Inertia uses explicit links, but they’re dead simple:

// app/javascript/components/PostPreview.jsx
import { Link } from "@inertiajs/react"

export const PostPreview = ({ post }) => (
  <div>
    <h2>{post.title}</h2>
    <Link href={`/posts/${post.id}`}>
      Show this post
    </Link>
  </div>
)

While we reference routes using string URLs, this doesn’t mean we’re implementing client-side routing. The <Link> component intercepts clicks and makes AJAX requests to Rails routes instead of triggering full page reloads. Rails routes file remains the single source of truth. Client-side navigation performance with server-side routing simplicity.

Need to link to an external URL or non-Inertia page? Simply use a standard <a> tag:

// app/javascript/components/HeadLinks.jsx
import { Link } from "@inertiajs/react"

export const HeadLinks = () => (
  <nav>
    <Link href="/">Home</Link>
    <a href="/log_in">Log in using Devise</a>
    <a href="https://github.com/evilmartians">GitHub</a>
  </nav>
)

The pattern becomes clear: why learn separate routing systems for every framework when Rails already solved this problem years ago?

Partial reloads: the lazy loading liberation

Data loading shouldn’t require complex client-side data fetching libraries. Inertia’s partial reloads show just how unnecessary a lot of modern complexity really is.

Take a blog post with potentially hundreds of comments. Loading them all upfront would be wasteful, but users should be able to access them easily:

class PostsController < ApplicationController
  def show
    render inertia: {
      post: serialize_post(@post),
      comments: InertiaRails.optional do
        serialize_comments(@post.comments.includes(:user))
      end
    }
  end
end

The page loads instantly with just the post content. The expensive comment serialization? It doesn’t run until the user explicitly asks for it:

// app/javascript/pages/posts/show.jsx
import { Link } from "@inertiajs/react"
import { CommentsList } from "@/components/CommentsList"

export default functionPostShow({ post, comments }) => {
  return (
    <div>
      <h1>{post.title}</h1>
      <p>{post.body}</p>
      {comments === undefined ? (
        <Link only={["comments"]}>Load Comments</Link>
      ) : (
        <CommentsList comments={comments} />
      )}
    </div>
  )
}

When clicked, this makes a new request to the same URL, but Inertia only includes the comments data in the response. One attribute change triggers the reload. Inertia handles the request, updates the specific data, and re-renders your component.

Perfect for expensive computations, lazy-loadable widgets, pagination, search filters, or any secondary content users might not need immediately.

Inertia 2.0 takes this further with deferred props and merging props for even more granular control.

Shared data: killing global state complexity

Developers have been convinced that global state requires Redux, Zustand, or Context APIs. Inertia’s shared properties reveal the truth:

class ApplicationController < ActionController::Base
  inertia_share flash: -> { flash.to_hash },
                current_user: -> { current_user&.as_json(...) },
                feature_flags: -> { FeatureFlags.all.as_json(...) }
end

This data gets automatically included in every Inertia response.
Server-side data flows automatically to every component. Your current_user is just there, like it always was in ERB templates:

export default function Header({ current_user }) {
  return <div>Welcome, {current_user.name}!</div>
}

The simplicity reveals how unnecessary most state management libraries really are.

Forms: Rails conventions with modern components

Rails developers who tried to move to SPAs were often led to believe that they had to abandon the simplicity of Rails forms: writing custom validation logic, managing form state manually, and duplicating server-side validation on the client.

Inertia.js forms can work exactly like Rails forms, just with modern JavaScript components.

Remember the good old days of Rails form builders?

<%= render_form_for @password_reset_form do |form| %>
  <%= form.text_field :email %>
  <%= form.submit %>
<% end %>

Inertia.js proves we can have our cake and eat it too. Here’s the same form, Inertia-style:

// app/javascript/components/CreateUserForm.jsx
import { useForm } from '@inertiajs/react'

export const CreateUserForm = ({ user }) => {
  const { data, setData, post, errors } = useForm({email: ""})

  return (
    <form onSubmit={e => { e.preventDefault(); post("/passwords") }}>
      <input type="text" value={data.email}
        onChange={(e) => setData("email", e.target.value)}
      />
      {errors.email && <div>{errors.email}</div>}
      <button type="submit">Reset Password</button>
    </form>
  )
}

A bit wordy, but bear with me… The validation happens exactly where it always did, in your Rails model:

class User < ApplicationRecord
  validates :email, uniqueness: true, presence: true, format: URI::MailTo::EMAIL_REGEXP
end

When validation fails, Inertia automatically passes the errors back to your component. No additional configuration, no client-server error mapping …just Rails doing what Rails does best.

But here’s where it gets really interesting. Just like Rails taught us to use form abstractions, we can do the same with Inertia components. useInertiaForm is a small library that provides a React hook to simplify form handling with Inertia. With it, you can create reusable form components that work just like Rails form builders:

// app/javascript/components/CreateUserForm.jsx
import { Submit } from 'use-inertia-form'
import { Form, TextInput, DynamicInputs } from '@/components/form'

const CreateUserForm = ({ user }) => (
  <Form model="user" data={ {user} } to={'users'}>
    <TextInput name="email" label="Email" />

    <DynamicInputs model="socials" emptyData={ {link: ''} }>
      <TextInput name="link" label="Link" />
    </DynamicInputs>

    <Submit>Create User</Submit>
  </Form>
)

This pattern eliminates entire form ecosystems. Rails validation logic stays in your models where it belongs. Your JavaScript components receive errors exactly like Rails forms always have.

Modals: no state, no problem

Another common piece of complexity? Modal state management. Open, close, backdrop clicks, escape keys …suddenly you’re importing modal libraries and managing local state for something that should be simple.

The Inertia Modal library cuts through this with zero backend configuration:

// app/javascript/layouts/posts.jsx
import { ModalLink } from '@inertiajs/modal'

export default function PostsLayout({ children }) {
  return (
    <div>
      <h1>Posts</h1>
      <ModalLink href="/posts/new">
        Create New Post
      </ModalLink>
      {children}
    </div>
  )
}

Your controller doesn’t even know it’s being rendered in a modal:

class PostsController < ApplicationController
  def new
    @post = Post.new
    render inertia: { post: @post }
  end
end

The page wraps itself in a modal component:

// app/javascript/pages/posts/new.jsx
import { Modal } from '@inertiajs/modal'

export default function New({ post }) {
  return (
    <Modal>
      <h1>Create New Post</h1>
      <PostForm post={post} />
    </Modal>
  )
}

No state management, no modal libraries, no configuration. The same controller action works for both regular pages and modals. Want more advanced modal behavior? Check out the Inertia Modal Cookbook to enhance your modals with base URL support.

ActionCable: real-time without complexity

Real-time features are where SPAs traditionally shine, but they often require learning entirely new libraries, WebSocket management, and complex state synchronization. Rails already solved WebSockets with ActionCable, and Inertia works seamlessly with the same patterns you’d use for Hotwire.

Your ActionCable channels look exactly the same:

class ChatChannel < ApplicationCable::Channel
  def subscribed
    stream_from "chat_#{params[:room_id]}"
  end
end

# Broadcasting from your models
class Message < ApplicationRecord
  # While I recommend cautiously using callbacks,
  # this is a common pattern in Hotwire demos
  after_create_commit :broadcast_new_message

  private

  def broadcast_new_message
    # Should be done asynchronously in production
    ActionCable.server.broadcast("chat_#{chat_room_id}", {
      message: self.as_json,
    })
  end
end

The difference is how you handle updates on the frontend. Instead of manually updating DOM elements like Hotwire, you update Inertia props using familiar patterns:

// app/javascript/channels/chat_channel.js

import { router } from "@inertiajs/react"
import { consumer } from "utils/cable"

// Subscribe to the channel
const chatChannel = consumer.subscriptions.create(
  { channel: "ChatChannel", room_id: roomId },
  {
    received(data) {
      switch(data.type) {
        case "message_created":
          this.handleNewMessage(data.message)
          break
      }
    },

    // Option 1: Full page reload (simplest, works everywhere)
    handleNewMessage() {
      router.reload()
    },

    // Option 2: Partial reload (more efficient)
    handleNewMessage() {
      router.reload({ only: ["messages"] })
    },

    // Option 3: Direct prop updates (most efficient)
    handleNewMessage(message) {
      router.replace({
        props: (current) => ({
          ...current,
          messages: [...current.messages, message]
        })
      })
    }
  }
)

Your React components receive the updated data naturally through props:

// app/javascript/components/ChatRoom.jsx
import { MessageForm } from "@/components/MessageForm"

export default function ChatRoom({ messages, currentUser, room }) {
  return (
    <div className="chat-room">
      <div className="messages">
        {messages.map(message => (
          <div key={message.id} className="message">
            <strong>{message.user.name}:</strong> {message.content}
          </div>
        ))}
      </div>
      <MessageForm roomId={room.id} />
    </div>
  )
}

This approach keeps your real-time features simple and consistent with Rails conventions. No need to learn new libraries or complex state management: just use the same ActionCable patterns you’re already familiar with.

TypeScript: automatic types without ceremony

One of the strongest arguments for full-stack JavaScript frameworks like Next.js or Remix is type safety across the frontend-backend boundary. When your API and frontend share the same TypeScript definitions, you get compile-time guarantees that your data structures match.

This is a real advantage… until you consider what you’re giving up to get it. Moving from Rails to a JavaScript backend means abandoning mature ecosystems, battle-tested patterns, and years of Rails conventions.

But what if you could get similar type safety benefits while keeping Rails? Typelizer bridges that gap.

The Typelizer gem automatically generates TypeScript definitions from your Rails serializers, giving you frontend-backend type integrity without leaving the Rails ecosystem.

Here’s how it works in practice. First, let’s define serializers using Alba with Typelizer’s DSL:

class ApplicationResource
  include Alba::Resource
  helper Typelizer::DSL
end

class PostResource < ApplicationResource
  attributes :id, :title, :category, :body, :published_at
  attribute :author, resource: AuthorResource
end

class AuthorResource < ApplicationResource
  # Specify the model to infer types from (optional)
  typelize_from User

  attributes :id, :name

  # For virtual attributes, you can use
  # the `typelize` method to define the type manually
  typelize :string, nullable: true
  attribute :avatar do
    "https://example.com/avatar.png" if active?
  end
end

Run the generator (or simply run dev server, which will automatically generate types on the fly):

rails typelizer:generate

Typelizer automatically generates TypeScript interfaces that match your serializers exactly:

export interface Post {
  id: number;
  title: string;
  body: string;
  published_at: string | null;
  category: "news" | "article" | "blog" | null;
  author: Author;
}

export interface Author {
  id: number;
  name: string;
  avatar?: string | null;
}

Your Inertia components get full “type safety” automatically:

// app/javascript/pages/posts/show.tsx

import { Post } from "@/types"

interface Props {
  post: Post;
}

export default function Show({ post }: Props) {
  // TypeScript will catch typos in property names
  const isPublished = post.published_at !== null
  return (
    <article>
      <h1>{post.title}</h1>
      <p>By {post.author.name}</p>
      <div>{post.body}</div>
      {!isPublished && <Link href={post.edit_url}>Edit draft</Link>}
    </article>
  )
}

Add listen gem to your Gemfile and types will be updated automatically when you change your Rails models or resources. Add a field and your frontend types stay synchronized, no page reloads required.

Now, not everyone wants to use TypeScript at all and that’s perfectly valid. But for teams that want type integrity benefits while staying in Rails, or for those transitioning from traditional Rails, this approach delivers frontend-backend type synchronization with Rails conventions.

Making the right choice

Inertia.js solves a specific problem: delivering modern JavaScript components and client-side interactivity without typical SPA architectural complexity.

Now, if you don’t need heavy client-side interactivity, Hotwire is genuinely simpler and probably the right choice. But when your application demands rich JavaScript components, dynamic interfaces, or complex client-side state, don’t let anyone convince you that means accepting architectural complexity or abandoning Rails conventions.

The Inertia-Rails ecosystem momentum is building: new starter kits, active development, growing adoption, and even RailsConf keynote shoutouts. Developers are rediscovering that you can have modern frontend capabilities without sacrificing Rails simplicity.

Every complex architecture, every additional abstraction layer, every “necessary” tool—question whether it’s actually solving your problem or just creating new ones.

So, if you’re ready to choose the right tool for your job:

  1. Try Inertia.js when you need modern JS components
  2. Share your experience with other developers
  3. Question complexity when it’s presented as inevitable
  4. Choose the right tool for your specific needs
  5. Remember: Complexity should solve problems, not create them

Inertia.js proves that modern frontend development doesn’t require architectural complexity. When you need JavaScript components, you can have them without abandoning Rails conventions or accepting SPA overhead.

The choice is straightforward: embrace complexity because it’s “modern,” or choose tools that solve actual problems without creating new ones.

Book a call

Irina Nazarova CEO at Evil Martians

Use Inertia.js to build modern JavaScript components and client-side interactivity without the typical SPA complexity.