In the App Router components are server components by default: they can read the database and secrets directly, and only the result reaches the browser. A component becomes a client one with the "use client" directive, which you need for state, event handlers and browser APIs. In Next.js 16 with Cache Components data is not cached by default: you either cache it with "use cache" plus cacheLife and cacheTag, or wrap the component in <Suspense> and stream it. Changes go through Server Actions, functions marked "use server" that call updateTag after writing and validate input and user permissions themselves.
After this you will be able to
- decide which component stays on the server and which becomes a client component, and draw the boundary with
"use client" - load data in a Server Component and choose a strategy: a
"use cache"entry withcacheTagor streaming with<Suspense> - handle a form with a Server Action using server-side validation,
useActionStateand cache updates viaupdateTag - lock the data access module with
server-onlyand list the checks a Server Action is unsafe without
Know beforehand
Verified with
Next.js 16.4 (Cache Components)React 19.3TypeScript 5.9Node.js 24 LTS (24.18)
Checked against docs: October 11, 2026
Contents
Server and Client Components: where code runs
In the App Router lesson pages and layouts simply returned markup. In fact they were all Server Components: React runs them on the server and sends the finished result to the browser. Client Components are rendered to HTML on the server for the first load too, but their JavaScript also goes to the browser, where the component comes alive as React attaches event handlers. This process is called hydration.
Server Component: when you need to
- Read a database or an API close to the source
- Use API keys, tokens and other secrets
- Send less JavaScript to the browser
- Show the first content sooner (FCP) and stream the rest
Client Component: when you need
- State:
useState,useReducer - Event handlers:
onClick,onChange - Effects:
useEffect - Browser APIs:
localStorage,window, geolocation
On the server Next.js turns the Server Component tree into the RSC Payload (React Server Component Payload), a compact description of the result: rendered markup of Server Components, placeholders for Client Components with references to their JavaScript, and the props the server passes to the client. On the first load the browser receives HTML (a fast non-interactive preview), the RSC Payload and JavaScript for hydration. On later navigations HTML is not needed; the RSC Payload is enough.
The practice project is a leads page in the studio CRM: the lead list loads on the server, a "new today" counter streams in, and the add form runs on a Server Action. The project was created with create-next-app defaults, so cacheComponents and partialPrefetching are enabled in next.config.ts. A JSON file, data/leads.json, stands in for a database so the example runs without external services. The names and phone numbers in it are made-up placeholders.
"use client" and the boundary
The "use client" directive at the top of a file declares a boundary between the server and client module graphs. Everything the file imports and the components it renders directly end up in the client bundle, so you do not need the directive in every file, only at the entry point of the interactive part. Hence the main rule: make small interactive leaves client components, not a whole layout or page.
- A Server Component can import and render a Client Component. Importing the other way does not keep a component on the server: everything a
"use client"file imports becomes client code. - You can pass a Server Component to a Client Component as
childrenor another prop: it renders on the server and the client receives the result. - Props from server to client must be serializable: strings, numbers, plain objects and arrays,
Date, promises. Functions cannot be passed, except Server Actions. - React context does not work in Server Components. A context provider is a Client Component that accepts
children; place it as deep in the tree as possible.
In our project the boundary runs along the form: it has error state and a pending indicator, so lead-form.tsx starts with "use client". The page, the list and the counter stay on the server, and their code never reaches the browser. We will go through the form itself in the Server Actions section.
Fetching data in a Server Component
A Server Component can be an async function and await data: a fetch to an API, an ORM query, a file read. Keys and database queries stay out of the client bundle. The docs recommend putting data access in a dedicated Data Access Layer: only it reads process.env and the database, checks permissions and decides which fields go into the render.
import "server-only";
import { cacheLife, cacheTag } from "next/cache";
import { randomUUID } from "node:crypto";
import { readFile, writeFile } from "node:fs/promises";
import path from "node:path";
export type Lead = {
id: string;
name: string;
phone: string;
createdAt: string;
};
// A JSON file stands in for a database in this lesson
const FILE = path.join(process.cwd(), "data", "leads.json");
export async function readLeads(): Promise<Lead[]> {
const json = await readFile(FILE, "utf8");
return JSON.parse(json) as Lead[];
}
export async function getLeads(): Promise<Lead[]> {
"use cache";
cacheTag("leads");
cacheLife("hours");
return readLeads();
}
export async function insertLead(input: { name: string; phone: string }) {
const leads = await readLeads();
const lead: Lead = {
id: randomUUID(),
...input,
createdAt: new Date().toISOString(),
};
await writeFile(FILE, JSON.stringify([lead, ...leads], null, 2));
return lead;
}We will cover getLeads with "use cache" in the next section. First, let's try the most direct path and read leads in the page:
import { readLeads } from "@/lib/leads";
export default async function LeadsPage() {
const leads = await readLeads();
return (
<main>
<h1>Leads</h1>
<ul>
{leads.map((lead) => (
<li key={lead.id}>
{lead.name} — {lead.phone}
</li>
))}
</ul>
</main>
);
}$ npm run build
…
Error: Route "/leads": Next.js encountered uncached or runtime data during prerendering.
`fetch(...)`, `cookies()`, `headers()`, `params`, `searchParams`, or `connection()` accessed outside of `<Suspense>` prevents the route from being prerendered, blocking the page load and leading to a slower user experience.
Ways to fix this:
- [stream] Provide a placeholder with `<Suspense fallback={...}>` around the data access
- [cache] For uncached data (`fetch`, database calls): cache the access with `"use cache"` (does not apply to `connection()`)
- [block] Set `export const instant = false` to allow a blocking route
Learn more: https://nextjs.org/docs/messages/blocking-prerender-dynamic
…
⨯ Next.js build worker exited with code: 1 and signal: nullnext build output in Next.js 16.4 with Cache Components; the project path and stack trace are shortenedThis is not a bug; it is how Cache Components works. Next.js tries to build a static shell for every route: HTML that can be served instantly, for example from a CDN. Reading a file is uncached async I/O: at build time Next.js cannot tell whether the result may be frozen, so it asks the developer to decide. The message lists three options:
[cache]: "use cache"
- What happens
- The result is stored and becomes part of the static shell
- When to choose it
- The data is the same for everyone and may be slightly stale
[stream]: <Suspense>
- What happens
- The shell holds a placeholder; data is read on every request and streamed in
- When to choose it
- You need fresh data or data for a specific user
[block]: instant = false
- What happens
- The route renders fully per request and the user waits
- When to choose it
- A temporary measure during migration, not for new pages
Caching: use cache, cacheLife and cacheTag
The "use cache" directive at the top of an async function or component caches its result. Function arguments become part of the cache key: getLead("1") and getLead("2") get separate entries. In getLeads from lib/leads.ts the directive comes with two calls, which the docs recommend adding every time:
cacheLife("hours")
The entry lifetime. With the
hoursprofile the client treats data as fresh for 5 minutes (stale), the server refreshes the entry in the background after an hour (revalidate), and after a day without requests it expires (expire). WithoutcacheLifethedefaultprofile applies.cacheTag("leads")
A label used to invalidate the entry on demand, for example right after a lead is added.
readLeads()
The read itself. Inside a cached function you cannot call
cookies(),headers()or readsearchParams; pass their values in as arguments from outside.
default
- stale
- 5 min
- revalidate
- 15 min
- expire
- never
minutes
- stale
- 5 min
- revalidate
- 1 min
- expire
- 1 h
hours
- stale
- 5 min
- revalidate
- 1 h
- expire
- 1 day
days
- stale
- 5 min
- revalidate
- 1 day
- expire
- 1 week
max
- stale
- 5 min
- revalidate
- 30 days
- expire
- 1 year
import { getLeads } from "@/lib/leads";
export async function LeadList() {
const leads = await getLeads();
return (
<ul>
{leads.map((lead) => (
<li key={lead.id}>
{lead.name} — {lead.phone}
</li>
))}
</ul>
);
}$ npm run build
…
Route (app) Revalidate Expire
┌ ○ /
├ ○ /_not-found
└ ◐ /leads 1h 1d
○ (Static) prerendered as static content
◐ (Partial Prerender) prerendered as static HTML with dynamic server-streamed contentYou can check what went into the static shell right in the build output: Next.js stores prerendered HTML in .next/server/app/:
$ grep -o "Aigerim\|Daniyar\|Counting today’s leads…\|New today" .next/server/app/leads.html
Counting today’s leads…
Aigerim
DaniyarStreaming and Suspense
The new-leads counter must be accurate at request time, so it cannot be cached. connection() from next/server tells Next.js explicitly that the code below depends on the incoming request; after it you can take the current time and read fresh data. We put the component in <Suspense>: the placeholder is part of the static shell, and the server sends the real counter in the same response as soon as it is ready. That is streaming: the page arrives in parts.
import { connection } from "next/server";
import { readLeads } from "@/lib/leads";
export async function LeadStats() {
await connection();
const leads = await readLeads();
const today = new Date().toISOString().slice(0, 10);
const count = leads.filter((lead) => lead.createdAt.startsWith(today)).length;
return (
<p>
New today: {count} (checked at {new Date().toISOString().slice(11, 19)} UTC)
</p>
);
}import { Suspense } from "react";
import { LeadForm } from "./lead-form";
import { LeadList } from "./lead-list";
import { LeadStats } from "./lead-stats";
export default function LeadsPage() {
return (
<main>
<h1>Leads</h1>
<LeadForm />
<Suspense fallback={<p>Counting today’s leads…</p>}>
<LeadStats />
</Suspense>
<LeadList />
</main>
);
}You can see it in the /leads response headers: x-nextjs-postponed: 1 means part of the page was postponed and streamed after the shell. Also Cache-Control: private, no-cache, no-store: the response as a whole is not cached by the browser or a CDN because it contains request-specific data.
What it wraps
loading.tsx- The whole page of a segment
<Suspense>- A specific component
What shows immediately
loading.tsx- The layout and a placeholder instead of the page
<Suspense>- The whole page except the boundary contents
When it fits
loading.tsx- The whole page depends on slow data
<Suspense>- Only one part of the page is slow
The deeper in the tree the async work sits, the more of the page goes into the shell. So the docs advise against awaiting params, cookies() and data at the layout level; pass the promise down and await it in the component that actually needs the value. One more detail: two consecutive awaits in one component run one after another. Start independent requests together and await them with Promise.all.
Server Actions: forms and mutations
A Server Function is an async function that runs on the server and is called from the browser over the network. When it handles a form submit or changes data it is called a Server Action. Functions are marked with the "use server" directive, either at the top of the function or, as here, at the top of the file, which makes every export a server function. You do not need a separate API route for the form: Next.js sends a POST to the current URL and returns new data and the updated UI in a single round trip.
"use server";
import { updateTag } from "next/cache";
import { insertLead } from "@/lib/leads";
export type LeadFormState = {
ok: boolean;
message: string;
errors?: { name?: string; phone?: string };
fields?: { name: string; phone: string };
};
export async function createLead(
_prevState: LeadFormState,
formData: FormData,
): Promise<LeadFormState> {
const name = String(formData.get("name") ?? "").trim();
const phone = String(formData.get("phone") ?? "").trim();
const errors: NonNullable<LeadFormState["errors"]> = {};
if (name.length < 2 || name.length > 80) {
errors.name = "Enter a name from 2 to 80 characters.";
}
if (!/^\+?[0-9 ()-]{7,20}$/.test(phone)) {
errors.phone = "Enter a phone number, for example +7 700 000 00 00.";
}
if (errors.name || errors.phone) {
return { ok: false, message: "Check the form.", errors, fields: { name, phone } };
}
await insertLead({ name, phone });
updateTag("leads");
return { ok: true, message: `Lead from ${name} saved.` };
}Read and normalize input
formData.getreturnsFormDataEntryValue | null. Convert it to a string and trim it: never trust the client, even if the form hasrequired.Expected errors as values
The docs advise returning validation errors as the result rather than throwing. We return the entered values too, so the user does not retype them.
Write and invalidate
updateTag("leads")immediately expires entries with this tag: the next render reads fresh data and the user sees their lead right away.updateTagcan only be called inside a Server Action.
"use client";
import { useActionState } from "react";
import { createLead, type LeadFormState } from "./actions";
const initialState: LeadFormState = { ok: false, message: "" };
export function LeadForm() {
const [state, formAction, pending] = useActionState(createLead, initialState);
return (
<form action={formAction} noValidate>
<label>
Name
<input
name="name"
defaultValue={state.fields?.name}
aria-invalid={Boolean(state.errors?.name)}
aria-describedby="name-error"
/>
</label>
<p id="name-error">{state.errors?.name}</p>
<label>
Phone
<input
name="phone"
type="tel"
defaultValue={state.fields?.phone}
aria-invalid={Boolean(state.errors?.phone)}
aria-describedby="phone-error"
/>
</label>
<p id="phone-error">{state.errors?.phone}</p>
<button type="submit" disabled={pending}>
{pending ? "Saving…" : "Add lead"}
</button>
<p aria-live="polite">{state.message}</p>
</form>
);
}React's useActionState takes an action and an initial state and returns three values: the current state (the action's last response), a function for the form's action, and a pending flag. After the action completes React resets uncontrolled form fields. On a validation error the input is not lost: the state returns the values and they go into defaultValue. The noValidate attribute in the example disables browser validation so the server validation is visible; in a real form keep required and type="tel" too, they help the user.
updateTag(tag)
- Where to call it
- Server Actions only
- Behavior
- Expires the entry immediately: users see their own change (read-your-own-writes)
revalidateTag(tag, "max")
- Where to call it
- Server Actions and Route Handlers
- Behavior
- Serves stale content while fresh content is prepared in the background (stale-while-revalidate)
revalidatePath(path)
- Where to call it
- Server Actions and Route Handlers
- Behavior
- Invalidates everything for a route; the docs suggest preferring tags
refresh()
- Where to call it
- Server Actions
- Behavior
- Refreshes the current page on the client without touching tagged cache
We checked it in a headless browser after next build and next start: submitting the name "A" and the phone "abc" showed both errors, the fields kept their input and the name field got aria-invalid="true". A valid lead appeared at the top of the list without a page reload, the form was cleared and the "New today" counter went up. Both times the browser sent a POST to /leads with a Next-Action header. Then we commented out updateTag and rebuilt: the lead was written to the file, but the list stayed the same, because the hours cache kept serving the old data as designed.
Security: what never reaches the client
The server/client boundary is a security boundary, and mistakes here are costly. The first line of defense is import "server-only" in modules that work with the database and secrets. If such a module is accidentally imported into a Client Component, the build fails instead of shipping the code to the browser. Installing the package is optional: Next.js handles this import itself. We checked it by importing readLeads into a Client Component:
$ npm run build
…
Error: 'server-only' cannot be imported from a Client Component module
> 1 | import "server-only";
| ^^^^^^^^^^^^^^^^^^^^^
It should only be used from a Server Component.
Import traces:
Client Component Browser:
./lib/leads.ts [Client Component Browser]
./app/leads/bad-client.tsx [Client Component Browser]
./app/leads/bad-client.tsx [Server Component]
./app/leads/page.tsx [Server Component]- Secrets live only in environment variables without the
NEXT_PUBLIC_prefix: Next.js inlines variables with that prefix into the client bundle - Modules that touch the database and
process.envare markedimport "server-only"and grouped into a data access layer - Client Components receive only the fields they need, not whole database records
- A Server Action is a public entry point: anyone can call it with a direct
POST, bypassing the UI. Every action checks the session and permission for the specific object, even if the page is already protected - Input from
formData, URL params and headers is validated on the server: length, format, allowed values - An action returns only what the UI needs: no error stack, no internal IDs
- Public forms (leads, subscriptions) have rate limiting
Next.js provides part of the protection itself. Action IDs are encrypted, non-deterministic and recalculated between builds. Actions can only be invoked with POST, and the Origin header is compared with Host, which blocks most CSRF (cross-site request forgery) attacks. Variables an action captures from a closure are encrypted. The docs warn plainly that encryption alone should not be relied on to protect secrets.
Exercises and next steps
A short decision sheet: data is the same for everyone and can wait, so use "use cache" with cacheLife and cacheTag; data is fresh or personal, so use <Suspense> and read it per request; data changes go through a Server Action with validation and a permission check, then updateTag.
Next on the track is Tailwind CSS: styling these same pages with utilities and a theme. A ready "lead form on a Server Action" solution with spam protection and a notification is covered in a separate recipe.
How this was verified7 · passed1 · not runchecked 11 October 2026
“Run” means the code was executed in a scratch project or container and the output in the article comes from that run. “Partial” means it was checked against stubs instead of external services. “Not run” means it needs a device, an account or a key; the steps were checked against the official documentation. The verification project files and run logs are kept in the InterLAB archive.