Skip to content

docs / core concepts

Client-Side Navigation

Client-side navigation in ruact: link interception, state preservation, redirects and revalidation.

ruact provides client-side navigation out of the box — no React Router config, no duplicate route definitions. Your Rails routes are the single source of truth.


When the RSC client boots in the browser, it intercepts clicks on <a> elements that point to the same origin. Instead of a full page reload, it:

  1. Sends GET /path with Accept: text/x-component
  2. Rails detects the header and calls ruact_render (which returns a Flight payload, no HTML shell)
  3. The client parses the Flight payload and renders the new component tree in place

The URL updates via history.pushState — the browser back/forward buttons work normally.

Regular links just work:

<%= link_to "View Post", post_path(@post) %>
<!-- or -->
<a href="/posts/<%= @post.id %>">View Post</a>

No special components needed. No <Link> wrapper. Plain HTML anchors.


The RSC client preserves client component state across navigations when the component tree structure remains the same. For example, if a <SearchInput> appears on every page at the same position, its state (search query, focus) is preserved when the user navigates.

State is reset when:

  • The component is unmounted (it no longer appears in the new page’s tree)
  • The component’s key changes

ruact intercepts form submissions for forms that target the same origin. The request is sent as a standard Rails form submission (with CSRF token), and the response is treated as a new Flight payload:

<%= form_with url: posts_path, method: :post do |f| %>
<%= f.text_field :title %>
<%= f.submit "Create" %>
<% end %>

In your controller:

def create
@post = Post.create!(post_params)
ruact_render # ← renders the updated UI as a Flight payload
end

For the standard Post-Redirect-Get pattern:

def create
@post = Post.create!(post_params)
redirect_to post_path(@post)
end

When ruact detects a redirect response to a Flight request, it serializes the redirect as a special Flight row:

0:{"redirectUrl":"/posts/1","redirectType":"push"}

The client follows the redirect client-side — no full page reload, no flash of unstyled content.


Some controller actions should remain plain Rails responses:

  • File downloads (send_file, send_data)
  • JSON APIs consumed by third parties
  • SSE streams, webhooks

In these cases, use regular render or respond_to blocks — render json:, send_data and a format.json branch are Rails’ own; only a render of one of the controller’s ruact pages goes through ruact (explicit render of a page). ruact only renders, on its own, the pages of controllers that include Ruact::Controller (narrowed with ruact_pages only: / except:); a controller without the include is untouched.


The router intercepts every same-origin link, and it cannot tell from the browser whether the page on the other side is a ruact page. The server can: for a request from the router, ruact checks the route before any action runs. If the destination is not a ruact page — an ordinary controller, an engine like Devise, a mounted Rack app — it answers “not mine” without running anything, and the browser loads the page normally. A form that posts to such a page is submitted by the browser the same way, so its action runs once and you see its real response, validation errors included.

Every page ruact renders also tells Turbo Drive to load it in full (<meta name="turbo-visit-control" content="reload">) rather than swap it into a Turbo page.

Anything but a page GET on a controller your app does not own — Devise’s sign-in and sign-out, an admin engine’s actions — is always handed to the browser: those controllers answer in their own terms, not ruact’s, even when they inherit your ApplicationController.

Two things to know about where it sits. Anyone can send the router’s header, so the answer reveals whether a route exists and whether it is a ruact page — though nothing runs to produce it. And middleware your app adds from its own initializers sits after it, so it does not see those answers.

This is Ruact::NavigationBoundary::Middleware, added by the gem. To opt out:

config/application.rb
config.middleware.delete Ruact::NavigationBoundary::Middleware

ruact and Turbo/Hotwire coexist in one app, page by page. rails generate ruact:install changes none of your controllers (island mode): a page is a ruact page when its controller has include Ruact::Controller — and, if the controller declares ruact_pages, the action is among them. Every other page stays Rails HTML, with Turbo as it was. (--app is the other mode: the concern on ApplicationController, every page through ruact.)

Between the two kinds of page, one rule: the page that rendered the document owns its navigation; crossing is a full page load.

  • ruact → a Rails page: the navigation boundary above answers before any action runs, and the browser loads the page once.
  • a Turbo page → ruact: Turbo fetches the page, meets the turbo-visit-control meta, and reloads — two GETs of the ruact page, the cost of this direction.
  • A form on a ruact page posting to a Rails action is submitted by the browser: the action runs once and you see its real response, a 422 included.
  • A form on a Turbo page posting to a ruact action needs data-turbo="false", or Turbo reloads the form’s page and the answer is lost; ruact warns about it in development.

Inside each world, nothing changes: Turbo Frames and Stimulus keep working on Rails pages, and ruact’s router moves between ruact pages without reloading. Progressive migration walks through it for an existing app.