Improving navigation experience in v0 with Next.js 16.4

How we used Partial Prefetching and a coding agent to improve navigation across v0 with Next.js 16.4.

Aurora Scharff 9 min readOriginal

Aurora Scharff

Previously, we shared how a coding agent used the

instant()

Playwright test helper to make navigations instant in v0, Vercel's AI app builder. That work focused on the core

/chat

experience.

In other parts of v0, users saw loading skeletons when they returned to pages they had already opened. Useful content was not always ready before the first click, either. We wanted those pages to behave more like a single-page app, with content prefetched on the way in and still available on the way back.

Next.js 16.3 introduced Partial Prefetching. Next.js 16.4 added more control over when cached content loads, along with the new next-partial-prefetching-optimizer Skill for optimizing what gets prefetched.

In this post, we'll look at how we used the Skill to improve v0's navigation experience.

In previous versions of Next.js,

prefetch={true}

fetched the entire destination, including dynamic content. This could fetch pages users never opened, while clicking before the prefetch finished still led to a slow navigation. Turning prefetching off avoided those requests but moved all of the work until after the click.

With Partial Prefetching, Next.js prefetches the reusable parts of a route so they're ready before the click, while the rest loads during navigation. Cached content, including personalized content stored in the browser, can be prefetched and reused on return visits. For selected links, prefetch={true} resolves the destination URL so cached content for a dynamic route such as

/products/[id]

can be included in the prefetch.

Starting with Next.js 16.4,

create-next-app

enables Cache Components and Partial Prefetching by default, as the recommended model for all Next.js apps:

import type { NextConfig } from 'next';
 
const nextConfig: NextConfig = {
  cacheComponents: true,
  partialPrefetching: true,
};
 
export default nextConfig;

When we started this work, v0 was already using Cache Components. The earlier work on

/chat

had also incrementally adopted Partial Prefetching for a few routes:

export const prefetch = 'partial';

To bring the same model to the rest of v0, we enabled Partial Prefetching globally before starting the refactor. The adoption guide includes an existing next-partial-prefetching-adoption Skill for this change.

Each navigation needed a clear, testable target for the prefetched UI.

The instant() Playwright helper acts as a lock around the click. It holds back content that only arrives during navigation, so a test can assert on the UI available before the click:

import { instant } from '@next/playwright';
import { expect, test } from '@playwright/test';
 
test('prefetches the primary template UI', async ({ page }) => {
  await page.goto('/templates');
  const card = page.getByTestId('template-card-link').first();
  await card.hover();
 
  await instant(page, async () => {
    await card.click();
    await page.waitForURL('**/templates/*');
    await expect(page.getByTestId('template-detail-skeleton')).toHaveCount(0);
    await expect(page.getByTestId('template-detail-header')).toBeVisible();
    await expect(page.getByTestId('template-detail-title')).toBeVisible();
    await expect(page.getByTestId('template-detail-preview')).toBeVisible();
  });
});

In this example, the test checks that the template header, title, and preview are already available without showing the broad loading skeleton. If any of that UI is missing, the test fails.

To automate this process, Next.js 16.4 includes the new next-partial-prefetching-optimizer Skill. It guides an agent through the following loop until the

instant()

test passes:

Choose the navigation and UI to prefetch │ ▼ Write a passing instant() test for the current UI │ ▼ Add the target assertions and confirm they fail │ ▼ Adjust caching and prefetching ◀──────────────┐ │ │ ▼ │ Rebuild and rerun the test │ │ │ ├── still failing ─────────────────┘ │ ▼ passing Verify the regression check and keep the test

This is especially useful in a larger codebase, where reaching the intended UI can take several iterations and refactors. The agent can keep running the loop until the

instant()

test passes.

The test remains in the codebase and can run in CI to catch future regressions.

The Skill can make a best effort from a broad goal. For this work, we wanted more control over exactly how the UI loaded, so the prompts below define what should be ready before the click and what could wait.

We asked an agent with the Skill installed to run this workflow across v0 and split the work into feature-specific PRs. For each PR, we tried the interaction in the preview before merging, then tracked four production metrics after it shipped:

Let's look at a few examples of how the Skill worked.

Before the refactor, opening a card from

/templates

showed a loading skeleton while the template details loaded. Returning to the same template fetched those details again and brought back the skeleton.

The UX we wanted instead was:

When I open a Template from the "Templates" page, prefetch its header, title, and preview so I see them right away.

To make that UX possible, the agent separated public template data from personalized viewer state. The public data was safe to share across users, so the agent cached it with 'use cache: remote'.

On Vercel, this stores the result in Runtime Cache, where requests from different users and function instances can reuse it. With a self-hosted

next start

,

'use cache: remote'

uses memory unless you configure cacheHandlers.remote. Plain 'use cache' also uses memory by default.

The cached data also needed a lifetime and a way to invalidate it. cacheLife() sets the lifetime, while cacheTag() provides an invalidation key:

import { cacheLife, cacheTag } from 'next/cache';
 
async function loadCachedPublicTemplatePageData(templateId: string) {
  'use cache: remote';
  cacheTag(`template:${templateId}`);
  cacheLife('hours');
 
  return loadPublicTemplatePageData(templateId);
}

Publishing or updating a template calls revalidateTag(), marking the cached data stale so a later request refreshes it.

With the template data now reusable, the agent added prefetch={true} to each link from

/templates

:

import Link from 'next/link';
 
<Link href={`/templates/${template.id}`} prefetch={true}>
  <TemplateCard template={template} />
</Link>;

After the refactor, the template header, title, and preview were ready before the click and remained available when users returned:

The first rollout made the selected template's content available immediately, but it also started a richer prefetch for every visible card in

/templates

. The production metrics showed that prefetch requests to

/templates/[id]

had increased 2.9×, so we tuned when the richer prefetch started.

We still wanted to prefetch the card a user was likely to open, so we asked the agent:

On the "Templates" page, wait until I hover, focus, or touch a card before prefetching its details. Don't prefetch every card as soon as it appears.

The agent changed the richer prefetch to wait for user intent, starting on hover, focus, or touch rather than when the card entered the viewport.

The core behavior can be expressed in a few lines:

const [prefetch, setPrefetch] = useState(false);
 
<Link
  href={href}
  prefetch={prefetch}
  onMouseEnter={() => setPrefetch(true)}
  onFocus={() => setPrefetch(true)}
  onTouchStart={() => setPrefetch(true)}
/>;

In v0, the agent added this behavior to an existing custom Link wrapper:

<PrefetchLink href={`/templates/${template.id}`} prefetch="intent">
  <TemplateCard template={template} />
</PrefetchLink>

For the Templates grid, this kept the selected Template's details available before the click without treating every card as equally likely to be opened. Throughout the refactor, the pattern was also used for other large lists and grids in v0. Smaller, fixed groups continued to prefetch when their links became visible.

Before the refactor, pages under

/settings

could show their loading state on the first visit and again when users returned. Because the content is personalized, it needed to stay in the browser rather than a cache shared between users.

The UX we wanted was:

When I move between pages in "Settings", I want "Advanced", "Workspace", and "Usage" prefetched before I click them. Let the other pages load when I open them, then keep them available if I go back.

The agent used 'use cache: private' to keep this content in the browser.

URLs under

/settings

include the account or team in

[scope]

, such as

/acme/settings/advanced

. Passing

scope

into the cached function makes it part of the cache key, keeping each account or team's content separate. The Advanced page could reuse the result for five minutes:

import { cacheLife, cacheTag } from 'next/cache';
 
async function AdvancedSettings({ scope }: { scope: string }) {
  'use cache: private';
  cacheLife({ stale: 300 });
  cacheTag('settings:advanced');
 
  return <AdvancedSettingsContent scope={scope} />;
}

For cached data that users could change, the agent paired each read with the action that updates it. For example, when users save Custom Instructions for v0 to follow across chats and projects, the Server Action calls updateTag(). This immediately expires the cached Advanced settings, so users see the new value in the same roundtrip.

Once this content was cached, the agent added

prefetch={true}

to the Advanced, Workspace, and Usage links to resolve each scoped destination and include its cached content in the prefetch. Other pages under

/settings

loaded after the click, then remained available when users returned.

After the refactor, the selected pages opened with their content ready, while the others stayed available after the first visit:

We also wanted to improve navigation from

/projects

into an individual project. Before the refactor, opening one showed a broad loading state. Reopening it fetched the content again and brought the loading state back.

The Recent Chats section was still useful to cache for return visits, but it did not need to be prefetched.

Next.js 16.4 includes a new navigation() API that lets you decide which cached work waits until navigation:

import { navigation } from 'next/cache';
 
async function Comments() {
  await navigation();
  const comments = await getComments();
  return <CommentList comments={comments} />;
}

Cached work after

navigation()

waits until the page opens, then stays available when users return. This works well for lower-priority content that isn't needed right away.

For this navigation, the UX we wanted was:

When I open a Project from the "Projects" page, I want the title and "Production Deployment" ready when I click. Prefetch them on hover, focus, or touch. Keep "Recent Chats" cached for return visits, but don't prefetch it.

The agent cached and prefetched the project details, with

navigation()

before Recent Chats:

import { Suspense } from 'react';
import { navigation } from 'next/cache';
 
function ProjectPage({ project }: { project: Project }) {
  return (
    <>
      <ProjectDetails project={project} />
      <Suspense fallback={<RecentChatsSkeleton />}>
        <RecentChats projectId={project.id} />
      </Suspense>
    </>
  );
}
 
async function RecentChats({ projectId }: { projectId: string }) {
  await navigation();
 
  const chats = await loadProjectChats(projectId);
  return <ChatList chats={chats} />;
}

Another use of

navigation()

was

/settings/usage

. The agent kept the tabs, filters, and Activity UI in the prefetch, while the credits dashboard and usage fields loaded during navigation and remained available on return visits.

After the refactor, the broad loading state was gone. The project title and Production Deployment section appeared first, followed by Recent Chats, which stayed cached for return visits:

We reviewed the metrics after each PR and tuned the work as we went. Once all the PRs had merged, the whole-app comparison showed total daily cost was 2.9% lower, even as Shared Runtime Cache spend grew to 4.3× its pre-refactor level, adding about $3.44 per day. Fewer requests and lower compute and transfer offset that increase, bringing request-serving costs down 14.4%.

Traffic and unrelated deployments changed during the same period, so we treated the whole-app numbers as confirmation that the refactor had not made v0 more expensive, not as savings caused solely by this work.

We also doubled the number of

instant()

tests covering these navigations. Across the areas we changed, users saw more useful content as soon as they opened a page and fewer loading states when they returned. The tests now protect that experience as v0 evolves.

You can use the next-partial-prefetching-optimizer Skill to run the same loop in your app. It assumes your app already uses Cache Components and Partial Prefetching. The Cache Components migration guide and Partial Prefetching adoption guide cover those prerequisites.

Install the Skill for your coding agent:

npx skills add vercel/next.js --skill next-partial-prefetching-optimizer

Once installed, most coding agents will invoke it automatically when the request matches. Depending on your harness, you can also ask the agent to invoke the

next-partial-prefetching-optimizer

Skill or use

/partial-prefetching-optimizer

.

Describe the navigation you want to improve in your own words, as in the examples above, or ask the agent to find opportunities in your app.

To apply these patterns yourself, follow the Optimizing prefetching guide. The Skill uses the same guidance under the hood.

Try it on your own app, and let us know how it goes:

Originally published on

Next.js Blog

Read original

Keep reading