docs/oss/building-features/client-side-routing-instant-navigation.md
React on Rails defaults to component-per-page: each page is its own react_component mount, and clicking a link is a normal Rails request. That is the right default for incremental adoption — but teams building a substantial React section often want SPA navigation: instant client-side transitions, a persistent shell layout that never unmounts, and server-driven data per route.
This guide shows the opt-in starter pattern for exactly that, built entirely from existing React on Rails Pro APIs:
createTanStackRouterRenderFunction — SSR + hydration for a TanStack Router appRSCRoute — streams a React Server Component's payload from a Rails endpoint during client navigationA complete runnable example lives in the Pro dummy app — see Working example below.
The starter is a React-routed island inside a normal Rails app, not a framework takeover:
/dashboard/*) to the React router. Every other route stays server-rendered Rails (ERB, Hotwire, anything).| Capability | Needs |
|---|---|
| Client-only TanStack Router (no SSR) | Open-source React on Rails — it's just a React app |
| SSR of the initial route | Pro: Node Renderer + config.rendering_returns_promises = true |
RSCRoute server-component routes | Pro: RSC support enabled (enable_rsc_support, rsc_payload_generation_url_path) and the matching rsc_payload_route mounted in config/routes.rb |
See Using TanStack Router for the SSR helper details and React Server Components inside client components for RSCRoute.
The starter blesses TanStack Router because it is the router with a first-class React on Rails Pro SSR helper (react-on-rails-pro/tanstack-router), end-to-end TypeScript route types, and built-in data loading.
React Router remains the documented manual alternative. Using React Router covers the manual integration pattern, and recommends React Router v6 over v7 (v7 merged with Remix and uses an architecture that is not a clean fit for React on Rails' manual SSR approach). If your team is already a React Router shop, that guide is the supported path — the starter pattern here (catch-all Rails route, persistent layout, scoped Turbo boundary) carries over; only the SSR dehydration/hydration helper is TanStack-specific.
# config/routes.rb
get "dashboard(/*all)" => "dashboard#index", as: :dashboard
Every URL under /dashboard renders the same view, so deep links server-render the matching route and navigation after that is client-side.
Prepare request-specific route data in Rails first (for example, @reports = current_user.reports.visible.select(:id, :title) in the controller), then pass display-safe fields into the React-routed island:
<%# app/views/dashboard/index.html.erb %>
<div data-turbo="false">
<%= react_component("DashboardApp",
props: {
reports: @reports.as_json(only: [:id, :title])
},
prerender: true,
raise_on_prerender_error: true) %>
</div>
raise_on_prerender_error: true makes SSR failures raise during development and test instead of silently serving a broken page. Decide separately whether that behavior fits your production error-handling policy.
The data-turbo="false" boundary keeps Turbo Drive from intercepting link clicks inside the React-routed subtree, so the two routers never compete. Outside this boundary, Turbo keeps working as usual. This guide does not address deeper Turbo integration (shared back/forward handling, scroll restoration across the boundary, Turbo Frames around streamed HTML) — that coexistence track is open in issue #3485.
'use client';
import React, { Suspense, useEffect, useMemo, useState } from 'react';
import {
Link,
Outlet,
RouterProvider,
createBrowserHistory,
createMemoryHistory,
createRootRoute,
createRoute,
createRouter,
} from '@tanstack/react-router';
import { createTanStackRouterRenderFunction } from 'react-on-rails-pro/tanstack-router';
import RSCRoute from 'react-on-rails-pro/RSCRoute';
// The shell renders once and stays mounted across navigations.
// Anything stateful here (sidebar state, form drafts, websocket
// connections) survives route changes.
const ReportDataContext = React.createContext({ reports: [] });
const ShellLayout = () => {
const [count, setCount] = useState(0);
return (
<div>
<nav>
<Link to="/dashboard">Home</Link>
<Link to="/dashboard/reports">Reports</Link>
<button type="button" onClick={() => setCount((c) => c + 1)}>
Clicks: {count}
</button>
</nav>
<Outlet />
</div>
);
};
const HomePage = () => <h2>Home</h2>;
// An RSCRoute-backed route: the page's content is a React Server
// Component, streamed from a Rails endpoint when the user navigates here.
// componentProps are serialized into the payload request, so the server
// component receives them when the payload is generated (see step 4).
//
// The mounted guard keeps RSCRoute out of the server render: with the
// react_component entry point, RSC payloads cannot be generated during the
// initial SSR (that needs wrapServerComponentRenderer +
// stream_react_component), so deep links server-render the placeholder and
// the client fetches the payload after mount — the same path used on
// client-side navigation.
const ReportsPage = () => {
const { reports } = React.useContext(ReportDataContext);
const [mounted, setMounted] = useState(false);
const componentProps = useMemo(() => ({ reports }), [reports]);
useEffect(() => {
setMounted(true);
}, []);
if (!mounted) {
return <p>Loading reports...</p>;
}
return (
<Suspense fallback={<p>Loading reports...</p>}>
<RSCRoute componentName="Reports" componentProps={componentProps} />
</Suspense>
);
};
const rootRoute = createRootRoute({ component: ShellLayout });
const routeTree = rootRoute.addChildren([
createRoute({ getParentRoute: () => rootRoute, path: '/dashboard', component: HomePage }),
createRoute({ getParentRoute: () => rootRoute, path: '/dashboard/reports', component: ReportsPage }),
]);
const DashboardApp = createTanStackRouterRenderFunction(
{
AppWrapper: ({ children, reports = [] }) => (
<ReportDataContext.Provider value={{ reports }}>{children}</ReportDataContext.Provider>
),
createRouter: () =>
createRouter({
routeTree,
// The Rails catch-all forwards every /dashboard/* path to this app,
// so give unmatched URLs a real page instead of a blank Outlet.
defaultNotFoundComponent: () => <h2>Page not found</h2>,
}),
},
{ RouterProvider, createMemoryHistory, createBrowserHistory },
);
export default DashboardApp;
[!NOTE] Keep
componentPropsreferences stable for values derived during render, and wrap productionRSCRouteroutes in an app-specific error boundary so payload fetch or render failures degrade to your chosen error UI. For constant or empty props, prefer a module-level constant overuseMemo.
With auto-bundling, placing this file in your components subdirectory registers it automatically — the generated client pack also sets up the default RSC provider that RSCRoute needs in the browser.
Reports is a plain React Server Component (no 'use client' directive). Its code never ships to the browser; only its rendered payload does:
import React from 'react';
const Reports = ({ reports = [] }) => (
<ul>
{reports.map((r) => (
<li key={r.id}>{r.title}</li>
))}
</ul>
);
export default Reports;
[!IMPORTANT] The RSC payload is generated by the Pro Node renderer, which has no Rails models, database connection, or user session — so a server component must not reach into Rails directly, and an in-component fetch bypasses Rails' authorization and caching.
- Let Rails resolve request-specific data first, then pass only display-safe data as props: through
RSCRoutecomponentProps(serialized into the payload request, as above) or via async props, where Rails resolves each prop and the component awaits it under<Suspense>.- Treat
componentPropsas untrusted client input. Client navigation re-sendscomponentPropsto the RSC payload endpoint; an attacker can forge arbitrary values, including values that control what data is fetched or rendered. Re-derive any security- or data-sensitive values (current user, permissions, record IDs, tenant) from the Rails session or database inside the controller or a Pro async prop, not from client-supplied props.See RSC Data Fetching Patterns for the full set of options.
RSCRoute route: the client fetches the server component's RSC payload over HTTP from the Rails rsc_payload endpoint and streams it into the outlet — server-driven data with no separate JSON API layer.RSCRoute route: the mounted guard means the server renders the placeholder and the client fetches the RSC payload after hydration. Server-rendering the RSC payload during the initial page load requires the wrapServerComponentRenderer + stream_react_component setup described in the RSC guide, which is a different entry point than the TanStack SSR helper.RSCRoute route: the default RSC provider caches payload promises by componentName plus serialized componentProps while the provider stays mounted, so returning to the same route can reuse the already-fetched payload. The local mounted guard can still briefly show the placeholder when the route outlet remounts before the cached payload is read.vs. Next.js App Router. Next gives file-system routing with nested layouts that persist across navigations, streaming, and route prefetching — but it owns the entire app. The starter gives you the same persistent-layout, instant-navigation experience for a section of a Rails app, with React Server Components streamed from your Rails app's rsc_payload endpoint (Rails prepares the data — no separate backend to run). There is no file-system routing convention here — routes are explicit code.
vs. Inertia (inertia-rails). Inertia's <Link> gives SPA navigation with server-driven props, plus prefetch and partial reloads, but it replaces the Rails view layer for every Inertia-rendered route. The starter is scoped: the React router owns one URL subtree, and RSCRoute streams server components (not just JSON props) per route. Inertia currently ships link prefetching out of the box; this starter does not (see below).
Honest gaps (current state).
componentName plus serialized componentProps. What is still missing is hover/viewport prefetch and a bounded eviction or invalidation policy for production-sized caches, tracked in issue #3564.RSCRoute routes. When the user navigates away from an RSCRoute-backed route and returns, TanStack Router unmounts the outlet component, so the placeholder briefly reappears before the cached or fetched payload is read. You can avoid that by lifting mount state above the outlet, but the starter keeps the simpler local-state pattern.The Pro dummy app contains the full runnable starter, exercised by a system test:
react_on_rails_pro/spec/dummy/client/app/ror-auto-load-components/TanStackStarterApp.jsxreact_on_rails_pro/spec/dummy/client/app/ror-auto-load-components/StarterServerData.jsxreact_on_rails_pro/spec/dummy/config/routes.rb (tanstack_starter), react_on_rails_pro/spec/dummy/app/controllers/tanstack_starter_controller.rb, react_on_rails_pro/spec/dummy/app/views/tanstack_starter/index.html.erbreact_on_rails_pro/spec/dummy/spec/system/tanstack_starter_spec.rbThe dummy keeps the running example intentionally small: it passes props: {}, so it does not need the AppWrapper/ReportDataContext pattern shown in Step 3. Use that Step 3 pattern when Rails passes request-specific data into the routed island.