The React on Rails Doctrine
By Justin Gordon, January 24, 2016.
Updated October 2026: React on Rails 17, React 19, React Server Components, Shakapacker 10, and the ShakaStack.
This document is an extension and complement to The Rails Doctrine. If you haven't read that one, start there.
Rails gives you the model, the controller, the router, background jobs, mailers, and sessions. It doesn't give you a UI layer that holds client state across interactions and stays typed to the boundary. It doesn't know how to drive a browser against your factories. It can't tell you whether last week's pull request made the page slower. And its deploy story doesn't hand you a review app for every pull request.
Those are the gaps. Everything in the ShakaStack fills one of them inside Rails conventions rather than beside them. Rails stays the application boundary, and nothing here asks you to stand up a second app.
Here's how that plays out alongside the values in the Rails Doctrine.
Optimize for Programmer Happiness
React on Rails is built so that a Rails developer can ship a React UI without leaving Rails.
- Start with a prompt. Starting fresh? Clone the official starter and tell your agent to read its
AGENTS.mdbefore it writes anything, or runnpx create-react-on-rails-app, which scaffolds a new app with Pro by default (--standardkeeps it to the open-source gem). Adding to an app you already have? Runrails generate react_on_rails:install, then point the agent at the conventions it generates. Either way the guardrails carry the load: no TanStack Start, no Vite, no file-based routing, because Rails owns the routes. - Adopt it page by page. Put one React component on one Rails view and leave the rest of the app alone. Everything on npm is there when you want it, and nothing forces a rewrite to get started.
- React 19 and TypeScript at the boundary. Suspense and typed props work in the open-source gem. Streaming and Server Components are the Pro tier, which I get to under Sharp Knives.
- One extra process, and then it's Rails again. Run the Shakapacker dev server next to
bin/rails sand you get hot reload against live Rails data. At deploy time the compiled output ships through the same deploy step you already have.
Convention over Configuration
React on Rails has already made the JavaScript decisions that fight with Rails, so you don't re-litigate your build setup every eighteen months. The docs encode the conventions, including the recommended project structure and testing configuration.
In 2026 that old virtue pays a new dividend. AI agents thrive on convention. Rails' structure, typed props at the seam, and an AGENTS.md that says "no" as clearly as it says "yes" give an agent a map and a much smaller space to go wrong in. You still own the review and the decisions. The reason a generated diff comes back reviewable at all is that the framework had already decided everything the agent would otherwise have had to invent.
The Rails Doctrine says it better:
When there's an obvious next step for every change, we can scoot through the many parts of an application that is the same or very similar to all the other applications that went before it. A place for everything and everything in its place. Constraints liberate even the most able minds.
The Menu Is Omakase
Here's the chef's selection from today's React on Rails community.
What you build with: React 19 for components, TypeScript for the contracts between them, TanStack Query for server state and Table for data grids, and shadcn/ui for accessible primitives you own outright instead of import.
What builds and checks it: Shakapacker, running webpack or Rspack, with ESLint flat config and Prettier.
Notice what's no longer on the menu. Redux and React Router were the 2016 picks, and neither is here now. That isn't a dodge; it's the whole point: Rails already answers both. Page routing is config/routes.rb. Server state is your controller, and TanStack Query just caches what the controller returns. TanStack Router shows up only when a single Rails route hosts an app with its own internal navigation, which is routing inside one page rather than instead of Rails. Most of what Redux was managing in 2016 belongs on the Rails side in 2026.
There's also safety in numbers. React has the largest working population of any UI library, and TanStack Query now turns up in most React codebases I see. That means easier hiring, faster answers when you're stuck, and patterns your agent has already seen many times over. As the Rails Doctrine puts it, we all watched the same show last night, so we can talk about it today.
React still isn't the default Rails menu, and it shouldn't be. That's Hotwire. The Rails Doctrine is clear that off-menu choices are welcome.
No One Paradigm
Rails has always mixed paradigms without apologizing for it, and so does this. Checkout is React because its fields depend on each other and the total moves while you type. Account settings is Turbo because it's a form. Same app, same layout, same session, same deploy. Use React where state is genuinely hard, and skip it where it isn't.
Exalt Beautiful Code
Beautiful code used to be an argument about syntax. Now it's an argument about review. When an agent writes the first draft, what decides whether you can trust the result is whether the seam between Rails and React is explicit enough to check at a glance.
Here's the seam:
<%# app/views/orders/show.html.erb %>
<%= react_component("OrderTracker",
props: { orderId: @order.id, status: @order.status },
prerender: true) %>// app/javascript/src/OrderTracker/ror_components/OrderTracker.tsx
interface Props {
orderId: number
status: "pending" | "preparing" | "delivered"
}
const OrderTracker = ({ orderId, status }: Props) => (
<p>
Order {orderId} is {status}
</p>
)
export default OrderTrackerThe controller owns the data. The view helper hands it across. The interface states exactly what crossed. And there's no registration call to keep in sync: with the generator's defaults, React on Rails picks up anything in a ror_components directory.
Rails and React don't share a type checker, so that interface won't catch a renamed column by itself. What it buys you is that the contract is written down, in one place, four lines long, at exactly the spot where an agent is most likely to guess. When the agent touches the controller, the interface is the diff you actually read. Once you're not writing the first draft yourself, reviewability is the property you're really buying. For the JSON your controllers return, React on Rails 17 goes a step further: register a response contract and a rake task generates the TypeScript types. There's more of this in the live examples.
Provide Sharp Knives
React on Rails doesn't pretend the complexity away. You decide whether to server-render, how to split your bundles, and how to size the renderer. Shakapacker hands you webpack or Rspack, configured the Rails way. It's the maintained fork of rails/webpacker, which Rails retired in 2022, so if you're on Webpacker today this is your upgrade path.
Here's the sharp edge. Using Pro's dedicated renderer means your React runs in a separate Node service that you now size, monitor, and get paged about. Get the pool wrong and you'll see p99 latency you never had when it was all ERB. The Node renderer, streaming SSR, and React Server Components live in React on Rails Pro, and the open-source gem server-renders fine without them. We ship the knob anyway, because the alternative is a framework deciding for you and being wrong on the page that matters most.
Value Integrated Systems
The realistic alternative isn't a fleet of microservices. It's a Next.js front end talking to a Rails API, or Inertia. Next.js means two deploys, two auth stories, and a serialization layer you now own. Inertia avoids all three, and it's a genuinely good answer when you want the whole view layer to be React. The difference is granularity: Inertia replaces the view layer, while React on Rails lets you put one component on one page, leave the rest of the app in ERB, and reach for server rendering or RSC only where a page earns it.
- React components render from a Rails view helper, with props straight from your controller. Mix React pages and classic Rails pages in the same app, even on the same page, and the Rails forms you already have keep working.
- Server rendering is a config flag and a view helper, not a second application. Fragment-cache the rendered HTML, and Turbo keeps working.
- One codebase and one deploy, so there's one place to look when something breaks.
The doctrine outgrew the build step. That's the ShakaStack:
- Build: React on Rails and Shakapacker.
- Test: E2E on Rails hands Cypress and Playwright your factories and your database cleaner, so browser tests set up state the way your request specs already do.
- Prove: ShakaPerf runs control-versus-experiment benchmarks in CI against the real app, so a regression fails a build instead of a customer.
- Deploy: Control Plane Flow gives you Heroku-style review apps, staging, and promotion to production, including the Node renderer if you run one.
We've run performance work for Popmenu for seven-plus years. Over that engagement their Largest Contentful Paint went from 23 seconds to 4.5 seconds, and initial downloads went from 9 MB to 1 MB. Most of what we learned doing that by hand is now encoded in ShakaPerf, so the next team doesn't have to rediscover it.
Progress over Stability
The interesting work in React on Rails is in the React runtime itself. React 19, streaming server rendering, and React Server Components let you send less JavaScript and paint sooner, straight from Rails-backed data. The build side moved too: since Shakapacker 10.2, new installs default to Rspack, which builds up to 10x faster than webpack. Existing apps keep webpack until you choose to switch.
Progress over stability means upgrades. React 19 made ref a regular prop and added use. Rails 8 shipped and we tracked it. Each of those has an upgrade guide, and we work hard not to break you in a minor version, but if you want a stack that never moves, this isn't it.
Raise a Big Tent
The Rails big tent is about people, not packages. Plenty of Rails developers think React is the wrong default, and some of them have made this project better by saying so in public. React on Rails ships next to Hotwire precisely because we don't need you to agree that React belongs on every page. We need it to work on the pages where you decide that you want it.
Thanks!
Thanks for reading, and for being a part of the React on Rails community. Feedback on this document, or on anything in React on Rails, is welcome: open an issue or a pull request, or come find us on the forum. The React Server Components work in particular came out of contributors pushing on what Rails could do, and I'm grateful for it.
If your team is working out how React and Rails fit together, or how to turn agents loose on the result without regretting it, we've been there. It's also what we do for a living.
Aloha, Justin
Closing Remark


