DEV Community

Cover image for Why Your Laravel + Inertia.js Fetch Requests Fail with 419 After Save
Vladimir Simić
Vladimir Simić

Posted on

Why Your Laravel + Inertia.js Fetch Requests Fail with 419 After Save

After a save or reset action, clicking "Preview" returns a 419 Page Expired — even though the page looks fine and you're clearly authenticated. Here's why, and the two-line fix.

The Setup
A settings page uses Inertia.js for save/reset (router.patch / router.delete) and a plain fetch() POST for a live email preview endpoint. The fetch manually reads the CSRF token from the meta tag:

headers: {
  'X-CSRF-TOKEN': (document.querySelector('meta[name="csrf-token"]') as HTMLMetaElement)?.content ?? '',
}
Enter fullscreen mode Exit fullscreen mode

This works on first load. After a save it breaks with 419.

Why It Breaks
Laravel's CSRF system works through a session-bound token. On every response, including Inertia partial responses, Laravel rotates the XSRF-TOKEN cookie to stay in sync with the session.

The meta[name="csrf-token"] tag is rendered once by Blade at initial page load:

<meta name="csrf-token" content="{{ csrf_token() }}">
Enter fullscreen mode Exit fullscreen mode

Inertia never touches that tag. It manages CSRF itself via the XSRF-TOKEN cookie. The same way Axios does. So after an Inertia response, the cookie holds the current token, but the meta tag is stale. Any subsequent fetch() reading the meta tag sends an outdated token, and Laravel rejects it.

The Second Bug
Even if CSRF were fine, the code calls res.text() unconditionally:

setPreviewHtml(await res.text());
Enter fullscreen mode Exit fullscreen mode

When Laravel returns 419, res.text() gets the "Page Expired" HTML error page, which gets rendered inside the iframe. The error looks like broken preview content rather than a clear failure, making it very confusing to diagnose.

The Fix
Two changes to fetchPreview:

const fetchPreview = async () => {
  setPreviewLoading(true);
  try {
    // Read the token Laravel actually keeps fresh — the XSRF-TOKEN cookie
    const xsrfToken = decodeURIComponent(
      document.cookie
        .split('; ')
        .find((c) => c.startsWith('XSRF-TOKEN='))
        ?.split('=')[1] ?? '',
    );

    const res = await fetch(preview(templateKey).url, {
      method: 'POST',
      credentials: 'same-origin',
      headers: {
        'Content-Type': 'application/json',
        'X-XSRF-TOKEN': xsrfToken,        // ← correct header name
      },
      body: JSON.stringify({ locale: activeLocale, body: currentForm.body }),
    });

    if (!res.ok) {                         // ← don't render error HTML in iframe
      toast.error(t('common:failed_to_update'));
      return;
    }

    setPreviewHtml(await res.text());
  } catch {
    toast.error(t('common:failed_to_update'));
  } finally {
    setPreviewLoading(false);
  }
};
Enter fullscreen mode Exit fullscreen mode

Two things changed:

  1. X-CSRF-TOKENX-XSRF-TOKEN, reading from the cookie Laravel keeps fresh instead of the stale Blade meta tag.
  2. if (!res.ok) guard - prevents error HTML from leaking into the iframe.

Why X-XSRF-TOKEN Works
Laravel's VerifyCsrfToken middleware checks for a valid token in two places:

  • _token field in the request body
  • X-CSRF-TOKEN header (raw token)
  • X-XSRF-TOKEN header (encrypted cookie value — automatically decrypted and verified)

The cookie is HttpOnly: false intentionally so JavaScript can read it. Inertia, Axios, and now your fetch all use this same mechanism.

Key Takeaway
When mixing Inertia navigation with manual fetch() calls in the same page:

  • Don't read meta[name="csrf-token"] — it's stale after any Inertia response.
  • Do read the XSRF-TOKEN cookie and send it as X-XSRF-TOKEN.
  • Always check res.ok before rendering a response body anywhere visible to the user.

Top comments (0)