From inertia-rails-skills
Guides Inertia Rails page components, persistent layouts, Link/router navigation, Head, Deferred, InfiniteScroll, and URL-driven state. React examples; Vue/Svelte refs. Use for pages, nav, lazy loading, infinite scroll in Rails SPAs.
npx claudepluginhub inertia-rails/skillsThis skill uses the workspace's default tool permissions.
Page components, layouts, navigation, and client-side APIs.
Guides Next.js Cache Components and Partial Prerendering (PPR) with cacheComponents enabled. Implements 'use cache', cacheLife(), cacheTag(), revalidateTag(), static/dynamic optimization, and cache debugging.
Guides building MCP servers enabling LLMs to interact with external services via tools. Covers best practices, TypeScript/Node (MCP SDK), Python (FastMCP).
Generates original PNG/PDF visual art via design philosophy manifestos for posters, graphics, and static designs on user request.
Page components, layouts, navigation, and client-side APIs.
Before building a page, ask:
Page.layout = ...; Vue: defineOptions({ layout }); Svelte: module script export) — wrapping in JSX/template remounts on every navigation, losing scroll position, audio playback, and component stateparams, pass as prop) AND component (derive from prop, no useState/useEffect) — use router.get to update URLrouter.reload({ only: [...] }) — never useEffect + fetchrouter.replaceProp — no fetch, no reloadNEVER:
window.location.search or use useSearchParams — derive URL state from controller propsuseState/useEffect to sync URL ↔ React state — the controller passes URL-derived data as props; the component just reads them<Deferred> render function — {(data) => ...} does NOT work; child reads via usePage()usePage().props.flash — flash is top-level: usePage().flashPage.layout = ... or global layout inside createInertiaApp's resolve callbackPages are default exports receiving controller props as function arguments.
Use type Props = { ... } (not interface — causes TS2344 in React). Vue uses defineProps<T>(), Svelte uses let { ... } = $props().
type Props = {
posts: Post[]
}
export default function Index({ posts }: Props) {
return <PostList posts={posts} />
}
Layouts persist across navigations — no remounting, preserving scroll, audio, etc.
import { AppLayout } from '@/layouts/app-layout'
export default function Show({ course }: Props) {
return <CourseContent course={course} />
}
// Single layout
Show.layout = (page: React.ReactNode) => <AppLayout>{page}</AppLayout>
Default layout in entrypoint:
// app/frontend/entrypoints/inertia.tsx
resolve: async (name) => {
const page = await pages[`../pages/${name}.tsx`]()
page.default.layout ??= (page: React.ReactNode) => <AppLayout>{page}</AppLayout> // default if not set
return page
}
<Link> and routerUse <Link href="..."> for internal navigation (not <a>) and router.get/post/patch/delete
for programmatic navigation. Key non-obvious features:
// Prefetching — preloads page data on hover
<Link href="/users" prefetch>Users</Link>
<Link href="/users" prefetch cacheFor="30s">Users</Link>
// Prefetch with cache tags — invalidate after mutations
<Link href="/users" prefetch cacheTags="users">Users</Link>
// Programmatic prefetch (e.g., likely next destination)
router.prefetch('/settings', {}, { cacheFor: '1m' })
// Partial reload — refresh specific props without navigation
router.reload({ only: ['users'] })
Full router API, visit options, and event callbacks are in
references/navigation.md — see loading trigger below.
Update props without a server round-trip:
// Replace a single prop (dot notation supported)
router.replaceProp('show_modal', false)
router.replaceProp('user.name', 'Jane Smith')
// With callback (receives current value + all props)
router.replaceProp('count', (current) => current + 1)
// Append/prepend to array props
router.appendToProp('messages', { id: 4, text: 'New' })
router.prependToProp('notifications', (current, props) => ({
id: Date.now(),
message: `Hello ${props.auth.user.name}`,
}))
These are shortcuts to router.replace() with preserveScroll and
preserveState automatically set to true.
router.replaceProp vs router.reload: Use router.replaceProp for client-only state changes
(toggling a modal, incrementing a counter) — no server round-trip. Use router.reload
when you need fresh data from the server (updated records, recalculated stats).
URL state = server state = props. ALWAYS implement both sides:
params and pass as a propuseState, no useEffect)router.get with query params to change URL (triggers server round-trip, new props arrive)NEVER use useState + useEffect to sync URL ↔ dialog/tab/filter state.
The server is the single source of truth — the component just reads props.
# Step 1: Controller reads params, passes as prop
def index
render inertia: {
users: User.all,
selected_user_id: params[:user_id]&.to_i
}
end
// Step 2+3: Derive state from props, router.get to update URL
type Props = {
users: User[]
selected_user_id: number | null // from controller
}
export default function Index({ users, selected_user_id }: Props) {
// Derive — no useState, no useEffect, no window.location parsing
const selectedUser = selected_user_id
? users.find(u => u.id === selected_user_id)
: null
const openDialog = (id: number) =>
router.get('/users', { user_id: id }, {
preserveState: true,
preserveScroll: true,
})
const closeDialog = () =>
router.get('/users', {}, {
preserveState: true,
preserveScroll: true,
})
return (
<Dialog open={!!selectedUser} onOpenChange={(open) => !open && closeDialog()}>
<DialogContent>{/* ... */}</DialogContent>
</Dialog>
)
}
Why not useEffect? When router.get('/users', { user_id: 5 }) fires, Inertia
makes a request to the server → controller runs with params[:user_id] = 5 →
returns new props with selected_user_id: 5 → component re-renders with the
dialog open. The cycle is: URL → server → props → render. Parsing
window.location client-side duplicates what the server already does.
Shared props (auth, flash) are typed globally via InertiaConfig (see inertia-rails-typescript skill) — page components only type their OWN props:
type Props = {
users: User[] // page-specific only
// auth is NOT here — typed globally via InertiaConfig
}
export default function Index({ users }: Props) {
const { props, flash } = usePage()
// props.auth typed via InertiaConfig, flash.notice typed via InertiaConfig
return <UserList users={users} />
}
Flash is top-level on the page object, NOT inside props — this is the #1
flash mistake. Flash config is in inertia-rails-controllers; toast UI is in shadcn-inertia.
// BAD: usePage().props.flash ← WRONG, flash is not in props
// GOOD: usePage().flash ← flash.notice, flash.alert
<Deferred> ComponentRenders fallback until deferred props arrive. Children can be plain ReactNode
or () => ReactNode render function. Either way, the child reads the deferred
prop from page props via usePage() — the render function receives no arguments.
import { Deferred } from '@inertiajs/react'
export default function Dashboard({ basic_stats }: Props) {
return (
<>
<QuickStats data={basic_stats} />
<Deferred data="detailed_stats" fallback={<Spinner />}>
<DetailedStats />
</Deferred>
</>
)
}
// Also valid — render function (no args, child still reads from usePage):
// <Deferred data="stats" fallback={<Spinner />}>
// {() => <Stats />}
// </Deferred>
// BAD — render function does NOT receive data as argument:
// <Deferred data="stats">{(data) => <Stats data={data} />}</Deferred>
<InfiniteScroll> ComponentAutomatic infinite scroll — loads next pages as user scrolls down. Pairs with
InertiaRails.scroll on the server (see inertia-rails-controllers):
import { InfiniteScroll } from '@inertiajs/react'
export default function Index({ posts }: Props) {
return (
<InfiniteScroll data="posts" loading={() => <Spinner />}>
{posts.map(post => <PostCard key={post.id} post={post} />)}
</InfiniteScroll>
)
}
Props: data (prop name), loading (fallback), manual (button instead of auto),
manualAfter={3} (auto for first 3 pages, then button), preserveUrl (don't update URL).
<WhenVisible> ComponentLoads data when element enters viewport. Use for lazy sections (comments,
related items), NOT for infinite scroll (use <InfiniteScroll> above):
import { WhenVisible } from '@inertiajs/react'
<WhenVisible data="comments" fallback={<Spinner />}>
<CommentsList />
</WhenVisible>
| Symptom | Cause | Fix |
|---|---|---|
| Layout remounts on every navigation | Wrapping layout in JSX return instead of Page.layout | Use persistent layout |
Deferred children never render | Render function expects args {(data) => ...} | Render function receives NO arguments — use {() => <Child />} or plain <Child />. Child reads prop via usePage() |
Flash is undefined | Accessing usePage().props.flash | Flash is top-level: usePage().flash, not inside props |
| URL state lost on navigation | Parsing window.location in useEffect | Derive from props — controller reads params and passes as prop |
WhenVisible never triggers | Element not in viewport or prop name wrong | data must match a prop name the controller provides on partial reload |
Component state resets on router.get | Missing preserveState: true | Add preserveState: true to visit options for filter/sort/tab changes |
| Scroll jumps to top after form submit | Missing preserveScroll | Add preserveScroll: true to the visit or form options |
inertia-rails-controllers (flash_keys)shadcn-inertia (Sonner + useFlash)inertia-rails-typescript (InertiaConfig)inertia-rails-controllers (InertiaRails.defer)shadcn-inertia (Dialog component)All examples above use React syntax. For Vue 3 or Svelte equivalents:
references/vue.md — defineProps, usePage() composable, scoped slots for <Deferred>/<WhenVisible>/<InfiniteScroll>, defineOptions({ layout }) for persistent layoutsreferences/svelte.md — $props(), $page store, {#snippet} syntax for <Deferred>/<WhenVisible>/<InfiniteScroll>, <svelte:head> instead of <Head>, module script layout exportMANDATORY — READ THE MATCHING FILE when the project uses Vue or Svelte. The concepts and NEVER rules above apply to all frameworks, but code syntax differs.
MANDATORY — READ ENTIRE FILE when implementing event callbacks (onBefore,
onStart, onProgress, onFinish, onCancel), client-side flash, or scroll
management:
references/navigation.md (~200 lines) — full callback
API, router.flash(), scroll regions, and history encryption.
MANDATORY — READ ENTIRE FILE when implementing nested layouts, conditional
layouts, or layout-level data sharing:
references/layouts.md (~180 lines) — nested layout patterns,
layout props, and default layout configuration.
Do NOT load references for basic <Link>, router.visit, or single-level
layout usage — the examples above are sufficient.