Next.js has undergone its most significant architectural shift since its creation. The App Router, introduced as stable in Next.js 13.4 and refined through versions 14 and 15, replaces the Pages Router paradigm that developers have relied on for years. At the heart of this change is React Server Components -- a fundamentally new way of thinking about where and how your React code executes.
This guide walks through the key concepts, practical patterns, and real-world considerations you need to understand to build with the Next.js App Router and server components effectively.
The Shift from Pages Router to App Router
The Pages Router followed a simple mental model: every file in the pages/ directory became a route. Data fetching happened through special functions like getServerSideProps and getStaticProps that ran on the server and passed data as props to your page component. Every component in the tree was a client component -- it shipped JavaScript to the browser, hydrated on load, and was fully interactive.
The App Router changes this model fundamentally. Routes are now defined in the app/ directory using a file-system convention based on folders. Each route segment can have its own page.tsx, layout.tsx, loading.tsx, error.tsx, and not-found.tsx files, enabling granular control over every aspect of the route's behavior.
Here is a basic App Router project structure:
app/
layout.tsx # Root layout (wraps all pages)
page.tsx # Home page (/)
about/
page.tsx # About page (/about)
blog/
layout.tsx # Blog-specific layout
page.tsx # Blog index (/blog)
[slug]/
page.tsx # Individual blog post (/blog/my-post)
loading.tsx # Loading UI for this route
error.tsx # Error boundary for this route
The most important conceptual shift: components in the App Router are server components by default. They render on the server, produce HTML, and send zero JavaScript to the client. Only components explicitly marked with the "use client" directive ship JavaScript to the browser. This inversion of the default is what makes the App Router fundamentally different from the Pages Router.
How React Server Components Work
React Server Components (RSC) are not server-side rendering in the traditional sense. SSR renders your components to HTML on the server but still ships all the JavaScript to the client for hydration. Server components never ship their JavaScript to the client at all. They execute exclusively on the server, and only their rendered output (a serialized React tree) is sent to the browser.
This has several immediate benefits:
- Smaller client bundles. Libraries used only in server components (database clients, markdown parsers, heavy utility libraries) are never included in the client bundle.
- Direct backend access. Server components can directly query databases, read from the file system, or call internal APIs without exposing endpoints to the client.
- Streaming and progressive rendering. Server components can stream their output as it becomes ready, improving perceived performance.
Here is a server component that fetches data directly from a database:
// app/products/page.tsx
// This is a Server Component by default -- no "use client" directive
import { db } from '@/lib/database';
export default async function ProductsPage() {
const products = await db.product.findMany({
orderBy: { createdAt: 'desc' },
take: 20,
});
return (
<main>
<h1>Our Products</h1>
<div className="grid grid-cols-3 gap-6">
{products.map((product) => (
<article key={product.id} className="border rounded-lg p-4">
<h2 className="font-semibold">{product.name}</h2>
<p className="text-gray-600">{product.description}</p>
<span className="text-lg font-bold">${product.price}</span>
</article>
))}
</div>
</main>
);
}
Notice there is no getServerSideProps, no API route, no useEffect with a loading state. The component is an async function that awaits data and renders it. The Prisma client, the database query, and all the data transformation logic stay entirely on the server. The client receives only the rendered HTML and the minimal React payload needed for navigation.
When to Use Client Components vs Server Components
The decision of when to add the "use client" directive is one of the most important architectural choices in an App Router application. The guiding principle is simple: keep components on the server unless they need interactivity or browser APIs.
Use server components for:
- Fetching and displaying data
- Accessing backend resources (databases, file systems, internal services)
- Rendering static or mostly-static content
- Keeping sensitive logic (API keys, business rules) off the client
- Components that import large dependencies you do not want in the client bundle
Use client components for:
- Interactive UI (forms, buttons with onClick handlers, toggles)
- Browser APIs (localStorage, geolocation, IntersectionObserver)
- React hooks that depend on client state (useState, useEffect, useReducer)
- Third-party libraries that use browser APIs or React context
Here is a practical example showing the boundary between server and client components:
// app/blog/[slug]/page.tsx — Server Component
import { getPost } from '@/lib/blog';
import { notFound } from 'next/navigation';
import LikeButton from '@/components/LikeButton';
import ShareMenu from '@/components/ShareMenu';
interface PageProps {
params: Promise<{ slug: string }>;
}
export default async function BlogPost({ params }: PageProps) {
const { slug } = await params;
const post = await getPost(slug);
if (!post) notFound();
return (
<article>
<h1>{post.title}</h1>
<p className="text-gray-500">{post.date}</p>
{/* Server-rendered content -- no JS shipped */}
<div dangerouslySetInnerHTML={{ __html: post.htmlContent }} />
{/* Client components for interactivity */}
<div className="flex gap-4 mt-8">
<LikeButton postId={post.id} initialCount={post.likes} />
<ShareMenu url={`/blog/${slug}`} title={post.title} />
</div>
</article>
);
}
// components/LikeButton.tsx — Client Component
'use client';
import { useState } from 'react';
export default function LikeButton({ postId, initialCount }: {
postId: string;
initialCount: number;
}) {
const [count, setCount] = useState(initialCount);
const [liked, setLiked] = useState(false);
async function handleLike() {
setLiked(true);
setCount((c) => c + 1);
await fetch(`/api/posts/${postId}/like`, { method: 'POST' });
}
return (
<button
onClick={handleLike}
disabled={liked}
className="flex items-center gap-2 px-4 py-2 rounded-lg border"
>
{liked ? 'Liked' : 'Like'} ({count})
</button>
);
}
The blog post content renders entirely on the server. Only the interactive LikeButton and ShareMenu components ship JavaScript to the client. This pattern -- server component as the container, client components as interactive leaves -- is the recommended architecture for App Router applications.
A common mistake is marking an entire page as "use client" because one small part of it needs interactivity. Instead, extract the interactive piece into its own client component and keep the rest on the server. This is sometimes called "pushing the client boundary down."
Data Fetching Patterns in the App Router
The App Router introduces a cleaner data fetching model that replaces the getServerSideProps/getStaticProps paradigm. Server components can fetch data directly using async/await. Next.js extends the native fetch API with caching and revalidation controls.
Direct async fetching in server components:
// Fetches fresh data on every request
async function getLatestPosts() {
const res = await fetch('https://api.example.com/posts', {
cache: 'no-store',
});
return res.json();
}
// Fetches and caches, revalidating every 60 seconds
async function getFeaturedPosts() {
const res = await fetch('https://api.example.com/posts/featured', {
next: { revalidate: 60 },
});
return res.json();
}
Server Actions for mutations:
Server Actions are async functions that run on the server and can be called directly from client components. They replace the need for API routes in many cases:
// app/actions.ts
'use server';
import { db } from '@/lib/database';
import { revalidatePath } from 'next/cache';
export async function createComment(formData: FormData) {
const content = formData.get('content') as string;
const postId = formData.get('postId') as string;
await db.comment.create({
data: { content, postId },
});
revalidatePath(`/blog/${postId}`);
}
// components/CommentForm.tsx
'use client';
import { createComment } from '@/app/actions';
export default function CommentForm({ postId }: { postId: string }) {
return (
<form action={createComment}>
<input type="hidden" name="postId" value={postId} />
<textarea name="content" placeholder="Write a comment..." required />
<button type="submit">Post Comment</button>
</form>
);
}
Server Actions provide type-safe, server-side mutation handling without manually creating API endpoints. They integrate with Next.js caching through revalidatePath and revalidateTag, ensuring the UI reflects the latest data after a mutation.
Parallel and sequential data fetching:
A common performance pitfall is sequential data fetching -- waiting for one request to complete before starting the next. In server components, you can initiate multiple fetches in parallel:
export default async function DashboardPage() {
// Start all fetches simultaneously
const [user, orders, analytics] = await Promise.all([
getUser(),
getRecentOrders(),
getAnalytics(),
]);
return (
<div>
<UserProfile user={user} />
<OrderList orders={orders} />
<AnalyticsChart data={analytics} />
</div>
);
}
Layouts, Loading States, and Error Handling
The App Router's file-based conventions for layouts, loading states, and error boundaries are one of its most practical improvements over the Pages Router.
Layouts persist across navigations and do not re-render when child routes change. This is ideal for navigation bars, sidebars, and other shell UI:
// app/dashboard/layout.tsx
import Sidebar from '@/components/dashboard/Sidebar';
export default function DashboardLayout({ children }: { children: React.ReactNode }) {
return (
<div className="flex min-h-screen">
<Sidebar />
<main className="flex-1 p-8">{children}</main>
</div>
);
}
Every page under /dashboard/* shares this layout. When users navigate between dashboard pages, the sidebar does not unmount or re-render. Only the {children} slot updates.
Loading states provide instant feedback during navigation using React Suspense under the hood:
// app/dashboard/analytics/loading.tsx
export default function AnalyticsLoading() {
return (
<div className="animate-pulse space-y-4">
<div className="h-8 bg-gray-200 rounded w-1/3" />
<div className="grid grid-cols-3 gap-4">
{[1, 2, 3].map((i) => (
<div key={i} className="h-32 bg-gray-200 rounded" />
))}
</div>
</div>
);
}
When a user navigates to /dashboard/analytics, they immediately see this skeleton UI while the server component fetches data. No manual loading state management, no useState(true) toggling. The framework handles the transition automatically.
Error boundaries catch errors at the route segment level:
// app/dashboard/analytics/error.tsx
'use client'; // Error components must be client components
export default function AnalyticsError({
error,
reset,
}: {
error: Error;
reset: () => void;
}) {
return (
<div className="text-center py-12">
<h2 className="text-xl font-semibold text-red-600">Something went wrong</h2>
<p className="text-gray-600 mt-2">{error.message}</p>
<button onClick={reset} className="mt-4 px-4 py-2 bg-blue-600 text-white rounded">
Try again
</button>
</div>
);
}
If the analytics page throws an error, only that segment shows the error UI. The rest of the dashboard -- the layout, sidebar, and other sections -- remain functional. This granular error isolation is a major improvement over the Pages Router, where errors often disrupted the entire page.
Practical Considerations for Adopting the App Router
Moving to the App Router is not just a file restructuring exercise. Here are key considerations from real-world projects:
Incremental adoption is fully supported. Next.js allows the app/ and pages/ directories to coexist. You can migrate routes one at a time, starting with the simplest pages and gradually moving complex ones. This is the recommended approach for existing projects.
Third-party library compatibility matters. Some libraries assume client-side execution and may break in server components. Common issues include libraries that access window, use React context at the top level, or rely on client-side-only hooks. The fix is usually wrapping them in a client component.
Caching behavior requires deliberate configuration. The App Router's caching system is powerful but has defaults that can surprise developers. Understand the difference between the Router Cache, Data Cache, and Full Route Cache. Use revalidatePath, revalidateTag, and the cache option on fetch to control exactly when data refreshes.
TypeScript support is first-class. The App Router's type system catches route parameter mismatches, layout/page prop errors, and metadata issues at compile time. Always use TypeScript with the App Router for the best development experience.
The App Router and React Server Components represent a genuine paradigm shift in how we build Next.js applications. The ability to keep rendering logic on the server while selectively adding client interactivity creates faster, leaner, and more maintainable web applications.
Start Building with the App Router
If you are planning a new web application or considering migrating an existing Next.js project to the App Router, the architectural decisions you make early will compound over the life of the project. Getting the server/client component boundaries right, choosing the correct data fetching patterns, and structuring your layouts effectively all require experience with the framework.
At Maranatha Technologies, we build production Next.js applications using the App Router and server components. From initial architecture to deployment and optimization, our team can help you leverage the full power of modern web development. Visit our web development services to learn how we can help you build a faster, more scalable web application.