AI Tools & Agents
AI tools emit React components. What happens next depends on your stack: if the server can’t render React as it comes out, every generated file pays a translation tax — you rewrite it into a partial, or into a page object, or into whatever shape the framework wants, before you can see it on screen. That rewrite is the thing this page is about removing. In ruact the generated file is the file your app runs.
The only line you have to add is "use client". One thing you have to
check rather than add: the component needs a PascalCase named export,
because that name is the tag you write in ERB. That is the entire adaptation.
Two ground rules, so you can trust the walkthrough below:
- Every code block below that names a file is a byte-for-byte excerpt of a
runnable example in the ruact repository (
docs/examples/ai-tools/) — the AI output, the adapted component, the ERB, the controller, the trap fixtures and the error message. CI boots the Rails side against the gem, type-checks the.tsxfiles, asserts the manifest claims against the gem’s own scanner, and fails if the page and the example drift apart. The three short blocks that name no file (the diff, the CLI lines, the spec expectation) are illustrative — though the diff has its own CI assertion behind it. - The component below was written by hand, not captured from a session.
It is a stand-in for the shape these tools emit — one self-contained file,
hooks, Tailwind class names, a default export called
Component— because that shape is what the walkthrough has to survive. Nothing here claims to be a specific product’s output.
The four steps
Section titled “The four steps”1. Generate the component
Section titled “1. Generate the component”Prompt v0, Cursor, GitHub Copilot, Claude, or whatever you already use, for the component you want — here, a pricing table with a monthly/yearly toggle. What comes back looks like this:
import { useState } from "react"
type Plan = { id: number; name: string; monthlyCents: number }
export default function Component({ plans, currency = "USD",}: { plans: Plan[] currency?: string}) { const [yearly, setYearly] = useState(false) const price = (cents: number) => new Intl.NumberFormat("en-US", { style: "currency", currency }).format( (yearly ? cents * 10 : cents) / 100, )
return ( <div className="rounded-lg border p-4"> <button className="text-sm underline" onClick={() => setYearly(!yearly)}> {yearly ? "Billed yearly" : "Billed monthly"} </button> <ul className="mt-3 space-y-2"> {plans.map((plan) => ( <li key={plan.id} className="flex justify-between"> <span>{plan.name}</span> <span className="font-medium">{price(plan.monthlyCents)}</span> </li> ))} </ul> </div> )}Two details in that file decide everything that follows: it has no
"use client" directive, and its export is a placeholder —
export default function Component.
2. Save it to app/javascript/components/
Section titled “2. Save it to app/javascript/components/”Save it as app/javascript/components/PricingTable.tsx. There is no registry
to edit and nothing to import: the Vite plugin scans that directory for
"use client" files — .jsx and .tsx alike — and builds the client
manifest itself, with ids that match in development and in production. Save
the file and the component is available.
3. Add "use client", and give the export a name
Section titled “3. Add "use client", and give the export a name”Two edits, and they are the only ones:
"use client"
import { useState } from "react"
type Plan = { id: number; name: string; monthlyCents: number }
export default function Component({export function PricingTable({"use client" goes at the top, above the imports: it is a module directive,
and JavaScript only reads it as one when nothing but comments comes before it.
That directive is what the plugin’s scan looks for when it decides which files
are client components, and what separates the parts of the tree that ship to
the browser from the parts that stay on the server — see
the "use client" directive.
The rename is not cosmetic. The tag you write in ERB is the component’s
export name, so naming the export PricingTable is what makes
<PricingTable /> resolve. (More on that, and the two ways it goes wrong,
below.)
4. Call it from ERB
Section titled “4. Call it from ERB”The view is an ordinary Rails template, and the tag is a self-closing PascalCase element:
<h1>Plans</h1><%# Self-closing, always: a client component tag takes props, never children. %><PricingTable plans={@plans} currency={@currency} />The controller is an ordinary Rails controller. The values in {…} are Ruby,
evaluated on the server, and they become the component’s props:
class PricingController < ApplicationController def show @currency = "USD" # Plain JSON-shaped data needs no serializer of your own. Hash keys arrive # as strings, so they are written as the prop names the component expects. @plans = Plan.all.map do |plan| { "id" => plan.id, "name" => plan.name, "monthlyCents" => plan.monthly_cents } end endendRun bin/dev (Rails and Vite together — Vite is what writes the manifest) and
the component renders, with its useState toggle working in the browser. No
serializer, no endpoint, no props type written twice.
Note the hash keys: the generated component expects monthlyCents, so the
Ruby hash says monthlyCents. Prop names cross the wire exactly as you write
them — ruact does not case-convert them for you, in either direction.
The tag is the export name, not the filename
Section titled “The tag is the export name, not the filename”ruact keys the client manifest by each component’s exported declaration name. The filename is only where the scanner found it. Two consequences worth knowing before they cost you an afternoon:
An anonymous default export registers nothing. There is no name to key it by, so the file contributes no manifest entry at all:
// TRAP FIXTURE — deliberately wrong. `scripts/check-manifest.mjs` asserts this// file registers NOTHING: the manifest is keyed by exported *declaration// names*, and an anonymous default export has none. No ERB tag can resolve to// it — writing one raises Ruact::ManifestError (see traps/views). Name it."use client"
export default function ({ label }: { label: string }) { return <span>{label}</span>}A named export registers under its own name, whatever the file is called:
// TRAP FIXTURE — the file is named RenamedExport.tsx but the export is// `PlanBadge`. `scripts/check-manifest.mjs` asserts the manifest key is// `PlanBadge`, i.e. the ERB tag follows the EXPORT name, never the filename."use client"
export function PlanBadge({ name }: { name: string }) { return <em>{name}</em>}That file is mounted as <PlanBadge />, not <RenamedExport />. Both
fixtures are in the repository and both are asserted against the gem’s own
scanner on every CI run, which is why this section can be specific rather
than cautionary.
Neither case is silent. A tag with no manifest entry behind it raises when the template renders, and the error names the component and where to put it:
ruact: Component "AnonymousDefault" not found in manifest. Did you mean to add app/javascript/components/AnonymousDefault.jsx and rebuild Vite? Did you run the Vite build? Run 'npm run build' or start the Vite dev server.When a similar name is registered — the usual case, because you wrote the
tag from the filename and the export is spelled slightly differently — the
middle line becomes Did you mean "…"? with the real export name. A request
spec in this repository asserts that message in full, so it is the message you
will actually get.
The practical rule: keep one component per file, give the export a PascalCase
name, and name the file after it. export default function Component — a very
common shape in AI output — is the case to fix on paste.
Tags are self-closing, and props are Ruby
Section titled “Tags are self-closing, and props are Ruby”Two rules the generated component does not know about, both of them about the ERB side rather than the React side:
- A component tag takes props, never children.
<PricingTable>…</PricingTable>is not a smaller version of the right thing; it fails at preprocess time with aRuact::PreprocessorErrornaming the file, the line, and the fix. Pass the content as a prop instead. {…}is Ruby, not JavaScript.plans={@plans}evaluates Ruby in the ERB. There are no JS ternaries, no spreads and no JSX inside the braces.
Both are on the five-trap list that ships with
ruact — the same list rails generate ruact:install writes into your app’s
AGENTS.md.
What crosses as props
Section titled “What crosses as props”Plain JSON-shaped data — strings, numbers, booleans, and arrays or hashes of them — needs no serializer of your own, which is why the controller above hands the component plain hashes and stops there. (Hash keys arrive as strings, so write them as the prop names the component expects.)
Domain objects are the deliberate exception: a model or PORO only crosses the
wire through an allowlist you declare —
include Ruact::Serializable + ruact_props :id, :name.
Without one, development warns and falls back to as_json, and production
raises. So “pass whatever you have to the component” is not the claim: pass
scalars and plain structures, or name the fields that may leave the server.
What “any AI tool” means here
Section titled “What “any AI tool” means here”Nothing on this page is specific to v0, Cursor or Copilot; they are examples
of a category. The claim generalizes to any tool that emits a standard React
client component: a PascalCase named export, "use client", props in,
compatible with React 19. Whether that component was typed by a person, a chat
window or an agent loop makes no difference to ruact — the manifest scan only
ever sees a file.
The honest boundary, so the first failure isn’t a surprise. Output that does not drop in unchanged:
- components importing app-aliased dependencies you don’t have (
@/components/ui/*,@/lib/*) — add the dependency, or ask for a self-contained version; - Next.js-specific imports (
next/image,next/link,next/navigation) — there is no Next.js runtime here; - server-component-only patterns:
asynccomponents,"use server"files, server-sideawaitinside the component body. ruact’s server half is ERB, not an async React component.
Each of those is a one-line prompt away from output that does drop in unchanged — “self-contained, no UI library imports, plain React” is usually enough.
If your agent writes the whole app
Section titled “If your agent writes the whole app”The walkthrough above assumes you paste. If an agent is writing the Rails side too, the question changes: what does it read, and how does it check itself without a browser?
Context it reads. rails generate ruact:install writes an AGENTS.md
into the app — the mental model, the verb rule for server functions, the
five traps, and where the ground truth lives.
llms.txt is that same reference at a stable public URL, for an agent that has
no app yet. Neither names a vendor; both are plain text meant to be read whole.
Ground truth it can read instead of guessing. The client half of the
server boundary is generated from the route table into
app/javascript/.ruact/server-functions.ts. An agent that needs an accessor
name reads that file rather than inferring one; bin/rails ruact:server_functions:generate
regenerates it. Naming rules and collisions are covered in
Server functions.
Diagnostics it can parse. Both health and route diagnostics speak JSON:
bin/rails ruact:doctor -- --json # install/config health; exits 1 on failurebin/rails ruact:routes -- --json # every accessor: name, kind, verb, path, paramsThe -- is required: it is what puts --json in ARGV instead of letting
Rails try to parse it. Both documents carry a schema_version field and are
still marked experimental — gate on it. See
ruact:doctor.
A verification loop with no browser. A request spec can assert that a page rendered a given component with given props, server-side:
require "ruact/testing" # in spec/rails_helper.rb
expect(response).to have_ruact_component("PricingTable") .with_props(including("plans"))That closes the loop: the agent writes the component and the ERB tag, and one request spec proves the tag resolved and the props arrived — no headless browser, no screenshot diffing. It is also exactly what CI runs against the example behind this page. Details in Testing.
Errors that name their own fix. A call site that breaks a declared
component contract fails
at preprocess time, naming the component, the file, the line and what to change
— and when the offending prop is a near-miss of a declared one, a did-you-mean
suggestion; accessor-name collisions fail at boot; query params
are typed from the query method’s keyword arguments, so tsc catches an
invented parameter before any request is made. An agent that can read an error
can correct itself from it.
For how this compares with the alternatives — including the honest concession that agents know Inertia and Next.js far better than they know ruact — see Why ruact?.
Where to go next
Section titled “Where to go next”- Get started —
rails newto a rendered, interactive component, with the fewest possible decisions. - Read the source on GitHub — the gem,
its tests, and the example behind this page (
docs/examples/ai-tools/).