Skip to content

Custom Error Pages (Optional) ​

Use one standalone Svelte page for 403, 404, 500, and 503 responses. Keep resources/views/app.blade.php; no Blade error templates to publish. This uses the Inertia 3 exception API.

Svelte Error Page ​

Use the existing AppHead, Button, and home route. Keep messages fixed; never pass exception details to the page.

File: resources/js/pages/ErrorPage.svelte

svelte
<script lang="ts">
    import { Link } from '@inertiajs/svelte';
    import AppHead from '@/components/AppHead.svelte';
    import { Button } from '@/components/ui/button';
    import { home } from '@/routes';

    let { status }: { status: 403 | 404 | 500 | 503 } = $props();

    const messages = {
        403: {
            title: 'Forbidden',
            description: 'Sorry, you do not have permission to access this page.',
        },
        404: {
            title: 'Page not found',
            description: 'Sorry, the page you are looking for could not be found.',
        },
        500: {
            title: 'Server error',
            description: 'Something went wrong on our servers. Please try again later.',
        },
        503: {
            title: 'Service unavailable',
            description: 'We are temporarily unavailable. Please check back soon.',
        },
    };

    const message = $derived(messages[status]);
</script>

<AppHead title={`${status}: ${message.title}`} />

<main class="dark flex min-h-svh items-center justify-center bg-background px-4 py-16 text-foreground">
    <div class="flex max-w-md flex-col items-center gap-6 text-center">
        <p class="bg-linear-to-r from-sky-500 to-indigo-500 bg-clip-text text-6xl font-semibold tracking-tight text-transparent">
            {status}
        </p>
        <h1 class="text-3xl font-bold tracking-tight">{message.title}</h1>
        <p class="text-muted-foreground">{message.description}</p>
        <Button asChild>
            {#snippet children(props)}
                <Link href={home()} {...props}>Go home</Link>
            {/snippet}
        </Button>
    </div>
</main>

Keep the page and its dependencies independent of auth, name, and other shared props: unmatched URLs can fail before Inertia middleware runs.

Skip the Application Layout ​

Add ErrorPage to the resolver's return null branch so it never mounts navigation that needs shared props. Keep the other layouts, theme initialization, and flash toast setup.

File: resources/js/app.ts

ts
import { createInertiaApp } from '@inertiajs/svelte';
import AppLayout from '@/layouts/AppLayout.svelte';
import AuthLayout from '@/layouts/AuthLayout.svelte';
import SettingsLayout from '@/layouts/settings/Layout.svelte';
import { initializeFlashToast } from '@/lib/flash-toast';
import { initializeTheme } from '@/lib/theme.svelte';

const appName = import.meta.env.VITE_APP_NAME || 'Laravel';

void createInertiaApp({
    title: (title) => (title ? `${title} - ${appName}` : appName),
    layout: (name) => {
        switch (true) {
            case name === 'Welcome':
            case name === 'ErrorPage':
                return null;
            case name.startsWith('auth/'):
                return AuthLayout;
            case name.startsWith('settings/'):
                return [AppLayout, SettingsLayout];
            default:
                return AppLayout;
        }
    },
    progress: {
        color: '#4B5563',
    },
});

// This will set light / dark mode on page load...
initializeTheme();

// This will listen for flash toast data from the server...
initializeFlashToast();

Handle Exception Responses ​

Add the imports and Inertia::handleExceptionsUsing() callback to boot(). Keep configureDefaults() and existing provider setup.

File: app/Providers/AppServiceProvider.php

php
<?php

namespace App\Providers;

use Carbon\CarbonImmutable;
use Illuminate\Support\Facades\Date;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\ServiceProvider;
use Illuminate\Validation\Rules\Password;
use Inertia\ExceptionResponse;
use Inertia\Inertia;

class AppServiceProvider extends ServiceProvider
{
    /**
     * Register any application services.
     */
    public function register(): void
    {
        //
    }

    /**
     * Bootstrap any application services.
     */
    public function boot(): void
    {
        $this->configureDefaults();

        Inertia::handleExceptionsUsing(function (ExceptionResponse $response): ?ExceptionResponse {
            if (app()->environment('testing')) {
                return null;
            }

            if (app()->environment('local') && $response->statusCode() === 500) {
                return null;
            }

            if ($response->request->is('api/*') || $response->request->expectsJson()) {
                return null;
            }

            if (! in_array($response->statusCode(), [403, 404, 500, 503], true)) {
                return null;
            }

            return $response->render('ErrorPage', [
                'status' => $response->statusCode(),
            ])->rootView('app');
        });
    }

    /**
     * Configure default behaviors for production-ready applications.
     */
    protected function configureDefaults(): void
    {
        Date::use(CarbonImmutable::class);

        DB::prohibitDestructiveCommands(
            app()->isProduction(),
        );

        Password::defaults(fn (): ?Password => app()->isProduction()
            ? Password::min(12)
                ->mixedCase()
                ->letters()
                ->numbers()
                ->symbols()
                ->uncompromised()
            : null,
        );
    }
}

Keep the original HTTP status. Handler behavior:

Request or environmentResponse
Browser or Inertia request, outside local/testingSvelte page for 403, 404, 500, and 503
Local developmentSvelte page for 403, 404, and 503; Laravel's normal response for 500
TestingLaravel's normal exception response
/api/* or a request expecting JSONSkip the Svelte page
Other statuses, including 419, 422, and 429Leave Laravel's existing handling unchanged

Keep rootView('app') for unmatched routes. Do not add withSharedData(); avoid resolving application props while handling an exception. Returning null keeps Laravel's response.

Keep APP_DEBUG=false in production. Local 500 debugging still depends on APP_DEBUG=true.

Error Response Tests ​

Cover direct/Inertia requests, unmatched URLs, environment fallbacks, JSON, status codes, and private-detail exclusion.

File: tests/Feature/ErrorPageTest.php

php
<?php

use App\Http\Middleware\HandleInertiaRequests;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Route;
use Inertia\Testing\AssertableInertia as Assert;

use function Pest\Laravel\get;

beforeEach(function () {
    app()->instance('env', 'production');
    config(['app.debug' => false, 'inertia.ssr.enabled' => false]);
});

test('production browser errors render the standalone error page', function (int $status) {
    Route::middleware('web')->get('/__test/error', fn () => abort($status, 'Private exception details.'));

    $response = get('/__test/error');

    $response->assertStatus($status)
        ->assertViewIs('app')
        ->assertInertia(fn (Assert $page) => $page
            ->component('ErrorPage')
            ->where('status', $status),
        )
        ->assertDontSee('Private exception details.');
})->with([403, 404, 500, 503]);

test('production Inertia errors return an error page instead of the development modal', function (int $status) {
    Route::middleware('web')->get('/__test/error', fn () => abort($status, 'Private exception details.'));

    $response = get('/__test/error', [
        'X-Inertia' => 'true',
        'X-Requested-With' => 'XMLHttpRequest',
        'X-Inertia-Version' => app(HandleInertiaRequests::class)->version(Request::create('/__test/error')) ?? '',
        'Accept' => 'text/html, application/xhtml+xml',
    ]);

    $response->assertStatus($status)
        ->assertHeader('X-Inertia', 'true')
        ->assertJsonPath('component', 'ErrorPage')
        ->assertJsonPath('props.status', $status)
        ->assertDontSee('Private exception details.');
})->with([403, 404, 500, 503]);

test('unmatched browser URLs render without shared application props', function (string $environment) {
    app()->instance('env', $environment);
    config(['app.debug' => $environment === 'local']);

    $response = get('/__test/missing');

    $response->assertNotFound()
        ->assertViewIs('app')
        ->assertInertia(fn (Assert $page) => $page
            ->component('ErrorPage')
            ->where('status', 404)
            ->missing('auth')
            ->missing('name'),
        );
})->with(['production', 'local']);

test('unmatched Inertia URLs return the error page', function (string $environment) {
    app()->instance('env', $environment);
    config(['app.debug' => $environment === 'local']);

    $response = get('/__test/missing', ['X-Inertia' => 'true']);

    $response->assertNotFound()
        ->assertHeader('X-Inertia', 'true')
        ->assertJsonPath('component', 'ErrorPage')
        ->assertJsonPath('props.status', 404);
})->with(['production', 'local']);

test('local forbidden and unavailable errors render the custom page', function (int $status) {
    app()->instance('env', 'local');
    config(['app.debug' => true]);
    Route::get('/__test/error', fn () => abort($status));

    $response = get('/__test/error');

    $response->assertStatus($status)
        ->assertInertia(fn (Assert $page) => $page
            ->component('ErrorPage')
            ->where('status', $status),
        );
})->with([403, 503]);

test('production API and JSON errors stay JSON', function (string $path, array $headers) {
    Route::get($path, fn () => abort(403, 'Access denied.'));

    $response = get($path, $headers);

    $response->assertForbidden()
        ->assertHeader('Content-Type', 'application/json')
        ->assertHeaderMissing('X-Inertia')
        ->assertJsonPath('message', 'Access denied.')
        ->assertJsonMissingPath('component');
})->with([
    'API path without a JSON accept header' => ['/api/__test/error', ['Accept' => 'text/html']],
    'web path requesting JSON' => ['/__test/error', ['Accept' => 'application/json']],
]);

test('local and testing environments retain Laravel debugging responses', function (string $environment) {
    app()->instance('env', $environment);
    config(['app.debug' => true]);
    Route::get('/__test/error', fn () => throw new RuntimeException('Debug exception details.'));

    $response = get('/__test/error', ['X-Inertia' => 'true']);

    $response->assertInternalServerError()
        ->assertHeaderMissing('X-Inertia')
        ->assertSee('Debug exception details.');
})->with(['local', 'testing']);

test('other production error statuses retain Laravel responses', function (int $status) {
    Route::get('/__test/error', fn () => abort($status));

    $response = get('/__test/error', ['X-Inertia' => 'true']);

    $response->assertStatus($status)
        ->assertHeaderMissing('X-Inertia')
        ->assertDontSee('data-page=');
})->with([419, 422, 429]);

The suite sets the environment to production with debug and SSR off, then overrides those settings per case. It does not validate SSR.

Run the focused tests and frontend checks:

bash
php artisan test --compact tests/Feature/ErrorPageTest.php
npm run types:check
npm run build

Browser Checks ​

  • Open a missing URL directly and through an Inertia link: standalone 404, no app navigation or error modal.
  • Check title, status, message, and Go home at mobile and desktop widths.
  • With APP_ENV=local and APP_DEBUG=true, confirm the 500 debug response still works.
  • To preview the custom 500 locally, set APP_ENV=production and APP_DEBUG=false, then clear config. Debug off alone is not enough. Restore local settings and clear config afterward.

Commit ​

bash
git add -A
git commit -m '08 - Custom Error Pages'

References ​