Inertia 2.0 Error Handling: Production-ready patterns
Errors happen. Servers go down, validation fails, users do unexpected things. The difference between an amateur app and a professional one is how it handles these situations.
Inertia doesn't have an obvious error handling story. There's no single "this is how you do it" in the docs. So developers end up with a mix of approaches or worse, no error handling at all.
I've spent way too much time figuring out the right patterns for this. Let me save you the trouble.
The Error Types You'll Face
Before diving into solutions, let's understand what we're dealing with:
- Validation errors (422) - User submitted bad data
- Not found (404) - Page or resource doesn't exist
- Forbidden (403) - User doesn't have permission
- Server errors (500) - Something broke on the backend
- Session expired (419) - CSRF token is stale
- Network errors - No internet, server unreachable
Each needs different handling.
Validation Errors
This is the easiest one. Inertia handles it automatically when you use Laravel's validation.
// Laravel Controller
public function store(Request $request)
{
$validated = $request->validate([
'email' => ['required', 'email'],
'name' => ['required', 'min:2'],
]);
// If validation fails, Inertia redirects back with errors
User::create($validated);
return redirect()->route('users.index');
}
// React Component
import { useForm } from '@inertiajs/react'
export default function CreateUser() {
const { data, setData, post, errors, processing } = useForm({
email: '',
name: '',
})
return (
<form onSubmit={e => { e.preventDefault(); post('/users') }}>
<div>
<input
type="email"
value={data.email}
onChange={e => setData('email', e.target.value)}
className={errors.email ? 'border-red-500' : ''}
/>
{errors.email && (
<p className="text-red-500 text-sm mt-1">{errors.email}</p>
)}
</div>
<div>
<input
type="text"
value={data.name}
onChange={e => setData('name', e.target.value)}
className={errors.name ? 'border-red-500' : ''}
/>
{errors.name && (
<p className="text-red-500 text-sm mt-1">{errors.name}</p>
)}
</div>
<button type="submit" disabled={processing}>
Create User
</button>
</form>
)
}
The errors object is populated automatically. No extra work needed.
Error Bags
When you have multiple forms on one page, use error bags to keep errors separate:
$request->validateWithBag('createUser', [
'email' => ['required', 'email'],
]);
const form = useForm({ email: '' })
form.post('/users', {
errorBag: 'createUser'
})
// Access errors from the specific bag
const { errors } = usePage().props
const createUserErrors = errors.createUser
Error Pages (404, 403, 500)
For full-page errors, create dedicated error components.
Setting Up Error Handling
In your app.tsx:
import { createInertiaApp } from '@inertiajs/react'
createInertiaApp({
resolve: (name) => {
const pages = import.meta.glob('./Pages/**/*.tsx', { eager: true })
return pages[`./Pages/${name}.tsx`]
},
// ...
})
Create error pages in resources/js/Pages/:
// Pages/Errors/404.tsx
export default function NotFound() {
return (
<div className="min-h-screen flex items-center justify-center">
<div className="text-center">
<h1 className="text-6xl font-bold text-grey-300">404</h1>
<p className="text-xl mt-4">Page not found</p>
<p className="text-grey-500 mt-2">
The page you're looking for doesn't exist or has been moved.
</p>
<a
href="/"
className="inline-block mt-6 px-4 py-2 bg-blue-500 text-white rounded"
>
Go Home
</a>
</div>
</div>
)
}
// Pages/Errors/403.tsx
export default function Forbidden() {
return (
<div className="min-h-screen flex items-center justify-center">
<div className="text-center">
<h1 className="text-6xl font-bold text-grey-300">403</h1>
<p className="text-xl mt-4">Access Denied</p>
<p className="text-grey-500 mt-2">
You don't have permission to view this page.
</p>
<a
href="/"
className="inline-block mt-6 px-4 py-2 bg-blue-500 text-white rounded"
>
Go Home
</a>
</div>
</div>
)
}
// Pages/Errors/500.tsx
export default function ServerError() {
return (
<div className="min-h-screen flex items-center justify-center">
<div className="text-center">
<h1 className="text-6xl font-bold text-grey-300">500</h1>
<p className="text-xl mt-4">Something went wrong</p>
<p className="text-grey-500 mt-2">
We're working on fixing this. Please try again later.
</p>
<button
onClick={() => window.location.reload()}
className="mt-6 px-4 py-2 bg-blue-500 text-white rounded"
>
Refresh Page
</button>
</div>
</div>
)
}
Handling Errors in Laravel
Create an exception handler that renders Inertia error pages:
// app/Exceptions/Handler.php (Laravel 10)
// or bootstrap/app.php (Laravel 11)
use Inertia\Inertia;
use Symfony\Component\HttpFoundation\Response;
// Laravel 11
->withExceptions(function (Exceptions $exceptions) {
$exceptions->respond(function (Response $response) {
$status = $response->getStatusCode();
if (in_array($status, [404, 403, 500, 503])) {
return Inertia::render("Errors/{$status}")
->toResponse(request())
->setStatusCode($status);
}
return $response;
});
})
Now when Laravel throws a 404, users see your nice error page instead of a blank screen.
Flash Messages and Toasts
For non-blocking errors and success messages, use flash notifications.
Backend Setup
// Controller
public function store(Request $request)
{
try {
Lead::create($request->validated());
return redirect()
->route('leads.index')
->with('success', 'Lead created successfully!');
} catch (\Exception $e) {
return redirect()
->back()
->with('error', 'Failed to create lead. Please try again.');
}
}
Share flash data in your middleware:
// app/Http/Middleware/HandleInertiaRequests.php
public function share(Request $request): array
{
return [
...parent::share($request),
'flash' => [
'success' => fn () => $request->session()->get('success'),
'error' => fn () => $request->session()->get('error'),
],
];
}
Frontend Toast Component
I recommend using sonner for toasts it's simple and looks great:
npm install sonner
Set up in your layout:
// Layouts/AppLayout.tsx
import { Toaster, toast } from 'sonner'
import { usePage } from '@inertiajs/react'
import { useEffect } from 'react'
interface FlashProps {
flash: {
success?: string
error?: string
}
}
export default function AppLayout({ children }: { children: React.ReactNode }) {
const { flash } = usePage<FlashProps>().props
useEffect(() => {
if (flash.success) {
toast.success(flash.success)
}
if (flash.error) {
toast.error(flash.error)
}
}, [flash])
return (
<div>
<Toaster position="top-right" richColors />
{children}
</div>
)
}
Now any flash message from Laravel automatically appears as a toast.
Toast from Form Submissions
For form-specific feedback:
const form = useForm({ name: '', email: '' })
function submit(e: React.FormEvent) {
e.preventDefault()
form.post('/leads', {
onSuccess: () => {
toast.success('Lead created!')
},
onError: () => {
toast.error('Please fix the errors below')
},
})
}
Global Error Handling
Catch errors that happen during any Inertia request:
// app.tsx
import { router } from '@inertiajs/react'
import { toast } from 'sonner'
// Handle all Inertia errors globally
router.on('invalid', (event) => {
// Happens when response is not valid Inertia response
toast.error('Something went wrong. Please refresh the page.')
})
router.on('exception', (event) => {
// JavaScript exception during rendering
console.error('Inertia exception:', event.detail.exception)
toast.error('An unexpected error occurred.')
})
router.on('error', (event) => {
// General errors
toast.error('Request failed. Please try again.')
})
Session Expired (419)
Handle CSRF token expiration:
router.on('invalid', (event) => {
if (event.detail.response.status === 419) {
toast.error('Your session has expired. Please refresh the page.')
// Optionally reload after a delay
setTimeout(() => {
window.location.reload()
}, 2000)
event.preventDefault()
}
})
React Error Boundaries
For JavaScript errors that crash your components:
// components/ErrorBoundary.tsx
import { Component, ReactNode } from 'react'
interface Props {
children: ReactNode
fallback?: ReactNode
}
interface State {
hasError: boolean
error?: Error
}
export class ErrorBoundary extends Component<Props, State> {
constructor(props: Props) {
super(props)
this.state = { hasError: false }
}
static getDerivedStateFromError(error: Error): State {
return { hasError: true, error }
}
componentDidCatch(error: Error, errorInfo: React.ErrorInfo) {
// Log to error tracking service
console.error('React Error:', error, errorInfo)
// Send to Sentry, etc.
// Sentry.captureException(error)
}
render() {
if (this.state.hasError) {
return this.props.fallback || (
<div className="p-8 text-center">
<h2 className="text-xl font-bold text-red-500">
Something went wrong
</h2>
<p className="mt-2 text-grey-600">
This component failed to load.
</p>
<button
onClick={() => this.setState({ hasError: false })}
className="mt-4 px-4 py-2 bg-blue-500 text-white rounded"
>
Try Again
</button>
</div>
)
}
return this.props.children
}
}
Wrap your app or specific components:
// Wrap entire app
<ErrorBoundary>
<App />
</ErrorBoundary>
// Or wrap specific risky components
<ErrorBoundary fallback={<div>Chart failed to load</div>}>
<ComplexChart data={data} />
</ErrorBoundary>
Network Error Handling
Handle offline states and network failures:
// hooks/useOnlineStatus.ts
import { useState, useEffect } from 'react'
export function useOnlineStatus() {
const [isOnline, setIsOnline] = useState(navigator.onLine)
useEffect(() => {
function handleOnline() {
setIsOnline(true)
}
function handleOffline() {
setIsOnline(false)
}
window.addEventListener('online', handleOnline)
window.addEventListener('offline', handleOffline)
return () => {
window.removeEventListener('online', handleOnline)
window.removeEventListener('offline', handleOffline)
}
}, [])
return isOnline
}
// Show offline banner
import { useOnlineStatus } from '@/hooks/useOnlineStatus'
export default function AppLayout({ children }) {
const isOnline = useOnlineStatus()
return (
<div>
{!isOnline && (
<div className="bg-yellow-500 text-white text-center py-2">
You're offline. Some features may not work.
</div>
)}
{children}
</div>
)
}
Logging Errors
For production, send errors to a service like Sentry:
npm install @sentry/react
// app.tsx
import * as Sentry from '@sentry/react'
Sentry.init({
dsn: import.meta.env.VITE_SENTRY_DSN,
environment: import.meta.env.VITE_APP_ENV,
})
// Wrap your app
const App = Sentry.withProfiler(YourApp)
Log Inertia errors to Sentry:
router.on('exception', (event) => {
Sentry.captureException(event.detail.exception)
})
Putting It All Together
Here's a complete error handling setup:
// app.tsx
import { createInertiaApp, router } from '@inertiajs/react'
import { toast, Toaster } from 'sonner'
import * as Sentry from '@sentry/react'
import { ErrorBoundary } from './components/ErrorBoundary'
// Initialize Sentry
if (import.meta.env.PROD) {
Sentry.init({
dsn: import.meta.env.VITE_SENTRY_DSN,
})
}
// Global error handlers
router.on('invalid', (event) => {
const status = event.detail.response.status
if (status === 419) {
toast.error('Session expired. Refreshing...')
setTimeout(() => window.location.reload(), 1500)
event.preventDefault()
} else {
toast.error('Something went wrong. Please try again.')
}
})
router.on('exception', (event) => {
Sentry.captureException(event.detail.exception)
toast.error('An unexpected error occurred.')
})
createInertiaApp({
resolve: (name) => {
const pages = import.meta.glob('./Pages/**/*.tsx', { eager: true })
const page = pages[`./Pages/${name}.tsx`]
// Apply default layout with error boundary
page.default.layout = page.default.layout || ((page) => (
<ErrorBoundary>
<AppLayout>{page}</AppLayout>
</ErrorBoundary>
))
return page
},
setup({ el, App, props }) {
createRoot(el).render(
<ErrorBoundary>
<App {...props} />
<Toaster position="top-right" richColors />
</ErrorBoundary>
)
},
})
The Bottom Line
Error handling isn't glamorous, but it's what separates professional apps from hobby projects.
Here's your checklist:
- ✅ Validation errors show inline with form fields
- ✅ Error pages for 404, 403, 500
- ✅ Toast notifications for flash messages
- ✅ Global handlers for session expiry
- ✅ Error boundaries for React crashes
- ✅ Offline detection
- ✅ Error logging in production
Don't wait until users complain. Set this up on day one. Future you will be grateful.
Every error in Queuewatch.io gets caught, logged, and shown to users nicely. We know about bugs before users report them because Sentry tells us. Error handling is boring until something breaks then it's the most important thing you built.