
Inertia Rails Controllers
- 406 installs
- 64 repo stars
- Updated February 13, 2026
- inertia-rails/skills
Rails controller patterns for Inertia: render inertia props, defer/optional/merge/once prop types, shared data, flash, PRG redirects, and validation errors.
About
Inertia-rails-controllers covers server-side patterns for controllers serving Inertia responses, including prop helpers, shared data, and external-URL redirects. A developer uses it when writing controllers that load data or serve Inertia pages.
- Prop helpers: defer, optional, merge, once via InertiaRails
- External URLs must use inertia_location, never redirect_to
Inertia Rails Controllers by the numbers
- 406 all-time installs (skills.sh)
- +31 installs in the week ending Aug 2, 2026 (Skillselion tracking)
- Ranked #1,068 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-controllersAdd your badge
Show developers this skill is listed on Skillselion. Paste this into your README.
| Installs | 406 |
|---|---|
| repo stars | ★ 64 |
| Last updated | February 13, 2026 |
| Repository | inertia-rails/skills ↗ |
What it does
Rails controller patterns for Inertia: render inertia props, defer/optional/merge/once prop types, shared data, flash, PRG redirects, and validation errors.
Files
Inertia Rails Controllers
Server-side patterns for Rails controllers serving Inertia responses.
Before adding a prop, ask:
- Needed on every page? →
inertia_sharein a base controller (InertiaController), not a per-action prop - Expensive to compute? →
InertiaRails.defer— page loads fast, data streams in after - Only needed on partial reload? →
InertiaRails.optional— skipped on initial load - Reference data that rarely changes? →
InertiaRails.once— cached across navigations
NEVER:
- Use
redirect_tofor external URLs (Stripe, OAuth, SSO) — it returns 302 but the Inertia client tries to parse the response as JSON, causing a broken redirect. Useinertia_location(returns 409 +X-Inertia-Locationheader). - Use
errors.full_messagesfor validation errors — it produces flat strings without field keys, so errors can't be mapped to the corresponding input fields on the frontend. Useerrors.to_hash(true). - Use
inertia.defer,Inertia.defer, orinertia_rails.defer— the correct syntax isInertiaRails.defer { ... }. All prop helpers are module methods on theInertiaRailsconstant. - Assume instance variables are auto-passed as props — they are NOT (unless
alba-inertiagem is configured). Every action that passes props to the frontend MUST callrender inertia: { key: data }. - Use
success/erroras flash keys without updatingconfig.flash_keys— Rails defaults tonotice/alert. Custom keys must be added to both the initializer config and theFlashDataTypeScript type.
Render Syntax
`default_render: true` TRAP: This setting only auto-infers the component
name from controller/action — it does NOT auto-pass instance variables as
props. Writing@posts = Post.allin an action withdefault_render: true
renders the correct component but sends zero data to the frontend.
Instance variables are only auto-serialized as props when alba-inertia gemis configured — check Gemfile before relying on this. Without it, you MUSTuse render inertia: { posts: data } to pass any data to the page.>
Empty actions (def index; end) are correct ONLY for pages that need no data(e.g., a static dashboard page, a login form). If the action queries the
database, it MUST call render inertia: with data.| Situation | Syntax | Component path |
|---|---|---|
| Action loads data | render inertia: { users: data } | Inferred from controller/action |
| Action loads NO data (static page) | Empty action or render inertia: {} | Inferred from controller/action |
| Rendering a different page | render inertia: 'errors/show', props: { error: e } | Explicit path |
Rule of thumb: If your action touches the database, it MUST call render inertia: with data. If the action body is empty, the page receives only shared props (from inertia_share).
# CORRECT — data passed as props
def index
render inertia: { users: users_data, stats: InertiaRails.defer { ExpensiveQuery.run } }
end
# CORRECT — static page, no data needed
def index; end
# WRONG — @posts is NEVER sent to the frontend (without alba-inertia)
def index
@posts = Post.all
endNote: If the project uses thealba-inertiagem (checkGemfile), instance
variables are auto-serialized as props and explicit render inertia: is not needed.See the alba-inertia skill for that convention.Prop Types
`InertiaRails.defer` — NOT inertia.defer, NOT Inertia.defer. All prop helpers are module methods on InertiaRails.
| Type | Syntax | Behavior |
|---|---|---|
| Regular | { key: value } | Always evaluated, always included |
| Lazy | -> { expensive_value } | Included on initial page render, lazily evaluated on partial reloads |
| Optional | InertiaRails.optional { ... } | Only evaluated on partial reload requesting it |
| Defer | InertiaRails.defer { ... } | Loaded after initial page render |
| Defer (grouped) | InertiaRails.defer(group: 'name') { ... } | Grouped deferred — fetched in parallel |
| Once | InertiaRails.once { ... } | Resolved once, remembered across navigations |
| Merge | InertiaRails.merge { ... } | Appended to existing array (infinite scroll) |
| Deep merge | InertiaRails.deep_merge { ... } | Deep merged into existing object |
| Always | InertiaRails.always { ... } | Included even in partial reloads |
| Scroll | InertiaRails.scroll { ... } | Scroll-aware prop for infinite scroll |
def index
render inertia: {
filters: filter_params,
messages: -> { messages_scope.as_json },
stats: InertiaRails.defer { Dashboard.stats },
chart: InertiaRails.defer(group: 'analytics') { Dashboard.chart },
countries: InertiaRails.once { Country.pluck(:name, :code) },
posts: InertiaRails.merge { @posts.as_json },
csrf: InertiaRails.always { form_authenticity_token },
}
endDeferred Props — Full Stack Example
Server defers slow data, client shows fallback then swaps in content:
# Controller
def show
render inertia: {
basic_stats: Stats.quick_summary,
analytics: InertiaRails.defer { Analytics.compute_slow },
}
end// Page component — child reads deferred prop from page props
import { Deferred, usePage } from '@inertiajs/react'
export default function Dashboard({ basic_stats }: Props) {
return (
<>
<QuickStats data={basic_stats} />
<Deferred data="analytics" fallback={<div>Loading analytics...</div>}>
<AnalyticsPanel />
</Deferred>
</>
)
}
function AnalyticsPanel() {
const { analytics } = usePage<{ analytics: Analytics }>().props
return <div>{analytics.revenue}</div>
}Shared Data
Use inertia_share in controllers — it needs controller context (current_user, request). The initializer only handles config.* settings (version, flash_keys).
class ApplicationController < ActionController::Base
# Static
inertia_share app_name: 'MyApp'
# Using lambdas (most common)
inertia_share auth: -> { { user: current_user&.as_json(only: [:id, :name, :email, :role]) } }
# Conditional
inertia_share if: :user_signed_in? do
{ notifications: -> { current_user.unread_notifications_count } }
end
endLambda and action-scoped variants are in `references/configuration.md`.
Evaluation order: Multiple inertia_share calls merge top-down. If a child controller shares the same key as a parent, the child's value wins. Block and lambda shares are lazily evaluated per-request — they don't run for non-Inertia requests.
Flash Messages
Flash is automatic. Configure exposed keys if needed:
# config/initializers/inertia_rails.rb
InertiaRails.configure do |config|
config.flash_keys = %i[notice alert toast] # default: %i[notice alert]
endUse standard Rails flash in controllers:
redirect_to users_path, notice: "User created!"
# or
flash.alert = "Something went wrong"
redirect_to users_pathRedirects & Validation Errors
After create/update/delete, always redirect (Post-Redirect-Get). Standard Rails redirect_to works. The Inertia-specific part is validation error handling:
def create
@user = User.new(user_params)
if @user.save
redirect_to users_path, notice: "Created!"
else
redirect_back_or_to new_user_path, inertia: { errors: @user.errors.to_hash(true) }
end
end`to_hash` vs `to_hash(true)`: to_hash gives { name: ["can't be blank"] }, to_hash(true) gives { name: ["Name can't be blank"] }. Keys must match input name attributes — mismatched keys mean errors won't display next to the right field.
NEVER use `errors.full_messages` — it produces flat strings without field keys, so errors can't be mapped to the corresponding input fields on the frontend.
Authorization as Props
Pass permissions as per-resource can hash — frontend controls visibility, server enforces access. See inertia-rails-controllers + inertia-rails-pages skills.
MANDATORY — READ ENTIRE FILE when implementing authorization props: `references/authorization.md` (~40 lines) — full-stack can pattern with Action Policy/Pundit/CanCanCan examples.
Do NOT load if not passing permission data to the frontend.
External Redirects (inertia_location)
CRITICAL: redirect_to for external URLs breaks Inertia — the client receives a 302 but tries to handle it as an Inertia response (JSON), not a full page redirect. inertia_location returns 409 with X-Inertia-Location header, which tells the client to do window.location = url.
# Stripe checkout — MUST use inertia_location, not redirect_to
def create
checkout_session = Current.user.payment_processor.checkout(
mode: "payment",
line_items: "price_xxx",
success_url: enrollments_url,
cancel_url: course_url(@course),
)
inertia_location checkout_session.url
endUse inertia_location for any URL outside the Inertia app: payment providers, OAuth, external services.
History Encryption
Encrypts page data in browser history state — config.encrypt_history = Rails.env.production?. Use redirect_to path, inertia: { clear_history: true } on logout/role change. Full setup with server-side and client-side examples is in `references/configuration.md`.
Configuration
See `references/configuration.md` for all InertiaRails.configure options (version, encrypt_history, flash_keys, etc.).
Troubleshooting
| Symptom | Cause | Fix |
|---|---|---|
| 302 loop on Stripe/OAuth redirect | redirect_to for external URL | Use inertia_location — it returns 409 + X-Inertia-Location header |
| Errors don't display next to fields | Error keys don't match input name | to_hash keys must match input name attributes exactly |
TS2305: postsPath not found in @/routes | js-routes not regenerated after adding routes | Run rails js_routes:generate after changing config/routes.rb |
Related Skills
- Form error display →
inertia-rails-forms - Flash toast UI →
inertia-rails-pages(access) +shadcn-inertia(Sonner) - Deferred on client →
inertia-rails-pages(<Deferred>component) - Type-safe props →
inertia-rails-typescriptoralba-inertia(serializers) - Testing →
inertia-rails-testing
References
MANDATORY — READ ENTIRE FILE when using advanced prop types (merge, scroll, deep_merge) or combining multiple prop options: `references/prop-types.md` (~180 lines) — detailed behavior, edge cases, and combination rules for all prop types.
Do NOT load prop-types.md for basic defer, optional, once, or always usage — the table above is sufficient.
Load `references/configuration.md` (~180 lines) only when setting up InertiaRails.configure for the first time or debugging configuration issues. Do NOT load for routine controller work.
Authorization as Props
Pass permissions as props — the frontend decides what to show, the server decides what to allow. Never check permissions client-side only.
Controller Pattern
def show
@user = User.find(params[:id])
render inertia: {
user: @user.as_json(only: [:id, :name, :email]),
can: {
edit: allowed_to?(:edit?, @user),
delete: allowed_to?(:destroy?, @user),
},
}
endFrontend Pattern
export default function Show({ user, can }: Props) {
return (
<>
<h1>{user.name}</h1>
{can.edit && <Link href={`/users/${user.id}/edit`}>Edit</Link>}
{can.delete && <DeleteButton userId={user.id} />}
</>
)
}Key Rules
- Always enforce server-side —
canprops control UI visibility, not access. Theupdate/destroyactions must independently authorize. - Per-record, not global — pass
canper resource, not a blanketisAdminflag. A user may edit their own profile but not others. - Default to Action Policy — examples use
allowed_to?(:edit?, @user)from Action Policy. For projects already using Pundit (policy(@user).edit?) or CanCanCan (can?(:edit, @user)), use those instead.
Inertia Rails Configuration
Table of Contents
---
Initializer
# config/initializers/inertia_rails.rb
InertiaRails.configure do |config|
# Asset version — triggers full page reload when assets change
config.version = ViteRuby.digest
# Flash keys exposed to client (default: %i[notice alert])
config.flash_keys = %i[notice alert toast]
# Encrypt browser history state (default: false)
config.encrypt_history = Rails.env.production?
# Always include errors hash in response (default: false)
config.always_include_errors_hash = true # true will be default in the next major release
# Deep merge shared data instead of shallow merge (default: false)
config.deep_merge_shared_data = false
# Component path resolver (default: infers from controller/action)
config.component_path_resolver = ->(path:, action:) {
"#{path}/#{action}"
}
endVersion Tracking
Inertia uses version tracking to detect asset changes and trigger full page reloads when the frontend bundle changes.
# With ViteRuby (recommended)
config.version = ViteRuby.digest
# With Propshaft
config.version = Rails.application.config.assets.version
# Manual version string
config.version = '1.0.0'
# Lambda (evaluated per request)
config.version = -> { Rails.application.config.asset_version }Flash Keys
Controls which Rails flash keys are exposed to the Inertia client.
# Default: only notice and alert
config.flash_keys = %i[notice alert]
# Add custom keys
config.flash_keys = %i[notice alert toast]Access on client: usePage().flash.notice, usePage().flash.alert, etc.
History Encryption
Encrypts page data in browser history state. Without it, sensitive props are stored in plaintext and visible via back/forward navigation or devtools.
Enable in production only — encryption can cause issues with HMR and hot-reloading in development:
config.encrypt_history = Rails.env.production?Clear history on logout to prevent back-button access to authenticated pages:
# Server-side — triggers client to clear encrypted history
def destroy
sign_out(current_user)
redirect_to root_path, inertia: { clear_history: true }
end// Client-side — clear history programmatically (rotates encryption key)
import { router } from '@inertiajs/react'
router.clearHistory()When to use `clear_history`: logout, role change, account switching — any moment when the previous session's data should not be accessible via back button.
Error Handling
# Always include errors hash (even when empty)
config.always_include_errors_hash = trueCustom error page for non-Inertia requests:
# app/controllers/application_controller.rb
rescue_from ActiveRecord::RecordNotFound do |e|
if request.inertia?
render inertia: 'errors/not-found', props: { message: e.message }, status: 404
else
render file: Rails.public_path.join('404.html'), status: 404
end
endSSR Configuration
# config/initializers/inertia_rails.rb
InertiaRails.configure do |config|
config.ssr_enabled = true
config.ssr_url = "http://localhost:13714"
endVite SSR setup:
// vite.config.ts
export default defineConfig({
plugins: [
ruby(),
react(),
tailwindcss(),
],
ssr: {
noExternal: ['@inertiajs/react'],
},
})Prop Types — Detailed Reference
Each prop type with real-world examples for inertia-rails 3.17+.
Table of Contents
- Regular Props
- Optional Props
- Deferred Props
- Once Props
- Merge Props
- Always Props
- Scroll Props
- Resetting Merge/Scroll Props
- Combining Prop Types
---
Regular Props
Always included in the initial page response. Wrap in -> {} to skip evaluation during partial reloads that don't request them.
render inertia: {
filters: { search: params[:search], sort: params[:sort] },
users: -> { User.where(active: true).as_json(only: [:id, :name, :email]) },
total_count: -> { User.active.count },
}With router.reload({ only: ['filters'] }), users and total_count are never evaluated.
Rule of thumb: wrap anything involving a query in -> {}. Use plain values only for cheap data like params or literals.
Optional Props
Only evaluated when explicitly requested via partial reload headers. Saves computation on initial page load.
render inertia: {
users: User.active.as_json(only: [:id, :name]),
# Only computed when client requests it
export_data: InertiaRails.optional { User.active.to_csv },
detailed_stats: InertiaRails.optional { Analytics.compute_detailed },
}Client requests optional props with router.reload:
// Only fetches export_data, skips everything else
router.reload({ only: ['export_data'] })Deferred Props
Loaded automatically after the initial page render. The page loads fast with a placeholder, then deferred data streams in.
render inertia: {
# Fast — included in initial response
course: @course.as_json(only: [:id, :title, :description]),
# Slow — loaded after page renders
reviews: InertiaRails.defer { @course.reviews.includes(:user).as_json },
# Grouped — fetched together in a single request
chart_data: InertiaRails.defer(group: 'analytics') { Analytics.chart_for(@course) },
engagement: InertiaRails.defer(group: 'analytics') { Analytics.engagement_for(@course) },
}React side with <Deferred>:
<Deferred data="reviews" fallback={<ReviewsSkeleton />}>
<ReviewsList />
</Deferred>Once Props
Resolved once per session, remembered across navigations. Use for reference data that rarely changes.
render inertia: {
# Fetched once, cached across navigations
countries: InertiaRails.once { Country.pluck(:name, :code) },
timezones: InertiaRails.once { ActiveSupport::TimeZone::MAPPING.keys },
roles: InertiaRails.once { User::ROLES },
}Merge Props
Appended to existing array on the client. Use for accumulating data across partial reloads — new items are added to the existing list without replacing it.
When merging arrays, you may use the match_on parameter to match existing items by a specific field and update them instead of appending new ones.
For infinite scroll, prefer InertiaRails.scroll (see Scroll Props) which handles scroll-aware loading automatically. Use merge for patterns where data accumulates from user actions or background updates:
# Activity log — poll or ActionCable triggers router.reload({ only: ['activities'] })
render inertia: {
activities: InertiaRails.merge { @recent_activities.as_json },
}
# Chat / live feed — new messages appended via partial reload
# update existing ones by id
render inertia: {
messages: InertiaRails.merge(match_on: 'id')) { @messages.as_json(only: [:id, :body, :created_at]) },
}
# Nested data
InertiaRails.merge(append: 'data', match_on: 'data.id') { post_data }
# Same as above, but using a hash shortcut...
InertiaRails.merge(append: { data: 'id' }) { post_data }
# Multiple properties with different match fields
InertiaRails.merge(append: { 'users.data' => 'id', 'messages' => 'uuid', }) { complex_data }Client-side, router.reload({ only: ['messages'] }) fetches the new batch and Inertia appends it to the existing messages array automatically.
Reset on fresh visit: Merge only appends during partial reloads. A full page visit (navigation, router.get) replaces the prop entirely — no stale accumulation across pages.
Always Props
Included even in partial reloads (which normally only include requested props). Use sparingly for data that must always be fresh.
render inertia: {
users: User.all.as_json,
# Always included, even when client requests only: ['users']
csrf: InertiaRails.always { form_authenticity_token },
version: InertiaRails.always { Rails.application.config.version },
}Scroll Props
Scroll-aware props for infinite scroll. Combines with <InfiniteScroll> on the client to automatically load more data as the user scrolls down.
Check the project's pagy version before writing pagination code — the API changed significantly in v42. Check Gemfile.lock for the installed version.
# Pagy v42+ syntax
class PostsController < ApplicationController
include Pagy::Method
def index
pagy, posts = pagy(:offset, Post.order(created_at: :desc), limit: 20)
# For cursor-based: pagy, posts = pagy(:keyset, Post.order(:id), limit: 20)
render inertia: {
posts: InertiaRails.scroll(pagy) { posts.as_json(only: [:id, :title, :body]) },
}
end
end
# Pagy pre-v42 syntax (if project uses older version)
class PostsController < ApplicationController
include Pagy::Backend
def index
pagy, posts = pagy(Post.order(created_at: :desc), limit: 20)
render inertia: {
posts: InertiaRails.scroll(pagy) { posts.as_json(only: [:id, :title, :body]) },
}
end
endReact side with <InfiniteScroll>:
import { InfiniteScroll } from '@inertiajs/react'
export default function Index({ posts }: Props) {
return (
<InfiniteScroll data="posts" loading={() => <PostsSkeleton />}>
{posts.map(post => <PostCard key={post.id} post={post} />)}
</InfiniteScroll>
)
}
// Manual mode — "Load more" button instead of auto-scroll
export function IndexManual({ posts }: Props) {
return (
<InfiniteScroll
data="posts"
manual
next={({ loading, fetch, hasMore }) =>
hasMore && (
<button onClick={fetch} disabled={loading}>
{loading ? 'Loading...' : 'Load more'}
</button>
)
}
>
{posts.map(post => <PostCard key={post.id} post={post} />)}
</InfiniteScroll>
)
}NEVER use `<WhenVisible>` for infinite scroll — use <InfiniteScroll> which handles page tracking, URL sync, and merge behavior automatically.
Resetting Merge/Scroll Props
Merge and scroll props accumulate data across partial reloads. To discard the accumulated state and start fresh without a full page visit, pass reset on the client:
// Fetch messages from scratch — clears the accumulated array first
router.reload({ only: ['messages'], reset: ['messages'] })Use when user changes filters / sort order: `reset` the prop so stale items are discarded.
Combining Prop Types
Prop helpers accept keyword arguments that layer behaviours together:
render inertia: {
# Deferred + merge — loads after initial render, then accumulates on subsequent reloads
notifications: InertiaRails.defer(merge: true) {
current_user.notifications.recent.as_json
},
# Deferred + merge in a group — multiple props fetched together, both accumulate
posts: InertiaRails.defer(group: 'feed', merge: true) {
@posts.as_json(only: [:id, :title, :body])
},
comments: InertiaRails.defer(group: 'feed', merge: true) {
@comments.as_json(only: [:id, :post_id, :body])
},
# Optional + merge — only fetched on demand, appends to existing data
activity_log: InertiaRails.optional(merge: true) {
@activities.as_json
},
}The merge: true flag works on defer, optional, and always. It tells Inertia to append results to the existing client-side array on partial reloads instead of replacing it — the same behaviour as InertiaRails.merge, but combined with another prop type.