Network waterfalls in SPAs
A common pattern in SPAs is to render a placeholder initially and fetch data after component mount. This causes child components that fetch data to wait until parent components finish loading their own data, creating sequential client-server request waterfalls. Next.js supports client-side data fetching but also allows shifting data fetching to the server to eliminate these waterfalls.
Streaming with React Suspense in Next.js
Next.js has built-in support for streaming through React Suspense, allowing you to be intentional about which parts of the UI load first and in what order without introducing network waterfalls. This enables building faster-loading pages that eliminate layout shifts.
Data fetching strategy choice in Next.js
Next.js allows you to choose your data fetching strategy on a per-page and per-component basis. You can decide to fetch at build time, at request time on the server, or on the client. For example, you can fetch data from a CMS and render blog posts at build time, which can then be efficiently cached on a CDN.
NextRequest geo and ip migration (15.0)
The next-request-geo-ip codemod transforms geo and ip properties of NextRequest to use @vercel/functions. Run with `npx @next/codemod@latest next-request-geo-ip .`. It installs @vercel/functions and replaces `const { geo, ip } = req` with `const geo = geolocation(req)` and `const ip = ipAddress(req)`.
Stream slow data with Suspense to prevent page blocking
When multiple async reads occur at the top level of a page component, the entire page blocks until the slowest read resolves. Split reads into separate components, pass each one a promise, and wrap them in <Suspense> boundaries. This makes the page synchronous, returns the shell immediately, and allows the server to stream each section in as its data resolves. The page shell paints first, then sections stream in as their data becomes available.
useOptimistic with useTransition for instant UI feedback
Use useOptimistic to provide a value to render while async Server Function work completes, and useTransition to run the Server Function as part of a transition. Call setOptimisticValue to update UI on the current frame, and read from the optimistic value instead of the stale prop. When the transition ends and fresh data arrives, the optimistic value reverts to the new server-rendered prop. If the Server Function throws, the error forwards to the nearest error boundary.
Handle pending feedback in reusable components with action props
Separate container logic from UI components. Container components handle navigation or mutation callbacks. Reusable UI components accept action props (named action or suffixed with Action by convention) and run them inside startTransition or useActionState. Use useOptimistic internally to track pending state and expose it via data-pending attribute. This allows ancestor components to style pending states with CSS without state coordination.
data-pending attribute for CSS-driven pending feedback
Set data-pending attribute to an empty string when pending and undefined when not. Ancestor components can style with has-data-pending: or group-has-data-pending: CSS variants. This allows any ancestor to react to pending state of descendant work without lifting state or threading callbacks. Compiles to CSS :has() selector which browser re-evaluates over anchored subtree when data-pending toggles.
Track pending comments with useOptimistic and empty initial array
Split comment lists into server component rendering persisted comments and client component tracking only pending comments. Use useOptimistic([]) with empty initial array. When form submits: clear input via formRef.reset() (applies on current frame), add optimistic comment with client-generated UUID to pending list, run Server Function in transition. When transition completes and fresh render arrives, pending list resets to empty and real comment appears in server list.
Optimistic updates with reducer for complex state
Use useOptimistic with a reducer function as second argument to handle complex state transformations. The reducer receives current state and an action object, and returns the new state. Example: remap card status when dropped to new column. If background refresh arrives mid-operation, React re-runs reducer with updated base data so optimistic update sits on top of fresh data.
useActionState for coordinated form lifecycle
useActionState handles three pieces of form state: isPending for button state, key-based reset for fields, and wrapped state updates. Returns [state, formAction, isPending]. Increment key in returned state to remount form fields and reset inputs. Runs action as transition so state updates within action stay part of transition. State updates after await are not automatically part of transition, so wrap post-await updates like setIsOpen(false) in startTransition.
Post-await state updates must wrap in startTransition
State updates after await inside useActionState are not automatically part of the transition. Wrap post-await state updates like setIsOpen(false) in startTransition to batch them with other updates triggered by the action, such as refresh() calls. Without this, dialog closes before board updates, appearing on separate frames.
Side effects after Server Function should not wrap in transition
Side effects like analytics, toasts, and focus changes run after the await resolves in Server Functions. They do not need wrapping in startTransition because they don't update React state. Place them after the await inside the action handler.
useOptimistic(false) for pending state without useTransition
When you need pending state without the broad UI effects of useTransition, use useOptimistic(false) directly. Call setIsPending(true) in form action handler, exposing state via data-pending attribute. This lets specific UI elements like delete buttons signal pending state while ancestor components react with CSS without triggering transitions.
Bind Server Functions to parameters for reusable components
Parent server components can bind Server Functions to specific parameters using .bind(null, param), passing the bound function as prop to reusable client components. This keeps components decoupled from knowing specific IDs or data while allowing the parent to control which data the function operates on.
Partial Prefetching and per-link prefetching in Next.js 16
Partial Prefetching (Next.js 16.3+) makes <Link> prefetch only the App Shell shared across links, not URL data. Use prefetch={true} on individual links to resolve URL-specific data ahead of the click. This is valuable when destination has URL-specific work users are likely to need next, particularly with cached reads.
Streaming improves Core Web Vitals
Streaming with <Suspense> lowers FCP (First Contentful Paint) and LCP (Largest Contentful Paint) by letting the shell paint while slow reads finish. Optimistic UI and transitions lower INP (Interaction to Next Paint) by keeping the click frame fast. These patterns improve perceived performance but do not replace shipping less client JavaScript or avoiding blocking roundtrips.
Standalone startTransition vs useTransition hook
useTransition hook returns isPending state useful for UI feedback. Standalone startTransition function runs without returning pending state. Use startTransition when you need transition behavior without exposing isPending to UI, such as when optimistic UI already provides feedback.
Example: cyclePriority Server Function with refresh
```ts
'use server'
import { refresh } from 'next/cache'
import { PRIORITY_CYCLE } from '@/lib/data'
import { getTaskById, updateTaskPriority } from '@/lib/db'
export async function cyclePriority(taskId: string) {
const task = await getTaskById(taskId)
if (!task) return null
const newPriority = PRIORITY_CYCLE[task.priority]
await updateTaskPriority(taskId, newPriority)
refresh()
return newPriority
}
```
This Server Function fetches task, updates priority, calls refresh() to rerun dynamic work, and returns new priority.
Example: Streaming data with Suspense
```tsx
import { Suspense } from 'react'
import { TaskDetail, TaskDetailSkeleton } from '@/features/task/components/task-detail'
import { CommentSection, CommentSectionSkeleton } from '@/features/task/components/comment-section'
export default function TaskPage({ params }) {
return (
<div>
<Suspense fallback={<TaskDetailSkeleton />}>
{params.then(({ id }) => (
<>
<TaskDetail id={id} />
<Suspense fallback={<CommentSectionSkeleton />}>
<CommentSection taskId={id} />
</Suspense>
</>
))}
</Suspense>
</div>
)
}
```
Calling params.then() inline keeps page synchronous. Nested Suspense boundaries show skeletons while data loads, enabling streaming.
Example: useOptimistic with useTransition for priority toggle
```tsx
'use client'
import { useOptimistic, useTransition } from 'react'
import { cyclePriority } from '@/features/task/task-actions'
import { PRIORITY_CYCLE } from '@/lib/data'
export function TaskCard({ id, priority }) {
const [optimisticPriority, setOptimisticPriority] = useOptimistic(priority)
const [, startTransition] = useTransition()
function handlePriority() {
startTransition(async () => {
setOptimisticPriority(PRIORITY_CYCLE[optimisticPriority])
await cyclePriority(id)
})
}
return (
<button onClick={handlePriority} className={priorityDot[optimisticPriority]}>
{optimisticPriority}
</button>
)
}
```
SetOptimisticPriority updates UI on current frame. Reading from optimisticPriority ensures rapid clicks cycle correctly.
Example: ChipGroup with pending feedback
```tsx
'use client'
import { startTransition, useOptimistic } from 'react'
export function ChipGroup({ items, value, changeAction }) {
const [optimisticValue, setOptimisticValue] = useOptimistic(value)
const [isPending, setIsPending] = useOptimistic(false)
function handleClick(newValue) {
startTransition(async () => {
setOptimisticValue(newValue)
setIsPending(true)
await changeAction(newValue)
})
}
return (
<div className="flex gap-1.5" data-pending={isPending ? '' : undefined}>
{items.map((item) => (
<button
key={item.value}
onClick={() => handleClick(item.value === optimisticValue ? null : item.value)}
className={item.value === optimisticValue ? 'active' : ''}
>
{item.label}
</button>
))}
</div>
)
}
```
Data-pending attribute allows ancestors to style via has-data-pending CSS selector.
Example: Optimistic comments with form reset
```tsx
'use client'
import { useOptimistic, useRef } from 'react'
import { addComment } from '@/features/task/task-actions'
import { CommentCard } from './comment-card'
export function OptimisticComments({ taskId }) {
const [pendingComments, setPendingComments] = useOptimistic([])
const formRef = useRef(null)
return (
<>
<form
ref={formRef}
action={async (formData) => {
const content = formData.get('content')?.trim()
if (!content) return
formRef.current?.reset()
const id = crypto.randomUUID()
setPendingComments((current) => [
{
id,
content,
userName: 'You',
createdAt: new Date().toISOString(),
},
...current,
])
await addComment(taskId, content)
}}
>
<input name="content" placeholder="Write a comment..." required />
<button type="submit">Send</button>
</form>
{pendingComments.map((comment) => (
<CommentCard key={comment.id} comment={comment} pending />
))}
</>
)
}
```
Form resets on current frame. Optimistic comment added with UUID before Server Function runs.
Example: Optimistic card movement with reducer
```tsx
'use client'
import { startTransition, use, useOptimistic } from 'react'
import { updateStatus } from '@/features/task/task-actions'
export function Board({ tasksPromise }) {
const tasks = use(tasksPromise)
const [optimisticTasks, moveTask] = useOptimistic(
tasks,
(currentTasks, action: { taskId: string; status: Status }) =>
currentTasks.map((t) =>
t.id === action.taskId ? { ...t, status: action.status } : t
)
)
function handleDrop(targetStatus, taskId) {
startTransition(async () => {
moveTask({ taskId, status: targetStatus })
await updateStatus(taskId, targetStatus)
})
}
return (
<div className="grid grid-cols-3 gap-4">
{columns.map((col) => (
<Column
key={col.status}
tasks={optimisticTasks.filter((t) => t.status === col.status)}
onDrop={(taskId) => handleDrop(col.status, taskId)}
/>
))}
</div>
)
}
```
Reducer maps over tasks and updates dragged card status. Card lands in target column immediately on drop.
Example: useActionState for form lifecycle
```tsx
'use client'
import { useActionState, startTransition, useState } from 'react'
import { createTask } from '@/features/task/task-actions'
export function CreateTaskModal() {
const [isOpen, setIsOpen] = useState(false)
const [{ key }, formAction, isPending] = useActionState(
async (prev, formData) => {
const title = String(formData.get('title'))
if (!title.trim()) return prev
await createTask({
title,
description: String(formData.get('description')),
status: 'todo',
priority: 'medium',
})
startTransition(() => setIsOpen(false))
return { key: prev.key + 1 }
},
{ key: 0 }
)
return (
<Dialog open={isOpen} onOpenChange={setIsOpen}>
<form action={formAction}>
<div key={key}>
<input name="title" placeholder="Task title..." required />
<input name="description" placeholder="Describe the task..." />
</div>
<button type="submit" disabled={isPending}>
{isPending ? 'Creating...' : 'Create Task'}
</button>
</form>
</Dialog>
)
}
```
Incrementing key remounts div and resets inputs. startTransition wraps post-await dialog close to batch with refresh().
Example: DeleteButton with useOptimistic pending state
```tsx
'use client'
import { useOptimistic } from 'react'
import { Trash2 } from 'lucide-react'
export function DeleteButton({
deleteAction,
}: {
deleteAction: () => void | Promise<void>
}) {
const [isPending, setIsPending] = useOptimistic(false)
return (
<form
action={async () => {
setIsPending(true)
await deleteAction()
}}
>
<button
type="submit"
disabled={isPending}
data-pending={isPending ? '' : undefined}
aria-label="Delete comment"
>
<Trash2 className="size-3" />
</button>
</form>
)
}
```
Exposes pending state via data-pending. Parent comment card uses has-data-pending: to fade itself.
Example: Link with prefetch={true}
```tsx
<Link href={`/task/${id}`} prefetch={true}>
{/* … */}
</Link>
```
Prefetch={true} opts link into per-link prefetching, resolving URL data ahead of click. Most valuable for destinations with URL-specific work users likely need next.
Reference: Interactive app patterns and their primitives
| Situation | Use |
|-----------|-----|
| Slow data should stream in without blocking page | <Suspense> |
| A value should update while async work runs | useOptimistic |
| Async work needs pending state, error handling, or coordinated UI updates | useTransition |
| A form needs pending, reset, and result state | useActionState |
| An ancestor should show pending state for work happening elsewhere | data-pending attribute styled with CSS |
| Reusable reads should survive across requests and stay fresh after writes | 'use cache' with cacheTag, revalidated by updateTag or revalidateTag |
| Navigation between pages of interactive app should feel instant | <Link> prefetching with Partial Prefetching, and per-link prefetching via prefetch={true} |
Client-side fetch requests use their own retry policy
Requests issued directly with fetch() inside a Client Component or through a client-side data library like React Query or SWR stay under that library's own retry policy. They are not affected by experimental.useOffline.
Client Components data fetching with static export
For client-side data fetching in a static export, use a Client Component with SWR to memoize requests. Route transitions happen client-side, making the application behave like a traditional Single-Page Application (SPA).
Three recommended data fetching approaches
Next.js recommends three main approaches for fetching data depending on project size and age: (1) External HTTP APIs for existing large applications and organizations, (2) Data Access Layer for new projects, and (3) Component-Level Data Access for prototypes and learning. Choose one approach and avoid mixing them to maintain clarity for developers and security auditors.
Zero Trust model for external HTTP APIs
When calling external REST or GraphQL API endpoints from Server Components in an existing project, follow a Zero Trust security model. This approach works well when you already have security practices in place and separate backend teams manage APIs independently.
Component-level data access for prototypes
For quick prototypes and iteration, database queries can be placed directly in Server Components. However, this approach makes it easier to accidentally expose private data to the client, so sanitization is critical before passing data to Client Components.