Building App-like Experiences with Next.js 16.3
We released Next.js 16.3 earlier this month with Instant Navigations, powered by Cache Components and Partial Prefetching. Cache Components make sure a route has UI it can show immediately, while Partial Prefetching brings that UI to the browser before someone clicks. Together, they give you the responsive navigation people expect from a single-page application (SPA), without giving up the benefits of Server Components.
Let’s see how these features come together in a set of demo apps: the music player Next Beats, the social feed Drop, the calendar Flow, and the team chat Huddle.
Navigating instantly
With Instant Navigations, you can click around and the next page is there right away, the way a single-page app feels.
In Next Beats, watch the loading fallback appear as soon as a track or playlist is selected:
The pages still render on the server. Cache Components ensure an initial prerendered shell of static, cached, and fallback UI, with dynamic content streaming through Suspense.
Partial Prefetching fetches that shell for visible components before the click and reuses one shell across links to the same route. The browser can show the prefetched UI immediately while the server finishes the rest.
Next Beats enables both features in next.config.ts:
import type { NextConfig } from 'next';
const nextConfig: NextConfig = {
cacheComponents: true,
partialPrefetching: true,
};
export default nextConfig;
Read the Instant Navigations guide to see how to structure routes with UI ready when someone clicks. If your project is not using Cache Components yet, follow the Cache Components migration guide, or give your coding agent the adoption Skill.
Caching across navigations
A loading fallback makes the first visit responsive. With Cache Components, the data behind a page can persist across navigations, so a revisit can skip that fallback.
In Drop, compare the first visits to Home and Profile with the return visits at the end:
Mark the read with 'use cache' so Next.js can reuse the result instead of querying the data source on each render. The cached function’s arguments become part of the cache key, and cacheLife can adjust how long the result stays fresh.
The browser also caches prefetched and visited route payloads. While a payload stays fresh, revisiting the page can reuse it without another server request.
In Drop, the post ID becomes part of the cache key, and the read adds tags that a later mutation can expire:
import { cacheLife, cacheTag } from 'next/cache';
async function getDrop(id: string) {
'use cache';
cacheLife('minutes');
cacheTag('drops', `drop-${id}`);
const row = await prisma.drop.findUnique({ where: { id } });
if (!row) notFound();
return toDrop(row);
}
Learn more about caching in Next.js, including how cached data is reused and revalidated.
Prefetching URL-specific content
Caching speeds up revisits. With Partial Prefetching, a first visit can arrive with more of its content already in place.
Back in Next Beats, notice how the track header is already there during the second set of clicks while the recommendations continue loading:
By default, a visible prefetches one App Shell per destination route, shared by links to that route. Static and cached content can be part of the shell, while dynamic or URL-dependent content streams in after navigation.
Add prefetch={true} when a specific link should also resolve its params, searchParams, or full URL before the click. URL-dependent reads marked with 'use cache' can then be included in that link’s prefetch, so a product or detail page arrives with its content ready.
A visible link with prefetch={true} can invoke the server as it enters the viewport, so use it where having the content ready is worth the request. The track links in Next Beats opt in:
import Link from 'next/link';
<Link href={`/track/${track.id}`} prefetch={true}>
{track.title}
Link>;
Read the prefetching guide for the default behavior and intent-triggered patterns, and optimizing prefetching for URL-specific content and the trade-offs of prefetch={true}. To update an existing app, follow the Partial Prefetching adoption guide, or let your coding agent work through it with the adoption Skill.
Adding client-side interactivity
Fast pages still need responsive controls. With Client Components, you can make the interactive parts of a page respond immediately while data fetching stays on the server.
In Next Beats, watch the play button, now-playing bar, and track controls stay in step as the player starts, pauses, and skips:
Mark an interactive module with 'use client'. Its components can use state, event handlers, and browser APIs, while the rest of the route stays server-rendered and ships less JavaScript.
Shared state can live in a context provider and be read through a hook, so interactive parts across the tree stay in sync. Placing the provider in a shared layout keeps it mounted as routes change, while its children can remain Server Components.
The shared layout in Next Beats wraps both the route content and persistent controls in the provider:
import { NowPlayingBar } from '@/components/now-playing-bar';
import { PlayerProvider } from '@/providers/player-provider';
export default function AppLayout({ children }: { children: React.ReactNode }) {
return (
<PlayerProvider>
{/* ...navigation... */}
<main>{children}main>
<NowPlayingBar />
PlayerProvider>
);
}
Read about combining Server and Client Components to add interactive islands without moving the whole app into the browser.
Revalidating after mutations
When interactive controls change server data, the cached views that show it need to stay in sync. You can keep the data cached and still see your changes immediately across pages.
In Drop, watch a repost appear on Profile after adding it from Home, then disappear after removing it:
Tag a 'use cache' read with cacheTag, then call updateTag from the Server Action to expire that tag. The current page can show local feedback while the Action runs.
The next request for the tagged data fetches a fresh result. With Partial Prefetching, a visible can fetch that update ahead of the click, so the fresh content is ready on navigation.
The tags to expire depend on where the changed data appears. In Drop, toggling a repost changes both the drop and the signed-in user’s profile, so the Action expires both after the write:
'use server';
import { updateTag } from 'next/cache';
import { verifyAuth } from '@/features/user/user-queries';
export async function toggleRepost(dropId: string) {
const me = await verifyAuth();
// ...create or delete the repost in the database...
updateTag(`drop-${dropId}`);
updateTag(`user-drops-${me}`);
// ...expire other affected views...
return { ok: true as const };
}
See how revalidation keeps cached data fresh after a mutation.
Handling connection drops
App-like experiences should also survive a temporary connection loss. When the connection drops mid-session, your app can wait it out and pick back up when you reconnect.
In Next Beats, watch what remains visible as tracks and playlists open offline, then how the unfinished playlist recovers after reconnecting:
With offline retry enabled, a failed soft navigation, React Server Component fetch, prefetch, or Server Action stays pending instead of throwing, then retries automatically. The useOffline hook lets you show a reconnecting bar while it waits.
Because the App Shell was already prefetched, a soft navigation can still render it, along with any data included in that prefetch.
Next Beats enables offline retry alongside Cache Components and Partial Prefetching:
import type { NextConfig } from 'next';
const nextConfig: NextConfig = {
cacheComponents: true,
partialPrefetching: true,
experimental: {
useOffline: true,
},
};
export default nextConfig;
Read our guide on handling connectivity drops for the supported requests, retry behavior, and reconnecting feedback.
Streaming with Suspense
Depending on what is cached and prefetched, sections of a route can become ready at different times. With Suspense, you can control how they are revealed so the page loads fast (LCP) and stays stable (CLS).
In Drop, compare how the replies appear below a long post and a short one:
Sometimes you don’t know the size of your content until it loads. If you split it into separate boundaries, they resolve independently and can push each other around as they land.
Instead, you can nest the boundaries. The work can still run in parallel, but the nested boundary waits to show a section until the one above it is in place. The page settles from the top down without delaying the work.
The Drop post route places the replies inside the boundary for the post above them:
import { Suspense } from 'react';
<Suspense fallback={<DropDetailSkeleton />}>
{params.then(({ id }) => (
<>
<DropDetail id={id} />
<Suspense fallback={<RepliesSkeleton />}>
<Replies id={id} />
Suspense>
>
))}
Suspense>;
Read the streaming guide for more ways to reveal content with Suspense.
Updating optimistically
Streaming keeps navigation responsive while data loads. For mutations, React features like transitions and optimistic updates can show feedback immediately, however slow the network is.
In Next Beats, watch playlists and favorites change before each save finishes, including what happens when a change is rejected:
A useTransition tracks the Server Action and resulting server update as one pending operation. Starting the Action inside startTransition keeps the update in the same transition.
Set an optimistic value with useOptimistic inside that transition to render it immediately. If the Action fails, React returns to the last confirmed value, and you can show an error toast.
The favorite button in Next Beats applies the optimistic value before calling the Server Action:
'use client';
import { useOptimistic, useTransition } from 'react';
import { toggleFavorite } from '@/features/track/track-actions';
export function FavoriteButton({ trackId, isFavorite }: FavoriteButtonProps) {
const [, startTransition] = useTransition();
const [optimisticFavorite, setOptimisticFavorite] = useOptimistic(isFavorite);
function handleToggle() {
startTransition(async () => {
setOptimisticFavorite(!optimisticFavorite);
await toggleFavorite(trackId);
});
}
return (
<button aria-pressed={optimisticFavorite} onClick={handleToggle}>
Favorite
button>
);
}
The interactive apps guide walks through transitions, optimistic updates, and Server Actions together.
Composing complex apps
Fetching on demand or per user doesn’t have to mean blocked navigations or endless spinners. These patterns compose into complex apps that respond immediately while still rendering and fetching data on the server.
In Flow, notice how switching views navigates instantly with content already available, and how creating a calendar and editing events for the signed-in user update right away:
Server Components verify the signed-in user and authorize the data, while Client Components own the interactions. A client provider can share interaction state across Client Components while its server-rendered children continue to fetch and render the user’s data on the server.
The Flow calendar places the provider around the streamed month or week view:
<CalendarEventsProvider>
<Suspense fallback={<CalendarViewFallback />}>
{Promise.all([params, searchParams]).then(([{ date }, { view }]) =>
toView(view) === 'month' ? (
<CalendarMonth date={date} />
) : (
<CalendarWeek date={date} />
),
)}
Suspense>
CalendarEventsProvider>
Partial Prefetching prepares the route before the click, and dynamic data streams through the Suspense fallback. When another change happens before the previous save finishes, the provider can run the saves in order with useActionState and keep the pending changes on screen with useOptimistic.
The provider dispatches each change inside a transition:
'use client';
import {
startTransition,
type ReactNode,
useActionState,
useOptimistic,
} from 'react';
import { toast } from 'sonner';
import { saveEventChange } from '@/features/calendar/calendar-actions';
import type { EventChange } from '@/features/calendar/types/calendar';
// ...context declarations...
export function CalendarEventsProvider({ children }: { children: ReactNode }) {
const [, dispatch] = useActionState(async (_: void, change: EventChange) => {
const result = await saveEventChange(change);
if (result.error) {
toast.error(result.error);
}
// ...success toasts...
}, undefined);
const [pendingChanges, addOptimisticChange] = useOptimistic<
EventChange[],
EventChange
>([], (changes, change) => [...changes, change]);
function mutate(change: EventChange) {
startTransition(() => {
addOptimisticChange(change);
dispatch(change);
});
}
return (
<CalendarEventsContext value={{ pendingChanges, mutate }}>
{children}
CalendarEventsContext>
);
}
Read more about building single-page applications, including how to coordinate repeated mutations with useActionState and useOptimistic.
Fetching data on the client
Server Components can own most data fetching, but some interactions need the browser to keep server state synchronized as it changes. A client data library can poll for new data, revalidate on focus, dedupe requests, and coordinate updates across components without giving up the initial server render.
In Huddle, notice how Activity and unread markers clear, how the command palette searches on demand, then how replies remain available while moving between two Huddle Bot threads:
For on-demand data, fetch from the Client Component when the interaction needs the result. Handle the loading state inside the component with useSWR or useQuery, or at a Suspense boundary with suspense: true or useSuspenseQuery.
When the initial view needs the data, start the request in a Server Component and provide it through SWRConfig or HydrationBoundary. This avoids a client waterfall, and the browser can take over polling, on-demand queries, and optimistic updates when the data arrives.
In Huddle’s SWR branch, the Server Component preloads the messages and passes them to the client tree through SWRConfig:
import { preload, SWRConfig } from 'swr';
import { messageKeys } from '@/features/message/message-cache';
import { getMessagesForUser } from '@/features/message/message-queries';
export async function MessageThread({ channelId }: { channelId: string }) {
const user = await getCurrentUser();
const messageData = preload(messageKeys.channel(channelId), () =>
getMessagesForUser(channelId, user.id),
);
return (
<SWRConfig value={{ cacheData: { ...messageData } }}>
<MessageList channelId={channelId} />
SWRConfig>
);
}
The Client Component reads the same key with suspense enabled and continues polling from there:
'use client';
import useSWR from 'swr';
import { messageKeys } from '@/features/message/message-cache';
import { fetchJson } from '@/lib/fetch-json';
export function useSuspenseMessages(channelId: string) {
return useSWR(messageKeys.channel(channelId), fetchJson, {
refreshInterval: 10_000,
suspense: true,
});
}
Read the client-side data fetching guide for complete SWR and React Query examples.
Animating with View Transitions
Once navigation, data, and mutations respond immediately, animation can make those changes easier to follow. With React’s , you can animate streamed reveals, list changes, and route transitions so content moves into place smoothly.
Watch streamed content fade in on Drop, lists and nearby content move into place on Next Beats, and Flow’s calendar slide with navigation:
1. Suspense reveals
In Drop, streamed feeds, posts, and replies are wrapped in a so they fade in when Suspense replaces the skeleton. A small wrapper handles each reveal:
import { ViewTransition, type ReactNode } from 'react';
export function Crossfade({ children }: { children: ReactNode }) {
return (
<ViewTransition enter="auto" default="none">
{children}
ViewTransition>
);
}
On the post route, one wraps the post detail and another wraps the replies so the nested Suspense boundaries animate independently:
import { Suspense } from 'react';
<Suspense fallback={<DropDetailSkeleton />}>
<Crossfade>
<DropDetail id={id} />
<Suspense fallback={<RepliesSkeleton />}>
<Crossfade>
<Replies id={id} />
Crossfade>
Suspense>
Crossfade>
Suspense>;
2. Morphs
In Next Beats, removing a favorite shortens the list and gives the remaining rows and recommendations below it new positions. View Transitions animate those layout changes instead of letting the content jump. The favorite update already runs inside a transition, so React can capture the layout before and after the item is removed.
Wrap each keyed favorite in a so React can move the remaining rows into their new positions:
import { ViewTransition } from 'react';
{
tracks.map((track, i) => (
<ViewTransition key={track.id}>
<div className="transition-opacity has-data-removing:opacity-50">
<TrackRow track={track} index={i} queue={tracks} />
div>
ViewTransition>
));
}
The shorter favorites list also moves the recommendations below it upward. A second animates that section into its new position:
<ViewTransition>
<section>
<h2>You Might Also Likeh2>
<Discover />
section>
ViewTransition>
3. Page transitions
A page transition can show whether navigation is moving forward or back. Flow’s calendar links add transition types for both directions:
import Link from 'next/link';
<Link
href={calendarHref(previous, view)}
prefetch={true}
transitionTypes={['nav-back']}
>
Previous {period}
Link>
<Link
href={calendarHref(next, view)}
prefetch={true}
transitionTypes={['nav-forward']}
>
Next {period}
Link>
The Flow calendar maps those transition types to animation names on the content that changes, scoping the transition to the calendar board:
import { ViewTransition } from 'react';
import type { ReactNode } from 'react';
const directionalSlide = {
'nav-back': 'nav-back',
'nav-forward': 'nav-forward',
default: 'none',
};
export function DirectionalSlide({
children,
name,
}: {
children: ReactNode;
name: string;
}) {
return (
<ViewTransition default="none" name={name} share={directionalSlide}>
{children}
ViewTransition>
);
}
The class names style the old and new view-transition snapshots. For forward navigation, the old content moves left while the new content enters from the right:
@keyframes slide {
from {
translate: var(--slide-offset);
}
}
::view-transition-old(.nav-forward) {
--slide-offset: -60px;
animation: 200ms ease-in-out both slide reverse;
}
::view-transition-new(.nav-forward) {
--slide-offset: 60px;
animation: 200ms ease-in-out both slide;
}
Back navigation mirrors the offsets with the nav-back names.
Read our guide to designing View Transitions for more patterns, guidance on choosing what should animate, and isolating persistent elements like headers and sticky controls, or give your coding agent the React View Transitions Skill to add them for you.
Demo apps
The videos in this post come from open-source apps you can clone and explore, built on Next.js 16.3:
- Next Beats: A music player with a library, playlists, favorites, and playback that continues across navigation.
- Drop: A developer-themed social network with posts, follows, profiles, tag feeds, and cached route data.
- Flow: A calendar and booking tool. Events are created, dragged, and deleted in the client, while the calendar weeks are cached and revalidated by tag.
- Huddle: A Slack-like team chat with channels, threads, reactions, unread state, and mention autocomplete. It is available in equivalent TanStack Query and SWR variants.
The apps include Playwright end-to-end tests using the instant() helper, which scopes assertions to the prefetched UI. An example from Next Beats asserts that the heading is already visible when opening the Library page:
import { instant } from '@next/playwright';
await page.goto('/');
await instant(page, async () => {
await page.getByRole('link', { name: 'Library' }).click();
await page.waitForURL('/library');
await expect(page.getByRole('heading', { name: 'Library' })).toBeVisible();
});
Feedback and Community
Share your feedback and help shape the future of Next.js: