Server functions
You already know how to write a Rails controller. A server function is one you can call from React — no new DSL, no generated endpoint, no client plumbing. You write the action; ruact gives React a typed function that calls it.
This page builds the whole model from a controller you've already written.
Start with a controller you already have
Here is an ordinary Rails controller. Nothing ruact-specific yet:
class PostsController < ApplicationController
def create
@post = Post.create!(post_params)
redirect_to @post
end
private
def post_params
params.require(:post).permit(:title, :body)
end
endIt's routed the way you'd route anything:
# config/routes.rb
resources :postsAdd one line
class PostsController < ApplicationController
include Ruact::Server # ← the only new line
def create
@post = Post.create!(post_params)
redirect_to @post
end
# ...
endinclude Ruact::Server makes this controller's non-GET routed actions callable from React. That's the whole rule:
The verb decides. A routed action reached by
POST/PATCH/PUT/DELETEis a server function.GETpage actions are untouched — they keep rendering pages exactly as before.
There is no separate endpoint and no per-action declaration. The action is the function, reachable at its real route (POST /posts), visible in rails routes like everything else. Routed means public — the same way a Rails route has always meant public.
The payoff
ruact generates a typed TypeScript module from your route table. Import the action by name and call it:
import { createPost } from "@/.ruact/server-functions";
const post = await createPost({ post: { title: "Hi", body: "…" } });You didn't learn a framework. You wrote Rails, and React got a function. Write Rails. Ship React.
The export name is the camelCase form of the route (create on posts → createPost); the naming rules are below. The import path @/.ruact/server-functions never changes — ruact's Vite plugin regenerates the module whenever your routes change.
How the request picks the response shape
The same action, at the same URL, answers different body shapes — and the same controller answers a third when it renders a page. Which shape you get is decided entirely by how the request arrived (its Accept header and HTTP verb), never by the Ruby. You don't choose a mode in your action — you write one action and the caller picks the shape.
This table maps every caller to the shape it receives and the signal that decides it:
| Caller | Deciding signal (Accept + verb) | Response shape | Content-Type |
|---|---|---|---|
Generated accessor — await createPost(args) | application/json + the route's non-GET verb | function-call JSON (ivars, or 204 when none, or a $redirect directive) | application/json (empty on 204) |
<form action={createPost}> (React 19 form action / useActionState) | application/json + the route's non-GET verb | function-call JSON — same as the accessor | application/json |
| Client-side router navigation | text/x-component + GET | Flight page — re-renders in place | text/x-component |
Native <form> a client router intercepts | text/x-component + the form's verb | Flight page — re-renders in place | text/x-component |
Plain browser GET (first load / address bar) | HTML-acceptable Accept (text/html or */*), no Flight header + GET | HTML shell — full document with <div id="root"> + inline Flight data | text/html |
Query — useQuery(...) / GET /q/<name> | application/json + GET (the query dispatch route) | plain JSON — the query method's return value | application/json |
<form action={createPost}> is function-call JSON, not Flight. React 19's <form action> on a generated accessor still fetches with Accept: application/json, so it lands in the same bucket as await createPost(...) — the JSON shape, not a Flight re-render. This is the one row people expect to be Flight and it is not.
The discriminator is the exact Accept header (plus the verb rule: a function call is non-GET, so a query's GET + application/json is a query, not a function call). In development, ruact echoes this decision as a [ruact] Controller#action — <signal> → <bucket> log line — one per negotiated request — so you can watch which shape was served and why without a debugger. The log speaks the same vocabulary as this table (e.g. Accept: application/json + POST → function-call JSON). The dev log currently covers Ruact::Server controllers (function calls + page renders); queries and page-only controllers appear in the table above but are not yet echoed by the log.
The JSON shape — instance variables
When the action is called through the generated accessor and finishes without an explicit render, ruact serializes the action's instance variables (the same set a view would see) into a JSON object keyed by ivar name:
def create
@post = Post.create!(post_params) # imperative caller receives { "post": { … } }
endconst { post } = await createPost({ post: { title: "Hi", body: "…" } });Each value goes through the same ruact_props / Ruact::Serializable / strict_serialization policy as the rest of ruact (see Ruact::Serializable). An action that assigns no ivars answers 204 No Content (the call resolves to null).
Redirects
redirect_to works in both shapes. For a JSON call — the generated accessor, including <form action={createPost}> — it surfaces as a $redirect directive that the runtime follows client-side (through the router, with a hard navigation as fallback). For a Flight navigation it is a Flight redirect. The same redirect_to @post line serves both. ruact reuses Rails' own open-redirect protection, so a cross-host redirect raises UnsafeRedirectError exactly as it would in a normal action.
Calling from React
Forms — <form action={fn}>
Pass the imported action straight to React 19's <form action> prop. On submit, React calls it with the form's FormData; the runtime sends it as multipart/form-data to the action's real route using that route's verb, forwards CSRF, and resolves with the action's JSON. React discards a form action's return value, so to use the result, read it with useActionState (below) or refresh the surrounding page with revalidate():
import { createPost } from "@/.ruact/server-functions";
export function NewPost() {
return (
<form action={createPost}>
<input name="post[title]" required />
<textarea name="post[body]" required />
<button>Create</button>
</form>
);
}Inside create, the submitted fields arrive as normal request params — params.require(:post).permit(:title, :body) works as always.
useActionState
To read the action's result into React state, wrap it so React's (prevState, formData) call shape forwards just the FormData:
import { useActionState } from "react";
import { createPost } from "@/.ruact/server-functions";
type PostState = { post: { id: number; slug: string } } | null;
export function PostForm() {
const [state, formAction, pending] = useActionState<PostState, FormData>(
async (_prev, formData) => (await createPost(formData)) as PostState,
null,
);
return (
<form action={formAction}>
<input name="post[title]" required />
<textarea name="post[body]" required />
<button disabled={pending}>{pending ? "Saving…" : "Create"}</button>
{state && <p>Created post #{state.post.id}</p>}
</form>
);
}The wrapper exists because the generated action type takes a single argument while useActionState calls its function with two; (_prev, fd) => createPost(fd) forwards only the FormData (prev-state is client-only React state, never sent to the server). If a future React release converges the two call shapes, the wrapper drops out.
Direct calls and argument shapes
You can also call an action directly from an event handler. The runtime picks the wire encoding from the argument:
| JS argument | Wire encoding | Server-side params |
|---|---|---|
plain object / null / undefined | application/json | decoded from the JSON body |
FormData | multipart/form-data (browser-managed boundary) | decoded from the multipart body |
Dynamic segments
When the route has a dynamic segment (PATCH /posts/:id), the runtime reads it by name from your single call argument and interpolates it into the URL; the full argument is still sent as the body, so params[:id] and the rest both arrive:
import { updatePost } from "@/.ruact/server-functions";
await updatePost({ id: 7, post: { title: "Edited" } }); // PATCH /posts/7Revalidating after a mutation — revalidate()
An imperative call updates React state but doesn't refetch the page from Rails. When a mutation changed data shown elsewhere on the page, call revalidate():
import { createPost, revalidate } from "@/.ruact/server-functions";
async function handleSubmit(formData: FormData) {
await createPost(formData);
await revalidate(); // refetch the current path as Flight, in place
}It refetches the current path (or a path you pass) as a Flight stream and swaps the tree in place — no history entry, no scroll. Mirrors Next.js' revalidatePath. It requires the ruact router to be installed (setupRouter() publishes the handle at boot) and throws a descriptive error if it isn't, rather than silently doing nothing.
Auto-revalidate — one await = mutation + refresh
Remembering the second await revalidate() is exactly the kind of step that's easy to drop — the mutation succeeds, the page looks stale, and it reads like a silent bug. Auto-revalidate is an opt-in that folds the refresh into the mutation call: after a successful, non-redirecting mutation it runs the in-place Flight refresh of the current path for you, before your await resolves. It's the same two requests as the manual two-step — the POST-JSON mutation and the GET-Flight refresh — just composed for you. There's no new wire shape: auto-revalidate never adds inbound Flight parsing.
There are two ways to opt in.
Per call — withRefresh(accessor). Wrap a generated accessor to refresh after that one call. Import withRefresh from the runtime (the same place configureRuactRuntime comes from):
import { createPost } from "@/.ruact/server-functions";
import { withRefresh } from "ruact/server-functions-runtime";
async function handleSubmit(formData: FormData) {
await withRefresh(createPost)(formData); // create, then refresh, in one await
}App-wide — configureRuactRuntime({ autoRevalidate: true }). Flip it on once at boot and every successful, non-redirecting mutation refreshes:
import { configureRuactRuntime } from "ruact/server-functions-runtime";
configureRuactRuntime({ autoRevalidate: true }); // default is falseA per-call withRefresh(...) wins over the global default, so you can force a refresh on a single call even with the app-wide default off.
The behavior in the edge cases mirrors revalidate() exactly:
- A
$redirectresponse wins. When the actionredirect_tos, ruact follows the redirect and skips the refresh — the destination already re-renders, so refreshing the old path would be wrong. The call resolvesnull, as it does without auto-revalidate. - A failed mutation never refreshes. If the mutation itself fails (a
422, a transport error), the error propagates and no refresh is attempted — the refresh is sequenced strictly after the mutation succeeds. - No router → the same loud error. Auto-revalidate reuses
revalidate(), so with no router installed you get the same descriptive "wiresetupRouter()" error rather than a silent no-op. - Refresh failure rejects the
await. Because the refresh uses the same loud-by-defaultrevalidate(), a mutation that succeeds but whose refresh then fails rejects yourawait createPost(...). That's deliberate — a stale page is surfaced, not swallowed — but it means a rejected promise no longer implies the mutation failed.
Validation errors
When a save raises — Post.create! on an invalid record — that exception flows through the structured-error path (a 422 with the dev overlay). But the idiomatic Rails shape is the non-exception one: if @post.save … else …, where save returns false and the action does not raise. For that happy-failure path, ruact gives you an Inertia-style errors round-trip — field-level messages back to the form, no per-action error channel to hand-roll.
It's opt-in and explicit, like everything else on the wire: you call ruact_errors(@post) once after the save attempt. Because the shape is derived from record.errors (empty on a valid record), the same call yields {} on success and the populated map on failure — so the client has one code path for both.
The shape is always { [attribute]: string[] } — attribute names as strings (a model-level error keys under "base"), values arrays of full messages. On a valid record ruact_errors returns {} (never nil), so the reader — and the errors prop a form binds to (below) — is always safe to read on both branches. On the wire, result.errors appears whenever the action falls through after ruact_errors; a success branch that redirect_tos navigates instead of resolving a result (see Redirects).
Imperative caller (await) — register the errors and let the action fall through (no explicit render/redirect_to on the failure branch); ruact's implicit render injects them into the JSON body under an errors key, alongside any instance variables:
class PostsController < ApplicationController
include Ruact::Server
def create
@post = Post.new(post_params)
if @post.save
redirect_to @post
else
ruact_errors(@post) # fall through → { "errors": { "title": ["Title can't be blank"] } }
end
end
endconst result = await createPost({ post: { title: "" } });
if (Object.keys(result.errors).length) {
setErrors(result.errors); // { title: ["Title can't be blank"] }
}The fall-through rule
Whether errors reaches the imperative caller comes down to how the action ends on the failure branch. The two branches, side by side:
Fall through → errors injected. Register, then let the implicit default_render run. ruact injects the collected map under errors in the JSON body (alongside any instance variables):
def create
@post = Post.new(post_params)
return redirect_to(@post) if @post.save
ruact_errors(@post) # fall through → body gets { "errors": { … } }
endExplicit render → injection opted out. An explicit render on a function-call (imperative-caller) request bypasses default_render, so the registered errors are silently dropped from the response:
def create
@post = Post.new(post_params)
return redirect_to(@post) if @post.save
ruact_errors(@post)
render json: { ok: false } # ⚠️ errors are NOT injected — they vanish
endIn development, ruact catches this exact shape and logs one [ruact]-prefixed warning naming the controller#action and the fix, so the trap surfaces at dev time instead of as a silent empty errors in the browser. It is log-only — the response is byte-identical in every environment.
Two branches never trip the warning because they are the correct ways to surface errors alongside an explicit response: a page render that binds errors={ruact_errors} (the form re-render below), and a redirect_to that carries the errors through flash (also below). An action that never calls ruact_errors is unaffected — no errors key is added, and an empty action still answers 204 as before.
Form / navigation (redirect-back) — the Inertia "redirect back with errors" pattern. Register the errors, then redirect; ruact stashes them in flash (single-use) so they survive the redirect and arrive as an errors prop on the re-rendered page:
def create
@post = Post.new(post_params)
if @post.save
redirect_to @post
else
ruact_errors(@post)
redirect_to new_post_path # errors survive in flash
end
end<%# app/views/posts/new.html.erb %>
<PostForm errors={ruact_errors} />The no-argument ruact_errors reads the always-present hash ({} when there's nothing to show), so the same <PostForm> works on a first visit and after a failed submit. This path needs the host's session middleware (standard for form apps); an API-only host without sessions uses the body path above instead.
This extends the structured-error chain — it doesn't replace it. A raised ActiveRecord::RecordInvalid is still a 422 for the overlay; ruact_errors is the if save … else path that returns a normal 200/redirect with field messages the form binds to.
Reading data — queries
Mutations are controller actions. Reads are query classes — the same "write Rails" idea, on the GET side.
Define a query class under app/queries/. Each public instance method is one query:
# app/queries/application_query.rb
class ApplicationQuery < Ruact::Query; end
# app/queries/catalog_query.rb
class CatalogQuery < ApplicationQuery
def categories
Category.active.pluck(:id, :name).map { |id, name| { value: id, label: name } }
end
def search_users(q:)
User.where("name ILIKE ?", "%#{q}%").limit(10).as_json(only: %i[id name])
end
endMount it in routes.rb — one line draws one named GET route per public method, visible in rails routes:
# config/routes.rb
ruact_queries CatalogQuery # GET /q/categories, GET /q/searchUsersThe route prefix defaults to /q and is configurable via Ruact.config.query_route_prefix (see Configuration).
useQuery
Read a query from React with the useQuery hook. It returns { data, loading, error }:
import { categories, searchUsers, useQuery } from "@/.ruact/server-functions";
function CategoryPicker() {
const { data, loading, error } = useQuery(categories);
if (loading) return <Spinner />;
if (error) return <p>Couldn't load categories.</p>;
return <Select options={data} />;
}
function UserSearch({ query }: { query: string }) {
const { data } = useQuery(searchUsers, { q: query });
return <Results users={data ?? []} />;
}loading is true until the first response; error carries the thrown error on failure — usually a structured RuactActionError for a non-OK server response (see Errors), or a TypeError / transport Error for a rejected parameter or a network failure. The hook refetches when the params change by value.
Query parameters
A query method's keyword arguments are its parameters, passed from useQuery's second argument. The wire is a GET query string, so only primitive values are accepted — string, number, boolean, null. Arrays and objects are rejected with a descriptive error naming the offending key; a missing required keyword argument or an unknown parameter is a 400 Bad Request. Reads are CSRF-free — a GET carries no body and no CSRF token.
Typed accessors
The generated module types each query accessor's params from the query method's declared keyword arguments — you get real parameter types with zero hand-written annotations. A query
class CatalogQuery < ApplicationQuery
def search_users(term:, limit: 10)
User.search(term).limit(limit)
end
endemits a typed accessor:
// in the generated @/.ruact/server-functions module
export const searchUsers: (params: {
term: string | number | boolean | null;
limit?: string | number | boolean | null;
}) => Promise<unknown>;so the call site is checked by tsc:
useQuery(searchUsers, { term: "ada" }); // ✅ ok
useQuery(searchUsers, { term: "ada", limit: 25 }); // ✅ ok
useQuery(searchUsers, { limit: 25 }); // ❌ missing required `term`
useQuery(searchUsers, { term: "ada", page: 2 }); // ❌ unknown key `page`Required keywords (term:) become required properties; keywords with a default (limit: 10) become optional properties — and the editor autocompletes the parameter names. A query that declares no keyword arguments keeps the bare () => Promise<unknown> signature.
Each parameter's value type is the wire union string | number | boolean | null — the exact set the GET query string can carry. Ruby is dynamically typed, so the generator reports parameter names and required/optional-ness (what reflection can honestly see), not per-parameter scalar types: limit? is string | number | boolean | null, not number. The return type stays Promise<unknown> — annotate the result yourself with useQuery's generic: useQuery<User[]>(searchUsers, …).
Action accessors are unchanged: an action reads params dynamically (there is no declared-input list to reflect), so it keeps its FormData | Record<string, unknown> argument and a Promise<unknown> return.
Reads run inside your real security context
Query dispatch runs through an internal controller that inherits Ruact.config.query_parent_controller (default ApplicationController), so the host's real callback chain — authenticate_user!, tenant scoping, Pundit — runs before the query object is instantiated. Inside a query, current_user, params, request, and session delegate to the dispatching controller, so current_user is the host's own method:
class CatalogQuery < ApplicationQuery
def my_categories
current_user.categories.pluck(:id, :name) # the host's current_user
end
endBecause the context is constructor-injected, a query is unit-testable with no Rails boot: CatalogQuery.new(fake_context).categories.
Request de-duplication
When several components mount useQuery(categories) with the same parameters while a request is in flight, they share one network request — three components needing categories issue one GET, not three, and all receive the same result (or the same error). De-duplication is in-flight only: there is no cache and no stale-while-revalidate. Once a request settles, a fresh mount issues a fresh request.
Record references — signed, scoped, expiring
When you pass a record to the client, don't send a raw id. A raw id (or an attribute hash) is a forgeable reference: nothing stops a client from changing { "id": 7 } to { "id": 8 } and submitting it back. If your action then trusts params[:id] and loads Post.find(params[:id]), it just reached a record the user was never authorized to touch — the classic forged-reference (IDOR) attack.
ruact's answer is a SignedGlobalID token: HMAC-signed by your app secret (tamper-proof), bound to a for: purpose (so a token minted for editing a post can't be replayed against, say, a delete endpoint), and expires_in:time-bounded. The client receives an opaque string it cannot forge or repurpose.
Mint one with Ruact.signed_global_id and resolve it back with Ruact.locate_signed:
class PostsController < ApplicationController
include Ruact::Server
# Producing the reference (e.g. for an edit form's hidden field)
def edit
@post = current_user.posts.find(params[:id])
@post_ref = Ruact.signed_global_id(@post, for: :post_edit, expires_in: 1.hour)
end
# Receiving it back from the client
def update
post = Ruact.locate_signed(params[:post_ref], for: :post_edit)
post.update!(post_params)
redirect_to post
end
endOn the client the token is just a string — drop it in a hidden field (or pass it to an imperative call):
<form action={updatePost}>
<input type="hidden" name="post_ref" value={postRef} />
{/* ...fields... */}
</form>What you get for free. A token that is tampered, expired, or presented for the wrong purpose fails verification: Ruact.locate_signed raises Ruact::InvalidSignedGlobalIDError, which ruact renders as a clean 400 Bad Request through the structured-error chain — no ActiveRecord::RecordNotFound leak, no raw-id trust, and (because verification fails before any database lookup) no signal about whether the target record even exists.
The contract — purpose and expiry are required. ruact refuses to silently mint an unscoped or non-expiring token, because that would be a silent insecure default. Pass for: and expires_in: per call, or set app-wide defaults:
Ruact.configure do |c|
c.signed_global_id_default_purpose = :ruact_ref
c.signed_global_id_default_expires_in = 15.minutes # an ActiveSupport::Duration
endIf neither the call nor the config supplies a purpose (or an expiry), the helper raises loudly rather than hand out a weak token. To deliberately mint a non-expiring token, pass an explicit expires_in: nil at the call site — a reviewed, per-call choice, never a default.
This is opt-in: ruact never auto-converts records in your props or auto-resolves params. You reach for the helper where a reference crosses a trust boundary — exactly the record references the generated CRUD scaffold will emit.
CSRF
For mutations, the runtime forwards the page's <meta name="csrf-token"> value as X-CSRF-Token, and your host's protect_from_forgery enforces it — the same mechanism a Rails form uses. There is no ruact-specific CSRF policy.
Include order matters. Add
include Ruact::Serverafterprotect_from_forgery(the usual case —protect_from_forgerylives inApplicationController, your controller includes the concern lower down), so the forgery check sits ahead of the action in the callback chain.
- Classic Rails. Layouts emit the CSRF meta tag via
csrf_meta_tags; the runtime reads it automatically. Nothing to wire. - API mode (
ActionController::API, noprotect_from_forgery). No meta tag; the runtime omits the header and the request is accepted — auth is the host's responsibility. Reads (queries) are CSRF-free in every mode.
Per-request auth headers — configureRuactRuntime
API-mode apps that authenticate with a bearer token attach it once at boot. Since ruact's bootstrap entry is hidden plumbing (a virtual module — there is no app/javascript/application.jsx to edit), call configureRuactRuntime at module scope in one of your own "use client" modules. Those modules are bundled into your app and their top-level code runs when the bundle loads, before any server function is ever called:
// app/javascript/components/RuntimeConfig.jsx ("use client", bundled at boot)
"use client";
import { configureRuactRuntime } from "ruact/server-functions-runtime";
// static token:
configureRuactRuntime({
defaultHeaders: { Authorization: `Bearer ${window.AUTH_TOKEN}` },
});
// function form — re-evaluated on every request so refreshed tokens win:
configureRuactRuntime({
defaultHeaders: () => ({ Authorization: `Bearer ${authStore.token()}` }),
});The import path is always ruact/server-functions-runtime (the gem's Vite plugin installs that alias). Three guarantees on top of defaultHeaders:
- On mutation calls, ruact owns
Accept,Content-Type, andX-CSRF-Token. Any value you pass for those keys is stripped (case-insensitively), so you can't downgrade JSON negotiation or break the multipart boundary. (Query GETs reserve onlyAccept— they carry no body and send no CSRF token regardless.) - The function form runs once per request — use it for tokens that refresh at runtime.
defaultHeaders: nullclears previously-set headers (handy in tests).
Errors and the dev overlay
When a server function raises, ruact converts the exception into a structured JSON body so React can show a rich dev overlay or render its own UI. The body has the same shape in both modes; production drops the dev-only fields.
In development and test (Ruact.config.dev_error_payload_enabled defaults to true there):
{
"_ruact_server_action_error": true,
"action_name": "create",
"error_class": "ActiveRecord::RecordInvalid",
"message": "Validation failed: Title can't be blank",
"app_frames": ["/…/app/controllers/posts_controller.rb:5:in `create'"],
"gem_frames": ["/…/lib/ruact/…"],
"suggestion": "Validation failed — check the model's `validates` rules",
"validation_errors": ["Title can't be blank"]
}In production the payload is just the four baseline fields (_ruact_server_action_error, action_name, error_class, message); the diagnostic keys are absent (not null). The dev overlay shows the action name, an error-class chip, the message, the validation list, the suggestion, app frames by default, gem frames behind a toggle, and a copy-to-clipboard button.
Force the production shape locally to verify what your component will receive:
# config/initializers/ruact.rb
Ruact.configure { |c| c.dev_error_payload_enabled = false }Status mapping
| Exception | HTTP status |
|---|---|
ActiveRecord::RecordInvalid | 422 |
ActionController::InvalidAuthenticityToken | 403 |
Ruact::UploadTooLargeError | 413 |
any other StandardError | 500 |
The runtime rejects with a RuactActionError carrying status, body, actionName, and the raw response, so React can branch on error.status.
Your rescue_from wins
ruact's structured handler is the outermost catch — a host rescue_from you declare (or inherit) takes precedence for the exceptions it owns. ruact only renders the structured payload for exceptions the host did not catch. Use this to give an owned error your own JSON shape, or to convert a failure into a 200 with inline state:
class PostsController < ApplicationController
include Ruact::Server
rescue_from BillingDeclined, with: :handle_billing_declined
def create
@post = Post.create!(post_params)
end
private
def handle_billing_declined(error)
render json: { code: "billing_declined", retry_at: error.retry_at },
status: :payment_required
end
endRendering errors inline in React
A component that wraps an action in useActionState can catch the rejection and render its own UI — works identically in development (the overlay does not fire for a caught error) and production:
async function submit(_prev, formData) {
try {
return { post: await createPost(formData), error: null };
} catch (err) {
return { post: null, error: err.body }; // RuactActionError carries the structured body
}
}The payload deliberately omits per-request context — request.env, params, session, cookies, and headers are never serialized, to avoid leaking PII. The full backtrace and request context are in the server log (always; the dev_error_payload_enabled toggle only governs the wire body).
File uploads
A <form action={fn}> containing <input type="file"> works without setting enctype: React 19 builds a FormData that includes the file, the runtime sends it as multipart/form-data to the action's route (using that route's verb), and Rails' multipart parser delivers the file as an ActionDispatch::Http::UploadedFile. Active Storage works unchanged:
class PostsController < ApplicationController
include Ruact::Server
def create
@post = Post.new(title: params[:title])
@post.cover.attach(params[:cover])
@post.save!
end
endimport { createPost } from "@/.ruact/server-functions";
export function CreatePost() {
return (
<form action={createPost}>
<input name="title" />
<input type="file" name="cover" accept="image/*" />
<button>Create</button>
</form>
);
}max_upload_bytes
ruact rejects oversized uploads before Rack's multipart parser runs, against the request's Content-Length:
# config/initializers/ruact.rb
Ruact.configure { |c| c.max_upload_bytes = 25 * 1024 * 1024 } # 25 MBThe default is 10 MB; set it to nil to disable the gem-side guard and let your reverse proxy own the cap. An oversized request gets 413 Payload Too Large with the structured error body (adding a dev-only upload_limit: { received_bytes, limit_bytes } block). The guard short-circuits for non-multipart/urlencoded content types, requests without a Content-Length, and max_upload_bytes = nil.
Use Direct Upload for big files
max_upload_bytes is a "fail fast" knob, not a streaming upload pipeline (Rack buffers the body before ruact sees it). For large files, route uploads through Active Storage Direct Upload or a presigned S3 URL, and set an operational cap at your reverse proxy (client_max_body_size in nginx, LimitRequestBody in Apache).
Multipart text fields arrive as UTF-8 strings — Rails does not coerce them to Boolean/Integer even for type="number"/type="checkbox" inputs. Cast them in your action (ActiveModel::Type::Boolean.new.cast(params[:published]), params[:count].to_i).
Naming
Ground truth: read the generated file. The generated
app/javascript/.ruact/server-functions.tsis the authoritative list of every accessor and its typed params. When in doubt about an accessor's name — or whether it exists at all — read that file instead of deriving the name in your head: autocomplete for humans,cat app/javascript/.ruact/server-functions.tsfor agents. Regenerate it withbin/rails ruact:server_functions:generate.
The generated export name is derived from the route. If two routes would produce the same JavaScript identifier, codegen fails loudly at boot — break the tie (or just pick a different name) with ruact_function_name:
class PostsController < ApplicationController
include Ruact::Server
ruact_function_name :publish_all, as: "publishEverything"
endThe target must be a valid JS identifier and not a reserved word or a name the runtime already binds; an invalid override fails at class-load time, not at codegen.
Query export names derive from the method name the same way (def search_users → searchUsers); rename a query by renaming its method.
See also
Ruact::Controller— the page-rendering concernRuact::Serializable— how return values and instance variables are serialized- Configuration —
max_upload_bytes,dev_error_payload_enabled,query_route_prefix,query_parent_controller