Skip to content

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 .tsx files, 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.

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:

as-generated (v0-style output)
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.

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.)

The view is an ordinary Rails template, and the tag is a self-closing PascalCase element:

app/views/pricing/show.html.erb
<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:

app/controllers/pricing_controller.rb
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
end
end

Run 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:

traps/AnonymousDefault.tsx
// 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:

traps/RenamedExport.tsx
// 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::ManifestError
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.

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 a Ruact::PreprocessorError naming 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.

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.

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: async components, "use server" files, server-side await inside 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.

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:

Terminal window
bin/rails ruact:doctor -- --json # install/config health; exits 1 on failure
bin/rails ruact:routes -- --json # every accessor: name, kind, verb, path, params

The -- 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?.

  • Get startedrails new to 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/).