How to buildOctober 11, 202612 min read
Next.jsStep 3 of 7

Next.js: Server Components, data fetching and Server Actions

In short

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.

TutorialLevel: IntermediateNext.jsPractice: ~3–6 h

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 with cacheTag or streaming with <Suspense>
  • handle a form with a Server Action using server-side validation, useActionState and cache updates via updateTag
  • lock the data access module with server-only and list the checks a Server Action is unsafe without

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
  1. 01Server and Client Components: where code runs
  2. 02"use client" and the boundary
  3. 03Fetching data in a Server Component
  4. 04Caching: use cache, cacheLife and cacheTag
  5. 05Streaming and Suspense
  6. 06Server Actions: forms and mutations
  7. 07Security: what never reaches the client
  8. 08Exercises and next steps

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.

Server Components read dataRSC Payload + HTMLBrowser shows HTMLClient Components hydratePage is interactive
serverbrowser
First load: data and secrets stay on the server; the browser gets markup and code for the interactive parts only.

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 children or 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.

lib/leads.ts
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;
}
Data access layer: a read, a cached read and a write. A JSON file stands in for a database

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:

app/leads/page.tsx
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>
  );
}
First attempt: reading data directly in the page, without cache or Suspense
Terminal
$ 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: null
Real next build output in Next.js 16.4 with Cache Components; the project path and stack trace are shortened

This 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:

  1. cacheLife("hours")

    The entry lifetime. With the hours profile 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). Without cacheLife the default profile applies.

  2. cacheTag("leads")

    A label used to invalidate the entry on demand, for example right after a lead is added.

  3. readLeads()

    The read itself. Inside a cached function you cannot call cookies(), headers() or read searchParams; 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
Built-in cacheLife profiles (a selection from the docs)
app/leads/lead-list.tsx
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>
  );
}
The list Server Component simply calls the cached function
Terminal
$ 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 content
Real build output: the Revalidate and Expire columns come from the hours profile

You can check what went into the static shell right in the build output: Next.js stores prerendered HTML in .next/server/app/:

Terminal
$ grep -o "Aigerim\|Daniyar\|Counting today’s leads…\|New today" .next/server/app/leads.html
Counting today’s leads…
Aigerim
Daniyar
Real output: the shell contains the cached list and the counter placeholder, but not the counter itself

Streaming 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.

app/leads/lead-stats.tsx
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>
  );
}
The uncached part: reads data on every request
app/leads/page.tsx
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>
  );
}
The final page: form and list in the shell, the counter streamed

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.

app/leads/actions.ts
"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.` };
}
Server Action: validation, write, cache invalidation by tag
  1. Read and normalize input

    formData.get returns FormDataEntryValue | null. Convert it to a string and trim it: never trust the client, even if the form has required.

  2. 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.

  3. 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. updateTag can only be called inside a Server Action.

app/leads/lead-form.tsx
"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>
  );
}
Client form: response state, field errors, pending indicator

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:

Terminal
$ 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]
Real output: the build stops, and the trace shows where the import came from
  • 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.env are marked import "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.

Tell us about your task — we'll review your request and get back to you

Got a project?
We'll scope and estimate it — for free.

Tell us about the product or the problem. After reviewing the task we'll come back with an architecture, a timeline and a price for the first version.

  • Scope and risks estimated before we start
  • NDA — yours or ours
  • Work starts within a week

Please do not send passwords, access keys or source code through this form — we will agree a safe way to share access separately.

By clicking the button you agree to the processing of your personal data.