December 29th, 2025

Inertia 2.0 Error Handling: Production-ready patterns

Inertia 2.0 Error Handling: Production-ready patterns
Sponsored by
Table of Contents

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:

  1. Validation errors (422) - User submitted bad data
  2. Not found (404) - Page or resource doesn't exist
  3. Forbidden (403) - User doesn't have permission
  4. Server errors (500) - Something broke on the backend
  5. Session expired (419) - CSRF token is stale
  6. 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.

1// Laravel Controller
2public function store(Request $request)
3{
4 $validated = $request->validate([
5 'email' => ['required', 'email'],
6 'name' => ['required', 'min:2'],
7 ]);
8 
9 // If validation fails, Inertia redirects back with errors
10 
11 User::create($validated);
12 
13 return redirect()->route('users.index');
14}
1// React Component
2import { useForm } from '@inertiajs/react'
3 
4export default function CreateUser() {
5 const { data, setData, post, errors, processing } = useForm({
6 email: '',
7 name: '',
8 })
9 
10 return (
11 <form onSubmit={e => { e.preventDefault(); post('/users') }}>
12 <div>
13 <input
14 type="email"
15 value={data.email}
16 onChange={e => setData('email', e.target.value)}
17 className={errors.email ? 'border-red-500' : ''}
18 />
19 {errors.email && (
20 <p className="text-red-500 text-sm mt-1">{errors.email}</p>
21 )}
22 </div>
23 
24 <div>
25 <input
26 type="text"
27 value={data.name}
28 onChange={e => setData('name', e.target.value)}
29 className={errors.name ? 'border-red-500' : ''}
30 />
31 {errors.name && (
32 <p className="text-red-500 text-sm mt-1">{errors.name}</p>
33 )}
34 </div>
35 
36 <button type="submit" disabled={processing}>
37 Create User
38 </button>
39 </form>
40 )
41}

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:

1$request->validateWithBag('createUser', [
2 'email' => ['required', 'email'],
3]);
1const form = useForm({ email: '' })
2 
3form.post('/users', {
4 errorBag: 'createUser'
5})
6 
7// Access errors from the specific bag
8const { errors } = usePage().props
9const createUserErrors = errors.createUser

Error Pages (404, 403, 500)

For full-page errors, create dedicated error components.

Setting Up Error Handling

In your app.tsx:

1import { createInertiaApp } from '@inertiajs/react'
2 
3createInertiaApp({
4 resolve: (name) => {
5 const pages = import.meta.glob('./Pages/**/*.tsx', { eager: true })
6 return pages[`./Pages/${name}.tsx`]
7 },
8 // ...
9})

Create error pages in resources/js/Pages/:

1// Pages/Errors/404.tsx
2export default function NotFound() {
3 return (
4 <div className="min-h-screen flex items-center justify-center">
5 <div className="text-center">
6 <h1 className="text-6xl font-bold text-grey-300">404</h1>
7 <p className="text-xl mt-4">Page not found</p>
8 <p className="text-grey-500 mt-2">
9 The page you're looking for doesn't exist or has been moved.
10 </p>
11 <a
12 href="/"
13 className="inline-block mt-6 px-4 py-2 bg-blue-500 text-white rounded"
14 >
15 Go Home
16 </a>
17 </div>
18 </div>
19 )
20}
1// Pages/Errors/403.tsx
2export default function Forbidden() {
3 return (
4 <div className="min-h-screen flex items-center justify-center">
5 <div className="text-center">
6 <h1 className="text-6xl font-bold text-grey-300">403</h1>
7 <p className="text-xl mt-4">Access Denied</p>
8 <p className="text-grey-500 mt-2">
9 You don't have permission to view this page.
10 </p>
11 <a
12 href="/"
13 className="inline-block mt-6 px-4 py-2 bg-blue-500 text-white rounded"
14 >
15 Go Home
16 </a>
17 </div>
18 </div>
19 )
20}
1// Pages/Errors/500.tsx
2export default function ServerError() {
3 return (
4 <div className="min-h-screen flex items-center justify-center">
5 <div className="text-center">
6 <h1 className="text-6xl font-bold text-grey-300">500</h1>
7 <p className="text-xl mt-4">Something went wrong</p>
8 <p className="text-grey-500 mt-2">
9 We're working on fixing this. Please try again later.
10 </p>
11 <button
12 onClick={() => window.location.reload()}
13 className="mt-6 px-4 py-2 bg-blue-500 text-white rounded"
14 >
15 Refresh Page
16 </button>
17 </div>
18 </div>
19 )
20}

Handling Errors in Laravel

Create an exception handler that renders Inertia error pages:

1// app/Exceptions/Handler.php (Laravel 10)
2// or bootstrap/app.php (Laravel 11)
3 
4use Inertia\Inertia;
5use Symfony\Component\HttpFoundation\Response;
6 
7// Laravel 11
8->withExceptions(function (Exceptions $exceptions) {
9 $exceptions->respond(function (Response $response) {
10 $status = $response->getStatusCode();
11 
12 if (in_array($status, [404, 403, 500, 503])) {
13 return Inertia::render("Errors/{$status}")
14 ->toResponse(request())
15 ->setStatusCode($status);
16 }
17 
18 return $response;
19 });
20})

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

1// Controller
2public function store(Request $request)
3{
4 try {
5 Lead::create($request->validated());
6 
7 return redirect()
8 ->route('leads.index')
9 ->with('success', 'Lead created successfully!');
10 
11 } catch (\Exception $e) {
12 return redirect()
13 ->back()
14 ->with('error', 'Failed to create lead. Please try again.');
15 }
16}

Share flash data in your middleware:

1// app/Http/Middleware/HandleInertiaRequests.php
2public function share(Request $request): array
3{
4 return [
5 ...parent::share($request),
6 'flash' => [
7 'success' => fn () => $request->session()->get('success'),
8 'error' => fn () => $request->session()->get('error'),
9 ],
10 ];
11}

Frontend Toast Component

I recommend using sonner for toasts it's simple and looks great:

1npm install sonner

Set up in your layout:

1// Layouts/AppLayout.tsx
2import { Toaster, toast } from 'sonner'
3import { usePage } from '@inertiajs/react'
4import { useEffect } from 'react'
5 
6interface FlashProps {
7 flash: {
8 success?: string
9 error?: string
10 }
11}
12 
13export default function AppLayout({ children }: { children: React.ReactNode }) {
14 const { flash } = usePage<FlashProps>().props
15 
16 useEffect(() => {
17 if (flash.success) {
18 toast.success(flash.success)
19 }
20 if (flash.error) {
21 toast.error(flash.error)
22 }
23 }, [flash])
24 
25 return (
26 <div>
27 <Toaster position="top-right" richColors />
28 {children}
29 </div>
30 )
31}

Now any flash message from Laravel automatically appears as a toast.

Toast from Form Submissions

For form-specific feedback:

1const form = useForm({ name: '', email: '' })
2 
3function submit(e: React.FormEvent) {
4 e.preventDefault()
5 
6 form.post('/leads', {
7 onSuccess: () => {
8 toast.success('Lead created!')
9 },
10 onError: () => {
11 toast.error('Please fix the errors below')
12 },
13 })
14}

Global Error Handling

Catch errors that happen during any Inertia request:

1// app.tsx
2import { router } from '@inertiajs/react'
3import { toast } from 'sonner'
4 
5// Handle all Inertia errors globally
6router.on('invalid', (event) => {
7 // Happens when response is not valid Inertia response
8 toast.error('Something went wrong. Please refresh the page.')
9})
10 
11router.on('exception', (event) => {
12 // JavaScript exception during rendering
13 console.error('Inertia exception:', event.detail.exception)
14 toast.error('An unexpected error occurred.')
15})
16 
17router.on('error', (event) => {
18 // General errors
19 toast.error('Request failed. Please try again.')
20})

Session Expired (419)

Handle CSRF token expiration:

1router.on('invalid', (event) => {
2 if (event.detail.response.status === 419) {
3 toast.error('Your session has expired. Please refresh the page.')
4 
5 // Optionally reload after a delay
6 setTimeout(() => {
7 window.location.reload()
8 }, 2000)
9 
10 event.preventDefault()
11 }
12})

React Error Boundaries

For JavaScript errors that crash your components:

1// components/ErrorBoundary.tsx
2import { Component, ReactNode } from 'react'
3 
4interface Props {
5 children: ReactNode
6 fallback?: ReactNode
7}
8 
9interface State {
10 hasError: boolean
11 error?: Error
12}
13 
14export class ErrorBoundary extends Component<Props, State> {
15 constructor(props: Props) {
16 super(props)
17 this.state = { hasError: false }
18 }
19 
20 static getDerivedStateFromError(error: Error): State {
21 return { hasError: true, error }
22 }
23 
24 componentDidCatch(error: Error, errorInfo: React.ErrorInfo) {
25 // Log to error tracking service
26 console.error('React Error:', error, errorInfo)
27 
28 // Send to Sentry, etc.
29 // Sentry.captureException(error)
30 }
31 
32 render() {
33 if (this.state.hasError) {
34 return this.props.fallback || (
35 <div className="p-8 text-center">
36 <h2 className="text-xl font-bold text-red-500">
37 Something went wrong
38 </h2>
39 <p className="mt-2 text-grey-600">
40 This component failed to load.
41 </p>
42 <button
43 onClick={() => this.setState({ hasError: false })}
44 className="mt-4 px-4 py-2 bg-blue-500 text-white rounded"
45 >
46 Try Again
47 </button>
48 </div>
49 )
50 }
51 
52 return this.props.children
53 }
54}

Wrap your app or specific components:

1// Wrap entire app
2<ErrorBoundary>
3 <App />
4</ErrorBoundary>
5 
6// Or wrap specific risky components
7<ErrorBoundary fallback={<div>Chart failed to load</div>}>
8 <ComplexChart data={data} />
9</ErrorBoundary>

Network Error Handling

Handle offline states and network failures:

1// hooks/useOnlineStatus.ts
2import { useState, useEffect } from 'react'
3 
4export function useOnlineStatus() {
5 const [isOnline, setIsOnline] = useState(navigator.onLine)
6 
7 useEffect(() => {
8 function handleOnline() {
9 setIsOnline(true)
10 }
11 
12 function handleOffline() {
13 setIsOnline(false)
14 }
15 
16 window.addEventListener('online', handleOnline)
17 window.addEventListener('offline', handleOffline)
18 
19 return () => {
20 window.removeEventListener('online', handleOnline)
21 window.removeEventListener('offline', handleOffline)
22 }
23 }, [])
24 
25 return isOnline
26}
1// Show offline banner
2import { useOnlineStatus } from '@/hooks/useOnlineStatus'
3 
4export default function AppLayout({ children }) {
5 const isOnline = useOnlineStatus()
6 
7 return (
8 <div>
9 {!isOnline && (
10 <div className="bg-yellow-500 text-white text-center py-2">
11 You're offline. Some features may not work.
12 </div>
13 )}
14 {children}
15 </div>
16 )
17}

Logging Errors

For production, send errors to a service like Sentry:

1npm install @sentry/react
1// app.tsx
2import * as Sentry from '@sentry/react'
3 
4Sentry.init({
5 dsn: import.meta.env.VITE_SENTRY_DSN,
6 environment: import.meta.env.VITE_APP_ENV,
7})
8 
9// Wrap your app
10const App = Sentry.withProfiler(YourApp)

Log Inertia errors to Sentry:

1router.on('exception', (event) => {
2 Sentry.captureException(event.detail.exception)
3})

Putting It All Together

Here's a complete error handling setup:

1// app.tsx
2import { createInertiaApp, router } from '@inertiajs/react'
3import { toast, Toaster } from 'sonner'
4import * as Sentry from '@sentry/react'
5import { ErrorBoundary } from './components/ErrorBoundary'
6 
7// Initialize Sentry
8if (import.meta.env.PROD) {
9 Sentry.init({
10 dsn: import.meta.env.VITE_SENTRY_DSN,
11 })
12}
13 
14// Global error handlers
15router.on('invalid', (event) => {
16 const status = event.detail.response.status
17 
18 if (status === 419) {
19 toast.error('Session expired. Refreshing...')
20 setTimeout(() => window.location.reload(), 1500)
21 event.preventDefault()
22 } else {
23 toast.error('Something went wrong. Please try again.')
24 }
25})
26 
27router.on('exception', (event) => {
28 Sentry.captureException(event.detail.exception)
29 toast.error('An unexpected error occurred.')
30})
31 
32createInertiaApp({
33 resolve: (name) => {
34 const pages = import.meta.glob('./Pages/**/*.tsx', { eager: true })
35 const page = pages[`./Pages/${name}.tsx`]
36 
37 // Apply default layout with error boundary
38 page.default.layout = page.default.layout || ((page) => (
39 <ErrorBoundary>
40 <AppLayout>{page}</AppLayout>
41 </ErrorBoundary>
42 ))
43 
44 return page
45 },
46 setup({ el, App, props }) {
47 createRoot(el).render(
48 <ErrorBoundary>
49 <App {...props} />
50 <Toaster position="top-right" richColors />
51 </ErrorBoundary>
52 )
53 },
54})

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.

Marian Pop

Written by

Marian Pop

Writing and maintaining @LaravelMagazine. Host of "The Laravel Magazine Podcast". Pronouns: vi/vim.

Comments

Stay Updated

Subscribe to our newsletter

Get latest news, tutorials, community articles and podcast episodes delivered to your inbox.

Weekly articles
We send a new issue of the newsletter every week on Friday.
No spam
We'll never share your email address and you can opt out at any time.