+1 (415) 599-8902

Inertia 2 with Laravel: Deferred Props, Prefetching, Polling and Infinite Scroll

Inertia 2 changed what a Laravel SPA feels like. Inertia 1 gave you server-driven routing with client-side rendering: every visit was a full page-props round trip, and anything slow in a controller held the whole page hostage. Inertia 2 adds deferred props, prefetching, polling, WhenVisible-based lazy loading and infinite scroll — so a dashboard can paint instantly and fill itself in, without you hand-rolling an API layer.

This tutorial walks through upgrading and then actually using those features in a Laravel 13 + Vue 3 application. The React and Svelte adapters expose the same primitives with different component names; the server side is identical.

What you need

  • Laravel 11, 12 or 13 with Inertia 1 already working, or a fresh install
  • @inertiajs/vue3 v2, inertiajs/inertia-laravel v2
  • Vite (if you are still on Laravel Mix, migrate that first — Inertia 2's prefetch cache behaves badly with Mix's older chunking)

Upgrading from Inertia 1

composer require inertiajs/inertia-laravel:^2.0
npm install @inertiajs/vue3@^2.0

Three breaking changes bite most codebases:

  1. Inertia::lazy() is gone, replaced by Inertia::optional(). Same behaviour: the prop is only evaluated when explicitly requested via a partial reload.
  2. Inertia::defer() is new and is what you almost always wanted lazy() to be.
  3. The remember / useRemember persistent-state API now keys per component instance, so two instances of the same component no longer stomp on each other's state.

Run a find-and-replace for Inertia::lazy(Inertia::optional( and check every router.reload({ only: [...] }) call still names props that exist.

Deferred props: paint the page, then fill it

The classic slow dashboard:

public function index(): Response
{
    return Inertia::render('Dashboard', [
        'user'      => $request->user()->only('name', 'email'),
        'mrr'       => fn () => $this->billing->monthlyRecurringRevenue(),   // 900ms
        'churn'     => fn () => $this->billing->churnLastNinetyDays(),       // 1.4s
        'openTickets' => fn () => Ticket::open()->count(),
    ]);
}

Closures in Inertia 1 are still evaluated on the initial request — they only help on partial reloads. So this page takes 2.3 seconds before a single pixel appears. Wrap the slow ones in Inertia::defer():

use Inertia\Inertia;

return Inertia::render('Dashboard', [
    'user'        => $request->user()->only('name', 'email'),
    'mrr'         => Inertia::defer(fn () => $this->billing->monthlyRecurringRevenue()),
    'churn'       => Inertia::defer(fn () => $this->billing->churnLastNinetyDays()),
    'openTickets' => Inertia::defer(fn () => Ticket::open()->count()),
]);

The initial response ships without those keys, the page renders, and Inertia immediately fires a follow-up partial request for them. On the client:

<script setup>
import { Deferred } from '@inertiajs/vue3'
</script>

<template>
  <Deferred data="mrr">
    <template #fallback>
      <MetricSkeleton label="MRR" />
    </template>
    <MetricCard label="MRR" :value="$page.props.mrr" />
  </Deferred>
</template>

<Deferred> accepts an array too, and only renders its default slot once all named props have arrived — useful when two numbers must be consistent on screen.

Group your deferred props

By default every deferred prop resolves in one follow-up request, so one 1.4-second query delays the two fast ones. Name a group as the second argument and Inertia issues one request per group, in parallel:

'mrr'         => Inertia::defer(fn () => $this->billing->monthlyRecurringRevenue(), 'billing'),
'churn'       => Inertia::defer(fn () => $this->billing->churnLastNinetyDays(), 'billing'),
'openTickets' => Inertia::defer(fn () => Ticket::open()->count(), 'support'),

Two groups, two parallel requests, and the support widget no longer waits on billing. Don't overdo it: each group is a real HTTP request that boots the framework and re-runs your middleware, so three or four groups is a sensible ceiling.

Optional props and partial reloads

Inertia::optional() is for data that should load only when the user asks for it — a detail drawer, an expensive export preview, a tab nobody clicks:

return Inertia::render('Invoices/Index', [
    'invoices' => InvoiceResource::collection($invoices),
    'auditLog' => Inertia::optional(fn () => $this->audit->forInvoices($invoices)),
]);
router.reload({ only: ['auditLog'] })

Rule of thumb: defer what the page needs but shouldn't block on; optional what the page may never need.

Prefetching: make navigation feel instant

Add prefetch to a link and Inertia fetches the page on hover and caches it:

<Link href="/invoices" prefetch>Invoices</Link>
<Link href="/invoices" prefetch="mount">Invoices</Link>
<Link href="/invoices" :prefetch="['mount', 'hover']" cache-for="30s">Invoices</Link>

cache-for controls staleness. Inertia will serve a stale cached response immediately and revalidate in the background, so the page appears instantly and corrects itself a moment later. That is exactly right for a list of invoices and exactly wrong for a page showing a balance the user just changed — set cache-for="0" there, or call router.flushAll() after a mutation.

Programmatic prefetch, for example after a form step completes:

import { router } from '@inertiajs/vue3'

router.prefetch('/checkout/review', { method: 'get' }, { cacheFor: '1m' })

Watch your server load. Prefetch on hover across a 50-row table with an icon-heavy sidebar can multiply requests per session several times over. Prefetch the two or three destinations users actually go to next, not everything.

Polling: live-ish data without WebSockets

If you already run Reverb, use broadcasting. If you just want a queue-depth widget to tick, Inertia 2 has polling built in:

<script setup>
import { usePoll } from '@inertiajs/vue3'

usePoll(5000, { only: ['queueDepth'] })
</script>

It performs a partial reload every five seconds and — importantly — pauses automatically when the browser tab is hidden, so you are not billing yourself for background tabs. For manual control:

const { start, stop } = usePoll(5000, { only: ['queueDepth'] }, { autoStart: false })

Pair it with a cheap controller path. A polled endpoint that runs your full dashboard query every five seconds per user is a self-inflicted load test; make sure the only props are the only thing evaluated, which is what Inertia::optional() guarantees.

Infinite scroll and WhenVisible

<WhenVisible> loads a prop when its placeholder scrolls into view:

<WhenVisible data="activity" :buffer="300">
  <template #fallback><ActivitySkeleton /></template>
  <ActivityFeed :items="$page.props.activity" />
</WhenVisible>

For real pagination, Inertia 2's merge props do the appending for you. Server side:

return Inertia::render('Activity/Index', [
    'events' => Inertia::merge(
        fn () => EventResource::collection(Event::latest()->cursorPaginate(25))
    ),
]);

Inertia::merge() tells the client to append rather than replace, so a partial reload for the next cursor page grows the list. Use Inertia::deepMerge() when the prop is a paginator object with data and meta keys you want merged at depth.

<script setup>
import { router, WhenVisible } from '@inertiajs/vue3'

const props = defineProps({ events: Object })

function loadMore() {
  if (! props.events.next_cursor) return
  router.reload({
    only: ['events'],
    data: { cursor: props.events.next_cursor },
    preserveUrl: true,
  })
}
</script>

<template>
  <EventRow v-for="e in events.data" :key="e.id" :event="e" />
  <WhenVisible always :buffer="400" @visible="loadMore">
    <template #fallback><Spinner /></template>
  </WhenVisible>
</template>

Two details that cause bugs in production:

  • Use cursor pagination, not offset. With paginate(), new rows inserted at the top shift every page boundary and users see duplicates.
  • Pass always to <WhenVisible> so it re-triggers for page three and beyond, and preserveUrl so the address bar doesn't fill up with cursor noise.

History encryption for anything sensitive

Inertia caches page props in browser history state, which survives logout and back-button navigation. If a page shows anything you would not want the next person on that laptop to read, encrypt it:

return Inertia::render('Patients/Show', [...])->encryptHistory();

Or globally in app/Http/Middleware/HandleInertiaRequests.php:

protected $encryptHistory = true;

Then clear it on logout:

Inertia::clearHistory();

This uses the Web Crypto API, so it requires a secure context — plain-HTTP local development silently falls back to unencrypted history. Test it on HTTPS.

Testing

Inertia's assertion helpers understand the new prop types, and this is where most upgrade regressions surface:

use Inertia\Testing\AssertableInertia as Assert;

it('defers the expensive metrics', function () {
    $this->actingAs(User::factory()->create())
        ->get('/dashboard')
        ->assertInertia(fn (Assert $page) => $page
            ->component('Dashboard')
            ->has('user')
            ->missing('mrr')                       // deferred: absent initially
        );
});

it('resolves the billing group on partial reload', function () {
    $this->actingAs(User::factory()->create())
        ->get('/dashboard', [
            'X-Inertia' => true,
            'X-Inertia-Partial-Component' => 'Dashboard',
            'X-Inertia-Partial-Data' => 'mrr,churn',
        ])
        ->assertInertia(fn (Assert $page) => $page->has('mrr')->has('churn'));
});

Assert missing() on every deferred prop. It is the only test that catches someone "fixing" a skeleton flash by quietly unwrapping Inertia::defer() and putting the 1.4-second query back on the critical path.

Add a Pest 4 browser test for the infinite scroll, since the failure mode there is visual duplicate rows rather than a failing status code.

A sensible migration order

  1. Upgrade the packages, replace lazy() with optional(), get the suite green with no behaviour changes.
  2. Profile your three slowest pages (Telescope or Pulse will name them) and wrap the offending props in Inertia::defer() with groups.
  3. Add skeleton fallbacks so deferred loading reads as intentional rather than broken.
  4. Add prefetch to the two or three highest-traffic nav links only, with a cache-for you have thought about.
  5. Replace any hand-rolled polling setInterval with usePoll.
  6. Convert "load more" buttons to Inertia::merge() plus cursor pagination.
  7. Turn on encryptHistory() for authenticated areas and clearHistory() on logout.

Done in that order, each step is independently shippable and independently revertable.

Where this usually goes wrong

The pattern we see most often in audits is a team that adopted deferred props and prefetching enthusiastically and then watched their server load double. Every deferred group and every hovered prefetch is another full Laravel request: middleware, session, auth, policies. Inertia 2 makes the front end feel faster while asking the back end to do more work, so pair the upgrade with query profiling, response caching on read-heavy endpoints, and a look at whether Octane is worth it for you.

If you're planning an Inertia 1 to 2 upgrade on a large application, or you have an SPA whose dashboard has quietly grown to a four-second first paint, get in touch — project rescue and performance work on Laravel front ends is a good part of what we do.