Skip to content

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

FixDevs ·

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/etc.) returns a JSON payload describing which component and props to render; the Inertia client-side library swaps in the component without a full reload.

Three common pain points:

  • Two halves to set up. Server adapter (Laravel: inertia/inertia-laravel; Rails: inertia-rails) and client adapter (@inertiajs/react, @inertiajs/vue3, etc.). Either side misconfigured breaks the SPA experience.
  • Shared data needs middleware. Cross-page props (current user, flash messages) come from HandleInertiaRequests middleware. Without it (or with wrong shape), props are missing.
  • Asset versioning. When you deploy new JS, in-flight Inertia visits should reload. Inertia compares an X-Inertia-Version header — if your server’s version changes, the client does a full reload to fetch new assets.
  • Forms via useForm are reactive. Submitting via plain fetch works but doesn’t update the page; useForm.post does an Inertia request that swaps props.

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).

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