React Server Components and Server Actions in Next.js
How React Server Components work in the Next.js App Router: the server and client boundary, data fetching, Server Actions, and the errors you will hit.
React Server Components invert an assumption that held for a decade: that a React component is something that runs in a browser. In the Next.js App Router, components run on the server by default, and only the ones you explicitly opt in ever reach the client bundle.
The mental adjustment is bigger than the API. There are three new rules, one new directive, and a boundary you cannot see in your editor. Once those click, most of the confusion goes away.
What a React Server Component is
A Server Component runs once, on the server, during the request. It can read the filesystem, query a database, use secrets. Its JavaScript is never sent to the browser. What the browser receives is a description of the rendered output, which React uses to build the DOM.
// app/posts/page.tsx
// No 'use client', so this is a Server Component.
import { db } from '@/lib/db';
export default async function PostsPage() {
// Runs on the server. The query, the connection string and the
// database driver never appear in the client bundle.
const posts = await db.post.findMany({ orderBy: { date: 'desc' } });
return (
<ul>
{posts.map((post) => (
<li key={post.id}>{post.title}</li>
))}
</ul>
);
}Two things in that snippet were impossible before. The component is async and awaits at the top level, and there is no useEffect, no loading state and no API route. The data fetching happens where the data lives.
The bundle size effect is the part that surprises people. A Server Component that formats dates with a 70KB library ships zero of those 70KB, because the formatting already happened.
The client boundary
Server Components cannot use state, effects, refs, event handlers or browser APIs, because none of those exist during a server render. When you need them, you mark the file with 'use client'.
'use client';
import { useState } from 'react';
export function Counter() {
const [count, setCount] = useState(0);
return <button onClick={() => setCount(count + 1)}>{count}</button>;
}The directive marks an entry point, not a file
This is the single most misunderstood part of the model. 'use client' does not mean "this file is a client component". It means "this file is where the client bundle starts". Everything it imports, and everything those files import, becomes part of the client bundle too, whether or not they have the directive.
'use client';
// These are now all client code, transitively, even though
// none of them says 'use client' anywhere.
import { formatCurrency } from '@/lib/format'; // -> client bundle
import { Chart } from '@/components/Chart'; // -> client bundle
import heavyLib from 'some-300kb-library'; // -> client bundleWhich is why a single 'use client' near the top of a tree can quietly pull most of your application into the browser. Put the directive as far down the tree as it will go. A page that needs one interactive button should not become a Client Component; the button should.
What crosses the boundary
A Server Component can render a Client Component and pass it props. Those props have to be serialisable, because they are turned into a payload and sent over the wire. Strings, numbers, booleans, arrays, plain objects, Dates, Maps, Sets and Promises are fine. Functions and class instances are not.
// Server Component
export default function Page() {
return (
<ClientWidget
title="Sales" // fine
data={[1, 2, 3]} // fine
onSelect={(id) => save(id)} // Error
/>
);
}That last line produces Functions cannot be passed directly to Client Components unless you explicitly expose it by marking it with 'use server'. Which is a hint about where this is going.
Passing server-rendered content as children
The rule people assume is "anything inside a Client Component is client code". That is true for imports and false for children. A Client Component can render server-rendered content passed to it as a prop, because the content was already rendered before it got there.
// A Server Component page.
export default function Page() {
return (
// Client: needs onClick and useState for the open/closed state.
<Accordion>
{/* Still a Server Component. Its data fetching and its
dependencies stay on the server. */}
<ExpensiveServerContent />
</Accordion>
);
}This is the most useful pattern in the whole model, and it is how you keep interactive shells thin. Providers are the everyday case: a theme or auth provider needs Context, so it is a Client Component, but wrapping your whole app in it does not force the app onto the client as long as the app arrives as children. Context itself remains client-only, which is worth remembering if you are used to reaching for it, and I covered its behaviour in the React Context API guide.
Fetching data
Fetch where the component is. No useEffect, no waterfall of client requests, no API route that exists only so the browser can ask for something the server already had.
async function Author({ id }: { id: string }) {
const author = await db.user.findUnique({ where: { id } });
return <span>{author?.name}</span>;
}
async function Post({ id }: { id: string }) {
const post = await db.post.findUnique({ where: { id } });
return (
<article>
<h2>{post?.title}</h2>
{/* Nested fetch, resolved on the server before anything is sent. */}
<Author id={post!.authorId} />
</article>
);
}Sequential awaits in the same component are still sequential, and that is the easiest performance mistake to make here. If two queries do not depend on each other, start them together:
// Waits for both, in parallel, rather than one after the other.
const [user, orders] = await Promise.all([
getUser(id),
getOrders(id),
]);Server Actions
A Server Action is a function that runs on the server but can be called from client code, including directly from a form. Mark it with 'use server'.
// app/actions.ts
'use server';
import { revalidatePath } from 'next/cache';
import { db } from '@/lib/db';
export async function createPost(formData: FormData) {
const title = formData.get('title');
if (typeof title !== 'string' || title.length === 0) {
return { error: 'Title is required' };
}
await db.post.create({ data: { title } });
// Tells Next.js the cached page is stale.
revalidatePath('/posts');
return { error: null };
}Pass it straight to a form. No onSubmit, no preventDefault, no fetch call, and it works before JavaScript has loaded because it is a real form submission.
export default function NewPost() {
return (
<form action={createPost}>
<input name="title" required />
<SubmitButton />
</form>
);
}For pending state and returned values, React 19 gives you useActionState and useFormStatus. Note the first was called useFormState during the React 18 canary period and was renamed, so older examples will not match.
'use client';
import { useActionState } from 'react';
import { useFormStatus } from 'react-dom';
export function SubmitButton() {
// Reads the status of the nearest enclosing form.
const { pending } = useFormStatus();
return <button disabled={pending}>{pending ? 'Saving...' : 'Save'}</button>;
}
export function PostForm() {
const [state, formAction, isPending] = useActionState(createPost, {
error: null,
});
return (
<form action={formAction}>
<input name="title" required />
{state.error && <p role="alert">{state.error}</p>}
<button disabled={isPending}>Save</button>
</form>
);
}'use server';
export async function deletePost(id: string) {
// Both checks belong here, not in the component that renders the button.
const session = await auth();
if (!session) throw new Error('Unauthorized');
if (!(await canDelete(session.user.id, id))) throw new Error('Forbidden');
await db.post.delete({ where: { id } });
revalidatePath('/posts');
}Caching, and why your data looks stale
The complaint I hear most often about the App Router is that a page shows old data after an update. It is almost always caching, and the confusion comes from there being several layers of it rather than one.
Route segments are prerendered at build time unless something in them opts out. Reading cookies() or headers(), or using a dynamic searchParams, marks a route dynamic and it renders per request. A page that does none of those is static, and a database query inside it runs once, at build, not on every visit. That is excellent for a marketing page and wrong for a dashboard.
// Opt a route out of static rendering entirely.
export const dynamic = 'force-dynamic';
// Or re-generate it on a schedule, in seconds.
export const revalidate = 60;For finer control, tag the fetches and invalidate by tag when the underlying data changes. This is the piece that makes Server Actions and caching work together rather than against each other:
// Somewhere in a Server Component.
const res = await fetch('https://api.example.com/posts', {
next: { tags: ['posts'], revalidate: 3600 },
});
// In the action that changes posts.
'use server';
import { revalidateTag } from 'next/cache';
export async function publishPost(id: string) {
await db.post.update({ where: { id }, data: { published: true } });
revalidateTag('posts'); // every cached fetch with this tag is now stale
}revalidatePath is the blunter version and is fine when one action affects one page. Reach for tags when the same data appears on several routes, because otherwise you end up listing every path that happens to render a post and forgetting one.
When something looks stale in development, check in this order: is the route static when it should be dynamic, is the fetch cached, and did the action that changed the data call a revalidation at all. That sequence has explained every instance of this I have hit.
Streaming with Suspense
Because rendering happens on the server, a slow query blocks the response. Suspense is the release valve: React sends the shell immediately with a fallback in place, then streams the real markup for that region when it resolves.
export default function Dashboard() {
return (
<>
{/* Sent immediately. */}
<Header />
{/* Streams in when the query finishes. */}
<Suspense fallback={<StatsSkeleton />}>
<SlowStats />
</Suspense>
</>
);
}A loading.tsx file in a route segment is shorthand for wrapping that segment in a boundary. The placement decisions and the fallback design matter as much here as on the client, and I went through them in React Suspense and lazy.
Where I still use Client Components
The default is server, but plenty of things are legitimately interactive. Anything with useState or an event handler. Anything reading window, localStorage or geolocation. Animation libraries, form libraries with live validation, charts that respond to hover, drag and drop.
What I do not do any more is fetch data on the client just because that is the habit. Most of the useEffect fetching in an App Router codebase is a Server Component that has not been written yet. Client-side fetching still earns its place for data that changes while the user is looking at it, which is where polling with TanStack Query or a socket belongs, and for anything driven by user input after load.
The honest summary
Server Components remove a category of work: the API route that exists only to move data you already had, the loading state for content that could have arrived in the HTML, the library shipped to a browser that only needed the output. That is a real reduction, and on content-heavy pages the difference in what ships is large.
They also add a boundary you have to hold in your head, error messages that point at the wrong file, and a set of libraries that have not finished adapting. Anything that calls createContext at module scope needs a client wrapper, and you will write a few of those.
I would not migrate a working Pages Router application for the architecture alone. For new work, the default is right often enough that starting on the server and moving down to the client where you need interactivity produces a leaner app than the reverse, with less deliberate effort than the old model required.
Frequently asked questions
What does the use client directive actually do?
It marks an entry point into the client bundle, not a single file. Everything the file imports, and everything those files import, becomes client code too, whether or not they carry the directive. That is why one use client near the top of a tree can pull most of an application into the browser. Push the directive as far down the tree as it will go.
Can a Client Component render a Server Component?
Yes, if the server content is passed in as children or another prop rather than imported. Imports cross into the client bundle; children do not, because they were already rendered before they reached the client component. This is how a Context provider can wrap your whole app without forcing it onto the client.
Are Server Actions secure?
Only if you make them so. Every Server Action compiles to an HTTP endpoint that anyone can call with any arguments, so the fact that your UI only calls it from an admin page means nothing. Authenticate and authorise inside the action itself, and validate the input, because FormData values are typed as string or File and a caller can send whatever they like.
Why is my Next.js page showing stale data?
Check three things in order. Whether the route is static when it should be dynamic, since a route that never reads cookies, headers or searchParams is prerendered at build time. Whether the fetch itself is cached. And whether the action that changed the data called revalidatePath or revalidateTag at all.


