Skip to content

2. Data Fetching, Rendering & Caching

The area interviewers dig into hardest, and the area that changed most in Next.js 16.


1. Rendering Strategies

StrategyRenderedCachedBest for
CSRIn the browserDashboards behind auth
SSRPer request on the serverNoPersonalised, always-fresh pages
SSGAt build timeYesBlogs, docs, marketing
ISRAt build, regenerated in backgroundYes, with a TTLLarge content sites
PPRStatic shell + streamed dynamic holesPartlyBest of both — the Next 16 default

Partial Prerendering (PPR)

Before PPR, a page was either fully static or fully dynamic. One cookies() call anywhere made the whole page dynamic.

PPR sends a static shell instantly from the CDN, with dynamic parts streaming in behind their <Suspense> fallbacks.

tsx
export default function Page() {
  return (
    <>
      <Header />                                {/* static shell */}
      <ProductInfo />                           {/* static shell */}
      <Suspense fallback={<CartSkeleton />}>
        <Cart />                                {/* streams per request */}
      </Suspense>
    </>
  );
}

In Next.js 16 the experimental.ppr flag is gone — PPR is the default behaviour under Cache Components.


2. Fetching in Server Components

No useEffect, no loading state, no waterfall.

tsx
async function Posts() {
  const res = await fetch("https://api.example.com/posts");
  if (!res.ok) throw new Error("Failed to fetch");
  const posts = await res.json();

  return <ul>{posts.map((p) => <li key={p.id}>{p.title}</li>)}</ul>;
}

Or hit the database directly — there is no API layer to cross:

tsx
async function Users() {
  const users = await db.user.findMany();
  return <UserList users={users} />;
}

Parallel vs Sequential

tsx
// ❌ Sequential waterfall — 300ms + 300ms
const user = await getUser(id);
const posts = await getPosts(id);

// ✅ Parallel — 300ms total
const [user, posts] = await Promise.all([getUser(id), getPosts(id)]);

This is a very common follow-up question. Only make it sequential when the second call genuinely needs the first result.


3. The Caching Model — Next.js 15 vs 16

This is the answer that changed. Know both.

Next.js 13–15: Implicit Caching (Opt-Out)

fetch results were cached by default. You opted out.

tsx
fetch(url)                                   // cached forever (SSG-like)
fetch(url, { cache: "no-store" })            // never cached (SSR)
fetch(url, { next: { revalidate: 60 } })     // ISR — revalidate every 60s
fetch(url, { next: { tags: ["posts"] } })    // taggable for on-demand purge

Route segment config:

tsx
export const dynamic = "force-dynamic";   // always SSR
export const revalidate = 3600;           // ISR for the whole segment

People found this confusing — a page could be accidentally static and serve stale data forever.

Next.js 16: Explicit Caching (Opt-In)

Nothing is cached unless you ask. Enable Cache Components:

ts
// next.config.ts
const nextConfig = { cacheComponents: true };
export default nextConfig;

Then use the "use cache" directive:

tsx
import { cacheLife, cacheTag } from "next/cache";

// Data-level caching
export async function getUsers() {
  "use cache";
  cacheLife("hours");
  return db.query("SELECT * FROM users");
}

// UI-level caching — cache a whole component
async function BlogPosts() {
  "use cache";
  cacheLife("hours");
  cacheTag("posts");

  const res = await fetch("https://api.example.com/posts");
  const posts = await res.json();
  return <PostList posts={posts} />;
}

Function arguments and closed-over values automatically become part of the cache key, so different inputs get separate entries.

Interview Point

The one-line summary: caching flipped from opt-out to opt-in. Next.js 16 executes everything at request time by default, which matches what developers actually expect from a full-stack framework. You add "use cache" where you want speed.


4. Revalidation

Time-Based

tsx
"use cache";
cacheLife("hours");   // built-in profiles: seconds, minutes, hours, days, weeks, max

Or define custom profiles in next.config.ts.

On-Demand — Three APIs

ts
"use server";
import { revalidateTag, updateTag, refresh } from "next/cache";
APISemanticsUse when
revalidateTag(tag, profile)Stale-while-revalidate — serve cached, refresh in backgroundContent that tolerates eventual consistency
updateTag(tag)Read-your-writes — expire and re-read immediatelyForms and settings, where the user must see their own change
refresh()Refresh uncached data onlyNotification counts, live metrics
ts
// SWR revalidation — note the required second argument in Next 16
revalidateTag("blog-posts", "max");
revalidateTag("products", { expire: 3600 });

// Read-your-writes, Server Actions only
export async function updateProfile(userId: string, data: Profile) {
  "use server";
  await db.users.update(userId, data);
  updateTag(`user-${userId}`);   // user sees their change immediately
}

revalidateTag(tag) with a single argument is deprecated in Next 16.

revalidatePath("/blog") also still exists for path-based purging.


5. Streaming Uncached Data

For data that must be fresh on every request, do not use "use cache". Wrap it in <Suspense> instead:

tsx
import { Suspense } from "react";

async function LatestPosts() {
  const posts = await fetch("https://api.example.com/posts").then((r) => r.json());
  return <PostList posts={posts} />;
}

export default function Page() {
  return (
    <>
      <h1>My Blog</h1>
      <Suspense fallback={<p>Loading posts…</p>}>
        <LatestPosts />
      </Suspense>
    </>
  );
}

The fallback ships in the static shell; the real content streams in.

Without the Suspense boundary, the whole route becomes blocking — Next 16's dev overlay surfaces a blocking-route insight telling you exactly this.


6. Runtime APIs Need Suspense

cookies(), headers(), searchParams and params are only known at request time.

tsx
import { cookies } from "next/headers";
import { Suspense } from "react";

async function UserGreeting() {
  const theme = (await cookies()).get("theme")?.value ?? "light";
  return <p>Theme: {theme}</p>;
}

export default function Page() {
  return (
    <>
      <h1>Dashboard</h1>
      <Suspense fallback={<p>Loading…</p>}>
        <UserGreeting />
      </Suspense>
    </>
  );
}

The Key Behavioural Change

In the old model, reading cookies() anywhere made the entire route dynamic. Under Cache Components it does not — the Suspense boundary contains it, and the static and cached content still ship in the initial HTML.


7. Maximising the Static Shell

A pattern worth knowing because it shows real understanding: push async work deeper into the tree.

tsx
// ❌ Awaiting params at the top blocks the whole layout from prerendering
export default async function Layout({ children, params }) {
  const { slug } = await params;
  return <div><Sidebar /><h1>{slug}</h1>{children}</div>;
}

// ✅ Await inside a boundary — Sidebar and children stay in the static shell
export default function Layout({ children, params }) {
  return (
    <div>
      <Sidebar />
      <Suspense fallback={<h1>Loading…</h1>}>
        {params.then(({ slug }) => <h1>{slug}</h1>)}
      </Suspense>
      {children}
    </div>
  );
}

The deeper the async work sits, the more of the page can be prerendered.


8. Static Params and ISR

tsx
// app/blog/[slug]/page.tsx
export async function generateStaticParams() {
  const posts = await getPosts();
  return posts.map((post) => ({ slug: post.slug }));
}

Listed URLs are prerendered at build time. Any other URL gets the App Shell instantly, then is upgraded in the background with its real content and cached for the next visitor. That is ISR under Cache Components.


9. Server Actions

Async functions that run on the server, callable directly from a component or a form. They replace "write an API route just to submit this form".

tsx
// app/actions.ts
"use server";

import { revalidatePath } from "next/cache";
import { redirect } from "next/navigation";
import { z } from "zod";

const schema = z.object({
  title: z.string().min(3),
  body: z.string().min(10),
});

export async function createPost(formData: FormData) {
  const session = await auth();
  if (!session) throw new Error("Unauthorized");   // ALWAYS check

  const parsed = schema.safeParse({
    title: formData.get("title"),
    body: formData.get("body"),
  });
  if (!parsed.success) return { error: parsed.error.flatten() };

  const post = await db.post.create({ data: parsed.data });

  revalidatePath("/blog");
  redirect(`/blog/${post.slug}`);
}

Using It In a Form

tsx
import { createPost } from "./actions";

export default function NewPost() {
  return (
    <form action={createPost}>
      <input name="title" />
      <textarea name="body" />
      <SubmitButton />
    </form>
  );
}

With Pending and Error State

tsx
"use client";
import { useActionState } from "react";
import { useFormStatus } from "react-dom";

function SubmitButton() {
  const { pending } = useFormStatus();
  return <button disabled={pending}>{pending ? "Saving…" : "Save"}</button>;
}

export function Form() {
  const [state, formAction, isPending] = useActionState(createPost, {});
  return (
    <form action={formAction}>
      <input name="title" />
      {state.error && <p>{state.error}</p>}
      <button disabled={isPending}>Save</button>
    </form>
  );
}

Progressive Enhancement

A <form action={serverAction}> works without JavaScript. Next.js generates a real POST endpoint behind it. That is the strongest argument for Server Actions over a client-side fetch.

Security — Say This Unprompted

A Server Action is a public HTTP endpoint. Next.js generates an ID for it and anyone can call it directly. Therefore:

  • Always authenticate and authorise inside the action
  • Always validate input with Zod or similar — never trust formData
  • Never rely on the fact that the UI hides the button

10. Where the Data Lives

Under Cache Components, a cached result is serialised into an RSC payload and can end up in three places:

StoreWhatLifetime
Prerendered HTMLStatic shell on disk / CDNrevalidate / expire
Shared storeIn-memory per instance by default; "use cache: remote" for a durable shared handlercacheLife
BrowserIncluded in the RSC sent on navigation or prefetch; "use cache: private" lives only herestale window

All of these are scoped to a single deployment — the cache key includes the build ID, so a new deploy starts fresh.


11. Choosing a Strategy

Say the reasoning, not just the acronym:

PageChoiceWhy
Marketing homepageStatic / "use cache" with a long lifeSame for everyone, changes rarely
Blog postCached + cacheTag, purged on publishContent updates on an editorial schedule
Product listingCached hourly + tag purge on stock changeTolerates minutes of staleness
Product page with live stockStatic shell + streamed stock widgetPPR — fast shell, fresh number
User dashboardUncached, behind SuspensePersonalised, must be fresh
Search resultsUncached, or cached by query argumentDepends on searchParams

12. Fetching in Client Components

Sometimes you genuinely need client-side fetching — polling, infinite scroll, anything driven by user interaction after load.

tsx
"use client";
import { useQuery } from "@tanstack/react-query";

export function LiveMetrics() {
  const { data } = useQuery({
    queryKey: ["metrics"],
    queryFn: () => fetch("/api/metrics").then((r) => r.json()),
    refetchInterval: 5000,
  });

  return <p>{data?.activeUsers} online</p>;
}

Interview Point

The rule of thumb: fetch on the server by default; drop to the client only for data that changes in response to user interaction after the page has loaded.

© 2025 DDocs · Dipak's Documentation Guide