2. Data Fetching, Rendering & Caching
The area interviewers dig into hardest, and the area that changed most in Next.js 16.
1. Rendering Strategies
| Strategy | Rendered | Cached | Best for |
|---|---|---|---|
| CSR | In the browser | — | Dashboards behind auth |
| SSR | Per request on the server | No | Personalised, always-fresh pages |
| SSG | At build time | Yes | Blogs, docs, marketing |
| ISR | At build, regenerated in background | Yes, with a TTL | Large content sites |
| PPR | Static shell + streamed dynamic holes | Partly | Best 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.
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.
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:
async function Users() {
const users = await db.user.findMany();
return <UserList users={users} />;
}Parallel vs Sequential
// ❌ 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.
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 purgeRoute segment config:
export const dynamic = "force-dynamic"; // always SSR
export const revalidate = 3600; // ISR for the whole segmentPeople 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:
// next.config.ts
const nextConfig = { cacheComponents: true };
export default nextConfig;Then use the "use cache" directive:
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
"use cache";
cacheLife("hours"); // built-in profiles: seconds, minutes, hours, days, weeks, maxOr define custom profiles in next.config.ts.
On-Demand — Three APIs
"use server";
import { revalidateTag, updateTag, refresh } from "next/cache";| API | Semantics | Use when |
|---|---|---|
revalidateTag(tag, profile) | Stale-while-revalidate — serve cached, refresh in background | Content that tolerates eventual consistency |
updateTag(tag) | Read-your-writes — expire and re-read immediately | Forms and settings, where the user must see their own change |
refresh() | Refresh uncached data only | Notification counts, live metrics |
// 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:
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.
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.
// ❌ 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
// 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".
// 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
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
"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:
| Store | What | Lifetime |
|---|---|---|
| Prerendered HTML | Static shell on disk / CDN | revalidate / expire |
| Shared store | In-memory per instance by default; "use cache: remote" for a durable shared handler | cacheLife |
| Browser | Included in the RSC sent on navigation or prefetch; "use cache: private" lives only here | stale 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:
| Page | Choice | Why |
|---|---|---|
| Marketing homepage | Static / "use cache" with a long life | Same for everyone, changes rarely |
| Blog post | Cached + cacheTag, purged on publish | Content updates on an editorial schedule |
| Product listing | Cached hourly + tag purge on stock change | Tolerates minutes of staleness |
| Product page with live stock | Static shell + streamed stock widget | PPR — fast shell, fresh number |
| User dashboard | Uncached, behind Suspense | Personalised, must be fresh |
| Search results | Uncached, or cached by query argument | Depends on searchParams |
12. Fetching in Client Components
Sometimes you genuinely need client-side fetching — polling, infinite scroll, anything driven by user interaction after load.
"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.