Skip to content

docs / api reference

Ruact::Controller

Ruact::Controller — the concern that adds RSC rendering to any Rails controller.

Ruact::Controller is an ActiveSupport::Concern that adds RSC rendering capability to any Rails controller.


Include it in the controllers whose pages should render through ruact:

class PostsController < ApplicationController
include Ruact::Controller
end

Every action of that controller with a corresponding .html.erb template then renders through ruact; your other controllers stay ordinary Rails. rails generate ruact:install changes no controller (island mode), and rails generate ruact:scaffold includes the concern in the controllers it generates.

To make every page of the app a ruact page instead, include it in ApplicationController — which is what rails generate ruact:install --app does (whole-app mode).

A page is a whole controller or the actions it declares:

class PostsController < ApplicationController
include Ruact::Controller
ruact_pages only: %i[show preview] # or: except: %i[index]
def index; end # ordinary Rails, with its own layout
def show; end # ruact
def preview
ruact_render(template: "posts/show") # ruact — declared, so it counts
end # without a template of its own
end

An action listed in only: is a page even without a template of its own, which is how an action that renders another template is treated as a ruact page by the router. A name in only: that is neither a method nor a template of the controller declaring it raises Ruact::ConfigurationError — a typo does not quietly make the page plain Rails. (except: is not checked: naming a missing action there excludes nothing. A declaration on a base controller is a policy for its subclasses and is not checked against each of them.)

The declaration covers forms too. A POST/PATCH/DELETE from the router to an action outside it is submitted by the browser, like any Rails form: its response is a full page load, and it shows validation errors the Rails way — render :new, status: :unprocessable_entity, which still renders new through ruact when new is a page (explicit render) — while ruact’s ruact_errors + redirect_to redirect-back is for actions inside the declaration. List an action in only: to keep its forms inside the client router.


Once the concern is included, the default_render hook activates RSC rendering automatically when:

  1. A matching .html.erb template exists for the current action
  2. The request is either an HTML request (Accept: text/html) or an RSC request (Accept: text/x-component)

For all other formats (JSON, XML, etc.), the default Rails rendering is used unchanged.


A render whose template is a ruact page of the same controller goes through ruact, with its status: and locals: — so the Rails idiom for a failed save works on a ruact page:

def create
@post = Post.new(post_params)
if @post.save
redirect_to @post
else
ruact_errors(@post)
render :new, status: :unprocessable_entity # new.html.erb, rendered by ruact, 422
end
end

render :new, render "new", render action: :new and render template: "posts/new" (from PostsController) all count — when that .html.erb is the template Rails would pick for the request. The format Rails negotiated decides: a .json URL, a format.json branch of respond_to or an Accept that prefers JSON render the JSON template as always, so a scaffold’s format.json { render :show, status: :created, location: @post } is untouched. “A ruact page” is what default_render uses: the action is a page (every action, or the ones in ruact_pages) and its .html.erb is in this controller’s own app/views folder. The client router renders the 422 answer in place and keeps the form’s URL.

Everything else is Rails’ own: render json:, plain:, html:, partial: and every other renderer; a template of another folder; an action outside ruact_pages; and a request that accepts neither HTML nor Flight (a server-function call asking for JSON). status:, locals:, location:, variant:, locale:, formats: and assigns: apply as Rails applies them; layout: and content_type: do not — a ruact page renders into config.layout, as HTML or Flight, like every ruact page. Any other option — a gem’s, like wicked_pdf’s pdf:, or a key Rails ignores, like alert: (the flash goes in flash.now) — leaves the render to Rails, and if the page holds a client component the error names that option. To render another folder’s template through ruact, call ruact_render with template: and status:; a client component in a template Rails renders on its own raises an error naming both ways.

respond_to works in both requests a ruact page gets. A browser load and a client-router request (navigation or form submit) both run the format.html branch, and its render, status or redirect is the answer, so the controller rails generate scaffold writes works as it is. A .json URL and an Accept: application/json request run format.json. A respond_to with no format.html answers a router request with 406, and the error names the missing branch; link to such an action with data-ruact="false" so the browser loads it instead.


Renders the RSC view for the current action.

def ruact_render(template: nil, locals: {}, status: nil)
Parameter Type Default Description
template String, nil nil Logical template name (e.g. "posts/custom"). When nil, uses the current action’s default template.
locals Hash {} Local variables to pass to the template.
status Symbol, Integer, nil nil The response status, as render status: takes it (:unprocessable_entity, 422…), in the HTML document and the Flight payload alike. nil keeps the response’s status (200).
  • RSC request (Accept: text/x-component or Ruact-Request: 1 header): responds with a text/x-component Flight payload.
  • HTML request: responds with an HTML document carrying the Flight payload in a <script> block.
  • In an action that streams, both are sent as the page renders: the shell first, then each <Suspense>’s content when it is ready.
# Use the action's default template (most common)
def index
@posts = Post.all
ruact_render
end
# Render a different template
def create
@post = Post.new(post_params)
if @post.save
redirect_to @post
else
ruact_render template: "posts/new", status: :unprocessable_entity
end
end
# Pass locals
def show
@post = Post.find(params[:id])
ruact_render locals: { highlight: params[:highlight] }
end

The concern overrides Rails’ redirect_to for RSC requests. Instead of issuing a 302 HTTP redirect, it emits a Flight redirect row:

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

The client-side router intercepts this row and navigates without an extra HTTP round-trip.

Behaviour:

  • RSC request + same-origin URL: emits Flight redirect row
  • RSC request + cross-origin URL: falls back to standard 302 redirect
  • Non-RSC request: falls back to standard Rails redirect_to
def create
@post = Post.create!(post_params)
redirect_to post_path(@post) # ← handled by RSC client when called from JS navigation
end

ruact_streaming declares the actions whose page is sent as it renders: the shell and every <Suspense> fallback first, then each <Suspense>’s content, run after the shell is sent, when it is ready — on the first load and on a navigation. See Suspense and streaming for what runs later and what does not.

class PostsController < ApplicationController
include Ruact::Controller
ruact_streaming only: :show
def show
@post = Post.find(params[:id])
@comments = @post.comments.load_async # runs while the shell is sent
end
end
Form Streams
ruact_streaming every ruact page of the controller (an action with no template of its own, a JSON one, does not)
ruact_streaming only: :show the listed actions; a name that is not an action fails loudly
ruact_streaming except: :index every ruact page but the listed ones

only: and except: cannot be combined. The declaration is inherited, and a subclass can redeclare it; one line in ApplicationController makes every page of the app stream.

The declared actions run under ActionController::Live, chosen per action before the action runs: the controller’s other actions respond as before — a buffered response, its ETag, on the request’s thread. An endpoint built with PostsController.action(:show) is included. A controller that includes ActionController::Live itself, and declares nothing, streams every page.

An action that renders a <Suspense> and does not stream renders its content in place, before anything is sent; in development the log says, once per response, which line to add.

A declared action that gets a request over HTTP/1.0 — nginx’s default to its upstream, and Rack::Test in request specs — cannot stream: Live gives it an ordinary response and the page is sent whole. The app logs a warning the first time, in every environment, naming the action and proxy_http_version 1.1;. See Deploying an app that streams.


The concern exposes a private helper you can use in before_actions or other callbacks:

before_action :require_authentication, unless: :ruact_request?

An RSC request is one that includes Accept: text/x-component or the header Ruact-Request: 1.