Next.js had instant navigation before 16.3
Next.js already supported instant navigation. What changed in 16.3, and when should you enable its new features?
It's been a while since Next.js 16.3 announcement and the excitement around it has settled. If you read about "Instant Navigation" when it came out, you may have already moved on.
However I wanted to revisit it with a question:
What does this update change for an app that already gives users an immediate response?
You could build instant navigation before 16.3. If you already use the App Router, loading.tsx, and Suspense, you may know the pattern. Once prefetched, loading UI appears on a link click while the server fetches the remaining content.
16.3 builds on that pattern with new prefetching behavior and tools to check your navigations. My take is that the team wants more apps to follow the Cache Components model consistently, especially when coding agents change your routes.
Whether those features help your app depends on what you already have and what you want Next.js to check for you.
What exactly does "instant" mean
Think of a product page. You click a link and see the page structure with "Checking availability...". The stock count arrives later.
That's an instant navigation. The browser shows destination UI without waiting for a new server response. Cached content, loading fallbacks, or both can provide that first view.
The initial UI still has to be ready before the click. A cold cache or an unfinished prefetch can cause a wait, as the instant navigation guide explains.
You could already do this in Next.js 15
The Next.js 15 docs already described instant loading states through loading.tsx.
Next.js wraps the page in a Suspense boundary using that file as its fallback. Once prefetched, the fallback can appear as soon as you click.
Here's a product route's loading state:
export default function Loading() {
return (
<main>
<p>Loading product...</p>
<p>Checking availability...</p>
</main>
);
}The page itself can wait for its data:
import { getInventory, getProduct } from '@/lib/product-data';
type ProductPageProps = {
params: Promise<{ slug: string }>;
};
export default async function ProductPage({ params }: ProductPageProps) {
const { slug } = await params;
const [product, inventory] = await Promise.all([
getProduct(slug),
getInventory(slug),
]);
return (
<main>
<h1>{product.name}</h1>
<p>{inventory} in stock</p>
</main>
);
}These examples leave out the database code. getProduct(slug) returns a product with a name, and getInventory(slug) returns its stock count.
With the fallback prefetched, you see "Loading product..." before either query finishes. The queries run in parallel, and the page arrives when both are ready.
The Next.js 15 navigation guide also covers manual Suspense boundaries for streaming individual parts of a page.
The distinction is practical. Suspense defines what can render during a wait. Prefetching makes destination UI available before the click.
You needed both pieces for immediate navigation before 16.3 too.
Put Suspense above the data fetch
Having Suspense in a file doesn't guarantee useful loading UI. Look at this page without a loading.tsx boundary:
import { Suspense } from 'react';
import { getProduct } from '@/lib/product-data';
type ProductPageProps = {
params: Promise<{ slug: string }>;
};
export default async function ProductPage({ params }: ProductPageProps) {
const { slug } = await params;
const product = await getProduct(slug);
return (
<main>
<Suspense fallback={<p>Loading product...</p>}>
<h1>{product.name}</h1>
</Suspense>
</main>
);
}The page awaits getProduct() before returning its boundary. That fallback cannot cover the wait. Only a boundary above the page could catch it.
Move the reads into children, and the parent can return its UI first:
import { Suspense } from 'react';
import { getInventory, getProduct } from '@/lib/product-data';
type ProductPageProps = {
params: Promise<{ slug: string }>;
};
export default function ProductPage({ params }: ProductPageProps) {
return (
<main>
<p>Product details</p>
<Suspense fallback={<p>Loading product...</p>}>
<ProductInfo params={params} />
</Suspense>
<Suspense fallback={<p>Checking availability...</p>}>
<Inventory params={params} />
</Suspense>
</main>
);
}
async function ProductInfo({ params }: ProductPageProps) {
const { slug } = await params;
const product = await getProduct(slug);
return <h1>{product.name}</h1>;
}
async function Inventory({ params }: ProductPageProps) {
const { slug } = await params;
const inventory = await getInventory(slug);
return <p>{inventory} in stock</p>;
}Now "Product details" and the fallbacks can render while the children fetch their data independently. This structure supported streaming before 16.3.
Cache Components builds on that pattern
With Cache Components, Next.js also prepares eligible UI ahead of time. Partial Prefetching makes the reusable UI available before navigation.
The history goes back further than 16.3. Next.js 15 had experimental 'use cache' and Partial Prerendering, or PPR.
PPR prepared static content and fallbacks ahead of a request, then streamed dynamic sections. The version 15 docs advised against using it in production.
Next.js 16 introduced Cache Components to bring these ideas together. Ordinary Suspense streaming alone doesn't give you the same prerendering and caching behavior.
For our product page, suppose names can stay cached for the 'hours' profile while inventory needs a fresh read:
import { cacheLife } from 'next/cache';
import { readInventory, readProduct } from '@/lib/product-queries';
export async function getProduct(slug: string) {
'use cache';
cacheLife('hours');
return readProduct(slug);
}
export async function getInventory(slug: string) {
return readInventory(slug);
}readProduct and readInventory stand in for database queries. 'use cache' caches the product result, with slug in the cache key.
Inventory stays uncached and loads behind its own fallback. You choose that split based on how fresh each piece of data needs to be.
What actually changes in 16.3
The router changes what it prefetches, and the development tools help you spot navigations that block. Those are concrete reasons to consider the update.
Links can share one loading shell
With Partial Prefetching, Next.js prefetches one reusable App Shell per route. That shell contains initial UI that doesn't depend on a particular link's URL.
For /products/[slug], the shoes and hats links can share the same shell. Next.js doesn't need a separate shell prefetch for every product link.
URL-specific content arrives after navigation by default. An explicit prefetch={true} can also fetch cached content for that URL.
With both flags enabled, our links look like this:
import Link from 'next/link';
export function ProductLinks() {
return (
<nav aria-label="Products">
<Link href="/products/shoes">Shoes</Link>
<Link href="/products/hats">Hats</Link>
<Link href="/products/hats" prefetch={true}>
Hats with product details prefetched
</Link>
<Link href="/products/bags" prefetch={false}>
Bags without prefetching
</Link>
</nav>
);
}Each link makes a different choice:
- The default shoes and hats links share the shell and its loading fallbacks.
- The explicit hats prefetch can also fetch the cached product name.
- The bags link requests no prefetch itself.
Uncached inventory still loads after the click. The prefetching guide explains how cached URL-specific content fits into this model.
There's a migration detail here. Previously, prefetch={true} could include uncached dynamic content too. Partial Prefetching changes that behavior.
If your app relies on full prefetches, review them before enabling the flag. The migration guide covers what to preserve.
Next.js can show you what blocks
With Cache Components enabled, Instant Insights and Navigation Inspector help you check what happens on a link click.
Instant Insights reports blocking navigations in development. Navigation Inspector pauses a transition at its initial UI so you can see what's ready before the data arrives.
If a route intentionally waits for server content, you can opt it out of validation:
export const instant = false;That export controls validation. It doesn't disable prefetching or remove a loading boundary.
16.3 also improves ISR for URLs you didn't prerender at build time. Those URLs can serve a loading shell while Next.js prepares the prerendered result in the background.
Why I'd enable this for coding agents
An instruction like "use Suspense for slow data" leaves room for mistakes. The earlier example already shows one.
An agent can also put a boundary in the root layout and assume it covers every navigation. It can cover the initial load while missing transitions beneath a shared layout.
The layout stays mounted, so the boundary needs to cover the part that changes. Navigation validation checks those paths.
That's why I like the new tools for agent workflows. Diagnostics identify a problem. An instant() test gives the agent behavior to preserve.
For our hats link, I want the product name to appear immediately and fresh inventory to arrive afterward. Here's how that expectation becomes a test:
import { expect, test } from '@playwright/test';
import { instant } from '@next/playwright';
test('product details appear before fresh inventory', async ({ page }) => {
await page.goto('/products/shoes');
await instant(page, async () => {
await page
.getByRole('link', {
name: 'Hats with product details prefetched',
exact: true,
})
.click();
await page.waitForURL((url) => url.pathname === '/products/hats');
await expect(page.getByRole('heading', { level: 1 })).toHaveText(
'Baseball Cap'
);
await expect(page.getByText('Checking availability...')).toBeVisible();
});
await expect(page.getByText('12 in stock', { exact: true })).toBeVisible();
});This example assumes the shoes page includes ProductLinks and the hats fixture has the name "Baseball Cap" with 12 in stock.
instant() holds back dynamic content during the callback. The prefetched product name must be ready, while "Checking availability..." stays visible.
Waiting for the destination URL prevents the test from matching the source page's heading. After the callback, inventory can arrive.
For a production test build, the helper needs this configuration:
import type { NextConfig } from 'next';
const nextConfig: NextConfig = {
cacheComponents: true,
partialPrefetching: true,
experimental: {
exposeTestingApiInProductionBuild: true,
},
};
export default nextConfig;The testing API option belongs in the build used for these tests. Your deployed app doesn't need it for instant navigation.
You still decide what needs to appear immediately and what must stay fresh. A passing check can't tell you whether a full-page spinner is a good experience.
So, should you enable the new features?
If your app already navigates responsively, you can keep your approach. Knowing your loading boundaries and prefetch behavior matters more than adopting a new feature name.
You can leave the opt-in features disabled when your current setup meets your needs. Just weigh that choice against the new prefetching behavior and tools.
If you want to follow the Cache Components model, I'd enable its checks and adopt Partial Prefetching where it fits. I'd make the same choice when agents regularly change routes.
The configuration for both features is:
import type { NextConfig } from 'next';
const nextConfig: NextConfig = {
cacheComponents: true,
partialPrefetching: true,
};
export default nextConfig;The flags do different jobs:
cacheComponentsenables the caching and rendering model, along with navigation diagnostics.partialPrefetchingchanges what links prefetch and requires Cache Components.
For an existing app, this is a migration. You still need to choose cache lifetimes, place boundaries, and write the tests you want.
Leaving these flags off is separate from skipping a framework upgrade. 16.3 also improves development memory usage, builds, and server rendering.
Instant navigation existed before 16.3. What makes this release worth considering is how it helps you, and your coding agents, keep that behavior consistent as the app changes.
Continue reading
All posts- Published on
I wrote my resume with MDX and React (New)
I write my resume in MDX, render it in PDF, and publish the PDF on my portfolio.
Gia Bảo•5 min read- Resume
- MDX
- React

- Published on
Welcome to giabao.dev
A minimal, shadcn/ui-inspired dev portfolio and blog.
Gia Bảo•1 min read- Portfolio
- Blog