The Next.js App Router flips a default that shaped a decade of React: with Next.js App Router server components, your components render on the server unless you explicitly opt them into the client. Getting comfortable with that server-first model — and the boundary between server and client components — is the single biggest thing that makes the App Router click. In this guide I walk through the mental model, how data fetching and streaming actually work, and the boundary mistakes I see developers hit most often.
Next.js App Router server components: the server-first model
When you create a component inside the app/ directory, it is a React Server Component by default. There is no directive to add and no import to remember — server is simply the starting point. A Server Component runs on the server (or at build time), renders to HTML, and ships zero JavaScript to the browser for its own logic. That is the whole point: less client JavaScript, faster loads, and direct access to your backend.
A component only becomes a Client Component when the file starts with the "use client" directive:
'use client';
import { useState } from 'react';
export default function Counter() {
const [count, setCount] = useState(0);
return <button onClick={() => setCount(count + 1)}>{count}</button>;
}
That directive marks a boundary. Everything imported into a "use client" file — and everything below it in the tree — becomes part of the client bundle. So the mental model is not "add use client when I need interactivity on this element." It is "keep components on the server, and push the use client boundary as far down the tree as possible."
Server vs client components: choosing the right one
The two component types are not competitors; they compose. The practical question is which capability you actually need.
Reach for a Server Component when you are:
- Fetching data from a database, API, or the filesystem
- Using secrets or API keys that must never reach the browser
- Rendering large, static, or content-heavy UI
- Importing a heavy dependency you would rather not ship (a Markdown parser, a date library, a backend SDK)
Reach for a Client Component when you need:
- State and effects (
useState,useReducer,useEffect) - Event handlers like
onClickoronChange - Browser-only APIs (
localStorage,window,IntersectionObserver) - Third-party libraries that rely on those hooks or APIs
A useful rule of thumb: if the code only produces output from input, it can stay on the server. If it needs to remember something or react to the user, it belongs on the client. Keeping most of your tree on the server is also one of the highest-leverage things you can do for load performance — I go deeper on that in my Next.js performance guide.
Data fetching in the App Router
This is where the App Router feels genuinely different. Because Server Components are async functions, you fetch data with plain async/await — no useEffect, no loading-state juggling, and no separate data-fetching library required for the common case.
// app/posts/page.jsx — a Server Component
export default async function PostsPage() {
const res = await fetch('https://api.example.com/posts', {
next: { revalidate: 3600 }, // revalidate at most once an hour
});
const posts = await res.json();
return (
<ul>
{posts.map((post) => (
<li key={post.id}>{post.title}</li>
))}
</ul>
);
}
The fetch runs on the server, so the API key or database URL never leaves it. The extended fetch options control caching: revalidate sets time-based revalidation, cache: 'no-store' opts out of caching for always-fresh data, and cache: 'force-cache' keeps a response static. If async server components feel unfamiliar, it is worth being solid on promises and async/await first — I cover those in must-know JavaScript concepts before React.
Streaming with Suspense and loading.js
Fetching on the server raises a fair question: does the whole page wait for the slowest request? Not if you stream. The App Router builds streaming on top of React Suspense, so you can send the shell of a page immediately and let slower sections fill in.
The simplest version is a loading.js file next to your page, which Next.js automatically wraps around the route in a Suspense boundary:
// app/dashboard/loading.jsx
export default function Loading() {
return <p>Loading dashboard…</p>;
}
For finer control, wrap a slow component in Suspense yourself so the rest of the page renders instantly:
import { Suspense } from 'react';
import Reviews from './reviews';
import ProductInfo from './product-info';
export default function ProductPage() {
return (
<section>
<ProductInfo />
<Suspense fallback={<p>Loading reviews…</p>}>
<Reviews />
</Suspense>
</section>
);
}
Here ProductInfo shows immediately while Reviews — perhaps a slow database call — streams in when it is ready. This is a real win for perceived performance and Core Web Vitals, since users see meaningful content sooner instead of staring at a blank screen.
The "use client" boundary and composition
The mistake that trips up almost everyone: you cannot import a Server Component into a Client Component and expect it to stay on the server. Once a module is imported by a "use client" file, it is pulled into the client bundle along with everything it imports.
The fix is composition through props — usually children. A Client Component can receive server-rendered content as a prop and render it without turning that content into client code:
// tabs.jsx
'use client';
import { useState } from 'react';
export default function Tabs({ children }) {
const [open, setOpen] = useState(true);
return (
<div>
<button onClick={() => setOpen((v) => !v)}>Toggle</button>
{open && children}
</div>
);
}
// page.jsx — a Server Component
import Tabs from './tabs';
import HeavyServerContent from './heavy-server-content';
export default function Page() {
return (
<Tabs>
<HeavyServerContent />
</Tabs>
);
}
Tabs owns the interactivity; HeavyServerContent is rendered on the server and passed through as children. The client component never imports the server one, so the boundary stays intact and the heavy content never bloats your bundle.
Mistakes to avoid
A handful of patterns cause most of the pain:
- Putting
"use client"at the top of the tree. Marking a root layout or a top-level page as a client component drags the whole subtree into the client bundle. Push the directive down to the leaf that actually needs it. - Passing non-serializable props across the boundary. Props from a Server Component to a Client Component must be serializable — strings, numbers, arrays, and plain objects are fine, but functions and class instances are not. Define event handlers inside the client component instead.
- Reaching for
useEffectto fetch data. If the data is available on the server, fetch it there. Client-side fetching inuseEffectbrings back request waterfalls, loading spinners, and layout shift you no longer need. - Leaking secrets. Environment variables without the
NEXT_PUBLIC_prefix are server-only. Read them in Server Components; never pass them as props into client code. - Forgetting that the directive is inherited. You only need
"use client"once at the boundary — every component imported below it is already a client component.
Frequently Asked Questions
What is the difference between server and client components in Next.js?
Server Components render on the server and send no JavaScript to the browser for their own logic, which makes them ideal for data fetching and static content. Client Components run in the browser and can use state, effects, and event handlers. In the App Router, everything is a Server Component until you add the "use client" directive.
Do I need "use client" on every component?
No. You add it only at the boundary where interactivity begins. Every component imported into a "use client" file automatically becomes a client component, so a single directive covers an entire interactive subtree. Most of your tree should stay on the server.
How do I fetch data in the Next.js App Router?
Make the component an async function and use await fetch (or query your database directly) right inside it. The request runs on the server, so credentials stay private, and you control caching with options like next: { revalidate } or cache: 'no-store'. You rarely need useEffect for initial data anymore.
Can I use useState in a Server Component?
No. Hooks like useState, useEffect, and useReducer only work in Client Components because they depend on the browser runtime. If a component needs state, add "use client" at the top of its file — and try to keep that stateful component small so the rest of the page can stay on the server.
Putting it into practice
The App Router rewards a simple habit: default to the server, and only cross into the client when you genuinely need interactivity. Fetch data in async Server Components, stream slow sections with Suspense, and keep your "use client" boundaries small and low in the tree. Do that and you get less JavaScript, faster pages, and a simpler data flow almost for free. If you want to keep going, the Next.js category collects deeper dives on routing, performance, and patterns.
