Next.js Data Best Practices
A condensed summary of the 25 most important best practices drawn from every page in this section.
- Fetch Inside async Server Components: Put
await fetch(...)directly in the Server Component body instead of reaching foruseEffect— no loading state, no client waterfall, and the data ships as HTML. - Await params and searchParams: In Next.js 15+, both are Promises, so destructuring without
awaityields a Promise object (often rendered as[object Promise]); always doconst { slug } = await params. - Parallelize Independent Fetches With Promise.all: Wrap independent reads —
const [user, posts] = await Promise.all([getUser(id), getPosts(id)])— so total latency equals the slowest call instead of the sum, and let TypeScript infer the result tuple for free. - Avoid Accidental Waterfalls: Never chain
const a = await getA(); const b = await getB();for independent data — promises start at creation, but eachawaitstill blocks the next line, producing sequential latency. - Use Promise.allSettled for Optional Data: When one source is non-critical (recommendations, analytics),
Promise.allSettledprevents a single rejection from discarding the other successful results and lets you render a partial page. - Wrap Non-fetch Calls With React.cache: Request memoization only covers the
fetchAPI, so wrap database and ORM functions —const getUser = cache((id: string) => db.user.findUnique({ where: { id } }))— to get per-render deduplication across layouts and pages. - Isolate no-store Fetches Behind Suspense: A single
cache: "no-store"anywhere in a route opts the whole route into dynamic rendering; move it into a separate component wrapped in<Suspense>so the rest can stay static. - Be Explicit About fetch Cache Options: The Next.js 15 default is
cache: "auto"(notforce-cache), so setcacheornext.revalidateon every fetch rather than relying on whatever the framework infers. - Name Revalidation Tags Specifically: Tags are global —
fetch(url, { next: { tags: ["product-abc123"] } })— use namespaced strings instead of generic"data"sorevalidateTag("product-abc123")does not quietly blow away unrelated caches. - Understand Stale-While-Revalidate:
revalidatePath/revalidateTagmark the cache stale for the next request — the current response still returns stale data, so reach forcache: "no-store"when you need freshness right now. - Use "layout" to Refresh Nested Pages:
revalidatePath("/")refreshes only the root page; pass"layout"—revalidatePath("/dashboard", "layout")— to invalidate the layout plus every child page under it. - Use unstable_cache With Unique Keys: For non-fetch sources that need persistent caching,
unstable_cache(fn, keyParts, { tags, revalidate })is the tool — but give each call distinct, descriptivekeyPartsor you will return corrupted data from key collisions. - Call router.refresh After Client Mutations: After a Server Action updates data, the client-side Router Cache can keep serving stale pages on back/forward, so trigger
router.refresh()(or tuneexperimental.staleTimes) to clear it. - Validate Server Action Inputs on the Server: Server Actions are reachable POST endpoints whose arguments may be closed-over values shipped to the client, so validate every field inside the action and never trust closures to hide secrets.
- Call redirect Outside try/catch:
redirect()throws a sentinel error that Next.js catches to perform navigation; wrapping it intry/catchswallows the redirect, so place it after your mutation and outside any error handler. - Return Errors via useActionState: When a form needs validation feedback, type the action as
(prevState, formData) => Promise<State>and wire it throughuseActionStateso pending state and error messages flow through React cleanly. - Pair useOptimistic With useTransition: For snappy UIs, call
addOptimisticinsidestartTransition—startTransition(async () => { addOptimistic(newItem); await saveItem(newItem) })— so users see the change instantly and the pending flag still drives disabled states. - Use Granular Suspense Boundaries: Wrap each independent data source in its own
<Suspense>so widgets stream as they resolve; one top-level boundary blocks the whole page on the slowest query and defeats streaming. - Size Skeletons to Match Final Content: Fallbacks must share the aspect ratio or fixed dimensions of the real content, otherwise the layout jumps when data arrives and you take an avoidable CLS hit.
- Pair Suspense With error.tsx: If any async child throws, the error bubbles past Suspense; every Suspense boundary should have a matching error boundary (
error.tsxor<ErrorBoundary>) at the same level. - Wrap useSearchParams in Suspense: On the client,
useSearchParams()throws during static prerender, so the component reading it must sit inside a<Suspense>boundary or the build will fail. - Prefer router.replace for Filter Updates: Filters, sort, and search typing create noisy history entries with
router.push; userouter.replaceso the back button returns to a meaningful previous page and also resetpage=1whenever the query changes. - Await cookies() and headers(): Both are async in Next.js 15+ —
const token = (await cookies()).get("session")?.value— and touching either makes the route dynamic; read them in a small leaf component under<Suspense>when you want the rest of the route to stay static. - Set Cookies Only in Actions or Middleware:
cookies()is read-only in Server Components, so writes must happen in a Server Action, Route Handler, or Middleware —(await cookies()).set("session", token, { httpOnly: true, secure: true, sameSite: "lax" }). - Stream Large Datasets With Async Generators: For paginated APIs, CSV exports, or SSE endpoints, use
async function*plusfor await...ofto keep only one batch in memory, compose with map/filter/take helpers, and remember generators are server-only and single-use.