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.
Before diving into solutions, let's understand what we're dealing with:
Each needs different handling.
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 errors10 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 <input14 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 <input26 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 User38 </button>39 </form>40 )41}
The errors object is populated automatically. No extra work needed.
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 bag8const { errors } = usePage().props9const createUserErrors = errors.createUser
For full-page errors, create dedicated error components.
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 <a12 href="/"13 className="inline-block mt-6 px-4 py-2 bg-blue-500 text-white rounded"14 >15 Go Home16 </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 <a12 href="/"13 className="inline-block mt-6 px-4 py-2 bg-blue-500 text-white rounded"14 >15 Go Home16 </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 <button12 onClick={() => window.location.reload()}13 className="mt-6 px-4 py-2 bg-blue-500 text-white rounded"14 >15 Refresh Page16 </button>17 </div>18 </div>19 )20}
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.
For non-blocking errors and success messages, use flash notifications.
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}
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?: string10 }11}12 13export default function AppLayout({ children }: { children: React.ReactNode }) {14 const { flash } = usePage<FlashProps>().props15 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.
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}
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 rendering13 console.error('Inertia exception:', event.detail.exception)14 toast.error('An unexpected error occurred.')15})16 17router.on('error', (event) => {18 // General errors19 toast.error('Request failed. Please try again.')20})
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})
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: boolean11 error?: Error12}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 service26 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 wrong38 </h2>39 <p className="mt-2 text-grey-600">40 This component failed to load.41 </p>42 <button43 onClick={() => this.setState({ hasError: false })}44 className="mt-4 px-4 py-2 bg-blue-500 text-white rounded"45 >46 Try Again47 </button>48 </div>49 )50 }51 52 return this.props.children53 }54}
Wrap your app or specific components:
1// Wrap entire app2<ErrorBoundary>3 <App />4</ErrorBoundary>5 6// Or wrap specific risky components7<ErrorBoundary fallback={<div>Chart failed to load</div>}>8 <ComplexChart data={data} />9</ErrorBoundary>
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 isOnline26}
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}
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 app10const App = Sentry.withProfiler(YourApp)
Log Inertia errors to Sentry:
1router.on('exception', (event) => {2 Sentry.captureException(event.detail.exception)3})
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 handlers15router.on('invalid', (event) => {16 const status = event.detail.response.status17 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 boundary38 page.default.layout = page.default.layout || ((page) => (39 <ErrorBoundary>40 <AppLayout>{page}</AppLayout>41 </ErrorBoundary>42 ))43 44 return page45 },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})
Error handling isn't glamorous, but it's what separates professional apps from hobby projects.
Here's your checklist:
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.
Written by
Writing and maintaining @LaravelMagazine. Host of "The Laravel Magazine Podcast". Pronouns: vi/vim.
Get latest news, tutorials, community articles and podcast episodes delivered to your inbox.