This is a Rails 8 application template using Inertia.js with React. It is a greenfield Rails app using Minitest with no existing models/controllers to reference.
Rails 8.1 + Inertia.js + React 19 + TypeScript + Vite. Uses shadcn/ui components, Tailwind CSS v4, and JS-Routes for typed routing (import { rootPath } from "@/routes"). Database-backed infrastructure: Solid Cache/Queue/Cable.
- Early development, no users. No backwards compatibility concerns. Do things RIGHT: clean, organized, zero tech debt. Never create compatibility shims.
- WE NEVER WANT WORKAROUNDS, we always want FULL implementations that are long term sustainable for many >1000 users. so dont come up with half baked solutions
- We want thin controllers, fat models/services. All business logic in models/services with unit tests. No controller/integration tests.
- We are using postgres for development and production and sqlite for the test environment. This means that things like jsonb columns will break the test environment. So avoid using jsonb columns or other postgres-specific features unless absolutely necessary.
- Entry:
app/frontend/entrypoints/inertia.ts - Styles:
app/frontend/entrypoints/application.css - Pages:
app/frontend/pages/(Inertia page components) - Components:
app/frontend/components/(shadcn/ui components) - Types:
app/frontend/types/
| Command | Purpose |
|---|---|
./bin/ci |
Run all checks (Minitest, Rubocop, TS/JS lint, type check) |
bin/rails test |
Run Minitest test suite |
./bin/rails generate model/migration |
Generate models/migrations |
./bin/rails g inertia:scaffold Model |
Generate full CRUD with Inertia (controller, model, pages) |
npm lint:fix |
Fix JS/TS lint issues |
npm format:fix |
Format code with Prettier |
npm typecheck |
Run TypeScript type checking |
npm test |
Run frontend tests with Vitest |
./bin/ciis the main command to run for tests, linting, and type checking.
- Standard:
<Link href="/path"> - Programmatic:
router.visit('/path')(for callbacks, conditionals)
Always use js-routes helpers - they provide type-safe, Rails-consistent routing with automatic parameter handling.
You never need to manually regenerate the routes file; it's auto-regenerated on editing config/routes.rb.
import { itemsPath, itemPath, newItemPath } from "@/routes"
// Basic paths (no parameters)
itemsPath()
// => "/items"
newItemPath()
// => "/items/new"
// ID parameters
itemPath(1)
// => "/items/1"
// Format option
itemPath(1, { format: "json" })
// => "/items/1.json"
// Anchor links
itemPath(1, { anchor: "details" })
// => "/items/1#details"
// Query parameters (single values)
itemsPath({ q: "search", status: "active" })
// => "/items?q=search&status=active"
// Query parameters (arrays) - automatically encoded
itemsPath({ tags: ["featured", "new"] })
// => "/items?tags%5B%5D=featured&tags%5B%5D=new"
// Combining ID with query parameters
itemPath(1, { tab: "history", expanded: true })
// => "/items/1?tab=history&expanded=true"
// Objects with id property (automatically extracts ID)
const item = { id: 42, name: "Widget" }
itemPath(item)
// => "/items/42"
// Objects with to_param (uses to_param instead of id)
const item = { id: 42, name: "Widget", to_param: "widget-42" }
itemPath(item)
// => "/items/widget-42"Common patterns:
// ✅ Programmatic navigation with query params
router.get(itemsPath({ q: searchQuery, status: filter }))
// ✅ Link with query params
<Link href={itemsPath({ status: 'active' })}>Active Items</Link>
// ✅ Combining everything
itemPath(item.id, { format: 'json', anchor: 'details', debug: true })
// => "/items/42.json?debug=true#details"
// ❌ Don't manually construct URLs
router.get(`/items?q=${searchQuery}`) // Wrong!
router.get(`${itemsPath()}?q=${searchQuery}`) // Wrong!Every page MUST have a descriptive browser tab title. Use Inertia's <Head> component to set titles on each page.
import { Head } from '@inertiajs/react';
interface Props {
projects: Project[];
}
export default function ProjectShow({ project }: { project: Project }) {
return (
<>
<Head title={project.name} />
{/* page content */}
</>
);
}To change the base title, update
baseTitlevalue in app/frontend/entrypoints/inertia.tsx<title>contents in app/views/layouts/application.html.erb
Use Inertia <Link> (not <a> tags) for all in-app navigation so page transitions are client-side and titles update correctly.
Always use explicit render inertia: calls to avoid security risks from accidentally leaking sensitive data.
class ItemsController < InertiaController
def index
items = Item.all.as_json(only: %i[id name])
render inertia: "items/index", props: { items: items }
end
def create
item = Item.new(item_params)
if item.save
redirect_to items_path, notice: "Item was successfully created."
else
render inertia: "items/new", props: { item: item }.merge(inertia_errors(item)), status: :unprocessable_content
end
end
endKey principle: Explicitly specify which data to pass as props. This prevents accidentally exposing instance variables like @current_user, memoized variables, or internal state to the frontend.
Add global props via inertia_share in InertiaController:
inertia_share do
{ auth: { user: Current.user&.as_json(only: %i[id email name]) } }
endCSRF tokens and flash messages (:notice, :alert) are handled automatically.
Pass authorization checks as props (don't rely on server helpers in React):
render inertia: 'posts/show', props: {
post: @post.as_json,
can_edit: policy(@post).update?
}Deferred props: stats: InertiaRails.defer { expensive_calculation } loads after initial render. Group with group: 'name' for parallel fetching.
Prefer <Form> over useForm - handles 90% of cases.
import { Form } from "@inertiajs/react"
// ✅ CORRECT: Uncontrolled form with resetOnSuccess
export default () => (
<Form action="/users" method="patch" resetOnSuccess>
{({ errors, processing }) => (
<>
<input name="user.name" defaultValue="John" />
{errors.name && <div>{errors.name}</div>}
<input name="user.skills[]" />
<input type="file" name="user.avatar" />
<button disabled={processing}>Submit</button>
</>
)}
</Form>
)Key principles:
- Use
nameattribute (notvalue+onChange) - Use
defaultValuefor initial values - Use
resetOnSuccessto clear form after submission - Access reactive state via slot props:
errors,processing,isDirty,wasSuccessful - Automatically handles nested data (
report.description), arrays (report.tags[]), file uploads - Use the
methodprop for RESTful verbs (post,patch,delete) not a hidden_methodinput
When to use useForm instead:
- Programmatic control over form state
- Real-time validation
- Complex field interdependencies
Use method spoofing (multipart doesn't support PUT/PATCH natively):
<Form action="/users/1" method="post">
<input type="hidden" name="_method" value="put" />
<input type="file" name="user.avatar" />
</Form>New Rails controllers should inherit from InertiaController:
class UsersController < InertiaController
endUse inertia_errors(model) helper for validation errors (returns { errors: { field: "message" } }).
CRITICAL: Inertia.js requires a full response from every controller action. You cannot use head :ok or similar status-only responses.
# ❌ WRONG - Inertia cannot handle status-only responses
def reorder
@item.update_position(params[:position])
head :ok # This will cause Inertia errors
end
# ✅ CORRECT - Use redirect or render inertia
def reorder
@item.update_position(params[:position])
redirect_back fallback_location: items_path
end
# ✅ ALSO CORRECT - Render Inertia page
def reorder
@item.update_position(params[:position])
render inertia: "items/index", props: { items: Item.all.as_json }
endWhy: Inertia intercepts all responses and expects either:
- A redirect (
redirect_to,redirect_back) - An Inertia page render (
render inertia:)
Status-only responses like head :ok, head :no_content, or render json: will break the frontend navigation flow.
This guide covers shadcn/ui component patterns and common import mistakes.
The shadcn/ui Field component does not export FieldInput. Use Input from @/components/ui/input instead.
// ❌ WRONG - FieldInput doesn't exist
import { Field, FieldLabel, FieldInput } from "@/components/ui/field"
// ✅ CORRECT - Import Input separately
import { Field, FieldLabel, FieldError } from "@/components/ui/field"
import { Input } from "@/components/ui/input"
// Usage
;<Field>
<FieldLabel htmlFor="email">Email</FieldLabel>
<Input name="email" type="email" id="email" />
{errors?.email && <FieldError>{errors.email}</FieldError>}
</Field>Field components:
Field- Wrapper componentFieldLabel- Label for fieldFieldError- Error message displayFieldDescription- Help text/description
Field grouping:
FieldGroup- Group multiple fieldsFieldSet- Fieldset wrapperFieldLegend- Legend for fieldsetFieldContent- Content wrapperFieldTitle- Title componentFieldSeparator- Visual separator
Separate imports needed:
Inputfrom@/components/ui/inputTextareafrom@/components/ui/textareaSelectfrom@/components/ui/select
Put id on SelectTrigger (not Select) and match with label's htmlFor:
<label htmlFor="filter_by_asset">Filter by Asset</label>
<Select value={value} onValueChange={setValue}>
<SelectTrigger id="filter_by_asset">
<SelectValue placeholder="All assets" />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">All assets</SelectItem>
</SelectContent>
</Select>Use Empty component with slot-based composition (no direct title/description props):
import {
Empty,
EmptyHeader,
EmptyTitle,
EmptyDescription,
EmptyContent,
} from "@/components/ui/empty"
import { Button } from "@/components/ui/button"
import { Link } from "@inertiajs/react"
export default function ItemsIndex({ items }) {
if (items.length === 0) {
return (
<Empty>
<EmptyHeader>
<EmptyTitle>No items found</EmptyTitle>
<EmptyDescription>
Get started by creating your first item.
</EmptyDescription>
</EmptyHeader>
<EmptyContent>
<Link href="/items/new">
<Button>Create Item</Button>
</Link>
</EmptyContent>
</Empty>
)
}
// ... render items
}import { Field, FieldLabel, FieldError } from "@/components/ui/field"
import { Input } from "@/components/ui/input"
;<Field>
<FieldLabel htmlFor="item_name">Name</FieldLabel>
<Input name="item.name" defaultValue={item.name} id="item_name" />
{errors?.name && <FieldError>{errors.name}</FieldError>}
</Field>import { Form } from "@inertiajs/react"
import {
Field,
FieldLabel,
FieldError,
FieldDescription,
} from "@/components/ui/field"
import { Input } from "@/components/ui/input"
import { Textarea } from "@/components/ui/textarea"
import { Button } from "@/components/ui/button"
type Props = {
item: {
name?: string
description?: string
price?: number
}
errors?: {
name?: string
description?: string
price?: string
}
}
export default function ItemForm({ item, errors }: Props) {
return (
<Form action="/items" method="post" resetOnSuccess>
{({ processing }) => (
<div className="space-y-6">
<Field>
<FieldLabel htmlFor="item_name">Name</FieldLabel>
<Input name="item.name" defaultValue={item.name} id="item_name" />
{errors?.name && <FieldError>{errors.name}</FieldError>}
</Field>
<Field>
<FieldLabel htmlFor="item_description">Description</FieldLabel>
<FieldDescription>
Provide a detailed description of the item
</FieldDescription>
<Textarea
name="item.description"
defaultValue={item.description}
id="item_description"
/>
{errors?.description && (
<FieldError>{errors.description}</FieldError>
)}
</Field>
<Field>
<FieldLabel htmlFor="item_price">Price</FieldLabel>
<Input
type="number"
name="item.price"
defaultValue={item.price}
step="0.01"
id="item_price"
/>
{errors?.price && <FieldError>{errors.price}</FieldError>}
</Field>
<div className="flex gap-4">
<Button type="submit" disabled={processing}>
{processing ? "Saving..." : "Save Item"}
</Button>
<Button type="button" variant="outline">
Cancel
</Button>
</div>
</div>
)}
</Form>
)
}IMPORTANT: Forms must be fully inside or fully outside Card components. Do not split Card structure across Form boundaries.
// ❌ WRONG - Form breaks Card component hierarchy
<Card>
<CardHeader>
<CardTitle>Create an account</CardTitle>
</CardHeader>
<Form action={signupPath()} method="post">
{({ processing }) => (
<>
<CardContent className="space-y-4">
{/* Form fields */}
</CardContent>
<CardFooter>
<Button type="submit">Submit</Button>
</CardFooter>
</>
)}
</Form>
</Card>
// ✅ CORRECT - Form inside CardContent
<Card>
<CardHeader>
<CardTitle>Create an account</CardTitle>
<CardDescription>Enter your details to sign up for an account</CardDescription>
</CardHeader>
<CardContent>
<Form action={signupPath()} method="post">
{({ processing }) => (
<div className="space-y-4">
{/* Form fields */}
<Button type="submit" disabled={processing}>
{processing ? 'Submitting...' : 'Submit'}
</Button>
</div>
)}
</Form>
</CardContent>
</Card>
// ✅ ALSO CORRECT - Form wraps entire Card
<Form action={signupPath()} method="post">
{({ processing }) => (
<Card>
<CardHeader>
<CardTitle>Create an account</CardTitle>
<CardDescription>Enter your details to sign up for an account</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
{/* Form fields */}
</CardContent>
<CardFooter>
<Button type="submit" disabled={processing}>
{processing ? 'Submitting...' : 'Submit'}
</Button>
</CardFooter>
</Card>
)}
</Form>- Breaking Card's component structure causes styling and semantic issues
- Card components expect direct children in a specific order (Header → Content → Footer)
- Form's render function creates a new component boundary that disrupts this hierarchy
General rule: Keep component hierarchies intact. If a parent component expects specific children structure (like Card), don't interrupt it with wrapper components like Form.
The persistent layout wraps all pages by default.
How to update PersistentLayout with header and footer:
// PersistentLayout
export default function PersistentLayout({ children }) {
return (
<div className="flex min-h-screen flex-col">
<header className="bg-background border-b">
<div className="h-16 ... ...">...</div>
</header>
<main className="flex flex-1">{children}</main>
<Toaster richColors />
</div>
)
}Override the layout for specific pages:
import CustomLayout from "@/layouts/custom-layout"
export default function SpecialPage() {
return <div>{/* page content */}</div>
}
SpecialPage.layout = (page: React.ReactNode) => (
<CustomLayout>{page}</CustomLayout>
)Pages render inside the PersistentLayout's <main> element. Never use min-h-screen in pages.
// ❌ WRONG - Nested min-h-screen
export default function ItemsIndex() {
return (
<div className="min-h-screen p-6">
{" "}
{/* Don't do this! */}
<h1>Items</h1>
</div>
)
}
// ✅ CORRECT - Use flex-1 to fill available space
export default function ItemsIndex() {
return (
<div className="flex flex-1 flex-col p-6">
<h1>Items</h1>
</div>
)
}
// ✅ CORRECT - Centered content page
export default function SignIn() {
return (
<div className="flex flex-1 items-center justify-center p-4">
<Card>...</Card>
</div>
)
}type Item = {
id: number
name: string
description: string | null
created_at: string
}
type Props = {
item: Item
errors?: Record<string, string>
}
export default function Show({ item, errors }: Props) {
// ...
}// Define in types/inertia.d.ts
declare module "@inertiajs/core" {
interface PageProps {
auth: {
user: {
id: number
email: string
name: string
} | null
}
flash: {
notice?: string
alert?: string
}
}
}
// Access in components
import { usePage } from "@inertiajs/react"
export default function MyComponent() {
const { auth, flash } = usePage().props
return (
<div>
{auth.user && <p>Hello, {auth.user.name}</p>}
{flash.notice && <div className="notice">{flash.notice}</div>}
</div>
)
}Follow this pattern when implementing CRUD resources (boards, projects, tasks, etc.) unless explicitly requested otherwise.
Add model with validations, enforce constraints in DB where practical (null: false, etc.)
# db/migrate/20240101000000_create_items.rb
class CreateItems < ActiveRecord::Migration[8.0]
def change
create_table :items do |t|
t.string :name, null: false
t.text :description
t.timestamps
end
end
end
# app/models/item.rb
class Item < ApplicationRecord
validates :name, presence: true, length: { maximum: 255 }
endUse resources :items (or set root "items#index" if applicable)
# config/routes.rb
Rails.application.routes.draw do
resources :items
# or
root "items#index"
endInherit from InertiaController, implement standard actions:
class ItemsController < InertiaController
before_action :set_item, only: %i[show edit update destroy]
def index
items = Item.all.as_json(only: %i[id name created_at])
render inertia: "items/index", props: { items: items }
end
def show
item = @item.as_json(only: %i[id name description created_at])
render inertia: "items/show", props: { item: item }
end
def new
item = Item.new.as_json(only: %i[name description])
render inertia: "items/new", props: { item: item }
end
def edit
item = @item.as_json(only: %i[id name description])
render inertia: "items/edit", props: { item: item }
end
def create
item = Item.new(item_params)
if item.save
redirect_to items_path, notice: "Item was successfully created."
else
render inertia: "items/new", props: { item: item }.merge(inertia_errors(item)), status: :unprocessable_content
end
end
def update
if @item.update(item_params)
redirect_to items_path, notice: "Item was successfully updated."
else
render inertia: "items/edit", props: { item: @item }.merge(inertia_errors(@item)), status: :unprocessable_content
end
end
def destroy
@item.destroy!
redirect_to items_path, notice: "Item was successfully deleted."
end
private
def set_item
@item = Item.find(params[:id])
end
def item_params
params.require(:item).permit(:name, :description)
end
end- Always use explicit
render inertia:calls with explicitly defined props to avoid security risks - Use
inertia_errors(model)to format validation errors (returns{ errors: { field: "message" } }) - Flash messages (
:notice,:alert) are automatically shared to frontend - Explicitly serialize props using
.as_json()to control exactly what data is sent to the client - Never rely on instance variables being automatically serialized - this can accidentally leak sensitive data
app/frontend/pages/
└── items/
├── index.tsx # List all items
├── show.tsx # Show single item
├── new.tsx # Create form
└── edit.tsx # Edit form
Controller renders match directory: render inertia: "items/index" → app/frontend/pages/items/index.tsx
index.tsx - List page:
import { Link } from "@inertiajs/react"
import { Button } from "@/components/ui/button"
type Item = {
id: number
name: string
created_at: string
}
type Props = {
items: Item[]
}
export default function Index({ items }: Props) {
return (
<div>
<div className="mb-6 flex items-center justify-between">
<h1 className="text-2xl font-bold">Items</h1>
<Link href="/items/new">
<Button>New Item</Button>
</Link>
</div>
<div className="space-y-4">
{items.map((item) => (
<div key={item.id} className="rounded border p-4">
<Link href={`/items/${item.id}`}>
<h2 className="text-lg font-semibold">{item.name}</h2>
</Link>
</div>
))}
</div>
</div>
)
}new.tsx - Create form:
import { Form } from "@inertiajs/react"
import { Field, FieldLabel, FieldError } from "@/components/ui/field"
import { Input } from "@/components/ui/input"
import { Textarea } from "@/components/ui/textarea"
import { Button } from "@/components/ui/button"
type Props = {
item: {
name?: string
description?: string
}
errors?: {
name?: string
description?: string
}
}
export default function New({ item, errors }: Props) {
return (
<div>
<h1 className="mb-6 text-2xl font-bold">New Item</h1>
<Form action="/items" method="post" resetOnSuccess>
{({ processing }) => (
<>
<Field>
<FieldLabel htmlFor="item_name">Name</FieldLabel>
<Input name="item.name" defaultValue={item.name} id="item_name" />
{errors?.name && <FieldError>{errors.name}</FieldError>}
</Field>
<Field>
<FieldLabel htmlFor="item_description">Description</FieldLabel>
<Textarea
name="item.description"
defaultValue={item.description}
id="item_description"
/>
{errors?.description && (
<FieldError>{errors.description}</FieldError>
)}
</Field>
<Button type="submit" disabled={processing}>
Create Item
</Button>
</>
)}
</Form>
</div>
)
}Follow existing <Form> preferences from main docs:
- Uncontrolled inputs with
nameattribute - Use
defaultValuefor initial values - Use
resetOnSuccessto clear form after submission - Access reactive state via slot props:
errors,processing
Security-first approach: Only serialize and send exactly what the page needs. Never serialize entire models.
# ✅ CORRECT - Minimal serialization with explicit fields
items = Item.all.as_json(only: %i[id name created_at])
render inertia: "items/index", props: { items: items }
# ✅ CORRECT - With associations, explicitly specify fields
item = Item.find(params[:id]).as_json(
only: %i[id name description],
include: {
author: { only: %i[id name] }
}
)
render inertia: "items/show", props: { item: item }
# ❌ WRONG - Serializes all attributes including sensitive data
item = Item.find(params[:id]).as_json
render inertia: "items/show", props: { item: item }- Complex forms: Use
useFormif you need programmatic control, real-time validation, or field interdependencies - Non-RESTful actions: Add custom routes/actions when CRUD doesn't fit the domain model
- Custom layouts: Override default
PersistentLayoutper-page if needed - Nested resources: Use nested routes (
resources :projects do resources :tasks end) when appropriate