Partial Prefetching in Next.js 16.4: instant navigation without prefetching the whole page

Next.js 16.4 turns on Cache Components and Partial Prefetching by default. How to decide what to prefetch, what to cache and what can wait.

Igor Calabraro

· 7 min read

In this post

For years, prefetching in Next.js was close to a binary choice: either fetch the entire destination page before the click, dynamic parts included, or turn prefetching off and push all the work until after the click. Next.js 16.4, released on October 6, settles that for good. My take: the question is no longer "prefetch or not", but "which part of each page has to be ready before the click". In this post I show how to make that call, with code, based on the case study Vercel published on October 16 about v0.

#What changed in 16.4

Partial Prefetching landed in 16.3, and 16.4 took the step that matters for new projects: create-next-app now ships with Cache Components and Partial Prefetching enabled, as the recommended model for every Next.js app.

// next.config.ts
import type { NextConfig } from "next";

const nextConfig: NextConfig = {
  cacheComponents: true,
  partialPrefetching: true,
};

export default nextConfig;

In practice, the framework now prefetches only the reusable parts of a route. Everything else loads during navigation. Cached content, including personalized content stored in the browser, can be part of the prefetch and reused when the user comes back. In existing apps you can adopt it route by route with export const prefetch = "partial" in the page file.

#Three decisions, one per page section

The core idea of the case study is to split each page into pieces and decide where each one goes. Here is how I would sum it up:

Content typeWhere the cache livesAPIHow to invalidate
Public, same for everyone (catalog, template, post)Shared server cache"use cache: remote" + cacheTag() + cacheLife()revalidateTag() when the data changes
Personalized (settings, user dashboard)The user's browser"use cache: private" + cacheLife()updateTag() in the Server Action that changes it
Low priority (comments, history, "recent" lists)Cached, but left out of the prefetchawait navigation() before fetchingSame as the block it lives in

#1. Public data: shared cache and tag-based invalidation

A Supabase example: a product page in an online store. Product details are the same for everyone, so they can go into the shared cache. Note that the Supabase client here does not read cookies: it uses the public key, so RLS only returns what the anonymous role is allowed to see. That keeps one user's data out of a cache everyone reads.

// lib/produtos.ts
import { createClient } from "@supabase/supabase-js";
import { cacheLife, cacheTag } from "next/cache";

const supabase = createClient(
  process.env.NEXT_PUBLIC_SUPABASE_URL ?? "",
  process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY ?? "",
);

export async function getPublicProduct(id: string) {
  "use cache: remote";
  cacheTag(`product:${id}`);
  cacheLife("hours");

  const { data, error } = await supabase
    .from("products")
    .select("id, name, description, price")
    .eq("id", id)
    .single();

  if (error) throw error;
  return data;
}

When the product is edited in the admin panel, a Server Action marks the cache as stale:

// app/admin/products/actions.ts
"use server";
import { revalidateTag } from "next/cache";

export async function publishProduct(id: string) {
  // ...write to the database
  revalidateTag(`product:${id}`, "max");
}

#2. Personalized data: cache in the browser

Settings screens and per-user dashboards must never land in a shared cache. "use cache: private" keeps the result in the browser. In v0, settings pages receive the account scope as an argument, which puts that value in the cache key and keeps each account's data apart. For data users change themselves, the pairing is always a cached read plus updateTag() in the Server Action, which expires the cache immediately and shows the new value in the same round trip.

That is the scenario of systems like the custom CRM in my portfolio, which has permission-based access: what each person sees depends on who they are, so that content is a candidate for the private cache, never the shared one.

#3. Low priority: cached, but outside the prefetch

The new API in 16.4 is navigation(), imported from next/cache. Any cached work after it waits until the page opens, yet stays available when the user returns. It fits blocks that are useful but not urgent:

// app/products/[id]/reviews.tsx
import { navigation } from "next/cache";

export async function Reviews({ productId }: { productId: string }) {
  await navigation();
  const reviews = await getReviews(productId);
  return <ReviewList items={reviews} />;
}

On the page, this component sits inside a Suspense with its own skeleton. Title, price and the buy button arrive with the prefetch, and reviews load right after.

#Watch out for large grids: prefetch on intent

This is the detail I found most interesting in the case study. In the first pass, the team added prefetch={true} to every card on v0's templates page. Content was ready before the click, but prefetch requests to the detail route went up 2.9×, because every visible card triggered a full prefetch.

The fix was to wait for a signal of intent: hover, focus or touch. A reusable component for that:

// components/intent-link.tsx
"use client";
import Link from "next/link";
import { type ReactNode, useState } from "react";

interface IntentLinkProps {
  href: string;
  children: ReactNode;
  className?: string;
}

export function IntentLink({ href, children, className }: IntentLinkProps) {
  const [intent, setIntent] = useState(false);
  const markIntent = () => setIntent(true);

  return (
    <Link
      href={href}
      className={className}
      prefetch={intent ? true : "auto"}
      onMouseEnter={markIntent}
      onFocus={markIntent}
      onTouchStart={markIntent}
    >
      {children}
    </Link>
  );
}

My rule of thumb: small, fixed groups (menus, tabs, three or four main links) can prefetch as soon as they are visible. Large lists and grids prefetch on intent.

#Turn the goal into a test

The case study relies on the instant() helper from the @next/playwright package. It holds back content that would only arrive during navigation, so the test can assert what was already in place before the click. If someone removes the cache from a block later on, the test fails in CI. I prefer that kind of guarantee over "it feels fast on my machine".

#Why this matters if you are hiring

Instant navigation is not decoration: it is what makes an internal dashboard feel like an app and keeps a store from losing the customer between the listing and the product page. But prefetching without a plan costs requests, CPU and bandwidth. What I want to show with this kind of analysis is the reasoning I bring to projects: separate public data from user data, decide what must be ready before the click, and lock that decision in with a test.

#Conclusion

  • Think per page section, not per route: public content goes to "use cache: remote", personalized content to "use cache: private", and whatever can wait goes after navigation().
  • Prefetch on intent in large lists. In v0, prefetching every visible card multiplied requests by 2.9.
  • Lock the behavior in with instant() in CI, and check where the remote cache lives if your app does not run on Vercel.

#Sources

Want this level of care in your project?

Free 30-minute diagnosis: I look at your stack and tell you what needs attention.

Get my diagnosis

Keep reading

All posts

Rather have me handle this in your project?

The same care as the posts, applied to your product. Free 30-minute diagnosis, no strings attached.