RSC with Next.js: Server Components, Actions, and the Limits
Server components, server actions, mixing server and client components — RSC as it actually works in Next.js, not in theory.
Next.js is the easiest way to use React Server Components. It handles the RSC server, the Webpack configuration, the Flight protocol, and the routing — you write components and it figures out where they run.
The App Router Default
In Next.js's App Router, every component is a server component by default. You opt into client rendering with "use client".
app/
layout.tsx ← server component
page.tsx ← server component
components/
NoteList.tsx ← server component (can query DB)
Counter.tsx ← needs "use client" for useStateA minimal page that reads from a database:
// app/page.tsx — server component
import db from '@/lib/db';
export default async function HomePage() {
const notes = await db.all('SELECT id, body FROM notes ORDER BY created_at DESC');
return (
<main>
<h1>Notes</h1>
<ul>
{notes.map(note => (
<li key={note.id}>{note.body}</li>
))}
</ul>
</main>
);
}No useEffect. No fetch in the browser. No API route. The await db.all(...) runs on the server at request time, and the rendered HTML ships to the client.
Server Actions: Submitting Data Without an API
Server actions let you write a server-side function and call it directly from a form or a client component — no fetch, no API route required.
// app/actions.ts
'use server'; // ← marks the entire file as server-action exports
import db from '@/lib/db';
import { revalidatePath } from 'next/cache';
export async function postNote(formData: FormData) {
const body = formData.get('body') as string;
const author = formData.get('author') as string;
if (!body || !author) return;
// Parameterized query — safe from SQL injection
await db.run(
'INSERT INTO notes (body, author_id) VALUES (?, ?)',
[body, 1] // simplified auth — real app would get userId from session
);
// Tell Next.js to re-fetch data for this route
revalidatePath('/');
}Wire it to a form using the action attribute — no onSubmit, no fetch:
// app/CreateNote.tsx (can be a server component)
import { postNote } from './actions';
export default function CreateNote() {
return (
<form action={postNote}>
<select name="author">
<option value="1">Alice</option>
<option value="2">Bob</option>
</select>
<textarea name="body" placeholder="Write a note..." required />
<button type="submit">Send</button>
</form>
);
}When the form is submitted, Next.js calls postNote on the server directly. After it completes, revalidatePath('/') tells Next.js to regenerate the cached page data.
Mixing Server and Client Components
The most useful pattern: a server component fetches initial data and passes it to a client component as props. The client component then handles interactivity and can poll for updates.
// app/NoteView.tsx — server component (runs at request time)
import { NoteViewClient } from '@/components/NoteViewClient';
import db from '@/lib/db';
export default async function NoteView() {
const notes = await db.all('SELECT * FROM notes ORDER BY created_at DESC');
return <NoteViewClient initialNotes={notes} />;
}// components/NoteViewClient.tsx — client component
'use client';
import { useState, useEffect } from 'react';
export function NoteViewClient({ initialNotes }) {
const [notes, setNotes] = useState(initialNotes);
// Poll for new notes every 5 seconds
useEffect(() => {
const interval = setInterval(async () => {
const res = await fetch('/api/notes');
const updated = await res.json();
setNotes(updated);
}, 5000);
return () => clearInterval(interval);
}, []);
return (
<ul>
{notes.map(note => (
<li key={note.id}>
<strong>{note.author}</strong>: {note.body}
</li>
))}
</ul>
);
}The server component provides the fast initial load (no client-side waterfall). The client component takes over for real-time updates.
The Limitations of RSC
Server components cannot be children of client components (re-read the previous post for the full explanation). The workaround is passing them as children props from a server context.
Server components cannot use:
useState,useEffect,useRef,useContext, or any other hookwindow,document,navigator, or any browser API- Event handlers (
onClick,onChange, etc.) - Any library that uses the above internally
Client components cannot:
- Be async functions
- Directly call
await db.query(...)(no server-side access) - Import server-only utilities without the
server-onlypackage protection
When RSC is the wrong choice:
- Very client-heavy apps (rich editors, canvas games, animation-heavy UIs) — the overhead of the server component boundary adds friction without much benefit
- Teams that don't have infrastructure or expertise for Next.js or a framework with RSC support
- Apps where the data layer is already behind a well-typed API — the API-elimination benefit of RSC is less compelling when you'd lose your existing API cache, auth middleware, and documentation
Next.js vs "Is Next.js required for RSC?"
No — but the alternative is setting up Webpack's RSC config, patching Node for server-side JSX, managing the React Flight server, and wiring the client runtime manually. It works, but there is no practical reason to do it in production.
The current frameworks with first-class RSC support:
- Next.js — most complete, most production-proven
- Remix / React Router v7 — selective adoption, not all-in RSC
- TanStack Start — newer, growing RSC support
If you're starting a new project and RSC is the right call, Next.js is the answer.
Practice what you just read.
Keep reading
Enjoyed this? Get more like it.
Deep dives on system design, React, web development, and personal finance — straight to your inbox. Free, always.