Livewire 4 wire:navigate — Get an SPA Feel Without Building a SPA

Add SPA-style navigation to a Laravel app using Livewire 4 wire:navigate. Prefetch on hover, persist audio players, and style active links — no JS framework.

Steven Richardson
Steven Richardson
· 10 min read

You want your Laravel app to feel like a SPA without strapping a second frontend framework to it. wire:navigate is the answer most Livewire devs have heard of but few finish wiring up properly. The attribute itself is a one-liner — but the prefetch tuning, @persist, wire:current, and the JavaScript hooks for a loading bar are what take it from "feels faster" to "feels like Next.js."

This is for Livewire 4 specifically. If you're still on v3, the directive exists there too, but the lifecycle event names changed in v4 — start with the practical Livewire 3 to Livewire 4 migration guide before copy-pasting any of the JavaScript hook examples below.

The first step is the lazy one. Every <a> tag that points inside your application gets the wire:navigate attribute. Livewire intercepts the click, fetches the target URL in the background, and replaces the current page's <body> and <title> instead of a full browser navigation. The URL bar updates, the back button works, scroll position is preserved.

{{-- resources/views/layouts/app.blade.php --}}
<nav class="flex gap-4">
    <a href="/" wire:navigate>Dashboard</a>
    <a href="/posts" wire:navigate>Posts</a>
    <a href="/users" wire:navigate>Users</a>
</nav>

If you redirect from a Livewire action and want the same in-place swap, pass navigate: true to the redirect() helper. Otherwise the redirect falls back to a full page request and you lose the SPA feel for that one transition.

public function save(): void
{
    $this->validate();
    Post::create($this->state);

    $this->redirect('/posts', navigate: true);
}

That's the bare minimum. Even without the rest of the steps below, page-to-page transitions are already perceptibly faster because Livewire ships a default prefetch strategy out of the box.

Livewire's default prefetch is subtle: between the moment a user presses the mouse button down and lifts it back up, Livewire already has the request in flight. On most desktops that's enough to load the next page before the click event even fires. For the click-happy crowd it's free perf.

For high-intent links — a primary nav item, a "View order" button on a dashboard card — you can be more aggressive. The .hover modifier starts the prefetch 60ms into a hover, so the page is sitting in cache before the cursor lands.

<nav class="flex gap-4">
    <a href="/dashboard" wire:navigate.hover>Dashboard</a>
    <a href="/posts" wire:navigate.hover>Posts</a>
    <a href="/users" wire:navigate.hover>Users</a>
</nav>

The trade-off is server load. Users hover over links they never click — search results, paginated lists, mega-menus — so .hover will fetch pages that nobody asked to see. The 60ms delay knocks out incidental flyovers, but on a dense UI you'll still pay for it. I use .hover on top-level nav and "primary action" links, plain wire:navigate everywhere else.

Persist the audio player across page changes with @persist#

This is the killer feature most Livewire tutorials never reach. Wrap any element in @persist('some-name') and Livewire will reuse the same DOM node across navigations. An audio player keeps playing. A sidebar keeps its scroll position. A live chat widget keeps its socket open.

{{-- resources/views/layouts/app.blade.php --}}
<!DOCTYPE html>
<html lang="en">
<body>
    <main>{{ $slot }}</main>

    @persist('player')
        <audio src="{{ $episode->file }}" controls></audio>
    @endpersist

    @livewireScripts
</body>
</html>

Two non-obvious rules. First, the persisted element must live outside any Livewire component — typically in the main layout. Livewire re-renders component DOM on every navigation; only the layout's persisted regions survive. Second, the matching @persist('player') block must exist on the destination page too. If only the source page has it, the element is dropped when Livewire morphs the new HTML in.

For a scrollable persisted element — a fixed sidebar, an infinite-scroll feed — add wire:navigate:scroll so Livewire restores its scroll position alongside the rest of the page. Without it the inner scroll resets every time.

@persist('sidebar')
    <aside class="overflow-y-scroll" wire:navigate:scroll>
        {{-- nav items --}}
    </aside>
@endpersist

The Blade @if (request()->is('/posts')) pattern doesn't survive @persist. The persisted nav lives across pages, so the request()->is() evaluation is frozen to whatever URL the layout was first rendered against. Livewire ships two purpose-built alternatives.

The first is wire:current. Whatever classes you pass get applied to the current page's link, and removed from the others, as you navigate.

<nav class="flex gap-4">
    <a href="/dashboard" wire:navigate
       wire:current="font-bold text-indigo-600">Dashboard</a>
    <a href="/posts" wire:navigate
       wire:current="font-bold text-indigo-600">Posts</a>
    <a href="/users" wire:navigate
       wire:current="font-bold text-indigo-600">Users</a>
</nav>

The second is the auto-applied data-current attribute. Livewire 4 adds it to every wire:navigate link that matches the current URL — you don't have to opt in. Pair it with Tailwind's data-* variants and you can drop the directive entirely.

<a href="/posts" wire:navigate
   class="data-current:font-bold data-current:text-indigo-600">Posts</a>

If you've already set up Tailwind v4 with CSS-first dark mode and custom variants, the data-current approach slots straight in alongside data-theme for a single source of truth in the CSS layer. The official docs note data-current is generally preferred for new code — wire:current is fine if you want the values in Blade rather than your class list.

Hook into livewire:navigated for a loading indicator#

The brief told me to use x-navigate:start and x-navigate:end. Those names are wrong for Livewire 4 — the actual events dispatched on document are livewire:navigate, livewire:navigating, and livewire:navigated. Use them for top-of-page progress bars, route announcements for screen readers, or third-party script re-initialisation.

// resources/js/app.js
document.addEventListener('livewire:navigate', (event) => {
    // Fired when the navigation is requested. You can preventDefault()
    // to cancel it, or inspect event.detail.url and event.detail.cached.
    document.querySelector('#nprogress')?.classList.add('is-loading');
});

document.addEventListener('livewire:navigated', () => {
    // Fired after the swap completes. Hide the bar, re-init libraries,
    // fire an analytics page view, etc.
    document.querySelector('#nprogress')?.classList.remove('is-loading');
}, { once: false });

There's already a built-in progress bar — it kicks in after 150ms — so for most apps you don't need to write this. If you want to disable or recolour the default bar, the config lives in config/livewire.php:

'navigate' => [
    'show_progress_bar' => true,
    'progress_bar_color' => '#6366f1',
],

If you're already using Alpine for client-side state, the same pattern works inline. A simple x-data overlay on the page-level wrapper can flip a loading flag on livewire:navigate and unflip it on livewire:navigated. Reach for the deeper Alpine integration patterns I covered in share state between Livewire and Alpine with $wire and @entangle if the loading state needs to drive component-level UI rather than a global bar.

One important warning: event listeners attached to document survive page swaps. If you addEventListener inside a <script> tag in the body, you'll register it again on every navigation and end up dispatching the same handler N times. Either register once in your bundled entry file, or use { once: true } if the handler is genuinely page-scoped.

Verify the swap in the Network tab#

Open DevTools, click the Network tab, then click a wire:navigate link. You should see a single request to the destination URL with a text/html response — and crucially, no second request for app.js or app.css. That's the win: the assets are not re-evaluated, the JavaScript heap is preserved, and the response payload is just the HTML diff.

With wire:navigate.hover enabled, hover over a link without clicking. After 60ms you should see a prefetch request appear in the Network tab. Click the link and you'll see the second request fire — but it resolves instantly because the response is already cached. If you do not see the prefetch, double-check the link target is same-origin and that the route renders a Livewire page component, not a vanilla controller.

For assets that have to bust the cache after a deploy — your versioned JS bundle, a CDN-hosted polyfill — add data-navigate-track to the script tag. Livewire compares the query string between pages and triggers a full page reload when it changes. The Laravel Vite plugin adds the attribute for you, so this is only relevant for manually-included scripts.

<head>
    @vite(['resources/css/app.css', 'resources/js/app.js'])
    <script src="https://cdn.example.com/widget.js?v=2026-05-20" data-navigate-track></script>
</head>

Gotchas and Edge Cases#

Third-party scripts in the <body> are re-evaluated on every page swap. That's normally what you want — but a snippet that registers a global event listener will register again on the next page. Either move the script into your bundled entry (so it runs once), add data-navigate-once if it really must live inline, or refactor it to be idempotent.

Scripts in <head> are the opposite — they run only on initial load. If you've put your analytics snippet in the head and wondered why your page-view counts dropped after enabling wire:navigate, that's why. Tools like Google Analytics and Plausible auto-detect SPA navigation, but Fathom needs data-spa="auto" on its script tag to fire on each page change.

The request()->is() and Route::currentRouteName() checks inside a @persist block do not update across navigations — the persisted DOM was rendered against the URL of the first page load. If you need URL-conditional UI inside a persisted region, drive it from a Livewire component property and re-render the inner section, or move the check out of the persisted block.

Finally, wire:navigate requires both source and destination routes to render Livewire page components. A route that returns a plain Blade view via Route::view(...) will still navigate, but anything that depends on Livewire's morph (wire:current, @persist, lifecycle hooks) only works when Livewire is rendering the destination.

Wrapping Up#

wire:navigate is the closest you can get to a SPA without taking on the cost of Inertia, React, or Vue. The default prefetch alone makes most apps feel sharper; .hover plus a couple of @persist blocks for a sidebar and an audio player is what gets you the rest of the way. Start by adding the attribute to your top-level nav, watch the Network tab for one round of clicks, then layer in @persist and wire:current once the basics behave.

If you've enabled wire:navigate and want to push perceived performance further, look at Livewire 4 islands for lazy-loading expensive components — islands defer the slow database queries on a page until the rest of the layout has rendered, so a wire:navigate'd page is interactive even faster. And for the other directive most v4 apps use day one, drag-and-drop reordering with Livewire 4 wire:sort drops in on the same pages without any of the SortableJS plumbing the v3 package needed.

FAQ#

What is wire:navigate in Livewire and how does it work?

wire:navigate is an HTML attribute you add to anchor tags so Livewire intercepts the click, fetches the target page in the background, and swaps the <body> and <title> in place instead of triggering a full browser navigation. The URL bar updates and the back button works, but the JavaScript heap, CSS, and persisted elements like audio players survive the page change. It's how Livewire gives you a SPA-style feel without you writing or shipping a client-side router.

How do I enable prefetching with wire:navigate?

Prefetching is on by default. Livewire starts fetching the target page the moment a user presses the mouse button down, so by the time the click completes the response is usually already in cache. If you want to fetch even earlier, add the .hover modifier to the link — wire:navigate.hover — and Livewire prefetches 60ms into a hover. There's no configuration to enable the baseline behaviour; it ships with the directive.

What does wire:navigate.hover do in Livewire 4?

The .hover modifier tells Livewire to prefetch the linked page after a user has hovered over it for 60 milliseconds. By the time they finish moving the cursor and click, the response is already loaded and the swap is instant. The trade-off is that hovered-but-never-clicked links still hit your server, so reserve .hover for high-intent destinations like primary navigation rather than search-result lists or dense link tables.

How do I keep a Livewire component's state across page changes?

Wrap the element in the @persist('name') Blade directive, and place that block outside any Livewire component — typically directly in your resources/views/layouts/app.blade.php file. When Livewire navigates to a new page, it looks for an element with the same @persist name in the destination markup and reuses the existing DOM node instead of replacing it. The original element keeps all its state, including audio playback position, scroll offset, and any JavaScript variables attached to it.

What is the difference between wire:navigate and Inertia.js?

Both give you a SPA-style feel, but wire:navigate is a single attribute on top of server-rendered Blade — there's no separate frontend bundle, no Vue or React component tree, and no separate page resolver. Inertia.js requires you to write your pages as Vue, React, or Svelte components and ships a non-trivial JavaScript bundle to the browser. If you're already invested in Livewire and Blade, wire:navigate gives you the SPA win for one attribute; Inertia is the better choice if you specifically want a JavaScript-rendered frontend.

Why isn't wire:navigate working on my links?

The most common cause is that the destination route returns a plain Blade view rather than a Livewire page component — Livewire only morphs the page when both sides are Livewire-rendered. The second is that the link points to a different origin, in which case Livewire intentionally falls back to a normal browser navigation. Other causes include target="_blank", a download attribute, modifier keys held during the click, or a missing @livewireScripts directive in your layout. Open the Network tab and confirm a single HTML response fires when you click — if you instead see assets reloading, Livewire isn't intercepting.

Steven Richardson
Steven Richardson

CTO at Digitonic. Writing about Laravel, architecture, and the craft of leading software teams from the west coast of Scotland.