Skip to content
Vladimir Chavkov
Go back

Understanding Next.js App Router and React Server Components

Edit page

The Next.js App Router represents a fundamental shift in how we build React applications. Built on top of React Server Components (RSC), it changes where and how your code runs, moving rendering logic to the server by default while preserving client-side interactivity where you need it.

How the App Router Works

The App Router uses a file-system-based routing convention inside the app/ directory. Each route is a folder containing a page.tsx file, with optional layout.tsx, loading.tsx, and error.tsx files for shared UI, streaming fallbacks, and error boundaries.

app/
layout.tsx # Root layout (wraps all pages)
page.tsx # Home route (/)
dashboard/
layout.tsx # Dashboard layout
page.tsx # /dashboard
settings/
page.tsx # /dashboard/settings

Layouts are preserved across navigations. A layout wraps its children and does not re-render when users navigate between sibling routes. This eliminates redundant data fetching and preserves UI state in shared components like sidebars.

React Server Components by Default

Every component inside app/ is a Server Component unless you explicitly mark it otherwise. Server Components run on the server at request time (or build time for static routes), which means they can directly access databases, read files, and call internal services without exposing that logic to the browser.

// app/posts/page.tsx — this is a Server Component
import { db } from "@/lib/database";
export default async function PostsPage() {
const posts = await db.post.findMany({
orderBy: { createdAt: "desc" },
take: 20,
});
return (
<main>
<h1>Recent Posts</h1>
{posts.map(post => (
<article key={post.id}>
<h2>{post.title}</h2>
<p>{post.summary}</p>
</article>
))}
</main>
);
}

No useEffect, no loading state management, no API route intermediary. The component fetches data as part of its render, and the HTML is streamed to the client.

When to Use ‘use client’

Add the 'use client' directive at the top of a file when you need browser APIs, React hooks like useState or useEffect, or event handlers. The key principle: push 'use client' as far down the component tree as possible.

components/like-button.tsx
"use client";
import { useState } from "react";
export function LikeButton({ postId }: { postId: string }) {
const [liked, setLiked] = useState(false);
return (
<button onClick={() => setLiked(!liked)}>
{liked ? "Liked" : "Like"}
</button>
);
}

This button is a Client Component, but the parent page that renders it remains a Server Component. Only the interactive leaf nodes ship JavaScript to the browser.

Data Fetching with Fetch and Caching

The App Router extends the native fetch API with caching and revalidation controls:

// Cached indefinitely (static data)
const data = await fetch("https://api.example.com/config", {
cache: "force-cache",
});
// Revalidate every 60 seconds (ISR-style)
const posts = await fetch("https://api.example.com/posts", {
next: { revalidate: 60 },
});
// Always fresh (dynamic data)
const user = await fetch("https://api.example.com/me", {
cache: "no-store",
});

Next.js automatically deduplicates identical fetch requests within a single render pass. If multiple components request the same URL, only one network call is made.

Streaming with Suspense

Suspense boundaries allow you to progressively render a page. Wrap slow components in <Suspense> and provide a fallback. The shell renders immediately while the suspended content streams in when ready.

import { Suspense } from "react";
import { PostFeed } from "./post-feed";
import { Recommendations } from "./recommendations";
export default function DashboardPage() {
return (
<main>
<h1>Dashboard</h1>
<Suspense fallback={<p>Loading feed...</p>}>
<PostFeed />
</Suspense>
<Suspense fallback={<p>Loading recommendations...</p>}>
<Recommendations />
</Suspense>
</main>
);
}

Each Suspense boundary streams independently. The feed might resolve in 200ms while recommendations take 800ms, and users see each section appear as it becomes ready.

Migrating from Pages Router

Migration does not need to happen all at once. The app/ and pages/ directories can coexist:

  1. Start with layouts. Move your _app.tsx wrapper logic into app/layout.tsx.
  2. Migrate static pages first. Pages without complex client state (about, docs, marketing) are the easiest to convert.
  3. Replace getServerSideProps with async components. The data fetching that lived in getServerSideProps moves directly into the component body.
  4. Replace getStaticProps with cached fetch or generateStaticParams. Static generation uses fetch with force-cache or the generateStaticParams function for dynamic routes.
  5. Extract client interactivity. Identify the interactive parts of your pages, extract them into 'use client' components, and keep the parent as a Server Component.

The App Router is not just a new API surface. It changes the rendering model of your application. Server Components reduce bundle size, eliminate client-server waterfalls, and simplify data access. Start with new routes in app/, migrate incrementally, and push client boundaries to the leaves.


Edit page
Share this post on:

Previous Post
Next.js Server Actions: Handling Forms Without API Routes
Next Post
Building Full-Stack Apps with SvelteKit: A Complete Guide