Skip to content

Fix: Inertia.js Not Working — Shared Data, Lazy Props, Versioning, Forms, and SSR

FixDevs · (Updated: )

Part of:  React & Frontend Errors

Quick Answer

How to fix Inertia.js errors — Inertia.render not returning a component, shared data missing on every page, lazy props not deferring, asset versioning forcing reloads, useForm helper, and SSR setup.

The Error

You set up Inertia + Laravel + React but the page renders blank:

// PostController.php
public function index() {
    return Inertia::render('Posts/Index', ['posts' => Post::all()]);
}

Browser console:

TypeError: Cannot read properties of undefined (reading 'name')

Or usePage().props.user is undefined despite being set in middleware:

const user = usePage().props.user;
console.log(user);  // undefined

Or every navigation triggers a full page reload:

[network] Initial load: 200 OK (text/html)
[click link] Full reload: 200 OK (text/html)
# Should be partial JSON response.

Or useForm() posts the form but the page doesn’t update:

const form = useForm({ title: "" });
form.post("/posts");  // Server responds, but UI doesn't refresh.

Why This Happens

Inertia.js is a “modern monolith” bridge: your backend (Laravel, Rails, or any framework with an adapter) returns a JSON payload describing which component to render and what props to pass; the Inertia client-side library swaps in the component without a full page reload. There is no separate REST API and no client-side router config. From the developer’s perspective, you write controllers that return components — but the wire format and the rendering lifecycle have specific contracts that fail loudly when you cross them.

The first pain point is that there are two halves to configure. The server adapter (inertia/inertia-laravel for Laravel, inertia-rails for Rails) registers a middleware that adds the X-Inertia headers and renders the initial HTML shell. The client adapter (@inertiajs/react, @inertiajs/vue3, or Svelte) intercepts link clicks, fires Inertia requests, and swaps components. Either side misconfigured breaks the SPA experience in different ways — a missing server middleware returns plain JSON on every navigation, while a missing client setup means every <Link> triggers a full reload.

The second pain point is the contract between shared data, lazy props, and the asset version hash. Cross-page props (current user, flash messages) come from the HandleInertiaRequests::share() method and arrive on every page; expensive props belong in Inertia::lazy() or Inertia::defer() so they only load on demand; the version() method returns a hash that the client compares to its cached version, triggering a full reload when assets change. Each of these has correctness traps — eager DB queries in share() slow every request, lazy props that are never explicitly requested are silently missing in your components, and a static version string means clients never reload after a deploy.

The third pain point is forms. useForm is reactive — it tracks data, errors, and processing state. Submitting via form.post() triggers an Inertia request that the server should respond to with another Inertia render or a redirect. Submitting via plain fetch or axios works at the HTTP level but doesn’t go through Inertia’s response handler, so the page never swaps. Most “form submits but UI doesn’t update” reports are this mismatch.

Diagnostic Timeline

Trace a “the page renders blank after login” failure step by step.

Minute 0 — first suspicion: re-render. The first reaction is to add key={...} to force re-render, or to wrap the component in a refresh hook. Neither helps because the component never received its props in the first place. Re-rendering nothing still renders nothing.

Minute 3 — first evidence: open DevTools Network tab. Click the login link. The response is HTML (the Blade shell), not JSON. Inertia expected a JSON response with the X-Inertia header, didn’t get one, and treated it as a full-page navigation. The server middleware isn’t returning Inertia responses for this route — usually because the controller is redirect()->away(...) to an external URL, or because a global middleware (CSRF, throttle) intercepted with a regular HTTP redirect.

Minute 6 — next check: SSR/CSR mismatch. If SSR is enabled, the server rendered an initial component with one set of props, and the client hydration computed a different set. The console shows Warning: Text content did not match. Look for code paths that run differently on server and client — Date.now(), window.localStorage, conditional typeof window !== "undefined" guards.

Minute 9 — discriminating evidence: props serialization. Open the Inertia response payload (the JSON). The user.created_at is a Carbon date object that didn’t serialize cleanly — it landed as "+00:00" or as an empty object. Your React component crashes on user.created_at.toLocaleDateString(). The server-side dehydration of Eloquent models doesn’t always produce JSON-safe shapes. Add ->toArray() or define an API resource class, and cast dates to ISO strings before sending.

Minute 12 — actual root cause: partial reload with only plus deferred props. Your dashboard uses router.reload({ only: ['stats'] }) after login. The reload requested stats but didn’t include auth, so the response props object only contained stats. Inertia merges partial responses into existing props — but on a first-time render after redirect, there were no existing props to merge into, and auth.user was undefined. Switch the lazy props to Inertia::always(fn () => ...) for keys that must appear on every response regardless of partial reload, or drop only for the initial post-login render.

Fix 1: Server-Side Adapter Setup (Laravel)

composer require inertiajs/inertia-laravel

app/Http/Middleware/HandleInertiaRequests.php:

<?php

namespace App\Http\Middleware;

use Inertia\Middleware;

class HandleInertiaRequests extends Middleware
{
    protected $rootView = 'app';

    public function version(Request $request): ?string
    {
        return parent::version($request);
    }

    public function share(Request $request): array
    {
        return array_merge(parent::share($request), [
            'auth' => [
                'user' => fn () => $request->user()?->only('id', 'name', 'email'),
            ],
            'flash' => [
                'success' => fn () => $request->session()->get('success'),
                'error' => fn () => $request->session()->get('error'),
            ],
        ]);
    }
}

Register in bootstrap/app.php:

->withMiddleware(function (Middleware $middleware) {
    $middleware->web(append: [
        \App\Http\Middleware\HandleInertiaRequests::class,
    ]);
})

Your root view (resources/views/app.blade.php):

<!DOCTYPE html>
<html>
<head>
    <meta charset="utf-8">
    <meta name="viewport" content="width=device-width, initial-scale=1">
    @viteReactRefresh
    @vite('resources/js/app.tsx')
    @inertiaHead
</head>
<body>
    @inertia
</body>
</html>

@inertia is the directive that renders the initial Inertia response into an HTML element with id="app". @inertiaHead is for SSR head tags.

Pro Tip: For Rails, the equivalent gem is inertia-rails. The patterns differ but the concept is the same: middleware shares data, controllers render inertia: 'Component', props: {...}.

Fix 2: Client-Side Setup (React)

npm install @inertiajs/react

resources/js/app.tsx:

import { createInertiaApp } from "@inertiajs/react";
import { createRoot } from "react-dom/client";

createInertiaApp({
  resolve: (name) => {
    const pages = import.meta.glob("./Pages/**/*.tsx");
    return pages[`./Pages/${name}.tsx`]();
  },
  setup({ el, App, props }) {
    createRoot(el).render(<App {...props} />);
  },
  progress: { color: "#4B5563" },
});

The resolve function maps Posts/Index (from Inertia::render('Posts/Index', ...)) to a component file. Use glob imports for code splitting:

resolve: (name) => {
  const pages = import.meta.glob("./Pages/**/*.tsx", { eager: false });
  return pages[`./Pages/${name}.tsx`]();
},

eager: false produces dynamic imports — pages load on demand.

Component file:

// resources/js/Pages/Posts/Index.tsx
import { Head, usePage, Link } from "@inertiajs/react";

interface Post {
  id: number;
  title: string;
}

export default function Index() {
  const { posts, auth } = usePage().props as { posts: Post[]; auth: { user: any } };
  
  return (
    <>
      <Head title="Posts" />
      <h1>Posts</h1>
      <p>Welcome, {auth.user?.name}</p>
      <ul>
        {posts.map((p) => (
          <li key={p.id}>
            <Link href={`/posts/${p.id}`}>{p.title}</Link>
          </li>
        ))}
      </ul>
    </>
  );
}

usePage().props is typed as Record<string, unknown> by default. Cast to your shape (or define a global type augmentation).

Common Mistake: Using regular <a href="..."> instead of <Link href="...">. Plain <a> causes a full reload; <Link> does an Inertia visit.

Fix 3: Shared Data Patterns

Some props (current user, flash messages, app version) should appear on every page without manually adding them.

In HandleInertiaRequests::share():

public function share(Request $request): array
{
    return array_merge(parent::share($request), [
        'auth' => fn () => [
            'user' => $request->user()?->only('id', 'name', 'avatar'),
        ],
        'app' => [
            'name' => config('app.name'),
            'env' => config('app.env'),
        ],
        'flash' => function () use ($request) {
            return [
                'success' => $request->session()->get('success'),
                'error' => $request->session()->get('error'),
            ];
        },
    ]);
}

Three patterns:

  • Plain values — same on every request.
  • Closures — computed per-request (e.g. fn () => $request->user()).
  • Inertia lazy props — see Fix 4.

Access in components:

const { auth, flash } = usePage().props;

For TypeScript globally:

// resources/js/types/inertia.d.ts
import "@inertiajs/react";

declare module "@inertiajs/react" {
  interface PageProps {
    auth: { user: { id: number; name: string } | null };
    flash: { success?: string; error?: string };
    app: { name: string; env: string };
  }
}

Now usePage().props.auth is typed across all components.

Common Mistake: Returning expensive data eagerly in share(). Every request runs share — heavy DB queries here slow every page. Use lazy props.

Fix 4: Lazy Props

For data that’s expensive and only needed sometimes:

return Inertia::render('Dashboard', [
    'users' => fn () => User::all(),       // Eager — runs on every render
    'stats' => Inertia::lazy(fn () => Stats::compute()),  // Lazy — only on explicit partial reload
]);

Inertia::lazy() defers the computation. The client can fetch lazily:

import { router } from "@inertiajs/react";

function StatsPanel() {
  const [loading, setLoading] = useState(false);

  return (
    <button onClick={() => {
      router.reload({ only: ['stats'], onStart: () => setLoading(true) });
    }}>
      Load Stats
    </button>
  );
}

router.reload({ only: ['stats'] }) sends an Inertia partial reload that only refetches stats. The server runs the Inertia::lazy closure and returns just that prop.

For Inertia 2.0+, Inertia::defer() is the new name for the same behavior with extra features (polling, intersection-based loading):

return Inertia::render('Posts/Show', [
    'post' => Post::find($id),
    'comments' => Inertia::defer(fn () => Comment::where('post_id', $id)->get()),
]);

The comments prop loads after the initial page renders — better LCP.

Pro Tip: Use defer (or lazy) for anything below the fold or in a tab that’s not initially visible. Faster initial render, less data per page.

Fix 5: Asset Versioning

When you deploy new JS, Inertia must know to do a full reload (not an in-page swap with mismatched code).

HandleInertiaRequests::version():

public function version(Request $request): ?string
{
    return md5_file(public_path('build/manifest.json'));
}

This hashes the Vite manifest. Every new build produces a different hash; clients with the old hash get a 409 response from Inertia and reload.

For non-Vite setups:

public function version(Request $request): ?string
{
    return parent::version($request);  // Defaults to hashing `public/mix-manifest.json` if present
}

Or pin to a manual version:

public function version(Request $request): ?string
{
    return 'v1.2.3';  // Bump on every deploy
}

Common Mistake: Forgetting versioning entirely. Clients hang on old JS bundles indefinitely — no auto-reload. Implement version() early.

Fix 6: Forms With useForm

For form submissions:

import { useForm } from "@inertiajs/react";

function CreatePost() {
  const form = useForm({
    title: "",
    body: "",
  });

  function submit(e: React.FormEvent) {
    e.preventDefault();
    form.post("/posts", {
      onSuccess: () => form.reset(),
    });
  }

  return (
    <form onSubmit={submit}>
      <input
        value={form.data.title}
        onChange={(e) => form.setData("title", e.target.value)}
      />
      {form.errors.title && <p>{form.errors.title}</p>}
      
      <textarea
        value={form.data.body}
        onChange={(e) => form.setData("body", e.target.value)}
      />
      {form.errors.body && <p>{form.errors.body}</p>}
      
      <button disabled={form.processing}>
        {form.processing ? "Saving..." : "Save"}
      </button>
    </form>
  );
}

The form helper:

  • form.data — current values.
  • form.setData(key, value) — update.
  • form.processing — true during submit.
  • form.errors — validation errors (from Laravel’s $request->validate(...)).
  • form.post(url, options) — submit via Inertia.
  • form.reset(...keys) — reset to initial values.

Errors come from the Laravel controller:

public function store(Request $request)
{
    $validated = $request->validate([
        'title' => 'required|min:3',
        'body' => 'required',
    ]);

    Post::create($validated);

    return redirect()->route('posts.index')->with('success', 'Post created!');
}

On validation failure, Laravel automatically returns a 422 with the errors. Inertia maps them into form.errors.

Common Mistake: Using fetch() or axios() to submit. The response isn’t an Inertia response, so the page doesn’t update. Always use form.post, form.put, etc. for Inertia.

Fix 7: Server-Side Rendering

For SEO and faster first paint, set up SSR:

npm install @inertiajs/server

resources/js/ssr.tsx:

import { createInertiaApp } from "@inertiajs/react";
import createServer from "@inertiajs/react/server";
import ReactDOMServer from "react-dom/server";

createServer((page) =>
  createInertiaApp({
    page,
    render: ReactDOMServer.renderToString,
    resolve: (name) => {
      const pages = import.meta.glob("./Pages/**/*.tsx");
      return pages[`./Pages/${name}.tsx`]();
    },
    setup: ({ App, props }) => <App {...props} />,
  }),
);

In vite.config.ts:

import { defineConfig } from "vite";
import react from "@vitejs/plugin-react";
import laravel from "laravel-vite-plugin";

export default defineConfig({
  plugins: [
    react(),
    laravel({
      input: ["resources/js/app.tsx"],
      ssr: "resources/js/ssr.tsx",
      refresh: true,
    }),
  ],
});

Build for SSR:

npm run build
# Produces both client and SSR bundles.

Run the SSR server:

php artisan inertia:start-ssr

Now Inertia renders pages on the server, hydrates on the client.

For production, run the SSR server alongside your PHP-FPM / web server. PM2 or systemd manage the Node process.

Pro Tip: SSR is a real Node process. Don’t put database queries in your React components — they run server-side and double-execute. Pass everything as Inertia props.

Fix 8: Progress Bar and Visit Lifecycle

The default progress bar:

createInertiaApp({
  // ...
  progress: { color: "#4B5563", showSpinner: false },
});

To disable and use your own:

import { router } from "@inertiajs/react";

router.on("start", (event) => {
  showLoadingIndicator();
});

router.on("finish", (event) => {
  hideLoadingIndicator();
});

For visit options:

router.visit("/posts", {
  method: "get",
  data: { sort: "newest" },
  preserveState: true,         // Don't reset component state
  preserveScroll: true,         // Don't scroll to top
  only: ["posts"],              // Partial reload
  onSuccess: (page) => { ... },
  onError: (errors) => { ... },
});

For programmatic navigation:

router.get("/posts");
router.post("/posts", { title: "..." });
router.put("/posts/1", { title: "..." });
router.delete("/posts/1");
router.reload();  // Re-fetch current page

Still Not Working?

A few less-obvious failures:

  • Page component not found. Glob pattern doesn’t match. Verify the path: ./Pages/Posts/Index.tsx for Inertia::render('Posts/Index', ...).
  • Initial page loads slow. Eager glob loads all pages on first hit. Use eager: false for lazy/code-split loading.
  • Errors not showing in form. Laravel returned a JSON error response (not an Inertia response). The 422 status with validation errors should come through as a regular response with X-Inertia: true header; check your middleware order.
  • CSRF token mismatch. Inertia requests need CSRF protection. The default Laravel setup handles it automatically; verify VerifyCsrfToken middleware is registered.
  • Browser back button broken. Inertia maintains its own history. Don’t manipulate window.history directly.
  • Modals on the same page break Inertia. A “modal route” requires extra setup. Look at the inertia-modal packages or implement a Modal component that doesn’t use Inertia visits.
  • Vite HMR doesn’t trigger. @viteReactRefresh directive missing or in the wrong place. Place it before @vite(...).
  • TypeScript can’t find usePage props. Augment PageProps interface (see Fix 3).
  • Partial reload’s only key drops auth. Inertia merges partial responses into existing props, but if the prop is configured as lazy and you didn’t request it, it disappears on subsequent visits. Use Inertia::always(fn () => ...) for keys that must persist across partial reloads.
  • Carbon dates serialize as empty objects. Eloquent’s default JSON cast for DateTime works on response, but inside Inertia::render arrays it can lose its type. Cast explicitly with ->toIso8601String() in the controller, or define a JsonResource that handles the serialization deterministically.
  • SSR process crashes silently after a deploy. The Node SSR server caches the bundle in memory — a new deploy invalidates the bundle but the running process doesn’t restart automatically. Wire php artisan inertia:start-ssr into your deploy script with PM2 restart or systemd ExecReload so the SSR process always matches the deployed client bundle.

For related full-stack and SPA issues, see Laravel queue job not processing, Vite failed to resolve import, React hydration error, and Next.js server action not working.

F

FixDevs

Solo developer based in Japan. Every solution is cross-referenced with official documentation and tested before publishing.

Was this article helpful?

Related Articles