Laravel Magazine

Inertia 2.0 Error Handling: Production-ready patterns

Eric Van Johnson · Laravel Inertia React
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:

  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.

// 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.

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.