skip to content

Tenants of a Laravel and Inertia dashboard sometimes see raw JSON instead of the page after pressing Back or reopening a tab; what causes it and how is it prevented?

level: seniorimportance: should knowfreq 22%

answer

  1. one URL, two bodies
  2. a cache keyed only by URL
  3. Vary: X-Inertia from the middleware
  4. routes outside HandleInertiaRequests lose it
  5. curl with and without X-Inertia

basics

~20 s

The same URL returns HTML or JSON depending on the X-Inertia request header. If a cache stores the JSON reply by URL alone, a later full load can get it. Inertia's middleware sends Vary: X-Inertia to prevent that.

solid answer

~50 s

An Inertia URL has two representations: the HTML root view for a normal request and the bare page object for a request carrying `X-Inertia: true`. A browser or shared cache that keys stored responses by URL alone can later serve the JSON variant to a normal document load, for example when a discarded tab is restored, and the tenant sees raw JSON. `Vary: X-Inertia` tells caches the body depends on that header, and `Inertia\Middleware` sets it on every response that passes through it. So first check that the affected route actually runs `HandleInertiaRequests`: a route outside the `web` group still answers `X-Inertia` requests with JSON, but without `Vary`, shared props or the version check. Then confirm with `curl -I` with and without the header, and make sure any cache in front of the app honours `Vary` or bypasses authenticated pages.

code

bash · 9 lines
bash
# Full-page variant: HTML, must still vary on X-Inertia
curl -sI https://app.test/leases/42 | grep -iE '^(vary|content-type|x-inertia):'

# Inertia visit variant: JSON page object
curl -sI -H 'X-Inertia: true' https://app.test/leases/42 \
  | grep -iE '^(vary|content-type|x-inertia):'

# Which middleware does the route run?
php artisan route:list --path=leases -v

go deeper

for a junior

Recall that one Inertia URL can return HTML or JSON, chosen by the X-Inertia request header.

for a middle

Explain how Vary tells caches which request headers select a representation, and that Inertia's middleware adds Vary: X-Inertia.

for a senior

Diagnose from headers and route middleware, find routes outside HandleInertiaRequests, and guard the contract with a feature test.

for a principal

Set caching policy for authenticated Inertia apps: what, if anything, a shared cache may hold, and how dual representations are documented.

## Why one URL has two bodies With Inertia, `/leases/42` answers differently depending on one request header: | Request | Response body | Response headers | |---|---|---| | no `X-Inertia` (address bar, refresh, restored tab) | root view HTML with the page object embedded | `Content-Type: text/html` | | `X-Inertia: true` (client-side visit) | the bare page object | `Content-Type: application/json`, `X-Inertia: true` | Both come from the same controller returning `Inertia::render('Leases/Show', ...)`; `Inertia\Response::toResponse()` picks the body by checking the header. ## How raw JSON reaches the screen HTTP caches, whether the browser's own or a shared proxy in front of the app, store responses and reuse them for later requests to the same URL. If the cache records only the URL, it cannot tell the two variants apart: 1. A tenant clicks through to `/leases/42`; the XHR response, the JSON page object, may be stored for that URL. 2. Later the browser needs `/leases/42` as a **document**, for example restoring a discarded tab or reopening a closed one, and no Inertia client is involved yet. 3. A cache that ignores the header difference hands back the JSON, and the browser displays it as text. Ordinary Back navigation between Inertia pages is usually handled by the client from its history state, so the symptom is intermittent and depends on the browser's cache decisions, which makes it look random. ## The prevention: `Vary: X-Inertia` The `Vary` response header lists request headers that select the representation. `Inertia\Middleware::handle()` sets `Vary: X-Inertia` on **every** response passing through it, HTML and JSON alike, so caches store separate entries for the two variants. Two details from the adapter's source matter when diagnosing: - The middleware uses `headers->set('Vary', 'X-Inertia')`, which **replaces** any `Vary` value set by code that ran inside it. If you also need `Vary: Accept-Language`, add it in middleware that runs after the Inertia middleware has finished with the response. - `Inertia::render` does not depend on the middleware to return JSON. A route that renders Inertia pages but does **not** run `HandleInertiaRequests` still answers `X-Inertia` requests with JSON, yet lacks `Vary`, shared props including `errors`, and the asset-version check. ## Diagnosing it - Confirm the route runs the middleware: `php artisan route:list -v` shows each route's middleware; the starter kits append `HandleInertiaRequests` to the `web` group in `bootstrap/app.php`. - Compare the two variants: `curl -I https://app.test/leases/42` and the same with `-H 'X-Inertia: true'`. Both should carry `Vary: X-Inertia`, and only the second `X-Inertia: true`. - Check any shared cache in front of the app: it must include `Vary` headers in its cache key, or it should not cache authenticated HTML and JSON at all. - Look for custom middleware or response macros that overwrite `Vary` afterwards. ## A worked diagnosis Suppose the lease pages were recently moved into a new route file registered from `bootstrap/app.php` with its own middleware list: 1. `curl -sI -H 'X-Inertia: true'` against `/leases/42` returns JSON and `X-Inertia: true` but no `Vary` header. 2. `php artisan route:list --path=leases -v` shows the lease routes without `HandleInertiaRequests`. 3. The validation messages on the lease edit form have also quietly stopped showing, because the shared `errors` prop is gone. Moving the routes back under the `web` group restores `Vary`, the shared props and the version check in one change. The raw-JSON reports stop as the previously cached copies expire. ## Why the symptom is intermittent - It needs a stored JSON variant and a later document load of the same URL, which depends on how long caches keep entries. - Different browsers and proxies decide differently whether and when to reuse a stored response. - Only routes missing the header are affected, so most pages look fine and the reports seem unrelated. ## Hardening - Keep every Inertia route in the group that runs `HandleInertiaRequests`; do not render Inertia pages from `routes/api.php` routes. - For authenticated dashboards, send cache headers that forbid shared caching. Tenant data should not sit in a shared cache anyway. - Add a feature test that requests a key page and asserts `assertHeader('Vary', 'X-Inertia')`. The middleware adds it to the HTML response too, so a plain `get()` suffices, and a routing change that drops the middleware fails in CI rather than in a tenant's browser.

  • What else is lost when an Inertia route does not run HandleInertiaRequests?
    Shared props, including the `errors` prop, so validation messages silently disappear; the asset-version check, so stale bundles are not reloaded; the root view chosen by the middleware; and the `Vary: X-Inertia` header. The page still renders, which is why the gap goes unnoticed until one of those features is needed.
  • Why might Vary: X-Inertia disappear even though the middleware runs?
    The middleware sets the header on the response it receives from the inner stack, but code that runs after it can overwrite `Vary`. Another middleware or a response macro calling `headers->set('Vary', ...)` replaces the value. Compare the header with `curl -I` and look for code that sets `Vary` without keeping existing values.

saying these in an interview costs you the question

  • Inertia uses different URLs for HTML and JSON responses.
  • The X-Inertia response header alone stops caches mixing the two variants.
  • Inertia::render needs the middleware to return JSON at all.
  • It is a frontend bug fixed by disabling the history feature.
  • Only the JSON response needs a Vary header.