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.

April 4, 20265 min read2 / 2

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

Plain text
app/ layout.tsx ← server component page.tsx ← server component components/ NoteList.tsx ← server component (can query DB) Counter.tsx ← needs "use client" for useState

A minimal page that reads from a database:

TSX
// 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.

TSX
// 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:

TSX
// 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.

TSX
// 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} />; }
TSX
// 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 hook
  • window, 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-only package 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.

Quiz: RSC in Next.js
1 exercise

Enjoyed this? Get more like it.

Deep dives on system design, React, web development, and personal finance — straight to your inbox. Free, always.