Laravel Daily's

Custom Error Pages in Inertia 3.x: Beautiful 404s and 500s in Your Laravel + Vue App

Bright Laravel, Vue, and Inertia illustration showing friendly 404 and 500 error screens

Error pages are part of your application’s interface.

A missing page, expired form, or temporary outage can decide whether someone keeps going or leaves. A polished error state gives users context, preserves your visual language, and provides a useful next step.

Inertia 3.x makes custom error pages straightforward. You can render a Vue component for common HTTP errors while keeping the original status code intact.

This approach works well for Laravel, Vue, and Inertia applications built with a modern PHP web framework. It also fits teams that use Laravel as the backend for a Vue frontend, or as the foundation to build a REST API with PHP.

How Inertia 3.x handles exceptions

During local development, Inertia shows non-Inertia responses in an error modal. This keeps Laravel’s detailed exception output available while you work.

Production applications usually need a proper page instead.

Inertia 3.x provides Inertia::handleExceptionsUsing() for this purpose. The callback receives an ExceptionResponse object. You can inspect the status code, render an Inertia page, and preserve the application’s shared data.

The relevant methods are:

  • statusCode() returns the original HTTP status.
  • render() selects the Inertia component.
  • withSharedData() adds shared props and resolves the root view.
  • usingMiddleware() selects a specific Inertia middleware.
  • rootView() selects a custom root view.

The key detail is withSharedData().

Illustration of Laravel exception handling flowing through Inertia into a Vue error page

Why exceptions bypass the normal route middleware stack

A normal Inertia request reaches a route. The route runs through its middleware, including your Inertia middleware. That middleware shares props such as:

  • The authenticated user
  • The application name
  • Flash messages
  • Navigation data
  • Feature flags
  • Tenant information

A 404 often happens before Laravel finds a route. A route may not match at all. Other exceptions can happen before the usual controller response is created.

That means the error response does not automatically receive the data your normal pages expect.

Without shared data, a layout that reads auth.user can fail. A navigation component can render with missing values. A theme preference can disappear on the exact page where consistency matters most.

Calling withSharedData() explicitly resolves the Inertia middleware and includes those shared props in the error response. Read the full behavior in the Inertia 3.x error handling documentation.

Register the handler in AppServiceProvider

For Inertia 3.x, the recommended setup lives in your application service provider.

<?php

namespace App\Providers;

use Inertia\ExceptionResponse;
use Inertia\Inertia;
use Illuminate\Support\ServiceProvider;

class AppServiceProvider extends ServiceProvider
{
    /**
     * Bootstrap application services.
     */
    public function boot(): void
    {
        Inertia::handleExceptionsUsing(function (ExceptionResponse $response) {
            if (app()->environment(['local', 'testing'])) {
                return null;
            }

            $status = $response->statusCode();

            if (in_array($status, [403, 404, 419, 500, 503], true)) {
                return $response
                    ->render('ErrorPage', [
                        'status' => $status,
                    ])
                    ->withSharedData();
            }

            return null;
        });
    }
}

Returning null allows Laravel to use its default exception rendering. Keeping the handler limited to production prevents it from replacing Laravel’s useful local debugging screen.

The example covers five common statuses:

  • 403 , the user is authenticated but not allowed to access a resource.
  • 404 , the requested page or model was not found.
  • 419 , the session or CSRF token expired.
  • 500 , an unexpected server error occurred.
  • 503 , the application is unavailable, often during maintenance.

You can add 429 for rate limiting or other application-specific statuses.

The bootstrap/app.php alternative

Laravel also supports exception response customization in bootstrap/app.php. This can be useful when your application already keeps its exception configuration in one place.

Use one approach as the source of truth. Do not register duplicate handlers that compete for the same response.

<?php

use Illuminate\Foundation\Application;
use Illuminate\Foundation\Configuration\Exceptions;
use Illuminate\Http\Request;
use Inertia\Inertia;
use Symfony\Component\HttpFoundation\Response;
use Throwable;

return Application::configure(basePath: dirname(__DIR__))
    ->withRouting(
        web: __DIR__.'/../routes/web.php',
        api: __DIR__.'/../routes/api.php',
        commands: __DIR__.'/../routes/console.php',
        health: '/up',
    )
    ->withExceptions(function (Exceptions $exceptions): void {
        $exceptions->respond(
            function (
                Response $response,
                Throwable $exception,
                Request $request
            ) {
                $status = $response->getStatusCode();

                if (
                    app()->environment(['local', 'testing']) ||
                    ! in_array($status, [403, 404, 419, 500, 503], true)
                ) {
                    return $response;
                }

                return Inertia::render('ErrorPage', [
                    'status' => $status,
                ])
                    ->toResponse($request)
                    ->setStatusCode($status);
            }
        );
    })
    ->create();

This is the manual equivalent of the Inertia integration. The handleExceptionsUsing() approach is usually cleaner because ExceptionResponse handles shared data and root view resolution for you.

The Laravel error handling documentation also covers reporting, rendering, logging, and default Blade error pages.

Build one reusable ErrorPage.vue

Create a normal Inertia page component at resources/js/Pages/ErrorPage.vue.

Pass the HTTP status as a prop. Then map each supported status to its own title, message, and action.

<script setup>
import { computed } from 'vue'
import { Head, Link, usePage } from '@inertiajs/vue3'
import AppLayout from '@/Layouts/AppLayout.vue'

const props = defineProps({
    status: {
        type: Number,
        required: true,
    },
})

const page = usePage()

const messages = {
    403: {
        title: 'Access denied',
        description: 'You do not have permission to view this page.',
    },
    404: {
        title: 'Page not found',
        description: 'The page you requested does not exist or has moved.',
    },
    419: {
        title: 'Page expired',
        description: 'Your session expired. Refresh the page and try again.',
    },
    500: {
        title: 'Something went wrong',
        description: 'We could not complete your request. Please try again soon.',
    },
    503: {
        title: 'Temporarily unavailable',
        description: 'We are making a few updates. Please check back shortly.',
    },
}

const content = computed(() => {
    return messages[props.status] ?? {
        title: 'Unexpected error',
        description: 'Something unexpected happened. Please try again.',
    }
})

const user = computed(() => page.props.auth?.user ?? null)
</script>

<template>
    <Head :title="`${status} : ${content.title}`" />

    <AppLayout>
        <main class="flex min-h-[70vh] items-center justify-center px-6 py-16">
            <section class="w-full max-w-xl text-center">
                <p class="text-sm font-semibold uppercase tracking-[0.3em] text-red-500">
                    Error {{ status }}
                </p>

                <h1 class="mt-5 text-4xl font-bold tracking-tight text-gray-950">
                    {{ content.title }}
                </h1>

                <p class="mx-auto mt-4 max-w-lg text-lg text-gray-600">
                    {{ content.description }}
                </p>

                <p v-if="user" class="mt-3 text-sm text-gray-500">
                    Signed in as {{ user.name }}.
                </p>

                <div class="mt-8 flex justify-center gap-3">
                    <Link
                        href="/"
                        class="rounded-lg bg-red-600 px-5 py-3 font-semibold text-white transition hover:bg-red-700"
                    >
                        Return home
                    </Link>

                    <button
                        type="button"
                        class="rounded-lg border border-gray-300 px-5 py-3 font-semibold text-gray-700 transition hover:bg-gray-50"
                        @click="window.history.back()"
                    >
                        Go back
                    </button>
                </div>
            </section>
        </main>
    </AppLayout>
</template>

Because the server response uses withSharedData(), usePage().props can access shared values such as auth.user.

Keep those values optional in the component. Error handling should remain resilient even if authentication or another shared service is unavailable.

Colorful Laravel, Vue, PHP, and Inertia icons connected to modular error page UI cards

Keep error pages on-brand

An error page does not need to look like a diagnostic console.

Use the same:

  • Typography
  • Color tokens
  • Navigation patterns
  • Spacing scale
  • Logo treatment
  • Button styles
  • Layout structure

The status code should be visible, but it does not need to dominate the screen. A short explanation and one useful action are often enough.

Your 404 page might suggest returning to the dashboard. A 403 page can link to the user’s account or contact support. A 419 page should explain that the form expired. A 503 page can point to your status page.

Avoid showing exception messages, stack traces, SQL fragments, or internal identifiers. Those details belong in your logs, not in a public response.

Logging still matters

A friendly 500 page does not mean the exception should disappear.

Laravel reports exceptions through its configured logging channels. You can send them to tools such as Laravel Nightwatch or another monitoring service.

Keep the user-facing response simple, then use logs to investigate:

  • The exception class
  • The request URL
  • The authenticated user ID
  • The deployment version
  • The request ID
  • Relevant tenant or account context

Laravel can also attach contextual information to exception logs. Review the exception reporting guidance before adding custom reporting logic.

Avoid N+1 queries on error pages

Shared data is powerful, but it runs during error handling too.

A global shared prop that loads ten navigation relationships can turn one 404 into a slow database request. A failed database connection can also cause the error page to fail while trying to prepare its props.

Keep shared error-page data small:

Inertia::share([
    'auth' => fn () => [
        'user' => request()->user(),
    ],
]);

Avoid loading large menus, dashboards, notifications, or relationship-heavy models unless the layout truly needs them.

You can also give your error layout fewer dependencies than your main application layout. A lightweight layout is easier to render when the original request has already failed.

Use eager loading where data is required. Use cached configuration for static values. Defer optional data when your Inertia setup supports it.

Progressive enhancement for visitors without JavaScript

Vue error pages require JavaScript to hydrate.

For a robust fallback, keep Laravel’s Blade error views available:

resources/views/errors/403.blade.php
resources/views/errors/404.blade.php
resources/views/errors/419.blade.php
resources/views/errors/500.blade.php
resources/views/errors/503.blade.php

Laravel uses these templates for its standard HTTP error rendering. You can publish the default templates with:

php artisan vendor:publish --tag=laravel-errors

Then provide a simple, branded HTML version for non-Inertia requests or environments where JavaScript cannot load.

You can also add a fallback to your root view:

<body>
    <div id="app">
        @inertia
    </div>

    <noscript>
        <main class="mx-auto max-w-xl px-6 py-16 text-center">
            <h1 class="text-3xl font-bold">This page needs JavaScript</h1>
            <p class="mt-3 text-gray-600">
                Enable JavaScript to use the full application.
            </p>
        </main>
    </noscript>
</body>

If your application uses SSR, the Vue error page can render on the server as well. Otherwise, Blade templates provide the most reliable status-specific fallback.

Test every status code

Do not test only the 404 page.

Trigger each response deliberately:

abort(403);
abort(404);
abort(419);
abort(500);
abort(503);

Also test real causes:

  • A missing route
  • A failed authorization policy
  • An expired session
  • A missing model
  • A thrown application exception
  • Maintenance mode

Confirm that:

  1. The Vue component receives the correct status.
  2. The HTTP response keeps the correct status code.
  3. Shared props do not cause additional failures.
  4. The page works after a client-side visit.
  5. Direct requests remain usable.
  6. API responses still return JSON where expected.

For API routes, do not return an Inertia page. Laravel determines whether to render HTML or JSON from the request. Keep your API exception behavior separate from your web application’s error experience.

Bright light-mode illustration of resilient error states with home button, status badges, monitoring, Laravel, Vue, and PHP icons

A small page with a large effect

Users do not expect every request to succeed. They do expect the application to respond clearly when something fails.

A branded 404 helps users recover. A useful 419 page explains what happened. A calm 500 page preserves confidence while your team investigates the logs.

With Inertia::handleExceptionsUsing(), withSharedData(), and a reusable ErrorPage.vue, Laravel and Vue can treat error states as part of the product rather than an afterthought.

That is a practical strength of the Laravel ecosystem: the framework, frontend adapter, deployment tools, and monitoring stack work together so your PHP developer tools support the whole application( not just the successful path.)

Previous
Code Splitting in Inertia 3.x: Lazy-Load Your Laravel + Vue SPA for Faster Boot Times
Next
Cut Your AI Bill in Half: Prompt Caching and Smarter Model Selection with the Laravel AI SDK