
Inertia Rails Architecture
- 440 installs
- 64 repo stars
- Updated February 13, 2026
- inertia-rails/skills
Server-driven architecture patterns for Inertia Rails + React with a decision matrix for data loading, forms, navigation, and state.
About
Inertia-rails-architecture teaches the server-owns-truth mental model for Rails + Inertia + React and routes to the right skill via a decision matrix. A developer loads it first when building any Inertia page, model view, or CRUD feature.
- Decision matrix mapping needs to server-driven solutions
- Warns against SPA patterns like useEffect+fetch and react-router
Inertia Rails Architecture by the numbers
- 440 all-time installs (skills.sh)
- +34 installs in the week ending Aug 2, 2026 (Skillselion tracking)
- Ranked #994 of 4,347 Backend & APIs skills by installs in the Skillselion catalog
- Data as of Aug 2, 2026 (Skillselion catalog sync)
npx skills add https://github.com/inertia-rails/skills --skill inertia-rails-architectureAdd your badge
Show developers this skill is listed on Skillselion. Paste this into your README.
| Installs | 440 |
|---|---|
| repo stars | ★ 64 |
| Last updated | February 13, 2026 |
| Repository | inertia-rails/skills ↗ |
What it does
Server-driven architecture patterns for Inertia Rails + React with a decision matrix for data loading, forms, navigation, and state.
Files
Inertia Rails Architecture
Server-driven architecture for Rails + Inertia.js + React when building pages, forms, navigation, or data refresh. Inertia is NOT a traditional SPA — the server owns routing, data, and auth. React handles rendering only.
The Core Mental Model
The server is the source of truth. React receives data as props and renders UI. There is no client-side router, no global state store, no API layer.
Before building any feature, ask:
- Where does the data come from? → If server: controller prop. If user interaction:
useState. - Who owns this state? → If it's in the URL or DB: server owns it (use props). If it's ephemeral UI: React owns it.
- Am I reaching for a React/SPA pattern? → Check the decision matrix below first — Inertia likely has a server-driven equivalent.
Decision Matrix
| Need | Solution | NOT This |
|---|---|---|
| Page data from server | Controller props | useEffect + fetch |
| Global data (auth, config) | inertia_share + usePage() | React Context / Redux |
| Flash messages / toasts | Rails flash + usePage().flash | inertia_share / React state |
| Form submission | <Form> component | fetch/axios + useState |
| Navigate between pages | <Link> / router.visit | react-router / window.location |
| Refresh specific data | router.reload({ only: [...] }) | React Query / SWR |
| Expensive server data | InertiaRails.defer | useEffect + loading state |
| Infinite scroll | InertiaRails.scroll + <InfiniteScroll> | Client-side pagination |
| Stable reference data | InertiaRails.once | Cache in React state |
| Real-time updates (core) | ActionCable + router.reload | Polling with setInterval |
| Simple polling (MVP/prototyping) | usePoll (auto-throttles in background tabs) | setInterval + router.reload |
| URL-driven UI state (dialogs, tabs) | Controller reads params → prop, router.get to update | useEffect + window.location |
| Ephemeral UI state | useState / useReducer | Server props |
| External API calls | Dedicated API endpoint | Mixing with Inertia props |
Rules (by impact)
| # | Impact | Rule | WHY |
|---|---|---|---|
| 1 | CRITICAL | Never useEffect+fetch for page data | Inertia re-renders the full component on navigation; a useEffect fetch creates a second data lifecycle that drifts from props and causes stale UI |
| 2 | CRITICAL | Never check auth client-side | Auth state in React can be spoofed; server-side checks are the only real gate. Client-side "guards" give false security |
| 3 | CRITICAL | Use <Form>, not fetch/axios | <Form> handles CSRF, redirect-following, error mapping, file detection, and history state — fetch duplicates or breaks all of this |
| 4 | HIGH | Use <Link> and router, not <a> or window.location | <a> triggers a full page reload, destroying all React state and layout persistence |
| 5 | HIGH | Use partial reloads, not React Query/SWR | React Query adds a second cache layer that conflicts with Inertia's page-based caching and versioning |
| 5b | HIGH | Use usePoll only for MVPs; prefer ActionCable for production real-time | usePoll is convenient but wastes bandwidth — every interval hits the server even when nothing changed. ActionCable pushes only on actual changes |
| 6 | HIGH | Use inertia_share for global data, not React Context | Context re-renders consumers on every change; shared props are per-request and integrated with partial reloads |
| 7 | HIGH | Use Rails flash for notifications, not shared props | Flash auto-clears after one response; shared props persist until explicitly changed, causing stale toasts |
| 8 | MEDIUM | Use deferred/optional props for expensive queries | Blocks initial render otherwise — user sees blank page until slow query finishes |
| 9 | MEDIUM | Use persistent layouts for state preservation | Without persistent layout, layout remounts on every navigation — scroll position, audio playback, and component state are lost |
| 10 | MEDIUM | Keep React components as renderers, not data fetchers | Mixing data-fetching into components makes them untestable and breaks Inertia's server-driven model |
Skill Map
Common workflows span multiple skills — load all listed for complete coverage:
| Workflow | Load these skills |
|---|---|
| New page with props | inertia-rails-controllers + inertia-rails-pages + inertia-rails-typescript |
| Form with validation | inertia-rails-forms + inertia-rails-controllers |
| shadcn form inputs | inertia-rails-forms + shadcn-inertia |
| Flash toasts | inertia-rails-controllers + inertia-rails-pages + shadcn-inertia |
| Deferred/lazy data | inertia-rails-controllers + inertia-rails-pages |
| URL-driven dialog/tabs | inertia-rails-controllers + inertia-rails-pages |
| Alba serialization | alba-inertia + inertia-rails-typescript |
| Testing controllers | inertia-rails-testing + inertia-rails-controllers |
References
MANDATORY — READ ENTIRE FILE before building a new Inertia page or feature: `references/AGENTS.md` (~430 lines) — full-stack examples for each pattern in the decision matrix above.
MANDATORY — READ ENTIRE FILE when unsure which Inertia pattern to use: `references/decision-trees.md` (~70 lines) — flowcharts for choosing between prop types, navigation methods, and data strategies.
Do NOT load references for quick questions about a single pattern already covered in the decision matrix above.
When You DO Need a Separate API
Not everything belongs in Inertia's request cycle. Use a traditional API endpoint when:
| Signal | Why | Example |
|---|---|---|
| Non-browser consumer | Inertia's JSON envelope (component, props, url, version) is designed for the frontend adapter — other consumers can't use it | Mobile API, CLI tools, payment webhooks |
| Large-dataset search | Dataset is too big to load as a prop; each input needs per-keystroke server filtering. Use raw fetch for the search, let Inertia handle post-selection side effects via props. | City/address autocomplete, postal code lookup |
| Binary/streaming response | Inertia can only deliver JSON props. Use a separate route with a standard download response. | PDF/CSV export, file downloads |
Architecture Rules — Expanded Reference
Incorrect/Correct pairs for all architecture rules. Use as a quick reference when reviewing or writing Inertia.js + Rails code.
Table of Contents
- Rule 1: Server Owns Data (CRITICAL)
- Rule 2: Server Owns Auth (CRITICAL)
- Rule 3: Use Form Component (CRITICAL)
- Rule 4: Navigation (HIGH)
- Rule 5: Data Refresh (HIGH)
- Rule 6: Global Data (HIGH)
- Rule 7: Flash Messages (HIGH)
- Rule 8: Expensive Queries (MEDIUM)
- Rule 9: Persistent Layouts (MEDIUM)
- Rule 10: Components as Renderers (MEDIUM)
---
Rule 1: Server Owns Data (CRITICAL)
Incorrect: useEffect + fetch for page data
// BAD — SPA pattern in an Inertia app
export default function Users() {
const [users, setUsers] = useState([])
const [loading, setLoading] = useState(true)
useEffect(() => {
fetch('/api/users')
.then(res => res.json())
.then(data => {
setUsers(data)
setLoading(false)
})
}, [])
if (loading) return <Spinner />
return <UserList users={users} />
}Correct: Server provides data as props
# app/controllers/users_controller.rb
class UsersController < InertiaController
def index
render inertia: {
users: User.all.as_json(only: [:id, :name, :email]),
}
end
end// app/frontend/pages/users/index.tsx — path matches controller/action
export default function Index({ users }: { users: User[] }) {
return <UserList users={users} />
}No loading state needed. No error handling for fetch. No race conditions. The data arrives with the page, fully server-rendered and type-safe.
Refreshing data without full page reload:
// Refresh only the users prop
router.reload({ only: ['users'] })
// After a mutation Rails redirects back and returns only the users prop
router.post('/users', {
data: formData,
only: ['users'],
})---
Rule 2: Server Owns Auth (CRITICAL)
Incorrect: Client-side auth checks
// BAD — checking auth in React
export default function Dashboard() {
const { auth } = usePage().props
if (!auth.user) {
router.visit('/login')
return null
}
return <DashboardContent />
}Correct: Server handles auth, React trusts it
# app/controllers/dashboard_controller.rb
class DashboardController < InertiaController
before_action :authenticate_user! # Redirect happens server-side
def index
render inertia: {
stats: DashboardStats.for(Current.user),
}
end
end// If this component renders, user IS authenticated
export default function Index({ stats }: DashboardIndexProps) {
return <DashboardContent stats={stats} />
}If unauthenticated, the user never receives the page component. The redirect happens server-side before any React code runs.
---
Rule 3: Use Form Component (CRITICAL)
Incorrect: Rolling your own form submission
// BAD — manual fetch/axios for forms
export default function CreateUser() {
const [name, setName] = useState('')
const [errors, setErrors] = useState({})
const [submitting, setSubmitting] = useState(false)
const handleSubmit = async (e) => {
e.preventDefault()
setSubmitting(true)
try {
const res = await fetch('/users', {
method: 'POST',
headers: { 'Content-Type': 'application/json', 'X-CSRF-Token': ... },
body: JSON.stringify({ name }),
})
if (!res.ok) setErrors(await res.json())
} finally { setSubmitting(false) }
}
}Correct: Inertia `<Form>` component
import { Form } from '@inertiajs/react'
export default function CreateUser() {
return (
<Form method="post" action="/users">
{({ errors, processing }) => (
<>
<input type="text" name="name" />
{errors.name && <span>{errors.name}</span>}
<button type="submit" disabled={processing}>Create</button>
</>
)}
</Form>
)
}<Form> handles: CSRF tokens, redirect following, error mapping, processing state, file upload detection, scroll preservation, and browser history state — all without manual onChange handlers or state management.
Use useForm hook only when you need programmatic control (dynamic fields, external submit triggers, complex transforms, pre-populated edit forms).
---
Rule 4: Navigation (HIGH)
Incorrect: Traditional links or window.location
// BAD — causes full page reload, loses SPA behavior
<a href="/users">Users</a>
window.location.href = '/users'Correct: Inertia Link and router
import { Link, router } from '@inertiajs/react'
// Declarative
<Link href="/users">Users</Link>
<Link href="/users/1/edit" method="get">Edit</Link>
// Programmatic
router.visit('/users')
router.post('/users', { data: { name: 'John' } })
// With prefetching
<Link href="/users" prefetch cacheFor="30s">Users</Link>Use <a> links only for external resources (i.e. socials).
---
Rule 5: Data Refresh (HIGH)
Incorrect: React Query / SWR for Inertia data
// BAD — separate data fetching layer in an Inertia app
import { useQuery } from '@tanstack/react-query'
export default function Users() {
const { data: users } = useQuery({
queryKey: ['users'],
queryFn: () => fetch('/api/users').then(r => r.json()),
})
}Correct: Partial reloads
// Refresh specific props without full page reload
router.reload({ only: ['users'] })
// Polling (simple/MVP — prefer ActionCable for production real-time)
// usePoll auto-throttles when tab is in background, auto-stops on unmount
import { usePoll } from '@inertiajs/react'
usePoll(30000, { only: ['notifications'] })
// keepAlive: true to continue polling even when tab is hidden
usePoll(30000, { only: ['notifications'], keepAlive: true })
// Manual control — start/stop polling on user action
const { start, stop } = usePoll(5000, { only: ['stats'] }, { autoStart: false })
// <button onClick={start}>Start Live Updates</button>
// <button onClick={stop}>Pause</button>
// BAD — manual setInterval (no background throttling, leaks on unmount):
// useEffect(() => {
// const interval = setInterval(() => router.reload({ only: ['notifications'] }), 30000)
// return () => clearInterval(interval)
// }, [])
// After user action Rails redirects back and returns only the users prop
const handleDelete = (id: number) => {
router.delete(`/users/${id}`, {
only: ['users'],
})
}---
Rule 6: Global Data (HIGH)
Incorrect: React Context for global app state
// BAD — reimplementing what inertia_share already does
const AuthContext = createContext(null)
const FlashContext = createContext(null)
function App({ children }) {
const [user, setUser] = useState(null)
useEffect(() => { fetch('/api/me').then(...) }, [])
return (
<AuthContext.Provider value={user}>
{children}
</AuthContext.Provider>
)
}Correct: Shared props via inertia_share
# app/controllers/application_controller.rb
class ApplicationController < ActionController::Base
inertia_share do
{ auth: { user: Current.user&.as_json(only: [:id, :name, :email, :role]) } }
end
end// Access anywhere in React tree
import { usePage } from '@inertiajs/react'
function UserMenu() {
const { props } = usePage()
return <span>{props.auth.user?.name}</span>
}---
Rule 7: Flash Messages (HIGH)
Incorrect: Passing flash through shared props manually
# BAD — flash is already automatic in inertia-rails
inertia_share do
{ flash: flash.to_hash.compact }
end// BAD — accessing flash as a regular prop
const { flash } = usePage().propsCorrect: Use Rails flash normally (it's automatic)
# Rails flash works automatically with Inertia.
# By default notice and alert keys are included,
# Configure if additional keys are needed:
# config/initializers/inertia_rails.rb
InertiaRails.configure do |config|
config.flash_keys = %i[notice alert toast]
end
# In controllers, just use Rails flash normally:
def create
@user = User.create!(user_params)
redirect_to users_path, notice: "User created!"
end// Access flash directly on the page object (NOT props)
import { usePage } from '@inertiajs/react'
function FlashMessages() {
const { flash } = usePage()
return (
<>
{flash.notice && <Toast type="success">{flash.notice}</Toast>}
{flash.alert && <Toast type="error">{flash.alert}</Toast>}
</>
)
}Flash data is NOT persisted in history state — it won't reappear when navigating back. Use router.flash('key', 'value') for client-side flash.
---
Rule 8: Expensive Queries (MEDIUM)
Incorrect: Loading states in React for server data
// BAD — client-side loading for data that should be deferred server-side
export default function Dashboard({ basicStats }) {
const [detailedStats, setDetailedStats] = useState(null)
useEffect(() => {
fetch('/api/detailed-stats').then(r => r.json()).then(setDetailedStats)
}, [])
return detailedStats ? <Details data={detailedStats} /> : <Spinner />
}Correct: Deferred props
# Server defers expensive computation
def index
render inertia: {
basic_stats: DashboardStats.quick,
detailed_stats: InertiaRails.defer { DashboardStats.detailed },
permissions: InertiaRails.defer(group: 'auth') { Current.user.permissions },
}
endimport { Deferred } from '@inertiajs/react'
export default function Index({ basic_stats }: DashboardIndexProps) {
return (
<>
<QuickStats data={basic_stats} />
<Deferred data="detailed_stats" fallback={<Spinner />}>
<DetailedStats />
</Deferred>
</>
)
}---
Rule 9: Persistent Layouts (MEDIUM)
Incorrect: Remounting layout on every navigation
// BAD — layout remounts, losing audio player state, etc.
export default function Show({ course }) {
return (
<AppLayout>
<CourseContent course={course} />
</AppLayout>
)
}Correct: Set a default persistent layout in `createInertiaApp`
// Inside createInertiaApp's resolve callback
page.default.layout ??= (page: ReactNode) => <Layout>{page}</Layout>Override per-page when needed:
import { AppLayout } from '@/layouts/app-layout'
export default function Show({ course }: CourseShowProps) {
return <CourseContent course={course} />
}
Show.layout = (page: React.ReactNode) => <AppLayout>{page}</AppLayout>The layout persists across page navigations. Audio players keep playing, WebSocket connections stay alive, and heavy components don't reinitialize.
---
Rule 10: Components as Renderers (MEDIUM)
Incorrect: React component that fetches its own data
// BAD — component is both a data fetcher and a renderer
function UserProfile({ userId }) {
const [user, setUser] = useState(null)
useEffect(() => {
fetch(`/api/users/${userId}`).then(r => r.json()).then(setUser)
}, [userId])
if (!user) return <Spinner />
return <ProfileCard user={user} />
}Correct: Component receives data, renders it
// Component is a pure renderer
function UserProfile({ user }: { user: User }) {
return <ProfileCard user={user} />
}# Data comes from the controller
# app/controllers/users_controller.rb
def show
render inertia: {
user: User.find(params[:id]).as_json(only: [:id, :name, :email, :bio])
}
endIf a child component needs data, pass it down as props from the page. The page component is the bridge between server data and React rendering.
Decision Trees
Quick flowcharts for common Inertia.js + Rails decisions.
"I need data in my component"
Is this data specific to this page?
├── YES → Controller prop (render inertia: props: { ... })
│ └── Is it expensive to compute?
│ ├── >500ms → InertiaRails.defer { ... }
│ ├── <100ms → Regular prop (defer overhead not worth it)
│ └── 100-500ms → Judgment call — defer if page has fast props to show first
├── NO, it's needed on every page → inertia_share
└── NO, it's from an external API → Dedicated API endpoint
(Stripe status, third-party webhook, etc.)"I need to update data"
Is this a form submission (create/update/delete)?
├── YES → <Form> component + controller action + redirect
│ └── Need programmatic control? → useForm hook instead
└── NO
├── Need to refresh page data? → router.reload({ only: [...] })
├── Need real-time updates?
│ ├── Core feature (chat, live feed)? → ActionCable + router.reload
│ └── MVP/prototype or no ActionCable yet? → usePoll(interval, { only: [...] })
├── Need optimistic UI? → useState for optimistic + router.post with onError rollback
└── Need search/filter? → router.visit with query params (preserveState)"I need state in my component"
Where does the data come from?
├── Server → It's a prop, not state
├── User interaction (modal open, dropdown) → useState
├── Form data → <Form> component (or useForm for complex cases)
├── Shared across pages (auth) → usePage().props (from inertia_share)
└── Multiple components need it → Lift to closest common parent as prop
└── Still unwieldy? → Consider React Context (rare in Inertia apps)"Should I prefetch / poll / defer / use ActionCable?"
PREFETCH — preload page data before navigation:
├── Frequently visited page (dashboard, main nav)? → YES, prefetch="mount"
├── Likely next click (nav links)? → YES, prefetch (hover, default)
├── Data changes constantly per user? → NO — cache will be stale immediately
├── Page requires POST data to load? → NO — prefetch only works with GET
└── Multiple pages share data? → Use cacheTags for coordinated invalidation
POLL — auto-refresh data on an interval:
├── Dashboard counters, queue status, leaderboard? → YES, usePoll with only: [...]
├── Query is expensive (>1s)? → NO — use ActionCable push instead
├── Updates are rare (<1/hour)? → NO — manual refresh or ActionCable
├── Need real-time (<1s latency)? → NO — use ActionCable/WebSockets
└── Need user control? → { autoStart: false } + start/stop
DEFER — load expensive data after initial render:
├── Query >500ms? → YES, always defer
├── Query <100ms? → NO — defer overhead not worth it
├── Data critical for initial render (form defaults, auth)? → NO — regular prop
└── 100-500ms? → Defer if page has fast props to show first
ACTIONCABLE — server pushes updates to client:
├── Core real-time feature (chat, live feed, collaboration)? → YES
├── Updates must arrive <1s after change? → YES
├── Multiple users see the same resource? → YES — broadcast on change
├── Only current user's data, low frequency? → usePoll is simpler
└── Pattern: ActionCable receives event → router.reload({ only: [...] })"I need to navigate"
Is this a link the user clicks?
├── YES → <Link href={...}> (with prefetch for common destinations)
├── NO, programmatic after action → router.visit / router.get
├── External URL from server? → inertia_location (CRITICAL — not redirect_to)
├── External URL from client? → window.location.href
└── Need to update URL params? → router.visit with preserveState"I need to show a notification"
Is it a one-time message (success, error)?
├── YES → Rails flash + usePage().flash
│ └── Need custom keys beyond notice/alert? → config.flash_keys
├── Need it to persist across navigations? → inertia_share (shared prop)
└── Client-side only (no server)? → router.flash('key', 'value')"I need to redirect after a mutation"
Is the destination inside the Inertia app?
├── YES → redirect_to path (standard Rails redirect)
│ └── With flash? → redirect_to path, notice: "Done!"
└── NO, external URL (Stripe, OAuth, etc.)
└── inertia_location url (returns 409 + X-Inertia-Location header)
NEVER: redirect_to external_url (breaks Inertia)