Next.js 16.3 ships a strong pitch for instant navigation, demonstrated in next-beats, a music player app built by Aurora Scharff.
Next.js team keep raising the bar with every release, and next-beats is a great example: source teaches you more than the announcement copy ever could.
But it's a single-page application, every navigation is a client-side React transition. That's exactly the case that never gets addressed: what happens to "instant navigation" when you're running a multi-page application (MPA) instead?
To find out, I forked next-beats into next-beats-mpa, live at next-beats-mpa.vercel.app, and, feature by feature, replaced the SPA-shaped pieces with their MPA-native equivalents.
This post walks through what changed, why, and shows the actual code on both sides.
Why "just migrate to a SPA" isn't always on the table
In a real MPA, clicking a link is a genuine HTTP request; the server returns a full HTML document. Two reasons make migrating away from that model to a SPA client-router unrealistic for a lot of real products:
-
Third-party scripts you don't control. Analytics, ads, and social widgets frequently assume
DOMContentLoadedfires, or that the browser does a full reload on every navigation. Going SPA means auditing (and possibly renegotiating) behavior with every one of those vendors, often impossible when the scripts are injected externally through a tag manager, outside your codebase. -
Memory leaks become your problem. An MPA reloads the page on every navigation, so listeners,
setIntervaltimers, and components that don't unmount cleanly get wiped for free. In a SPA nothing reloads, so anything you forget to clean up accumulates for the lifetime of the session. The bugs don't disappear in an MPA, they're just masked by the full reload.
Given that framing, the real question is: which of Next.js 16.3's new instant-navigation features actually help an MPA, versus which ones only matter once you've already committed to a SPA client router?
View Transitions
next-beats uses React's <ViewTransition> to crossfade between routes. Concretely: components/ui/crossfade.tsx wraps app/(app)/playlist/page.tsx:
// components/ui/crossfade.tsx (next-beats)
import { ViewTransition } from 'react';
export function Crossfade({ children }: { children: React.ReactNode }) {
return (
<ViewTransition enter="auto" default="none">
{children}
</ViewTransition>
);
}
This works because React actually unmounts the old page and mounts the new one, client-side; there's no equivalent lifecycle hook during a real cross-document MPA navigation.
The MPA-native equivalent
For an MPA, the platform already has you covered with the Cross-document view transitions.
In next-beats-mpa, app/globals.css turns it on with a couple of lines:
/* app/globals.css (next-beats-mpa) */
@view-transition {
navigation: auto;
}
Use the mobile viewport if you want a more engaging page transition animation:

That alone gives the browser a cross-fade between full document loads, no JS required.
Hover-triggered prefetch
next-beats prefetches a link's data only once the pointer or focus reaches it, instead of eagerly prefetching everything in the viewport.
It's a genuinely nice feature, but it's built on next/link, which is designed around client-side navigation.
In an MPA you don't use next/link for cross-document navigation, so this prefetch mechanism simply isn't reachable.
The MPA-native equivalent
Two browser-native mechanisms get most of the same win without a client router.
First, BFCache: make sure pages qualify for it, so back/forward navigation restores from an in-memory snapshot instead of a fresh round-trip.
Second, and closer to a direct analogue of hover-prefetch, the Speculation Rules API: you declare rules that tell the browser to prefetch (or even prerender) a document ahead of the click, based on hover/pointerdown intent, and the browser does the fetching itself.
next-beats-mpa implements this as a single static script, components/speculation-rules.tsx:
// components/speculation-rules.tsx (next-beats-mpa)
const RULES = JSON.stringify({
prefetch: [{ where: { selector_matches: '[data-prefetch]' }, eagerness: 'moderate' }],
prerender: [{ where: { selector_matches: '[data-nav-link]' }, eagerness: 'conservative' }],
});
export function SpeculationRules() {
return <script type="speculationrules" dangerouslySetInnerHTML={{ __html: RULES }} />;
}
The trade-off:
Speculation Rules currently only ships in Chromium browsers (Chrome, Edge). Other browsers just ignore the unrecognized <script type="speculationrules">, so it degrades gracefully, but if your audience isn't Chromium-heavy, the win is partial.
use cache
Cache Components ('use cache', cacheTag, cacheLife) are genuinely useful on their own, and next-beats leans on them to cache query results with good DX.
This one's more of an architectural call than a technical rejection: if your backend is a set of external microservices, I'd rather push caching optimization to the backend/API layer than duplicate cache logic inside the Next.js app.
It's a legitimate tool for an MPA too, nothing about it depends on a client router, but if the goal is "make navigation instant" and you're fronting an external backend, I'd optimize at the source of truth first and treat use cache as a secondary lever, not the primary one.
Summary
The pattern across all of this is consistent: Next.js 16.3's instant-navigation story is built and demoed around a SPA-shaped client router.
The good news is the browser itself already ships MPA-native equivalents for almost everything:
| SPA-flavored 16.3 feature | MPA-native equivalent | Where in next-beats-mpa
|
|---|---|---|
<ViewTransition> (React) |
@view-transition { navigation: auto; } + pageswap/pagereveal direction tagging (native CSS, cross-document) |
app/globals.css, components/view-transition-inline-script.tsx
|
Hover-triggered prefetch (next/link) |
BFCache tuning + Speculation Rules API |
components/speculation-rules.tsx, components/ui/prefetch-link.tsx
|
use cache |
Same tool, works in an MPA too — but optimize backend/API-level caching first if your BE is external/microservices | n/a |
The full diff between the two approaches is browsable directly: next-beats (SPA) vs. next-beats-mpa (MPA fork).
Top comments (1)
Great breakdown, the "masked not fixed" point on memory leaks is one people miss constantly when they treat MPA-to-SPA as a pure win. On the prerender rule for data-nav-link, does that ever cause double-fires on analytics or other side-effect scripts that run on page load, since the browser is rendering the destination page speculatively before the user actually commits to the click?