Let's fix sluggish route transitions by combining Partial Prefetching, remote caching, and automated Playwright tests to make dynamic pages feel like a single-page app.
What you'll learn:
- How Partial Prefetching separates static layout shells from dynamic data fetches.
- Setting up the
instant()test helper in Playwright to verify pre-click UI states. - Caching public and private data safely using
'use cache'directives. - Fine-tuning prefetch strategies on large grids to avoid unnecessary bandwidth costs.
Enabling Partial Prefetching Globally
To get instant navigations, you want Next.js to prefetch reusable UI components before a user even clicks a link. Next.js 16.4 enables cacheComponents and partialPrefetching right out of the box in create-next-app.
Here is how to configure it manually in your project config:
// next.config.ts
import type { NextConfig } from 'next';
const nextConfig: NextConfig = {
cacheComponents: true,
partialPrefetching: true,
};
export default nextConfig;
Speed Note: Partial Prefetching solves the classic trade-off where
prefetch={true}either downloads massive payloads or leaves everything until after the click event. Only the static shell and cached data fragments travel ahead.
Testing Instants with Playwright
Before optimizing your routes, you need a deterministic way to prove that the UI is ready before the navigation click happens. We use the @next/playwright helper to lock execution.
Write a test that asserts critical headers and previews are already visible:
// e2e/template-navigation.spec.ts
import { instant } from '@next/playwright';
import { expect, test } from '@playwright/test';
test('prefetches primary template UI before click', async ({ page }) => {
await page.goto('/templates');
const card = page.getByTestId('template-card-link').first();
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();
});
});
Gotcha: If your test fails on skeleton checks, it means your prefetch payload isn't resolving fast enough or the route lacks proper caching directives.
Caching Public Data with Remote Caches
When dealing with shared public data like product details or templates, you can use 'use cache: remote' to store results in a shared Runtime Cache across users and server instances.
Combine this with cacheLife and cacheTag for invalidation control:
// app/templates/[id]/page.tsx
import { cacheLife, cacheTag } from 'next/cache';
async function loadCachedPublicTemplateData(templateId: string) {
'use cache: remote';
cacheTag(`template:${templateId}`);
cacheLife('hours');
return fetchPublicTemplateData(templateId);
}
export default async function TemplatePage({ params }: { params: { id: string } }) {
const data = await loadCachedPublicTemplateData(params.id);
return <TemplateView data={data} />;
}
When you update or publish a template in a Server Action, simply call revalidateTag(\template:${id}`)` to purge stale caches instantly without a full rebuild.
Controlling Bandwidth on Large Grids
If you have large lists or grids (like dozens of template cards), prefetching every single item on page load will spike your network transfer and CPU costs. Instead, trigger prefetches based on user intent.
Build a wrapper that listens for hover, focus, or touch events:
// app/components/prefetch-link.tsx
'use client';
import Link from 'next/link';
import { useState, type ComponentProps } from 'react';
export function PrefetchLink({ href, children, ...props }: ComponentProps<typeof Link>) {
const [intent, setIntent] = useState(false);
return (
<Link
href={href}
prefetch={intent ? true : 'auto'}
onMouseEnter={() => setIntent(true)}
onFocus={() => setIntent(true)}
onTouchStart={() => setIntent(true)}
{...props}
>
{children}
</Link>
);
}
Trade-off: Using
prefetch="intent"keeps your initial bundle requests lightweight while ensuring smooth navigations the exact millisecond a user shows interest in a card.
Handling Private User Settings
Personalized pages under /settings shouldn't live in a shared remote cache. Use private browser caching to keep user data secure while still enabling instant back-and-forth navigation.
Pass user scopes into your cache keys:
// app/[scope]/settings/advanced/page.tsx
import { cacheLife, cacheTag } from 'next/cache';
async function AdvancedSettings({ scope }: { scope: string }) {
'use cache: private';
cacheLife({ stale: 300 });
cacheTag(`settings:advanced:${scope}`);
const settings = await fetchUserSettings(scope);
return <AdvancedSettingsForm settings={settings} />;
}
Whenever a user mutates their preferences via a Server Action, trigger revalidateTag() to invalidate the scoped cache entry on the fly.
Summary
Partial Prefetching bridges the gap between static speed and dynamic flexibility in Next.js 16.4. By isolating static shells, leveraging remote and private caches, and triggering loads based on actual user intent, you eliminate loading skeletons for good.
Next Steps:
- Enable
partialPrefetching: truein yournext.config.ts. - Add Playwright
instant()tests around your most critical user journeys. - Audit your list grids and switch high-cost links to intent-based prefetching.