
Laravel Inertia React
- 965 installs
- 58 repo stars
- Updated May 16, 2026
- asyrafhussin/agent-skills
laravel-inertia-react is an agent skill that supplies precise Laravel, Inertia.js, and React patterns for developers building modern monolithic web applications with shared server-driven navigation.
About
laravel-inertia-react is a comprehensive agent skill for building modern monolithic applications with a Laravel backend, Inertia.js adapter, and React frontend. It organizes guidance into 24 rules across six categories covering Inertia page components, useForm hook forms, Link-based navigation, HandleInertiaRequests shared data, and Laravel-Inertia integration conventions. Developers reach for laravel-inertia-react when AI agents need consistent, production-aligned patterns instead of mixing SPA routing approaches that fight Inertia's server-driven model.
- 24 rules across 6 key categories including Page Components and Forms & Validation
- CRITICAL priority guidance for TypeScript interfaces, useForm hook, and persistent layouts
- Patterns for Inertia Link navigation, shared data via HandleInertiaRequests, and file uploads
- Head management for SEO, partial reloads for performance, and scroll preservation
- Hard-gated rules that prevent common Inertia + React anti-patterns before code generation
Laravel Inertia React by the numbers
- 965 all-time installs (skills.sh)
- +47 installs in the week ending Jul 29, 2026 (Skillselion tracking)
- Ranked #379 of 2,245 Frontend Development skills by installs in the Skillselion catalog
- Security screen: LOW risk (skills.sh audit)
- Data as of Jul 29, 2026 (Skillselion catalog sync)
npx skills add https://github.com/asyrafhussin/agent-skills --skill laravel-inertia-reactAdd your badge
Show developers this skill is listed on Skillselion. Paste this into your README.
| Installs | 965 |
|---|---|
| repo stars | ★ 58 |
| Security audit | 3 / 3 scanners passed |
| Last updated | May 16, 2026 |
| Repository | asyrafhussin/agent-skills ↗ |
How do you build Laravel apps with Inertia.js and React?
Get precise, battle-tested patterns for building Laravel backends with Inertia.js and React frontends using AI coding agents.
Who is it for?
PHP developers building or extending Laravel monoliths who pair Inertia.js with React and want agent-generated code to follow established project patterns.
Skip if: Greenfield SPAs that use client-side React Router with a separate JSON API instead of Inertia's server-driven page model.
When should I use this skill?
The user builds or modifies Laravel Inertia React pages, forms, shared props, or navigation and needs consistent monolith patterns.
What you get
Inertia page components, validated form flows, shared middleware data, and navigation patterns aligned with Laravel backend conventions.
- Inertia page components
- Form flows with useForm
- Shared middleware data configuration
By the numbers
- Covers 24 rules across 6 key categories
- Targets the Laravel + Inertia.js + React monolith stack
Files
Laravel + Inertia.js + React
Comprehensive patterns for building modern monolithic applications with Laravel, Inertia.js, and React. Contains 30+ rules for seamless full-stack development.
When to Apply
Reference these guidelines when:
- Creating Inertia page components
- Handling forms with useForm hook
- Managing shared data and authentication
- Implementing persistent layouts
- Navigating between pages
Rule Categories by Priority
| Priority | Category | Impact | Prefix |
|---|---|---|---|
| 1 | Page Components | CRITICAL | page- |
| 2 | Forms & Validation | CRITICAL | form- |
| 3 | Navigation & Links | HIGH | nav- |
| 4 | Shared Data | HIGH | shared- |
| 5 | Layouts | MEDIUM | layout- |
| 6 | File Uploads | MEDIUM | upload- |
| 7 | Advanced Patterns | LOW | advanced- |
Quick Reference
1. Page Components (CRITICAL)
page-props-typing- Type page props from Laravelpage-component-structure- Standard page component patternpage-head-management- Title and meta tags with Headpage-default-layout- Assign layouts to pages
2. Forms & Validation (CRITICAL)
form-useform-basic- Basic useForm usageform-validation-errors- Display Laravel validation errorsform-processing-state- Handle form submission stateform-reset-preserve- Reset vs preserve form dataform-nested-data- Handle nested form dataform-transform- Transform data before submit
3. Navigation & Links (HIGH)
nav-link-component- Use Link for navigationnav-preserve-state- Preserve scroll and statenav-partial-reloads- Reload only what changednav-replace-history- Replace vs push history
4. Shared Data (HIGH)
shared-auth-user- Access authenticated usershared-flash-messages- Handle flash messagesshared-global-props- Access global propsshared-typescript- Type shared data
5. Layouts (MEDIUM)
layout-persistent- Persistent layouts patternlayout-nested- Nested layoutslayout-default- Default layout assignmentlayout-conditional- Conditional layouts
6. File Uploads (MEDIUM)
upload-basic- Basic file uploadupload-progress- Upload progress trackingupload-multiple- Multiple file uploads
7. Advanced Patterns (LOW)
advanced-polling- Real-time pollingadvanced-prefetch- Prefetch pagesadvanced-modal-pages- Modal as pagesadvanced-infinite-scroll- Infinite scrolling
Essential Patterns
Page Component with TypeScript
// resources/js/Pages/Posts/Index.tsx
import { Head, Link } from '@inertiajs/react'
interface Post {
id: number
title: string
excerpt: string
created_at: string
author: {
id: number
name: string
}
}
interface Props {
posts: {
data: Post[]
links: { url: string | null; label: string; active: boolean }[]
}
filters: {
search?: string
}
}
export default function Index({ posts, filters }: Props) {
return (
<>
<Head title="Posts" />
<div className="container mx-auto py-8">
<h1 className="text-2xl font-bold mb-6">Posts</h1>
<div className="space-y-4">
{posts.data.map((post) => (
<article key={post.id} className="p-4 bg-white rounded-lg shadow">
<Link href={route('posts.show', post.id)}>
<h2 className="text-xl font-semibold hover:text-blue-600">
{post.title}
</h2>
</Link>
<p className="text-gray-600 mt-2">{post.excerpt}</p>
<p className="text-sm text-gray-400 mt-2">
By {post.author.name}
</p>
</article>
))}
</div>
</div>
</>
)
}Form with useForm
// resources/js/Pages/Posts/Create.tsx
import { Head, useForm, Link } from '@inertiajs/react'
import { FormEvent } from 'react'
interface Category {
id: number
name: string
}
interface Props {
categories: Category[]
}
export default function Create({ categories }: Props) {
const { data, setData, post, processing, errors, reset } = useForm({
title: '',
body: '',
category_id: '',
})
const handleSubmit = (e: FormEvent) => {
e.preventDefault()
post(route('posts.store'), {
onSuccess: () => reset(),
})
}
return (
<>
<Head title="Create Post" />
<form onSubmit={handleSubmit} className="max-w-2xl mx-auto py-8">
<div className="mb-4">
<label htmlFor="title" className="block font-medium mb-1">
Title
</label>
<input
id="title"
type="text"
value={data.title}
onChange={(e) => setData('title', e.target.value)}
className="w-full border rounded px-3 py-2"
/>
{errors.title && (
<p className="text-red-500 text-sm mt-1">{errors.title}</p>
)}
</div>
<div className="mb-4">
<label htmlFor="category" className="block font-medium mb-1">
Category
</label>
<select
id="category"
value={data.category_id}
onChange={(e) => setData('category_id', e.target.value)}
className="w-full border rounded px-3 py-2"
>
<option value="">Select a category</option>
{categories.map((category) => (
<option key={category.id} value={category.id}>
{category.name}
</option>
))}
</select>
{errors.category_id && (
<p className="text-red-500 text-sm mt-1">{errors.category_id}</p>
)}
</div>
<div className="mb-4">
<label htmlFor="body" className="block font-medium mb-1">
Content
</label>
<textarea
id="body"
value={data.body}
onChange={(e) => setData('body', e.target.value)}
rows={10}
className="w-full border rounded px-3 py-2"
/>
{errors.body && (
<p className="text-red-500 text-sm mt-1">{errors.body}</p>
)}
</div>
<div className="flex gap-4">
<button
type="submit"
disabled={processing}
className="px-4 py-2 bg-blue-600 text-white rounded disabled:opacity-50"
>
{processing ? 'Creating...' : 'Create Post'}
</button>
<Link
href={route('posts.index')}
className="px-4 py-2 border rounded"
>
Cancel
</Link>
</div>
</form>
</>
)
}Persistent Layout
// resources/js/Layouts/AppLayout.tsx
import { Link, usePage } from '@inertiajs/react'
import { ReactNode } from 'react'
interface Props {
children: ReactNode
}
export default function AppLayout({ children }: Props) {
const { auth } = usePage().props as { auth: { user: { name: string } } }
return (
<div className="min-h-screen bg-gray-100">
<nav className="bg-white shadow">
<div className="container mx-auto px-4 py-3 flex justify-between">
<Link href="/" className="font-bold">
My App
</Link>
<span>Welcome, {auth.user.name}</span>
</div>
</nav>
<main className="container mx-auto px-4 py-8">
{children}
</main>
</div>
)
}
// resources/js/Pages/Dashboard.tsx
import AppLayout from '@/Layouts/AppLayout'
export default function Dashboard() {
return <h1>Dashboard</h1>
}
// Assign persistent layout
Dashboard.layout = (page: ReactNode) => <AppLayout>{page}</AppLayout>Laravel Controller
<?php
namespace App\Http\Controllers;
use App\Http\Requests\StorePostRequest;
use App\Models\Post;
use App\Models\Category;
use Illuminate\Http\RedirectResponse;
use Inertia\Inertia;
use Inertia\Response;
class PostController extends Controller
{
public function index(): Response
{
return Inertia::render('Posts/Index', [
'posts' => Post::with('author:id,name')
->latest()
->paginate(10),
'filters' => request()->only('search'),
]);
}
public function create(): Response
{
return Inertia::render('Posts/Create', [
'categories' => Category::all(['id', 'name']),
]);
}
public function store(StorePostRequest $request): RedirectResponse
{
$post = Post::create([
...$request->validated(),
'user_id' => auth()->id(),
]);
return redirect()
->route('posts.show', $post)
->with('success', 'Post created successfully.');
}
public function show(Post $post): Response
{
return Inertia::render('Posts/Show', [
'post' => $post->load('author', 'category'),
]);
}
}Shared Data (HandleInertiaRequests)
<?php
namespace App\Http\Middleware;
use Illuminate\Http\Request;
use Inertia\Middleware;
class HandleInertiaRequests extends Middleware
{
public function share(Request $request): array
{
return array_merge(parent::share($request), [
'auth' => [
'user' => $request->user() ? [
'id' => $request->user()->id,
'name' => $request->user()->name,
'email' => $request->user()->email,
] : null,
],
'flash' => [
'success' => $request->session()->get('success'),
'error' => $request->session()->get('error'),
],
]);
}
}Flash Messages Component
// resources/js/Components/FlashMessages.tsx
import { usePage } from '@inertiajs/react'
import { useEffect, useState } from 'react'
export default function FlashMessages() {
const { flash } = usePage().props as {
flash: { success?: string; error?: string }
}
const [visible, setVisible] = useState(false)
useEffect(() => {
if (flash.success || flash.error) {
setVisible(true)
const timer = setTimeout(() => setVisible(false), 3000)
return () => clearTimeout(timer)
}
}, [flash])
if (!visible) return null
return (
<div className="fixed top-4 right-4 z-50">
{flash.success && (
<div className="bg-green-500 text-white px-4 py-2 rounded shadow">
{flash.success}
</div>
)}
{flash.error && (
<div className="bg-red-500 text-white px-4 py-2 rounded shadow">
{flash.error}
</div>
)}
</div>
)
}How to Use
Read individual rule files for detailed explanations and code examples:
rules/form-useform-basic.md
rules/page-props-typing.md
rules/layout-persistent.mdProject Structure
laravel-inertia-react/
├── SKILL.md # This file - overview and examples
├── README.md # Quick reference guide
├── AGENTS.md # Integration guide for AI agents
├── metadata.json # Skill metadata and references
└── rules/
├── _sections.md # Rule categories and priorities
├── _template.md # Template for new rules
├── page-*.md # Page component patterns (6 rules)
├── form-*.md # Form handling patterns (8 rules)
├── nav-*.md # Navigation patterns (5 rules)
├── shared-*.md # Shared data patterns (4 rules)
└── layout-*.md # Layout patterns (1 rule)References
- Inertia.js Documentation - Official Inertia.js docs
- Laravel Documentation - Laravel framework docs
- React Documentation - Official React docs
- Ziggy - Laravel route helper for JavaScript
License
MIT License. This skill is provided as-is for educational and development purposes.
Metadata
- Version: 1.0.1
- Last Updated: 2026-01-17
- Maintainer: Asyraf Hussin
- Rule Count: 24 rules across 6 categories
- Tech Stack: Laravel 10+, Inertia.js 1.0+, React 18+, TypeScript 5+
Laravel + Inertia.js + React Skill for AI Agents
This document provides guidance for AI agents on how to effectively use the Laravel + Inertia.js + React skill patterns.
Overview
This skill provides comprehensive patterns for building modern monolithic applications with Laravel backend, Inertia.js adapter, and React frontend. It covers 24 rules across 6 key categories.
When to Apply This Skill
Apply this skill when:
- Building Laravel applications with Inertia.js and React
- Creating or modifying Inertia page components
- Implementing forms with the useForm hook
- Setting up navigation with Inertia's Link component
- Configuring shared data through HandleInertiaRequests
- Implementing persistent layouts
- Working with TypeScript in Inertia React apps
- Handling file uploads, validation, or flash messages
Skill Categories
1. Page Components (CRITICAL)
Priority: CRITICAL | Rules: 6
Core patterns for structuring Inertia page components:
- Component structure with TypeScript interfaces
- Props typing extending PageProps
- Head management for SEO and meta tags
- Layout assignment using the layout property
- Scroll preservation during navigation
- Partial reloads for performance
When to reference: Creating new pages, typing props, managing document head, or optimizing page loads.
2. Forms & Validation (CRITICAL)
Priority: CRITICAL | Rules: 8
Complete form handling with Inertia's useForm hook:
- Basic useForm setup and methods
- Displaying Laravel validation errors
- File uploads with progress tracking
- Form state management (dirty, processing, errors)
- Data transformation before submission
- Reset and cleanup patterns
When to reference: Building forms, handling validation, file uploads, or managing form state.
3. Navigation (CRITICAL-HIGH)
Priority: CRITICAL to HIGH | Rules: 5
SPA-like navigation without full page reloads:
- Link component for internal navigation
- Programmatic navigation with router
- External links and download handling
- State preservation during navigation
- History management with replace option
When to reference: Implementing navigation, links, routing, or programmatic page transitions.
4. Shared Data (CRITICAL-HIGH)
Priority: CRITICAL to HIGH | Rules: 4
Global props shared across all pages:
- Authentication user data
- Flash messages from Laravel
- Ziggy routes for type-safe routing
- App configuration and feature flags
When to reference: Accessing user data, displaying flash messages, using Laravel routes in JS, or sharing global config.
5. Layouts (CRITICAL)
Priority: CRITICAL | Rules: 1
Persistent layout implementation:
- Layout property pattern
- Nested layouts
- State preservation across navigation
- Performance benefits
When to reference: Setting up layouts, preventing layout re-renders, or optimizing navigation performance.
6. Advanced Patterns (MEDIUM)
Priority: MEDIUM | Rules: Covered in other sections
Advanced techniques integrated into other categories:
- Partial reloads (Page Components)
- Scroll preservation (Page Components)
- Progress indicators (Forms)
- Dirty tracking (Forms)
Integration Patterns
Laravel Controller → Inertia Page
// Laravel Controller
public function index(): Response
{
return Inertia::render('Users/Index', [
'users' => User::paginate(10),
'filters' => request()->only('search', 'role'),
]);
}// React Page Component
interface Props extends PageProps {
users: PaginatedData<User>;
filters: { search: string; role: string };
}
export default function Index({ users, filters }: Props) {
// Implementation
}HandleInertiaRequests → Shared Props
// Laravel Middleware
public function share(Request $request): array
{
return [
'auth' => ['user' => $request->user()],
'flash' => ['success' => session('success')],
];
}// React Usage
const { auth, flash } = usePage<PageProps>().props;Form Submission → Laravel Validation
// React Form
const { data, setData, post, errors } = useForm({
name: '',
email: '',
});
post(route('users.store'));// Laravel Controller
public function store(StoreUserRequest $request)
{
User::create($request->validated());
return redirect()->route('users.index')
->with('success', 'User created!');
}Common Patterns to Recommend
1. Type-Safe Page Components
Always extend PageProps and define interfaces:
interface Props extends PageProps {
users: User[];
stats: Stats;
}
export default function Dashboard({ auth, users, stats }: Props) {
// auth comes from PageProps (shared data)
// users and stats are page-specific props
}2. Form Handling with useForm
Use the useForm hook for all forms:
const { data, setData, post, processing, errors } = useForm({
// initial values
});3. Navigation with Link
Use Link for internal navigation:
<Link href={route('users.show', user.id)}>View User</Link>4. Programmatic Navigation
Use router for programmatic navigation:
router.post(route('users.store'), data, {
onSuccess: () => reset(),
});5. Persistent Layouts
Assign layouts using the layout property:
Dashboard.layout = (page) => <AppLayout>{page}</AppLayout>;Best Practices for AI Agents
1. Always Type Props: Use TypeScript interfaces extending PageProps 2. Use route() Helper: Never hardcode URLs, always use Ziggy's route() function 3. Handle Errors: Display validation errors inline with proper UX 4. Preserve State: Use preserveState for filters and preserveScroll for pagination 5. Lazy Load: Use Inertia::lazy() for expensive props on Laravel side 6. Flash Messages: Set up flash message handling in layouts 7. File Uploads: Use progress tracking for file uploads 8. External Links: Use regular <a> tags, not Link component 9. Dirty Tracking: Warn users about unsaved changes 10. Replace History: Use replace: true for filters and search
Rule File Structure
Each rule file follows this pattern:
---
section: [category]
priority: [level]
description: [one-line description]
keywords: [relevant, keywords]
---
# Rule Title
Explanation
## Bad Example
(anti-patterns)
## Good Example
(best practices with TypeScript and Laravel)
## Why
(benefits and reasoning)Quick Reference
| Task | Reference Rules |
|---|---|
| Create page component | page-component-structure, page-props-typing |
| Add form | form-useform-basic, form-validation-errors |
| Handle file upload | form-file-uploads, form-progress-indicator |
| Set up navigation | nav-link-component, nav-programmatic |
| Display flash messages | shared-flash-messages |
| Access current user | shared-auth-user |
| Use Laravel routes | shared-ziggy-routes |
| Create layout | layout-persistent |
| Partial reload | page-partial-reloads |
| Preserve scroll | page-scroll-preservation |
Tech Stack Requirements
- PHP: >= 8.1
- Laravel: >= 10.0
- inertiajs/inertia-laravel: >= 0.6
- @inertiajs/react: >= 1.0
- React: >= 18.0
- TypeScript: >= 5.0
Official Documentation
- Inertia.js - Core concepts and API
- Laravel - Backend framework
- React - Frontend library
- Ziggy - Laravel routes in JavaScript
Support
For issues or questions about this skill:
- Review the rule files in the
rules/directory - Check the examples in SKILL.md
- Refer to official documentation links above
---
Version: 1.0.0 Last Updated: 2026-01-17 Maintainer: Asyraf Hussin
{
"name": "laravel-inertia-react",
"displayName": "Laravel + Inertia.js + React",
"version": "1.0.2",
"phpVersion": "8.3+",
"laravelVersion": "13.x",
"description": "Comprehensive patterns for building modern monolithic applications with Laravel, Inertia.js, and React. Covers page components, forms, navigation, shared data, and persistent layouts.",
"author": "Asyraf Hussin",
"license": "MIT",
"keywords": [
"laravel",
"inertia",
"inertiajs",
"react",
"typescript",
"fullstack",
"spa",
"forms",
"validation",
"routing",
"ziggy"
],
"categories": [
"page-components",
"forms",
"navigation",
"shared-data",
"layouts",
"advanced-patterns"
],
"priority": {
"critical": ["page-components", "forms", "navigation", "shared-data", "layouts"],
"high": ["navigation", "shared-data"],
"medium": ["layouts", "advanced-patterns"]
},
"references": [
{
"name": "Inertia.js Documentation",
"url": "https://inertiajs.com/",
"description": "Official Inertia.js documentation covering React adapter, routing, forms, and more"
},
{
"name": "Laravel Documentation",
"url": "https://laravel.com/docs",
"description": "Laravel framework documentation for backend API and routing"
},
{
"name": "React Documentation",
"url": "https://react.dev/",
"description": "Official React documentation for hooks, components, and TypeScript"
},
{
"name": "Ziggy Documentation",
"url": "https://github.com/tighten/ziggy",
"description": "Use Laravel named routes in JavaScript with type safety"
}
],
"dependencies": {
"php": ">=8.3",
"laravel": ">=12.0",
"inertiajs/inertia-laravel": ">=2.0",
"@inertiajs/react": ">=2.0",
"react": ">=19.0",
"typescript": ">=5.0"
},
"sections": {
"page-components": {
"title": "Page Components",
"description": "Standard structure for Inertia page components with TypeScript typing and layout assignment",
"priority": "critical",
"rules": [
"page-component-structure",
"page-props-typing",
"page-head-management",
"page-default-layout",
"page-scroll-preservation",
"page-partial-reloads"
]
},
"forms": {
"title": "Forms & Validation",
"description": "Complete form handling with useForm hook, validation, file uploads, and state management",
"priority": "critical",
"rules": [
"form-useform-basic",
"form-useform-hook",
"form-validation-errors",
"form-file-uploads",
"form-progress-indicator",
"form-transform-data",
"form-reset-handling",
"form-dirty-tracking"
]
},
"navigation": {
"title": "Navigation",
"description": "SPA-like navigation with Link component and router for programmatic navigation",
"priority": "critical",
"rules": [
"nav-link-component",
"nav-programmatic",
"nav-external-links",
"nav-preserve-state",
"nav-replace-history"
]
},
"shared-data": {
"title": "Shared Data",
"description": "Global props shared across all pages including auth, flash messages, and config",
"priority": "critical",
"rules": [
"shared-auth-user",
"shared-flash-messages",
"shared-ziggy-routes",
"shared-app-config"
]
},
"layouts": {
"title": "Layouts",
"description": "Persistent layouts that maintain state across navigation for better performance",
"priority": "critical",
"rules": [
"layout-persistent"
]
}
},
"tags": [
"fullstack",
"monolith",
"spa",
"typescript",
"forms",
"validation",
"routing",
"authentication"
],
"updatedAt": "2026-01-17"
}
Laravel + Inertia.js + React
Patterns for building modern monolithic applications with Laravel, Inertia.js, and React.
Overview
This skill provides guidance for:
- Page component structure and typing
- Form handling with useForm
- Navigation and partial reloads
- Shared data and authentication
- Persistent layouts
- File uploads
Categories
1. Page Components (Critical)
Structure, typing, and best practices for Inertia pages.
2. Forms & Validation (Critical)
useForm hook, error handling, and form state management.
3. Navigation & Links (High)
Link component, preserve state, and partial reloads.
4. Shared Data (High)
Authentication, flash messages, and global props.
5. Layouts (Medium)
Persistent layouts for better UX and performance.
6. File Uploads (Medium)
Handling file uploads with progress tracking.
Quick Start
// Page component with form
import { useForm } from '@inertiajs/react'
export default function Create() {
const { data, setData, post, processing, errors } = useForm({
title: '',
body: '',
})
const handleSubmit = (e) => {
e.preventDefault()
post(route('posts.store'))
}
return (
<form onSubmit={handleSubmit}>
<input
value={data.title}
onChange={(e) => setData('title', e.target.value)}
/>
{errors.title && <span>{errors.title}</span>}
<button disabled={processing}>Submit</button>
</form>
)
}Usage
This skill triggers automatically when:
- Building Inertia.js pages
- Handling forms with useForm
- Managing shared data
- Implementing layouts
References
Rule Sections
Priority Levels
| Level | Description | When to Apply |
|---|---|---|
| CRITICAL | Essential for any Inertia app | Always |
| HIGH | Important for maintainability and UX | Most projects |
| MEDIUM | Performance and developer experience | Growing applications |
| LOW | Specialized patterns | Large-scale apps |
Section Overview
Page Components (CRITICAL)
Core patterns for building Inertia page components. Proper TypeScript typing, Head management, layout assignment, and props handling.
Forms & Validation (CRITICAL)
Complete form handling with useForm hook. Validation errors, file uploads, progress tracking, dirty state, and data transformation.
Navigation (CRITICAL-HIGH)
Inertia's Link component and router for SPA-like navigation. Programmatic navigation, state preservation, history management, and external links.
Shared Data (CRITICAL-HIGH)
Global props shared across all pages via HandleInertiaRequests middleware. Authentication data, flash messages, Ziggy routes, and app configuration.
Layouts (CRITICAL)
Persistent layout patterns that maintain state across navigation. Prevents unnecessary re-renders and provides better performance.
Advanced Patterns (MEDIUM)
Partial reloads, scroll preservation, and optimization techniques for complex Inertia applications.
Rule Title
Brief explanation of what this rule covers and why it matters for Laravel + Inertia.js + React development.
Bad Example
// Anti-pattern: What NOT to do
// Explain why this approach is problematicGood Example
// Correct approach: Best practice implementation
// resources/js/Pages/Example.tsx
import { useForm } from '@inertiajs/react';
// Full working example with TypeScript
export default function Example() {
// Implementation
}Laravel Backend (if applicable)
// app/Http/Controllers/ExampleController.php
<?php
namespace App\Http\Controllers;
use Inertia\Inertia;
use Inertia\Response;
class ExampleController extends Controller
{
public function index(): Response
{
return Inertia::render('Example', [
// Props
]);
}
}Why
This pattern is important because:
1. Reason 1: Explanation of first benefit 2. Reason 2: Explanation of second benefit 3. Reason 3: Explanation of third benefit 4. Performance: Performance implications 5. Type Safety: TypeScript benefits 6. Developer Experience: DX improvements 7. Maintainability: Long-term maintenance benefits
Form Dirty Tracking
Use Inertia's isDirty flag to track unsaved changes and warn users before navigating away from modified forms.
Bad Example
// Anti-pattern: Not tracking form changes
export default function EditProfile({ user }) {
const { data, setData, put } = useForm({
name: user.name,
email: user.email,
});
// Users can navigate away and lose all changes without warning
return (
<form onSubmit={(e) => { e.preventDefault(); put('/profile'); }}>
<input
value={data.name}
onChange={(e) => setData('name', e.target.value)}
/>
<button type="submit">Save</button>
</form>
);
}
// Anti-pattern: Manual dirty tracking
const [originalData] = useState({ name: user.name, email: user.email });
const [isDirty, setIsDirty] = useState(false);
useEffect(() => {
setIsDirty(
data.name !== originalData.name ||
data.email !== originalData.email
);
}, [data]);Good Example
// resources/js/Pages/Profile/Edit.tsx
import { useForm, router } from '@inertiajs/react';
import { FormEventHandler, useEffect } from 'react';
interface ProfileForm {
name: string;
email: string;
bio: string;
}
interface EditProfileProps {
user: User;
}
export default function EditProfile({ user }: EditProfileProps) {
const { data, setData, put, processing, errors, isDirty, reset } = useForm<ProfileForm>({
name: user.name,
email: user.email,
bio: user.bio || '',
});
// Warn before browser close/refresh
useEffect(() => {
const handleBeforeUnload = (e: BeforeUnloadEvent) => {
if (isDirty) {
e.preventDefault();
e.returnValue = '';
}
};
window.addEventListener('beforeunload', handleBeforeUnload);
return () => window.removeEventListener('beforeunload', handleBeforeUnload);
}, [isDirty]);
// Intercept Inertia navigation
useEffect(() => {
const removeListener = router.on('before', (event) => {
if (isDirty && !confirm('You have unsaved changes. Are you sure you want to leave?')) {
event.preventDefault();
}
});
return () => removeListener();
}, [isDirty]);
const submit: FormEventHandler = (e) => {
e.preventDefault();
put(route('profile.update'), {
onSuccess: () => {
// Form will no longer be dirty after successful save
// because data matches what was saved
},
});
};
return (
<div>
{/* Unsaved changes indicator */}
{isDirty && (
<div className="mb-4 flex items-center justify-between rounded-md bg-amber-50 px-4 py-2 text-amber-800">
<span>You have unsaved changes</span>
<button
type="button"
onClick={() => reset()}
className="text-sm underline hover:no-underline"
>
Discard changes
</button>
</div>
)}
<form onSubmit={submit} className="space-y-6">
<div>
<label htmlFor="name" className="block text-sm font-medium">
Name
</label>
<input
id="name"
value={data.name}
onChange={(e) => setData('name', e.target.value)}
className="mt-1 block w-full rounded-md border-gray-300"
/>
</div>
<div>
<label htmlFor="email" className="block text-sm font-medium">
Email
</label>
<input
id="email"
type="email"
value={data.email}
onChange={(e) => setData('email', e.target.value)}
className="mt-1 block w-full rounded-md border-gray-300"
/>
</div>
<div>
<label htmlFor="bio" className="block text-sm font-medium">
Bio
</label>
<textarea
id="bio"
value={data.bio}
onChange={(e) => setData('bio', e.target.value)}
rows={4}
className="mt-1 block w-full rounded-md border-gray-300"
/>
</div>
<div className="flex items-center gap-4">
<button
type="submit"
disabled={processing || !isDirty}
className="rounded-md bg-blue-600 px-4 py-2 text-white disabled:opacity-50"
>
{processing ? 'Saving...' : 'Save Changes'}
</button>
<span className="text-sm text-gray-500">
{isDirty ? 'Unsaved changes' : 'All changes saved'}
</span>
</div>
</form>
</div>
);
}
// Custom hook for reusable dirty tracking with navigation blocking
import { useForm as useInertiaForm, router } from '@inertiajs/react';
import { useEffect, useCallback } from 'react';
function useFormWithNavBlock<T extends Record<string, unknown>>(initialData: T) {
const form = useInertiaForm<T>(initialData);
// Block browser navigation
useEffect(() => {
const handler = (e: BeforeUnloadEvent) => {
if (form.isDirty) {
e.preventDefault();
e.returnValue = '';
}
};
window.addEventListener('beforeunload', handler);
return () => window.removeEventListener('beforeunload', handler);
}, [form.isDirty]);
// Block Inertia navigation
useEffect(() => {
return router.on('before', (event) => {
if (form.isDirty) {
if (!confirm('Discard unsaved changes?')) {
event.preventDefault();
}
}
});
}, [form.isDirty]);
return form;
}
// Usage
function MyForm({ initialData }) {
const { data, setData, post, isDirty } = useFormWithNavBlock({
title: initialData.title,
content: initialData.content,
});
// Navigation blocking is automatic
return <form>{/* ... */}</form>;
}Why
Dirty tracking prevents accidental data loss:
1. User Protection: Warn users before they lose unsaved work 2. Built-in Support: isDirty is provided by useForm automatically 3. Browser Events: Handle both page refresh and navigation away 4. Inertia Navigation: Intercept SPA navigation with router.on('before') 5. Visual Feedback: Show users when they have unsaved changes 6. Button States: Disable submit button when no changes exist 7. Better UX: Users trust the form won't lose their data
Form File Uploads
Handle file uploads with Inertia using the useForm hook with proper progress tracking and preview functionality.
Bad Example
// Anti-pattern: Manual FormData handling
export default function UploadForm() {
const [file, setFile] = useState(null);
const handleSubmit = async (e) => {
e.preventDefault();
const formData = new FormData();
formData.append('avatar', file);
await fetch('/upload', {
method: 'POST',
body: formData,
headers: {
'X-CSRF-TOKEN': document.querySelector('meta[name="csrf-token"]').content,
},
});
};
return (
<form onSubmit={handleSubmit}>
<input type="file" onChange={(e) => setFile(e.target.files[0])} />
<button type="submit">Upload</button>
</form>
);
}
// Anti-pattern: Not handling progress for large files
const { data, setData, post } = useForm({ document: null });
const submit = () => {
post('/documents'); // No progress indicator for large files
};Good Example
// resources/js/Pages/Profile/Edit.tsx
import { useForm } from '@inertiajs/react';
import { FormEventHandler, useState, useRef } from 'react';
import InputError from '@/Components/InputError';
interface ProfileForm {
name: string;
email: string;
avatar: File | null;
_method?: string;
}
export default function EditProfile({ user }: { user: User }) {
const [preview, setPreview] = useState<string | null>(user.avatar_url);
const fileInputRef = useRef<HTMLInputElement>(null);
const { data, setData, post, processing, progress, errors, reset } = useForm<ProfileForm>({
name: user.name,
email: user.email,
avatar: null,
});
const handleFileChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0];
if (file) {
// Validate file on client side
if (file.size > 5 * 1024 * 1024) {
alert('File must be less than 5MB');
return;
}
if (!['image/jpeg', 'image/png', 'image/webp'].includes(file.type)) {
alert('File must be an image (JPEG, PNG, or WebP)');
return;
}
setData('avatar', file);
// Create preview
const reader = new FileReader();
reader.onloadend = () => setPreview(reader.result as string);
reader.readAsDataURL(file);
}
};
const removeFile = () => {
setData('avatar', null);
setPreview(user.avatar_url);
if (fileInputRef.current) {
fileInputRef.current.value = '';
}
};
const submit: FormEventHandler = (e) => {
e.preventDefault();
// Use post with _method for Laravel's form method spoofing
post(route('profile.update'), {
forceFormData: true, // Ensure FormData is used even for small payloads
onSuccess: () => {
if (fileInputRef.current) {
fileInputRef.current.value = '';
}
},
});
};
return (
<form onSubmit={submit} className="space-y-6">
<div>
<label className="block text-sm font-medium text-gray-700">
Profile Photo
</label>
<div className="mt-2 flex items-center gap-4">
{/* Preview */}
<div className="h-20 w-20 overflow-hidden rounded-full bg-gray-100">
{preview ? (
<img
src={preview}
alt="Avatar preview"
className="h-full w-full object-cover"
/>
) : (
<div className="flex h-full w-full items-center justify-center text-gray-400">
No image
</div>
)}
</div>
<div className="flex flex-col gap-2">
<input
ref={fileInputRef}
type="file"
accept="image/jpeg,image/png,image/webp"
onChange={handleFileChange}
className="text-sm text-gray-500 file:mr-4 file:rounded-md file:border-0 file:bg-indigo-50 file:px-4 file:py-2 file:text-sm file:font-semibold file:text-indigo-700 hover:file:bg-indigo-100"
/>
{data.avatar && (
<button
type="button"
onClick={removeFile}
className="text-sm text-red-600 hover:text-red-800"
>
Remove new photo
</button>
)}
</div>
</div>
<InputError message={errors.avatar} className="mt-2" />
</div>
{/* Progress bar for file upload */}
{progress && (
<div className="w-full">
<div className="mb-1 flex justify-between text-sm">
<span>Uploading...</span>
<span>{progress.percentage}%</span>
</div>
<div className="h-2 w-full overflow-hidden rounded-full bg-gray-200">
<div
className="h-full bg-indigo-600 transition-all duration-300"
style={{ width: `${progress.percentage}%` }}
/>
</div>
</div>
)}
{/* Other form fields */}
<div>
<label htmlFor="name">Name</label>
<input
id="name"
value={data.name}
onChange={(e) => setData('name', e.target.value)}
className="mt-1 block w-full rounded-md border-gray-300"
/>
<InputError message={errors.name} className="mt-2" />
</div>
<button
type="submit"
disabled={processing}
className="rounded-md bg-indigo-600 px-4 py-2 text-white disabled:opacity-50"
>
{processing ? 'Saving...' : 'Save Changes'}
</button>
</form>
);
}
// Multiple file uploads
interface GalleryForm {
title: string;
images: File[];
}
function GalleryUpload() {
const { data, setData, post, progress, errors } = useForm<GalleryForm>({
title: '',
images: [],
});
const handleMultipleFiles = (e: React.ChangeEvent<HTMLInputElement>) => {
const files = Array.from(e.target.files || []);
setData('images', [...data.images, ...files]);
};
const removeImage = (index: number) => {
setData('images', data.images.filter((_, i) => i !== index));
};
return (
<form onSubmit={(e) => { e.preventDefault(); post('/gallery'); }}>
<input
type="file"
multiple
accept="image/*"
onChange={handleMultipleFiles}
/>
<div className="grid grid-cols-4 gap-2">
{data.images.map((file, index) => (
<div key={index} className="relative">
<img
src={URL.createObjectURL(file)}
alt={`Preview ${index}`}
className="h-24 w-24 object-cover"
/>
<button
type="button"
onClick={() => removeImage(index)}
className="absolute -right-2 -top-2 rounded-full bg-red-500 p-1 text-white"
>
X
</button>
</div>
))}
</div>
{errors.images && <InputError message={errors.images} />}
</form>
);
}Why
Using Inertia's built-in file upload handling provides:
1. Automatic FormData: Inertia converts data to FormData when files are present 2. Progress Tracking: Real-time upload progress for better UX 3. CSRF Handling: Tokens are automatically included 4. Validation Integration: Server-side errors display like regular field errors 5. Simplified API: No manual FormData construction or fetch calls 6. Preview Support: Client-side previews improve user experience 7. Chunked Uploads: For very large files, consider pairing with libraries like Filepond
Form Progress Indicator
Show upload progress for forms with file uploads and processing states for all form submissions.
Bad Example
// Anti-pattern: No feedback during submission
export default function DocumentUpload() {
const { data, setData, post } = useForm({
document: null,
});
return (
<form onSubmit={(e) => { e.preventDefault(); post('/documents'); }}>
<input
type="file"
onChange={(e) => setData('document', e.target.files[0])}
/>
<button type="submit">Upload</button>
{/* No progress indicator - user has no idea if upload is working */}
</form>
);
}
// Anti-pattern: Only using processing state
<button disabled={processing}>
{processing ? 'Uploading...' : 'Upload'}
</button>
// Missing actual progress percentage for large filesGood Example
// resources/js/Components/ProgressBar.tsx
interface ProgressBarProps {
progress: {
percentage: number;
loaded?: number;
total?: number;
} | null;
showBytes?: boolean;
}
function formatBytes(bytes: number): string {
if (bytes === 0) return '0 Bytes';
const k = 1024;
const sizes = ['Bytes', 'KB', 'MB', 'GB'];
const i = Math.floor(Math.log(bytes) / Math.log(k));
return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i];
}
export default function ProgressBar({ progress, showBytes = false }: ProgressBarProps) {
if (!progress) return null;
return (
<div className="w-full">
<div className="mb-1 flex justify-between text-sm text-gray-600">
<span>Uploading...</span>
<span>
{progress.percentage}%
{showBytes && progress.loaded && progress.total && (
<span className="ml-2 text-gray-400">
({formatBytes(progress.loaded)} / {formatBytes(progress.total)})
</span>
)}
</span>
</div>
<div className="h-2.5 w-full overflow-hidden rounded-full bg-gray-200">
<div
className="h-full rounded-full bg-blue-600 transition-all duration-300 ease-out"
style={{ width: `${progress.percentage}%` }}
/>
</div>
</div>
);
}
// resources/js/Pages/Documents/Upload.tsx
import { useForm } from '@inertiajs/react';
import { FormEventHandler, useState } from 'react';
import ProgressBar from '@/Components/ProgressBar';
interface UploadForm {
title: string;
document: File | null;
}
export default function Upload() {
const [uploadState, setUploadState] = useState<'idle' | 'uploading' | 'processing' | 'complete'>('idle');
const { data, setData, post, processing, progress, errors, reset } = useForm<UploadForm>({
title: '',
document: null,
});
const submit: FormEventHandler = (e) => {
e.preventDefault();
post(route('documents.store'), {
onStart: () => setUploadState('uploading'),
onProgress: (progress) => {
// When upload is complete but server is still processing
if (progress.percentage === 100) {
setUploadState('processing');
}
},
onSuccess: () => {
setUploadState('complete');
setTimeout(() => {
setUploadState('idle');
reset();
}, 2000);
},
onError: () => setUploadState('idle'),
});
};
return (
<form onSubmit={submit} className="space-y-6">
<div>
<label htmlFor="title" className="block text-sm font-medium">
Document Title
</label>
<input
id="title"
type="text"
value={data.title}
onChange={(e) => setData('title', e.target.value)}
disabled={processing}
className="mt-1 block w-full rounded-md border-gray-300 disabled:bg-gray-100"
/>
</div>
<div>
<label htmlFor="document" className="block text-sm font-medium">
Document File
</label>
<input
id="document"
type="file"
onChange={(e) => setData('document', e.target.files?.[0] || null)}
disabled={processing}
className="mt-1 block w-full disabled:opacity-50"
/>
{errors.document && (
<p className="mt-1 text-sm text-red-600">{errors.document}</p>
)}
</div>
{/* Progress indicator */}
{uploadState === 'uploading' && progress && (
<ProgressBar progress={progress} showBytes />
)}
{/* Processing state (after upload, server is working) */}
{uploadState === 'processing' && (
<div className="flex items-center gap-2 text-sm text-gray-600">
<svg className="h-5 w-5 animate-spin text-blue-600" viewBox="0 0 24 24">
<circle
className="opacity-25"
cx="12"
cy="12"
r="10"
stroke="currentColor"
strokeWidth="4"
fill="none"
/>
<path
className="opacity-75"
fill="currentColor"
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z"
/>
</svg>
<span>Processing document...</span>
</div>
)}
{/* Success state */}
{uploadState === 'complete' && (
<div className="flex items-center gap-2 text-sm text-green-600">
<svg className="h-5 w-5" fill="currentColor" viewBox="0 0 20 20">
<path
fillRule="evenodd"
d="M10 18a8 8 0 100-16 8 8 0 000 16zm3.707-9.293a1 1 0 00-1.414-1.414L9 10.586 7.707 9.293a1 1 0 00-1.414 1.414l2 2a1 1 0 001.414 0l4-4z"
clipRule="evenodd"
/>
</svg>
<span>Upload complete!</span>
</div>
)}
<button
type="submit"
disabled={processing || !data.document}
className="flex items-center gap-2 rounded-md bg-blue-600 px-4 py-2 text-white disabled:opacity-50"
>
{processing ? (
<>
<svg className="h-4 w-4 animate-spin" viewBox="0 0 24 24">
<circle
className="opacity-25"
cx="12"
cy="12"
r="10"
stroke="currentColor"
strokeWidth="4"
fill="none"
/>
<path
className="opacity-75"
fill="currentColor"
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z"
/>
</svg>
<span>
{uploadState === 'processing' ? 'Processing...' : 'Uploading...'}
</span>
</>
) : (
'Upload Document'
)}
</button>
</form>
);
}Why
Progress indicators are essential for good user experience:
1. User Confidence: Users know their action is being processed 2. Large Files: Essential for uploads that take more than a second 3. Accurate Feedback: Percentage shows actual progress, not just "loading" 4. State Distinction: Differentiate between uploading, server processing, and completion 5. Prevent Duplicates: Disabled buttons prevent accidental resubmission 6. Accessibility: Screen readers can announce progress updates 7. Error Recovery: Users understand when something goes wrong
Form Reset Handling
Properly reset form state after successful submissions or when clearing user input using Inertia's reset helpers.
Bad Example
// Anti-pattern: Manual state reset
export default function ContactForm() {
const { data, setData, post, processing } = useForm({
name: '',
email: '',
message: '',
});
const submit = (e) => {
e.preventDefault();
post('/contact', {
onSuccess: () => {
// Manually resetting each field - tedious and error-prone
setData('name', '');
setData('email', '');
setData('message', '');
},
});
};
}
// Anti-pattern: Recreating the form on success
const submit = (e) => {
e.preventDefault();
post('/contact', {
onSuccess: () => {
window.location.reload(); // Destroys all state unnecessarily
},
});
};Good Example
// resources/js/Pages/Contact.tsx
import { useForm } from '@inertiajs/react';
import { FormEventHandler, useRef } from 'react';
interface ContactForm {
name: string;
email: string;
subject: string;
message: string;
attachment: File | null;
}
export default function Contact() {
const fileInputRef = useRef<HTMLInputElement>(null);
const { data, setData, post, processing, errors, reset, clearErrors } = useForm<ContactForm>({
name: '',
email: '',
subject: '',
message: '',
attachment: null,
});
const submit: FormEventHandler = (e) => {
e.preventDefault();
post(route('contact.store'), {
preserveScroll: true,
onSuccess: () => {
// Reset all fields to initial values
reset();
// Clear file input (not controlled by React)
if (fileInputRef.current) {
fileInputRef.current.value = '';
}
},
});
};
// Reset specific fields only
const resetMessageFields = () => {
reset('subject', 'message');
clearErrors('subject', 'message');
};
// Clear form manually (user action)
const handleClear = () => {
reset();
clearErrors();
if (fileInputRef.current) {
fileInputRef.current.value = '';
}
};
return (
<form onSubmit={submit} className="space-y-6">
<div>
<label htmlFor="name">Name</label>
<input
id="name"
value={data.name}
onChange={(e) => setData('name', e.target.value)}
/>
</div>
<div>
<label htmlFor="email">Email</label>
<input
id="email"
type="email"
value={data.email}
onChange={(e) => setData('email', e.target.value)}
/>
</div>
<div>
<label htmlFor="subject">Subject</label>
<input
id="subject"
value={data.subject}
onChange={(e) => setData('subject', e.target.value)}
/>
</div>
<div>
<label htmlFor="message">Message</label>
<textarea
id="message"
value={data.message}
onChange={(e) => setData('message', e.target.value)}
rows={4}
/>
</div>
<div>
<label htmlFor="attachment">Attachment</label>
<input
ref={fileInputRef}
id="attachment"
type="file"
onChange={(e) => setData('attachment', e.target.files?.[0] || null)}
/>
</div>
<div className="flex gap-4">
<button
type="submit"
disabled={processing}
className="rounded bg-blue-600 px-4 py-2 text-white"
>
Send Message
</button>
<button
type="button"
onClick={handleClear}
disabled={processing}
className="rounded bg-gray-200 px-4 py-2 text-gray-700"
>
Clear Form
</button>
<button
type="button"
onClick={resetMessageFields}
className="rounded bg-gray-200 px-4 py-2 text-gray-700"
>
Clear Message Only
</button>
</div>
</form>
);
}
// Edit form with reset to original values
interface EditPostProps {
post: Post;
}
function EditPost({ post }: EditPostProps) {
const { data, setData, put, processing, errors, reset, isDirty } = useForm({
title: post.title,
content: post.content,
published: post.published,
});
const submit: FormEventHandler = (e) => {
e.preventDefault();
put(route('posts.update', post.id));
};
// Reset to original prop values
const handleRevert = () => {
reset();
// Or reset to specific values:
// setData({
// title: post.title,
// content: post.content,
// published: post.published,
// });
};
return (
<form onSubmit={submit}>
{/* Form fields */}
<div className="flex items-center gap-4">
<button type="submit" disabled={processing || !isDirty}>
Save Changes
</button>
{isDirty && (
<button
type="button"
onClick={handleRevert}
className="text-gray-600 hover:text-gray-800"
>
Discard Changes
</button>
)}
</div>
{isDirty && (
<p className="mt-2 text-sm text-amber-600">
You have unsaved changes
</p>
)}
</form>
);
}Why
Proper reset handling ensures a smooth user experience:
1. Clean State: Forms return to a known initial state after submission 2. Selective Reset: Reset specific fields while preserving others 3. Error Clearing: clearErrors works alongside reset for complete cleanup 4. File Inputs: Remember to clear uncontrolled file inputs separately 5. Dirty Tracking: isDirty flag helps show unsaved changes warnings 6. Revert Capability: Let users undo their changes on edit forms 7. Performance: No page reload needed to reset form state
Form Transform Data
Use the transform method to modify form data before submission without changing the original form state.
Bad Example
// Anti-pattern: Modifying data directly before submit
export default function CreateProduct() {
const { data, setData, post } = useForm({
name: '',
price: '', // Stored as string for input
tags: [], // Array that needs to be comma-separated
});
const submit = (e) => {
e.preventDefault();
// Mutating data directly - causes issues
const submitData = {
...data,
price: parseFloat(data.price) * 100, // Convert to cents
tags: data.tags.join(','),
};
// This still sends original data, not submitData!
post('/products');
};
}
// Anti-pattern: Maintaining separate submission state
const [submitData, setSubmitData] = useState({});
const submit = (e) => {
e.preventDefault();
// Complex state synchronization prone to bugs
setSubmitData({
...data,
price: parseFloat(data.price) * 100,
});
// Then somehow use submitData...
};Good Example
// resources/js/Pages/Products/Create.tsx
import { useForm } from '@inertiajs/react';
import { FormEventHandler } from 'react';
interface ProductForm {
name: string;
price: string; // String for controlled input
discount_percent: string;
tags: string[];
publish_date: Date | null;
is_featured: boolean;
}
export default function Create() {
const { data, setData, post, processing, errors, transform } = useForm<ProductForm>({
name: '',
price: '',
discount_percent: '',
tags: [],
publish_date: null,
is_featured: false,
});
const submit: FormEventHandler = (e) => {
e.preventDefault();
// Transform data just before submission
transform((data) => ({
...data,
// Convert price to cents for backend
price: Math.round(parseFloat(data.price) * 100),
// Convert percentage string to decimal
discount_percent: data.discount_percent
? parseFloat(data.discount_percent) / 100
: null,
// Format date for Laravel
publish_date: data.publish_date
? data.publish_date.toISOString().split('T')[0]
: null,
// Convert tags array to comma-separated string
tags: data.tags.join(','),
}));
post(route('products.store'));
};
return (
<form onSubmit={submit} className="space-y-6">
<div>
<label htmlFor="name">Product Name</label>
<input
id="name"
value={data.name}
onChange={(e) => setData('name', e.target.value)}
/>
</div>
<div>
<label htmlFor="price">Price ($)</label>
<input
id="price"
type="number"
step="0.01"
min="0"
value={data.price}
onChange={(e) => setData('price', e.target.value)}
placeholder="29.99"
/>
{/* User sees dollars, backend receives cents */}
</div>
<div>
<label htmlFor="discount">Discount (%)</label>
<input
id="discount"
type="number"
min="0"
max="100"
value={data.discount_percent}
onChange={(e) => setData('discount_percent', e.target.value)}
placeholder="10"
/>
{/* User enters 10, backend receives 0.10 */}
</div>
<TagInput
tags={data.tags}
onChange={(tags) => setData('tags', tags)}
/>
<button type="submit" disabled={processing}>
Create Product
</button>
</form>
);
}
// More complex transformation example
interface RegistrationForm {
first_name: string;
last_name: string;
email: string;
phone: string;
address: {
street: string;
city: string;
zip: string;
};
}
function Registration() {
const { data, setData, post, transform } = useForm<RegistrationForm>({
first_name: '',
last_name: '',
email: '',
phone: '',
address: {
street: '',
city: '',
zip: '',
},
});
const submit: FormEventHandler = (e) => {
e.preventDefault();
transform((data) => ({
// Combine names for backend
name: `${data.first_name} ${data.last_name}`.trim(),
email: data.email.toLowerCase().trim(),
// Normalize phone number
phone: data.phone.replace(/\D/g, ''),
// Flatten nested address for backend
street: data.address.street,
city: data.address.city,
zip: data.address.zip,
}));
post(route('register'));
};
return (
<form onSubmit={submit}>
{/* Form fields use the structured data shape */}
<input
value={data.first_name}
onChange={(e) => setData('first_name', e.target.value)}
placeholder="First Name"
/>
<input
value={data.last_name}
onChange={(e) => setData('last_name', e.target.value)}
placeholder="Last Name"
/>
<input
value={data.address.street}
onChange={(e) => setData('address', { ...data.address, street: e.target.value })}
placeholder="Street Address"
/>
{/* ... */}
</form>
);
}Why
The transform method provides clean data transformation:
1. Separation of Concerns: UI-friendly data shapes vs backend-required formats 2. Immutability: Original form data remains unchanged for continued editing 3. Type Flexibility: Input types (string) can differ from submission types (number) 4. Validation Compatibility: Errors still map to original field names 5. Clean Components: No manual data conversion scattered throughout submit handlers 6. Reusability: Same form can submit to different endpoints with different transformations 7. Testability: Transform logic is isolated and easy to test
Form useForm Hook
The useForm hook is Inertia's primary way to handle forms. It provides automatic form state management, error handling, processing state, and seamless integration with Laravel validation.
Incorrect
// ❌ Manual state management
import { useState } from 'react'
import { router } from '@inertiajs/react'
function CreatePost() {
const [title, setTitle] = useState('')
const [body, setBody] = useState('')
const [errors, setErrors] = useState({})
const [processing, setProcessing] = useState(false)
const handleSubmit = async (e) => {
e.preventDefault()
setProcessing(true)
router.post('/posts', { title, body }, {
onError: (errors) => setErrors(errors),
onFinish: () => setProcessing(false),
})
}
return (
<form onSubmit={handleSubmit}>
{/* ... */}
</form>
)
}Correct
// ✅ Using useForm hook
import { useForm } from '@inertiajs/react'
import { FormEvent } from 'react'
interface FormData {
title: string
body: string
category_id: string
}
export default function CreatePost() {
const { data, setData, post, processing, errors, reset, clearErrors } =
useForm<FormData>({
title: '',
body: '',
category_id: '',
})
const handleSubmit = (e: FormEvent) => {
e.preventDefault()
post(route('posts.store'), {
onSuccess: () => {
reset() // Clear form on success
},
})
}
return (
<form onSubmit={handleSubmit}>
<div>
<label htmlFor="title">Title</label>
<input
id="title"
type="text"
value={data.title}
onChange={(e) => setData('title', e.target.value)}
onFocus={() => clearErrors('title')}
/>
{errors.title && <span className="text-red-500">{errors.title}</span>}
</div>
<div>
<label htmlFor="body">Body</label>
<textarea
id="body"
value={data.body}
onChange={(e) => setData('body', e.target.value)}
/>
{errors.body && <span className="text-red-500">{errors.body}</span>}
</div>
<button type="submit" disabled={processing}>
{processing ? 'Creating...' : 'Create Post'}
</button>
</form>
)
}useForm Methods
const {
data, // Current form data
setData, // Update form data
post, // POST request
put, // PUT request
patch, // PATCH request
delete: destroy,// DELETE request (renamed to avoid keyword)
processing, // Is form submitting?
errors, // Validation errors from Laravel
reset, // Reset form to initial values
clearErrors, // Clear specific or all errors
isDirty, // Has form been modified?
transform, // Transform data before submit
setError, // Set custom error
recentlySuccessful, // Was last submit successful?
} = useForm({
// Initial values
})Setting Data
// Single field
setData('title', 'New Title')
// Multiple fields
setData({
title: 'New Title',
body: 'New Body',
})
// Using callback (access previous data)
setData((prevData) => ({
...prevData,
title: prevData.title.toUpperCase(),
}))
// Nested data
setData('author.name', 'John')HTTP Methods
// POST - Create
post(route('posts.store'))
// PUT - Full update
put(route('posts.update', post.id))
// PATCH - Partial update
patch(route('posts.update', post.id))
// DELETE
destroy(route('posts.destroy', post.id), {
onBefore: () => confirm('Are you sure?'),
})Options
post(route('posts.store'), {
// Preserve state on success
preserveState: true,
// Preserve scroll position
preserveScroll: true,
// Replace history instead of push
replace: true,
// Callbacks
onBefore: (visit) => {
// Return false to cancel
},
onStart: (visit) => {},
onProgress: (progress) => {
// For file uploads
console.log(progress.percentage)
},
onSuccess: (page) => {
reset()
},
onError: (errors) => {
// Handle errors
},
onCancel: () => {},
onFinish: () => {
// Always called (success or error)
},
})Edit Form Pattern
interface Post {
id: number
title: string
body: string
}
interface Props {
post: Post
}
export default function Edit({ post }: Props) {
// Initialize with existing data
const { data, setData, put, processing, errors } = useForm({
title: post.title,
body: post.body,
})
const handleSubmit = (e: FormEvent) => {
e.preventDefault()
put(route('posts.update', post.id))
}
return (
<form onSubmit={handleSubmit}>
{/* Form fields */}
<button disabled={processing}>
{processing ? 'Saving...' : 'Save Changes'}
</button>
</form>
)
}Transform Data Before Submit
const { data, setData, transform, post } = useForm({
remember: false,
email: '',
password: '',
})
// Transform before sending
transform((data) => ({
...data,
remember: data.remember ? 'on' : '',
}))Benefits
- Automatic error handling from Laravel
- Processing state management
- Form reset functionality
- TypeScript support
- Seamless Laravel integration
Form useForm Hook
Use Inertia's useForm hook for all form handling to get automatic state management, validation error handling, and submission progress tracking.
Bad Example
// Anti-pattern: Manual state management
import { useState } from 'react';
import { router } from '@inertiajs/react';
export default function CreatePost() {
const [title, setTitle] = useState('');
const [content, setContent] = useState('');
const [errors, setErrors] = useState({});
const [processing, setProcessing] = useState(false);
const handleSubmit = (e) => {
e.preventDefault();
setProcessing(true);
setErrors({});
router.post('/posts', { title, content }, {
onError: (errors) => setErrors(errors),
onFinish: () => setProcessing(false),
});
};
return (
<form onSubmit={handleSubmit}>
<input
value={title}
onChange={e => setTitle(e.target.value)}
/>
{errors.title && <span>{errors.title}</span>}
{/* ... more fields */}
</form>
);
}Good Example
// resources/js/Pages/Posts/Create.tsx
import { useForm } from '@inertiajs/react';
import { FormEventHandler } from 'react';
import InputError from '@/Components/InputError';
import InputLabel from '@/Components/InputLabel';
import TextInput from '@/Components/TextInput';
import PrimaryButton from '@/Components/PrimaryButton';
interface CreatePostForm {
title: string;
content: string;
category_id: number | '';
published: boolean;
tags: string[];
}
export default function Create() {
const { data, setData, post, processing, errors, reset } = useForm<CreatePostForm>({
title: '',
content: '',
category_id: '',
published: false,
tags: [],
});
const submit: FormEventHandler = (e) => {
e.preventDefault();
post(route('posts.store'), {
onSuccess: () => reset(),
});
};
return (
<form onSubmit={submit} className="space-y-6">
<div>
<InputLabel htmlFor="title" value="Title" />
<TextInput
id="title"
type="text"
value={data.title}
onChange={(e) => setData('title', e.target.value)}
className="mt-1 block w-full"
/>
<InputError message={errors.title} className="mt-2" />
</div>
<div>
<InputLabel htmlFor="content" value="Content" />
<textarea
id="content"
value={data.content}
onChange={(e) => setData('content', e.target.value)}
className="mt-1 block w-full rounded-md border-gray-300"
rows={6}
/>
<InputError message={errors.content} className="mt-2" />
</div>
<div>
<InputLabel htmlFor="category" value="Category" />
<select
id="category"
value={data.category_id}
onChange={(e) => setData('category_id', Number(e.target.value) || '')}
className="mt-1 block w-full rounded-md border-gray-300"
>
<option value="">Select a category</option>
{/* Category options */}
</select>
<InputError message={errors.category_id} className="mt-2" />
</div>
<div className="flex items-center gap-2">
<input
type="checkbox"
id="published"
checked={data.published}
onChange={(e) => setData('published', e.target.checked)}
/>
<InputLabel htmlFor="published" value="Publish immediately" />
</div>
<PrimaryButton disabled={processing}>
{processing ? 'Creating...' : 'Create Post'}
</PrimaryButton>
</form>
);
}Why
The useForm hook provides significant advantages over manual form handling:
1. Automatic State Management: Single source of truth for all form data 2. Built-in Error Handling: Validation errors from Laravel automatically populate 3. Processing State: Track submission status for button states and UI feedback 4. Type Safety: Generic typing ensures form data matches expected shape 5. Helper Methods: setData, reset, clearErrors, and transform simplify common operations 6. Memory Management: Proper cleanup prevents memory leaks on unmount 7. Consistent API: post, put, patch, delete methods handle HTTP verbs correctly
Form Validation Errors
Display Laravel validation errors using Inertia's built-in error handling with proper UX patterns for inline feedback.
Bad Example
// Anti-pattern: Not handling errors properly
export default function ContactForm() {
const { data, setData, post } = useForm({
email: '',
message: '',
});
return (
<form onSubmit={(e) => { e.preventDefault(); post('/contact'); }}>
<input
type="email"
value={data.email}
onChange={(e) => setData('email', e.target.value)}
/>
{/* No error display */}
<textarea
value={data.message}
onChange={(e) => setData('message', e.target.value)}
/>
<button type="submit">Send</button>
</form>
);
}
// Anti-pattern: Alert-based error display
const submit = (e) => {
e.preventDefault();
post('/contact', {
onError: (errors) => {
alert(Object.values(errors).join('\n'));
},
});
};Good Example
// resources/js/Components/InputError.tsx
interface InputErrorProps {
message?: string;
className?: string;
}
export default function InputError({ message, className = '' }: InputErrorProps) {
if (!message) return null;
return (
<p className={`text-sm text-red-600 ${className}`}>
{message}
</p>
);
}
// resources/js/Pages/Contact.tsx
import { useForm, usePage } from '@inertiajs/react';
import { FormEventHandler, useEffect, useRef } from 'react';
import InputError from '@/Components/InputError';
interface ContactForm {
name: string;
email: string;
subject: string;
message: string;
}
export default function Contact() {
const { data, setData, post, processing, errors, clearErrors } = useForm<ContactForm>({
name: '',
email: '',
subject: '',
message: '',
});
const emailRef = useRef<HTMLInputElement>(null);
// Focus first field with error
useEffect(() => {
const firstErrorField = Object.keys(errors)[0];
if (firstErrorField) {
document.getElementById(firstErrorField)?.focus();
}
}, [errors]);
const submit: FormEventHandler = (e) => {
e.preventDefault();
post(route('contact.store'));
};
return (
<form onSubmit={submit} className="space-y-6" noValidate>
{/* Show summary of errors at top for accessibility */}
{Object.keys(errors).length > 0 && (
<div
className="rounded-md bg-red-50 p-4"
role="alert"
aria-labelledby="error-heading"
>
<h3 id="error-heading" className="text-sm font-medium text-red-800">
There were {Object.keys(errors).length} errors with your submission
</h3>
<ul className="mt-2 list-disc pl-5 text-sm text-red-700">
{Object.entries(errors).map(([field, message]) => (
<li key={field}>{message}</li>
))}
</ul>
</div>
)}
<div>
<label htmlFor="name" className="block text-sm font-medium text-gray-700">
Name
</label>
<input
id="name"
type="text"
value={data.name}
onChange={(e) => setData('name', e.target.value)}
onFocus={() => clearErrors('name')}
aria-invalid={!!errors.name}
aria-describedby={errors.name ? 'name-error' : undefined}
className={`mt-1 block w-full rounded-md shadow-sm ${
errors.name
? 'border-red-300 focus:border-red-500 focus:ring-red-500'
: 'border-gray-300 focus:border-indigo-500 focus:ring-indigo-500'
}`}
/>
<InputError message={errors.name} className="mt-2" id="name-error" />
</div>
<div>
<label htmlFor="email" className="block text-sm font-medium text-gray-700">
Email
</label>
<input
ref={emailRef}
id="email"
type="email"
value={data.email}
onChange={(e) => setData('email', e.target.value)}
onFocus={() => clearErrors('email')}
aria-invalid={!!errors.email}
aria-describedby={errors.email ? 'email-error' : undefined}
className={`mt-1 block w-full rounded-md shadow-sm ${
errors.email
? 'border-red-300 focus:border-red-500 focus:ring-red-500'
: 'border-gray-300 focus:border-indigo-500 focus:ring-indigo-500'
}`}
/>
<InputError message={errors.email} className="mt-2" id="email-error" />
</div>
<div>
<label htmlFor="message" className="block text-sm font-medium text-gray-700">
Message
</label>
<textarea
id="message"
value={data.message}
onChange={(e) => setData('message', e.target.value)}
onFocus={() => clearErrors('message')}
aria-invalid={!!errors.message}
aria-describedby={errors.message ? 'message-error' : undefined}
rows={4}
className={`mt-1 block w-full rounded-md shadow-sm ${
errors.message
? 'border-red-300 focus:border-red-500 focus:ring-red-500'
: 'border-gray-300 focus:border-indigo-500 focus:ring-indigo-500'
}`}
/>
<InputError message={errors.message} className="mt-2" id="message-error" />
</div>
<button
type="submit"
disabled={processing}
className="rounded-md bg-indigo-600 px-4 py-2 text-white hover:bg-indigo-700 disabled:opacity-50"
>
{processing ? 'Sending...' : 'Send Message'}
</button>
</form>
);
}Why
Proper error handling improves both UX and accessibility:
1. Immediate Feedback: Users see exactly which fields need correction 2. Accessibility: ARIA attributes help screen readers announce errors 3. Error Summary: Top-level summary helps users understand total issues 4. Visual Indicators: Red borders and text clearly mark problematic fields 5. Focus Management: Focusing the first error field guides user attention 6. Clear on Focus: Removing errors when focusing encourages retry 7. Laravel Integration: Errors automatically map from Laravel validation responses
Persistent Layouts
Persistent layouts maintain state between page visits. Without them, layouts remount on every navigation, losing state like scroll position, form inputs in navigation, or audio/video playback.
Incorrect
// ❌ Layout wrapping in page - remounts on every navigation
import AppLayout from '@/Layouts/AppLayout'
export default function Dashboard() {
return (
<AppLayout>
<h1>Dashboard</h1>
</AppLayout>
)
}
// ❌ Layout in _app.jsx - still remounts
function App({ Component, pageProps }) {
return (
<AppLayout>
<Component {...pageProps} />
</AppLayout>
)
}Problem: Layout remounts on every page change, losing any state.
Correct
Using layout Property
// resources/js/Layouts/AppLayout.tsx
import { Link, usePage } from '@inertiajs/react'
import { ReactNode } from 'react'
interface Props {
children: ReactNode
}
export default function AppLayout({ children }: Props) {
const { auth } = usePage().props as any
return (
<div className="min-h-screen bg-gray-100">
<nav className="bg-white shadow-sm">
<div className="max-w-7xl mx-auto px-4 py-3">
<div className="flex justify-between items-center">
<div className="flex space-x-4">
<Link href="/" className="font-bold">
Logo
</Link>
<Link href="/dashboard">Dashboard</Link>
<Link href="/posts">Posts</Link>
</div>
<span>{auth.user?.name}</span>
</div>
</div>
</nav>
<main className="max-w-7xl mx-auto py-6 px-4">
{children}
</main>
</div>
)
}// resources/js/Pages/Dashboard.tsx
import AppLayout from '@/Layouts/AppLayout'
import { ReactNode } from 'react'
export default function Dashboard() {
return (
<div>
<h1 className="text-2xl font-bold">Dashboard</h1>
{/* Page content */}
</div>
)
}
// Assign persistent layout
Dashboard.layout = (page: ReactNode) => <AppLayout>{page}</AppLayout>TypeScript Declaration
// resources/js/types/inertia.d.ts
import { ReactNode } from 'react'
declare module '@inertiajs/react' {
interface PageComponent {
layout?: (page: ReactNode) => ReactNode
}
}Default Layout in app.tsx
// resources/js/app.tsx
import { createInertiaApp } from '@inertiajs/react'
import { createRoot } from 'react-dom/client'
import AppLayout from './Layouts/AppLayout'
createInertiaApp({
resolve: (name) => {
const pages = import.meta.glob('./Pages/**/*.tsx', { eager: true })
const page = pages[`./Pages/${name}.tsx`] as any
// Set default layout if page doesn't have one
page.default.layout = page.default.layout || ((page: ReactNode) =>
<AppLayout>{page}</AppLayout>
)
return page
},
setup({ el, App, props }) {
createRoot(el!).render(<App {...props} />)
},
})Nested Layouts
// resources/js/Layouts/SettingsLayout.tsx
import { Link } from '@inertiajs/react'
import { ReactNode } from 'react'
interface Props {
children: ReactNode
}
export default function SettingsLayout({ children }: Props) {
return (
<div className="flex">
<aside className="w-64 border-r p-4">
<nav className="space-y-2">
<Link href="/settings/profile">Profile</Link>
<Link href="/settings/password">Password</Link>
<Link href="/settings/notifications">Notifications</Link>
</nav>
</aside>
<main className="flex-1 p-6">{children}</main>
</div>
)
}// resources/js/Pages/Settings/Profile.tsx
import AppLayout from '@/Layouts/AppLayout'
import SettingsLayout from '@/Layouts/SettingsLayout'
import { ReactNode } from 'react'
export default function Profile() {
return (
<div>
<h2>Profile Settings</h2>
{/* Content */}
</div>
)
}
// Nested persistent layouts
Profile.layout = (page: ReactNode) => (
<AppLayout>
<SettingsLayout>{page}</SettingsLayout>
</AppLayout>
)Conditional Layouts
// resources/js/Pages/Login.tsx
import GuestLayout from '@/Layouts/GuestLayout'
import { ReactNode } from 'react'
export default function Login() {
return (
<div>
<h1>Login</h1>
{/* Login form */}
</div>
)
}
// Different layout for auth pages
Login.layout = (page: ReactNode) => <GuestLayout>{page}</GuestLayout>Layout Without Persistence
// When you DON'T want persistence (rare)
export default function SpecialPage() {
return <div>No layout</div>
}
// Explicitly no layout
SpecialPage.layout = (page: ReactNode) => pageBenefits
- State preserved between page navigations
- Audio/video continues playing
- Form inputs in navigation preserved
- Scroll position in sidebars maintained
- Better perceived performance
External Links Handling
Use standard anchor tags for external links and file downloads, reserving Inertia's Link component for internal navigation only.
Bad Example
// Anti-pattern: Using Inertia Link for external URLs
import { Link } from '@inertiajs/react';
export default function Footer() {
return (
<footer>
{/* These will cause Inertia to try to handle them as internal routes */}
<Link href="https://twitter.com/mycompany">Twitter</Link>
<Link href="https://github.com/mycompany">GitHub</Link>
<Link href="/files/document.pdf">Download PDF</Link>
</footer>
);
}
// Anti-pattern: Using router.visit for external URLs
const openExternal = () => {
router.visit('https://example.com'); // Will fail or cause unexpected behavior
};Good Example
// resources/js/Components/ExternalLink.tsx
import { AnchorHTMLAttributes, ReactNode } from 'react';
interface ExternalLinkProps extends AnchorHTMLAttributes<HTMLAnchorElement> {
href: string;
children: ReactNode;
showIcon?: boolean;
}
export default function ExternalLink({
href,
children,
showIcon = true,
className = '',
...props
}: ExternalLinkProps) {
return (
<a
href={href}
target="_blank"
rel="noopener noreferrer"
className={`inline-flex items-center gap-1 ${className}`}
{...props}
>
{children}
{showIcon && (
<svg
className="h-4 w-4"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14"
/>
</svg>
)}
</a>
);
}
// resources/js/Components/DownloadLink.tsx
interface DownloadLinkProps {
href: string;
filename?: string;
children: ReactNode;
className?: string;
}
export default function DownloadLink({
href,
filename,
children,
className = '',
}: DownloadLinkProps) {
return (
<a
href={href}
download={filename}
className={`inline-flex items-center gap-1 ${className}`}
>
<svg
className="h-4 w-4"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-4l-4 4m0 0l-4-4m4 4V4"
/>
</svg>
{children}
</a>
);
}
// resources/js/Pages/Resources.tsx
import { Link } from '@inertiajs/react';
import ExternalLink from '@/Components/ExternalLink';
import DownloadLink from '@/Components/DownloadLink';
interface ResourcesProps {
documents: Document[];
socialLinks: SocialLink[];
}
export default function Resources({ documents, socialLinks }: ResourcesProps) {
return (
<div className="space-y-8">
{/* Internal navigation uses Inertia Link */}
<nav className="flex gap-4">
<Link href={route('resources.guides')}>Guides</Link>
<Link href={route('resources.tutorials')}>Tutorials</Link>
<Link href={route('resources.faq')}>FAQ</Link>
</nav>
{/* External links use regular anchors */}
<section>
<h2>Follow Us</h2>
<div className="flex gap-4">
{socialLinks.map((link) => (
<ExternalLink
key={link.id}
href={link.url}
className="text-blue-600 hover:text-blue-800"
>
{link.platform}
</ExternalLink>
))}
</div>
</section>
{/* File downloads use regular anchors with download attribute */}
<section>
<h2>Documents</h2>
<ul className="space-y-2">
{documents.map((doc) => (
<li key={doc.id}>
<DownloadLink
href={doc.url}
filename={doc.filename}
className="text-blue-600 hover:text-blue-800"
>
{doc.name} ({doc.size})
</DownloadLink>
</li>
))}
</ul>
</section>
{/* Mixed content example */}
<section>
<h2>Getting Started</h2>
<p>
Read our{' '}
<Link href={route('docs.getting-started')} className="text-blue-600">
getting started guide
</Link>{' '}
or check out the{' '}
<ExternalLink
href="https://github.com/company/repo"
className="text-blue-600"
>
GitHub repository
</ExternalLink>
.
</p>
</section>
</div>
);
}
// Utility function to determine link type
function isExternalUrl(url: string): boolean {
try {
const urlObj = new URL(url, window.location.origin);
return urlObj.origin !== window.location.origin;
} catch {
return false;
}
}
// Smart Link component that auto-detects external URLs
import { Link as InertiaLink } from '@inertiajs/react';
interface SmartLinkProps {
href: string;
children: ReactNode;
className?: string;
}
export function SmartLink({ href, children, className }: SmartLinkProps) {
if (isExternalUrl(href)) {
return (
<a
href={href}
target="_blank"
rel="noopener noreferrer"
className={className}
>
{children}
</a>
);
}
return (
<InertiaLink href={href} className={className}>
{children}
</InertiaLink>
);
}Why
Proper handling of external links and downloads is important because:
1. Correct Behavior: Inertia's Link is designed for internal SPA navigation only 2. Security: External links should use rel="noopener noreferrer" to prevent tabnabbing 3. User Expectations: External links should open in new tabs with visual indicators 4. Downloads: File downloads need regular anchors with download attribute 5. SEO: Search engines correctly follow standard anchor tags 6. Accessibility: Screen readers announce external links appropriately 7. Error Prevention: Using Inertia Link for external URLs causes errors or unexpected behavior
Navigation Link Component
Use Inertia's Link component for internal navigation to enable SPA-like page transitions without full page reloads.
Bad Example
// Anti-pattern: Using regular anchor tags
export default function Navigation() {
return (
<nav>
<a href="/dashboard">Dashboard</a>
<a href="/users">Users</a>
<a href={`/users/${user.id}`}>Profile</a>
</nav>
);
}
// Anti-pattern: Using React Router's Link
import { Link } from 'react-router-dom';
export default function Navigation() {
return (
<nav>
<Link to="/dashboard">Dashboard</Link>
</nav>
);
}
// Anti-pattern: Manual navigation on click
<button onClick={() => window.location.href = '/dashboard'}>
Dashboard
</button>Good Example
// resources/js/Components/NavLink.tsx
import { Link, InertiaLinkProps } from '@inertiajs/react';
interface NavLinkProps extends InertiaLinkProps {
active?: boolean;
children: React.ReactNode;
}
export default function NavLink({
active = false,
className = '',
children,
...props
}: NavLinkProps) {
return (
<Link
{...props}
className={`inline-flex items-center border-b-2 px-1 pt-1 text-sm font-medium leading-5 transition duration-150 ease-in-out focus:outline-none ${
active
? 'border-indigo-400 text-gray-900 focus:border-indigo-700'
: 'border-transparent text-gray-500 hover:border-gray-300 hover:text-gray-700 focus:border-gray-300 focus:text-gray-700'
} ${className}`}
>
{children}
</Link>
);
}
// resources/js/Layouts/AuthenticatedLayout.tsx
import { Link, usePage } from '@inertiajs/react';
import NavLink from '@/Components/NavLink';
export default function AuthenticatedLayout({ children }) {
const { url } = usePage();
return (
<div>
<nav className="border-b bg-white">
<div className="mx-auto max-w-7xl px-4 sm:px-6 lg:px-8">
<div className="flex h-16 justify-between">
<div className="flex">
{/* Logo/Home link */}
<Link href={route('home')} className="flex items-center">
<ApplicationLogo className="h-9 w-auto" />
</Link>
{/* Navigation Links */}
<div className="hidden space-x-8 sm:ml-10 sm:flex">
<NavLink
href={route('dashboard')}
active={route().current('dashboard')}
>
Dashboard
</NavLink>
<NavLink
href={route('projects.index')}
active={route().current('projects.*')}
>
Projects
</NavLink>
<NavLink
href={route('users.index')}
active={route().current('users.*')}
>
Users
</NavLink>
</div>
</div>
{/* User dropdown */}
<div className="flex items-center">
<Link
href={route('profile.edit')}
className="text-sm text-gray-700 hover:text-gray-900"
>
Profile
</Link>
<Link
href={route('logout')}
method="post"
as="button"
className="ml-4 text-sm text-gray-700 hover:text-gray-900"
>
Log Out
</Link>
</div>
</div>
</div>
</nav>
<main>{children}</main>
</div>
);
}
// Using Link with various options
function AdvancedLinkExamples() {
return (
<div className="space-y-4">
{/* Basic link */}
<Link href="/users">Users</Link>
{/* Using Ziggy routes */}
<Link href={route('users.show', { user: 1 })}>View User</Link>
{/* Preserve scroll position */}
<Link href="/users?page=2" preserveScroll>
Next Page
</Link>
{/* Replace history instead of push */}
<Link href="/users" replace>
Users (replace)
</Link>
{/* Preserve component state */}
<Link href="/users?filter=active" preserveState>
Active Users
</Link>
{/* Only reload specific props */}
<Link href="/dashboard" only={['notifications']}>
Refresh Notifications
</Link>
{/* POST request as link */}
<Link
href={route('posts.favorite', { post: 1 })}
method="post"
as="button"
className="text-blue-600 hover:underline"
>
Add to Favorites
</Link>
{/* DELETE with confirmation */}
<Link
href={route('posts.destroy', { post: 1 })}
method="delete"
as="button"
onBefore={() => confirm('Are you sure?')}
className="text-red-600 hover:underline"
>
Delete Post
</Link>
{/* With custom headers */}
<Link
href="/api/resource"
headers={{ 'X-Custom-Header': 'value' }}
>
Custom Request
</Link>
</div>
);
}Why
Using Inertia's Link component is essential for proper SPA behavior:
1. No Full Reload: Pages transition smoothly without browser refresh 2. State Preservation: React component state persists across navigation 3. Prefetching: Inertia can prefetch pages on hover for faster navigation 4. Progress Indicator: Integrates with Inertia's loading progress bar 5. HTTP Methods: Support for POST, PUT, PATCH, DELETE via method prop 6. Ziggy Integration: Works seamlessly with Laravel's named routes 7. History Management: Proper browser back/forward button behavior 8. Accessibility: Renders semantic anchor tags with proper href attributes
Navigation Preserve State
Use preserveState to maintain local component state during navigation, useful for tabs, filters, and accordion states.
Bad Example
// Anti-pattern: Losing local state on navigation
import { Link } from '@inertiajs/react';
import { useState } from 'react';
export default function UserList({ users, filters }) {
const [expandedRows, setExpandedRows] = useState<number[]>([]);
const [viewMode, setViewMode] = useState<'grid' | 'list'>('list');
return (
<div>
<div className="flex gap-2">
{/* These links will reset expandedRows and viewMode */}
<Link href={route('users.index', { status: 'active' })}>
Active
</Link>
<Link href={route('users.index', { status: 'inactive' })}>
Inactive
</Link>
</div>
{/* User list that loses expanded state when filtering */}
{users.map(user => (
<UserRow
key={user.id}
user={user}
expanded={expandedRows.includes(user.id)}
onToggle={() => {
setExpandedRows(prev =>
prev.includes(user.id)
? prev.filter(id => id !== user.id)
: [...prev, user.id]
);
}}
/>
))}
</div>
);
}Good Example
// resources/js/Pages/Users/Index.tsx
import { Link, router } from '@inertiajs/react';
import { useState } from 'react';
interface UsersIndexProps {
users: PaginatedData<User>;
filters: {
status: string;
search: string;
};
}
export default function Index({ users, filters }: UsersIndexProps) {
// Local UI state that should persist during navigation
const [expandedRows, setExpandedRows] = useState<number[]>([]);
const [viewMode, setViewMode] = useState<'grid' | 'list'>('list');
const [selectedIds, setSelectedIds] = useState<number[]>([]);
// Filter navigation with state preservation
const filterByStatus = (status: string) => {
router.get(
route('users.index'),
{ ...filters, status },
{
preserveState: true, // Keep expandedRows, viewMode, selectedIds
preserveScroll: true, // Keep scroll position too
}
);
};
// Search with debounce and state preservation
const handleSearch = (search: string) => {
router.get(
route('users.index'),
{ ...filters, search },
{
preserveState: true,
preserveScroll: true,
replace: true, // Don't add to history for each keystroke
}
);
};
return (
<div>
{/* Search input */}
<input
type="search"
defaultValue={filters.search}
onChange={(e) => handleSearch(e.target.value)}
placeholder="Search users..."
/>
{/* Status filter tabs */}
<div className="flex gap-2 border-b">
{['all', 'active', 'inactive', 'pending'].map((status) => (
<button
key={status}
onClick={() => filterByStatus(status)}
className={`px-4 py-2 ${
filters.status === status
? 'border-b-2 border-blue-500 text-blue-600'
: 'text-gray-500'
}`}
>
{status.charAt(0).toUpperCase() + status.slice(1)}
</button>
))}
</div>
{/* View mode toggle - persists across filter changes */}
<div className="flex gap-2">
<button
onClick={() => setViewMode('list')}
className={viewMode === 'list' ? 'bg-blue-100' : ''}
>
List View
</button>
<button
onClick={() => setViewMode('grid')}
className={viewMode === 'grid' ? 'bg-blue-100' : ''}
>
Grid View
</button>
</div>
{/* Bulk actions using preserved selection */}
{selectedIds.length > 0 && (
<div className="bg-blue-50 p-4">
<span>{selectedIds.length} users selected</span>
<button onClick={() => bulkDelete(selectedIds)}>
Delete Selected
</button>
</div>
)}
{/* User list/grid */}
<div className={viewMode === 'grid' ? 'grid grid-cols-3 gap-4' : 'space-y-2'}>
{users.data.map((user) => (
<UserCard
key={user.id}
user={user}
viewMode={viewMode}
expanded={expandedRows.includes(user.id)}
selected={selectedIds.includes(user.id)}
onToggleExpand={() => toggleExpanded(user.id)}
onToggleSelect={() => toggleSelected(user.id)}
/>
))}
</div>
{/* Pagination with state preservation */}
<Pagination
links={users.links}
preserveState={true}
preserveScroll={true}
/>
</div>
);
function toggleExpanded(id: number) {
setExpandedRows((prev) =>
prev.includes(id) ? prev.filter((i) => i !== id) : [...prev, id]
);
}
function toggleSelected(id: number) {
setSelectedIds((prev) =>
prev.includes(id) ? prev.filter((i) => i !== id) : [...prev, id]
);
}
}
// Reusable Pagination component with preserveState option
interface PaginationProps {
links: PaginationLink[];
preserveState?: boolean;
preserveScroll?: boolean;
}
function Pagination({ links, preserveState = false, preserveScroll = false }: PaginationProps) {
return (
<div className="flex gap-1">
{links.map((link, index) => (
<Link
key={index}
href={link.url || '#'}
preserveState={preserveState}
preserveScroll={preserveScroll}
className={`px-3 py-1 rounded ${
link.active ? 'bg-blue-600 text-white' : 'bg-gray-100'
} ${!link.url ? 'opacity-50 cursor-not-allowed' : ''}`}
dangerouslySetInnerHTML={{ __html: link.label }}
/>
))}
</div>
);
}Why
Preserving state during navigation provides a better user experience:
1. UI Continuity: Expanded rows, view modes, and selections persist 2. Filter Experience: Users can filter without losing their context 3. Pagination: Navigate pages without resetting UI preferences 4. Reduced Friction: No need to re-select items or re-expand details 5. Natural Feel: Behaves like a traditional SPA or desktop application 6. Complementary: Combine with preserveScroll for complete state retention 7. Selective Use: Only preserve state when it makes sense for the interaction
Programmatic Navigation
Use Inertia's router for programmatic navigation, redirects, and form submissions outside of Link components.
Bad Example
// Anti-pattern: Using window.location
const handleClick = () => {
window.location.href = '/dashboard';
};
// Anti-pattern: Using browser history directly
const goBack = () => {
window.history.back();
};
// Anti-pattern: Using fetch for navigation
const handleSubmit = async () => {
const response = await fetch('/api/users', {
method: 'POST',
body: JSON.stringify(data),
});
if (response.ok) {
window.location.href = '/users';
}
};Good Example
// resources/js/Pages/Users/Create.tsx
import { router } from '@inertiajs/react';
import { FormEventHandler } from 'react';
export default function CreateUser() {
const handleCancel = () => {
// Simple navigation
router.visit(route('users.index'));
};
const handleCreate = (userData: UserData) => {
// POST request with callbacks
router.post(route('users.store'), userData, {
onSuccess: () => {
// Redirect happens automatically from Laravel
// But you can do additional client-side logic here
},
onError: (errors) => {
console.error('Validation failed:', errors);
},
});
};
return (
<div>
<UserForm onSubmit={handleCreate} />
<button onClick={handleCancel}>Cancel</button>
</div>
);
}
// Advanced router usage examples
import { router } from '@inertiajs/react';
// GET request with query parameters
function searchUsers(query: string) {
router.get(route('users.index'), { search: query }, {
preserveState: true,
preserveScroll: true,
});
}
// POST request
function createPost(data: PostData) {
router.post(route('posts.store'), data, {
onSuccess: (page) => {
console.log('Post created!', page.props.flash);
},
});
}
// PUT request for updates
function updateUser(userId: number, data: UserData) {
router.put(route('users.update', userId), data, {
preserveScroll: true,
});
}
// PATCH for partial updates
function togglePublished(postId: number) {
router.patch(route('posts.toggle-published', postId));
}
// DELETE request
function deleteUser(userId: number) {
if (confirm('Are you sure you want to delete this user?')) {
router.delete(route('users.destroy', userId), {
onSuccess: () => {
// User deleted, page will be re-rendered
},
});
}
}
// Reload current page
function refreshData() {
router.reload();
}
// Partial reload - only fetch specific props
function refreshNotifications() {
router.reload({ only: ['notifications'] });
}
// Navigate with all options
function advancedNavigation() {
router.visit(route('dashboard'), {
method: 'get',
data: { tab: 'analytics' },
replace: true, // Replace history entry
preserveState: true, // Keep component state
preserveScroll: true, // Keep scroll position
only: ['stats'], // Partial reload
headers: { 'X-Custom': 'value' }, // Custom headers
onBefore: (visit) => {
// Return false to cancel
return confirm('Navigate away?');
},
onStart: (visit) => {
console.log('Navigation started');
},
onProgress: (progress) => {
console.log(`${progress.percentage}% loaded`);
},
onSuccess: (page) => {
console.log('Navigation successful', page);
},
onError: (errors) => {
console.error('Errors:', errors);
},
onCancel: () => {
console.log('Navigation cancelled');
},
onFinish: () => {
console.log('Navigation finished (success or error)');
},
});
}
// Using router in event handlers
function ProductActions({ product }: { product: Product }) {
const handleDuplicate = () => {
router.post(route('products.duplicate', product.id), {}, {
onSuccess: () => {
// Will redirect to new product edit page
},
});
};
const handleArchive = () => {
router.patch(
route('products.archive', product.id),
{},
{
preserveScroll: true,
onSuccess: () => {
// Product archived, list will update
},
}
);
};
const handleExport = () => {
// For file downloads, use regular window.location
window.location.href = route('products.export', product.id);
};
return (
<div className="flex gap-2">
<button onClick={handleDuplicate}>Duplicate</button>
<button onClick={handleArchive}>Archive</button>
<button onClick={handleExport}>Export PDF</button>
</div>
);
}
// Conditional navigation based on form state
function FormWithNavigation() {
const [hasChanges, setHasChanges] = useState(false);
const navigateAway = (url: string) => {
if (hasChanges) {
if (confirm('You have unsaved changes. Continue?')) {
router.visit(url);
}
} else {
router.visit(url);
}
};
return (
<div>
<button onClick={() => navigateAway(route('home'))}>
Go Home
</button>
</div>
);
}Why
Programmatic navigation with Inertia's router provides:
1. Consistent Behavior: Same SPA navigation as Link components 2. Full Control: Access to all visit options and lifecycle callbacks 3. HTTP Methods: Support for GET, POST, PUT, PATCH, DELETE 4. Event Handling: Navigate from button clicks, form submissions, etc. 5. Conditional Logic: Navigate based on validation or user confirmation 6. Progress Tracking: Same loading indicator as Link navigation 7. State Management: Preserve scroll, state, and partial reload support 8. Error Handling: Proper callback structure for success and error states
Navigation Replace History
Use the replace option to modify the current history entry instead of adding a new one, preventing cluttered browser history.
Bad Example
// Anti-pattern: Every filter change adds to history
import { router } from '@inertiajs/react';
import { useState } from 'react';
export default function ProductSearch({ products, filters }) {
const handleFilterChange = (key: string, value: string) => {
// Each change pushes to history - back button becomes unusable
router.get(route('products.index'), {
...filters,
[key]: value,
});
};
const handleSearchInput = (search: string) => {
// Every keystroke adds history entry!
router.get(route('products.index'), { search });
};
return (
<div>
<input
type="search"
onChange={(e) => handleSearchInput(e.target.value)}
/>
<select onChange={(e) => handleFilterChange('category', e.target.value)}>
{/* options */}
</select>
</div>
);
}Good Example
// resources/js/Pages/Products/Index.tsx
import { router, Link } from '@inertiajs/react';
import { useState, useCallback } from 'react';
import debounce from 'lodash/debounce';
interface ProductsIndexProps {
products: PaginatedData<Product>;
filters: {
search: string;
category: string;
sort: string;
min_price: string;
max_price: string;
};
categories: Category[];
}
export default function Index({ products, filters, categories }: ProductsIndexProps) {
const [localSearch, setLocalSearch] = useState(filters.search);
// Debounced search that replaces history
const debouncedSearch = useCallback(
debounce((search: string) => {
router.get(
route('products.index'),
{ ...filters, search },
{
replace: true, // Don't add history entry for each keystroke
preserveState: true,
preserveScroll: true,
}
);
}, 300),
[filters]
);
const handleSearchChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const value = e.target.value;
setLocalSearch(value);
debouncedSearch(value);
};
// Filter changes should replace history
const updateFilter = (key: string, value: string) => {
router.get(
route('products.index'),
{ ...filters, [key]: value },
{
replace: true, // Replace instead of push
preserveState: true,
preserveScroll: true,
}
);
};
// Clear all filters - this SHOULD add history (intentional action)
const clearFilters = () => {
router.get(
route('products.index'),
{},
{
replace: false, // Push to history so user can "undo"
}
);
};
return (
<div>
{/* Search input - replaces history */}
<input
type="search"
value={localSearch}
onChange={handleSearchChange}
placeholder="Search products..."
className="rounded-md border-gray-300"
/>
{/* Category filter - replaces history */}
<select
value={filters.category}
onChange={(e) => updateFilter('category', e.target.value)}
className="rounded-md border-gray-300"
>
<option value="">All Categories</option>
{categories.map((category) => (
<option key={category.id} value={category.slug}>
{category.name}
</option>
))}
</select>
{/* Sort - replaces history */}
<select
value={filters.sort}
onChange={(e) => updateFilter('sort', e.target.value)}
className="rounded-md border-gray-300"
>
<option value="newest">Newest</option>
<option value="price_asc">Price: Low to High</option>
<option value="price_desc">Price: High to Low</option>
<option value="popular">Most Popular</option>
</select>
{/* Price range - replaces history */}
<div className="flex gap-2">
<input
type="number"
placeholder="Min"
value={filters.min_price}
onChange={(e) => updateFilter('min_price', e.target.value)}
/>
<input
type="number"
placeholder="Max"
value={filters.max_price}
onChange={(e) => updateFilter('max_price', e.target.value)}
/>
</div>
{/* Clear filters - adds to history */}
<button onClick={clearFilters} className="text-blue-600 hover:underline">
Clear All Filters
</button>
{/* Product grid */}
<div className="grid grid-cols-3 gap-4">
{products.data.map((product) => (
<ProductCard key={product.id} product={product} />
))}
</div>
{/* Pagination - adds to history (intentional navigation) */}
<div className="flex gap-2">
{products.links.map((link, index) => (
<Link
key={index}
href={link.url || '#'}
replace={false} // Pagination SHOULD add history
className={link.active ? 'font-bold' : ''}
dangerouslySetInnerHTML={{ __html: link.label }}
/>
))}
</div>
</div>
);
}
// Tab navigation - replace history for tab switches
function TabbedContent({ activeTab }: { activeTab: string }) {
const switchTab = (tab: string) => {
router.get(
route('content.show'),
{ tab },
{
replace: true, // Tab switches replace history
preserveState: true,
}
);
};
return (
<div>
<div className="flex border-b">
{['overview', 'details', 'reviews'].map((tab) => (
<button
key={tab}
onClick={() => switchTab(tab)}
className={activeTab === tab ? 'border-b-2 border-blue-500' : ''}
>
{tab}
</button>
))}
</div>
</div>
);
}Why
Using replace for history management provides:
1. Clean History: Back button takes users to logical previous pages 2. Filter UX: Multiple filter changes don't pollute browser history 3. Search Experience: Typing in search doesn't create dozens of history entries 4. Intentional Actions: Use push (default) for deliberate navigation actions 5. Tab Navigation: Switching tabs shouldn't add to history stack 6. Performance: Fewer history entries means less memory usage 7. User Expectations: History behaves like traditional websites
Page Component Structure
Inertia page components should follow a consistent structure with proper TypeScript typing, clear separation of concerns, and explicit layout assignment.
Bad Example
// Anti-pattern: Unstructured component without typing
export default function Dashboard(props) {
return (
<div>
<h1>Dashboard</h1>
<p>Welcome {props.user.name}</p>
<div>
{props.stats.map(stat => (
<div key={stat.id}>{stat.value}</div>
))}
</div>
</div>
);
}Good Example
// resources/js/Pages/Dashboard.tsx
import { Head } from '@inertiajs/react';
import AuthenticatedLayout from '@/Layouts/AuthenticatedLayout';
import { PageProps } from '@/types';
import StatsGrid from '@/Components/StatsGrid';
interface Stat {
id: number;
label: string;
value: number;
change: number;
}
interface DashboardProps extends PageProps {
stats: Stat[];
recentActivity: Activity[];
}
export default function Dashboard({ auth, stats, recentActivity }: DashboardProps) {
return (
<>
<Head title="Dashboard" />
<div className="py-12">
<div className="mx-auto max-w-7xl sm:px-6 lg:px-8">
<h1 className="text-2xl font-semibold text-gray-900">
Welcome back, {auth.user.name}
</h1>
<StatsGrid stats={stats} />
<RecentActivityList activities={recentActivity} />
</div>
</div>
</>
);
}
Dashboard.layout = (page: React.ReactNode) => (
<AuthenticatedLayout children={page} />
);Why
A well-structured page component provides several benefits:
1. Type Safety: TypeScript interfaces catch prop mismatches at compile time rather than runtime 2. Maintainability: Clear structure makes it easy to understand what data the page expects 3. Reusability: Extracting sub-components keeps pages focused on composition 4. SEO: Using the Head component ensures proper meta tags for each page 5. Layout Consistency: Explicit layout assignment prevents layout-related bugs 6. Developer Experience: Consistent patterns across pages reduce cognitive load
Page Default Layout
Assign layouts to pages using the static layout property pattern to enable persistent layouts and avoid unnecessary re-renders.
Bad Example
// Anti-pattern: Wrapping layout inside the component
import AuthenticatedLayout from '@/Layouts/AuthenticatedLayout';
export default function Dashboard({ auth }) {
return (
<AuthenticatedLayout user={auth.user}>
<div className="py-12">
<h1>Dashboard</h1>
</div>
</AuthenticatedLayout>
);
}
// Anti-pattern: Using a wrapper component
function DashboardWithLayout(props) {
return (
<AuthenticatedLayout>
<Dashboard {...props} />
</AuthenticatedLayout>
);
}
export default DashboardWithLayout;Good Example
// resources/js/Pages/Dashboard.tsx
import { Head } from '@inertiajs/react';
import AuthenticatedLayout from '@/Layouts/AuthenticatedLayout';
import { PageProps } from '@/types';
export default function Dashboard({ auth }: PageProps) {
return (
<>
<Head title="Dashboard" />
<div className="py-12">
<div className="mx-auto max-w-7xl sm:px-6 lg:px-8">
<h1>Welcome, {auth.user.name}</h1>
</div>
</div>
</>
);
}
Dashboard.layout = (page: React.ReactNode) => (
<AuthenticatedLayout children={page} />
);
// For TypeScript, extend the function type
// resources/js/types/index.d.ts
import { ReactNode } from 'react';
declare module '@inertiajs/react' {
interface PageComponent<P = {}> {
(props: P): ReactNode;
layout?: (page: ReactNode) => ReactNode;
}
}
// Alternative: Typed page component
import { PageComponent } from '@/types';
const Dashboard: PageComponent<PageProps> = ({ auth }) => {
return (
<>
<Head title="Dashboard" />
<div>Dashboard content</div>
</>
);
};
Dashboard.layout = (page) => <AuthenticatedLayout children={page} />;
export default Dashboard;Why
Using the static layout property pattern is important because:
1. Persistent Layouts: Layouts remain mounted between page visits, preserving their state 2. Performance: Audio players, video, scroll position, and form state persist across navigation 3. Fewer Re-renders: The layout doesn't unmount/remount on every page change 4. Cleaner Components: Page components focus on their content, not layout wrapping 5. Consistency: All pages follow the same pattern, making the codebase predictable 6. Flexibility: Easy to switch layouts or use conditional layouts per page
Page Head Management
Use Inertia's Head component to manage document head elements like title, meta tags, and Open Graph data on a per-page basis.
Bad Example
// Anti-pattern: Using document.title directly
import { useEffect } from 'react';
export default function ProductPage({ product }) {
useEffect(() => {
document.title = product.name + ' | My Store';
}, [product.name]);
return <div>{product.name}</div>;
}
// Anti-pattern: Missing head management entirely
export default function ProductPage({ product }) {
return (
<div>
<h1>{product.name}</h1>
</div>
);
}Good Example
// resources/js/Pages/Products/Show.tsx
import { Head } from '@inertiajs/react';
interface Product {
id: number;
name: string;
description: string;
price: number;
image_url: string;
category: string;
}
interface ProductShowProps {
product: Product;
}
export default function Show({ product }: ProductShowProps) {
return (
<>
<Head>
<title>{product.name}</title>
<meta name="description" content={product.description.substring(0, 160)} />
{/* Open Graph */}
<meta property="og:title" content={product.name} />
<meta property="og:description" content={product.description.substring(0, 160)} />
<meta property="og:image" content={product.image_url} />
<meta property="og:type" content="product" />
{/* Twitter Card */}
<meta name="twitter:card" content="summary_large_image" />
<meta name="twitter:title" content={product.name} />
<meta name="twitter:description" content={product.description.substring(0, 160)} />
<meta name="twitter:image" content={product.image_url} />
{/* Structured Data */}
<script type="application/ld+json">
{JSON.stringify({
'@context': 'https://schema.org',
'@type': 'Product',
name: product.name,
description: product.description,
image: product.image_url,
offers: {
'@type': 'Offer',
price: product.price,
priceCurrency: 'USD',
},
})}
</script>
</Head>
<div className="product-page">
<h1>{product.name}</h1>
<p>{product.description}</p>
</div>
</>
);
}
// Simple usage with title shorthand
export default function About() {
return (
<>
<Head title="About Us" />
<div>About page content</div>
</>
);
}Why
Using Inertia's Head component is crucial for:
1. SEO: Search engines need proper titles and meta descriptions to index pages correctly 2. Social Sharing: Open Graph and Twitter Card meta tags control how pages appear when shared 3. SSR Compatibility: Head component works with server-side rendering, unlike direct DOM manipulation 4. Automatic Cleanup: Inertia automatically removes head elements when navigating away 5. Template Support: You can set a title template in app.tsx like titleTemplate="%s | My App" 6. Accessibility: Proper page titles help screen reader users understand page context
Page Partial Reloads
Use partial reloads to refresh only specific props without reloading the entire page, improving performance and user experience.
Bad Example
// Anti-pattern: Full page reload for updating single data
import { router } from '@inertiajs/react';
export default function Dashboard({ stats, notifications, recentActivity }) {
const refreshAll = () => {
// This reloads ALL props, even unchanged ones
router.reload();
};
return (
<div>
<button onClick={refreshAll}>Refresh Notifications</button>
<NotificationList notifications={notifications} />
<StatsGrid stats={stats} />
<ActivityFeed activities={recentActivity} />
</div>
);
}
// Anti-pattern: Making separate API calls
const refreshNotifications = async () => {
const response = await fetch('/api/notifications');
const data = await response.json();
setNotifications(data); // Mixing Inertia with manual state
};Good Example
// resources/js/Pages/Dashboard.tsx
import { router } from '@inertiajs/react';
import { useState } from 'react';
interface DashboardProps {
stats: Stat[];
notifications: Notification[];
recentActivity: Activity[];
}
export default function Dashboard({
stats,
notifications,
recentActivity
}: DashboardProps) {
const [isRefreshing, setIsRefreshing] = useState(false);
// Reload only notifications
const refreshNotifications = () => {
router.reload({
only: ['notifications'],
onStart: () => setIsRefreshing(true),
onFinish: () => setIsRefreshing(false),
});
};
// Reload multiple specific props
const refreshDashboardData = () => {
router.reload({
only: ['stats', 'recentActivity'],
preserveScroll: true,
});
};
// Reload everything except heavy data
const refreshWithoutActivity = () => {
router.reload({
except: ['recentActivity'],
});
};
return (
<div>
<div className="flex gap-4">
<button
onClick={refreshNotifications}
disabled={isRefreshing}
>
{isRefreshing ? 'Refreshing...' : 'Refresh Notifications'}
</button>
<button onClick={refreshDashboardData}>
Refresh Stats
</button>
</div>
<NotificationList
notifications={notifications}
isLoading={isRefreshing}
/>
<StatsGrid stats={stats} />
<ActivityFeed activities={recentActivity} />
</div>
);
}
// Laravel Controller with lazy props for optimization
// app/Http/Controllers/DashboardController.php
/*
use Inertia\Inertia;
public function index()
{
return Inertia::render('Dashboard', [
'stats' => fn () => $this->getStats(),
'notifications' => fn () => auth()->user()->unreadNotifications,
'recentActivity' => Inertia::lazy(fn () => $this->getRecentActivity()),
]);
}
*/
// Polling with partial reloads
import { useEffect } from 'react';
function NotificationBell({ count }: { count: number }) {
useEffect(() => {
const interval = setInterval(() => {
router.reload({ only: ['notificationCount'] });
}, 30000); // Refresh every 30 seconds
return () => clearInterval(interval);
}, []);
return <span className="badge">{count}</span>;
}Why
Partial reloads are essential for building efficient Inertia applications:
1. Performance: Only transfer and process the data that actually changed 2. Bandwidth: Reduce network payload, especially important for mobile users 3. Server Load: Laravel only evaluates the requested props when using closures 4. UX Continuity: Other parts of the page remain stable during updates 5. Real-time Features: Enable efficient polling without full page refreshes 6. Lazy Loading: Combine with Inertia::lazy() for on-demand data loading
Page Props Typing
All Inertia page props should be strongly typed using TypeScript interfaces that extend a base PageProps type containing shared data.
Bad Example
// Anti-pattern: Using any or missing types
export default function Users({ users, filters }: any) {
return (
<div>
{users.map((user) => (
<div key={user.id}>{user.name}</div>
))}
</div>
);
}
// Anti-pattern: Inline typing without extending base props
export default function Users({
users
}: {
users: { id: number; name: string }[]
}) {
// Missing auth, flash, and other shared props
}Good Example
// resources/js/types/index.d.ts
export interface User {
id: number;
name: string;
email: string;
email_verified_at: string | null;
created_at: string;
updated_at: string;
}
export interface PaginatedData<T> {
data: T[];
links: {
first: string;
last: string;
prev: string | null;
next: string | null;
};
meta: {
current_page: number;
from: number;
last_page: number;
path: string;
per_page: number;
to: number;
total: number;
};
}
export interface PageProps {
auth: {
user: User;
};
flash: {
success?: string;
error?: string;
};
ziggy: {
url: string;
port: number | null;
defaults: Record<string, unknown>;
routes: Record<string, unknown>;
};
}
// resources/js/Pages/Users/Index.tsx
import { PageProps, PaginatedData, User } from '@/types';
interface Filters {
search: string;
role: string;
status: 'active' | 'inactive' | 'all';
}
interface UsersIndexProps extends PageProps {
users: PaginatedData<User>;
filters: Filters;
roles: { value: string; label: string }[];
}
export default function Index({ auth, users, filters, roles }: UsersIndexProps) {
return (
<div>
<h1>Users ({users.meta.total})</h1>
{users.data.map((user) => (
<UserCard key={user.id} user={user} />
))}
</div>
);
}Why
Proper props typing is essential for Inertia applications:
1. Contract Enforcement: Types create a contract between Laravel controllers and React components 2. IDE Support: Autocomplete and inline documentation improve developer productivity 3. Error Prevention: Catch typos and missing properties before runtime 4. Refactoring Safety: TypeScript will flag all affected components when data shapes change 5. Documentation: Types serve as living documentation for the data flow 6. Shared Data Access: Extending PageProps ensures access to auth, flash messages, and Ziggy routes
Related skills
How it compares
Use laravel-inertia-react for Inertia monolith conventions—not for headless Laravel API plus separate React SPA architectures.
FAQ
How many rules does laravel-inertia-react include?
laravel-inertia-react provides 24 rules organized across six key categories, giving agents structured guidance for Laravel backends paired with Inertia.js and React frontends.
When should I use laravel-inertia-react?
laravel-inertia-react applies when building or modifying Laravel applications that use Inertia.js with React—especially for page components, useForm forms, Link navigation, and HandleInertiaRequests shared data.
Is Laravel Inertia React safe to install?
skills.sh reports 3 of 3 security scanners passed. Review the Security Audits panel on this page before installing in production.