Next.js App Router: Server Components and Layouts
Next.js 13 introduced the App Router as a fundamental rethinking of how we build React applications. Unlike the Pages Router where every component is a Client Component by default, the App Router...
Key Insights
- Server Components are the default in Next.js App Router, enabling direct database access and zero JavaScript shipped to the client for non-interactive components
- Layouts provide persistent UI shells that don’t re-render on navigation, with support for nesting to create sophisticated shared UI patterns across route segments
- The composition boundary between Server and Client Components is critical—pass Server Components as children to Client Components to maximize server-side rendering benefits
Understanding the App Router Paradigm Shift
Next.js 13 introduced the App Router as a fundamental rethinking of how we build React applications. Unlike the Pages Router where every component is a Client Component by default, the App Router inverts this model: everything is a Server Component unless you explicitly opt into client-side rendering.
This isn’t just a new file structure—it’s a different mental model. The app directory uses file-based routing where page.js defines route segments, layout.js creates persistent UI shells, and the component tree renders on the server by default. This architecture reduces JavaScript bundles, enables direct backend access, and supports streaming HTML for faster perceived performance.
The Pages Router isn’t deprecated, but the App Router represents Next.js’s vision for modern React applications. If you’re starting a new project, use the App Router. If you’re maintaining an existing app, you can adopt it incrementally—both routers coexist in the same project.
Server Components: The New Default
React Server Components execute exclusively on the server. They never hydrate on the client, which means they can access databases, file systems, and environment variables directly without exposing secrets or bloating your JavaScript bundle.
Here’s a Server Component fetching data directly from a database:
// app/products/page.tsx
import { db } from '@/lib/database';
async function getProducts() {
const products = await db.query('SELECT * FROM products WHERE active = true');
return products;
}
export default async function ProductsPage() {
const products = await getProducts();
return (
<div className="grid grid-cols-3 gap-4">
{products.map((product) => (
<div key={product.id} className="border rounded-lg p-4">
<h2 className="text-xl font-bold">{product.name}</h2>
<p className="text-gray-600">${product.price}</p>
<p className="mt-2">{product.description}</p>
</div>
))}
</div>
);
}
Notice what’s missing: no useState, no useEffect, no loading states, no client-side fetching logic. The component is async, fetches data, and returns JSX. The server does all the work and sends rendered HTML to the client.
Benefits are substantial:
- Zero client-side JavaScript for this component
- Direct database access without API routes
- Automatic request deduplication when the same data is fetched multiple times
- Streaming support for progressive rendering
Server Components should be your default choice. Only opt into Client Components when you need interactivity.
When to Use Client Components
Client Components are necessary when you need:
- Interactivity: onClick handlers, form inputs, event listeners
- React hooks: useState, useEffect, useContext, custom hooks
- Browser APIs: localStorage, window object, geolocation
- Third-party libraries that depend on client-side features
You opt into client-side rendering with the 'use client' directive at the top of your file:
// app/components/AddToCartButton.tsx
'use client';
import { useState } from 'react';
interface AddToCartButtonProps {
productId: string;
productName: string;
}
export default function AddToCartButton({ productId, productName }: AddToCartButtonProps) {
const [isAdding, setIsAdding] = useState(false);
const [message, setMessage] = useState('');
const handleAddToCart = async () => {
setIsAdding(true);
try {
const response = await fetch('/api/cart', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ productId }),
});
if (response.ok) {
setMessage(`${productName} added to cart!`);
setTimeout(() => setMessage(''), 3000);
}
} catch (error) {
setMessage('Failed to add to cart');
} finally {
setIsAdding(false);
}
};
return (
<div>
<button
onClick={handleAddToCart}
disabled={isAdding}
className="bg-blue-600 text-white px-4 py-2 rounded hover:bg-blue-700 disabled:opacity-50"
>
{isAdding ? 'Adding...' : 'Add to Cart'}
</button>
{message && <p className="mt-2 text-sm text-green-600">{message}</p>}
</div>
);
}
This component requires useState and event handlers, so it must be a Client Component. Import and use it in your Server Component:
// app/products/[id]/page.tsx
import { db } from '@/lib/database';
import AddToCartButton from '@/app/components/AddToCartButton';
export default async function ProductPage({ params }: { params: { id: string } }) {
const product = await db.query('SELECT * FROM products WHERE id = ?', [params.id]);
return (
<div>
<h1>{product.name}</h1>
<p>{product.description}</p>
<AddToCartButton productId={product.id} productName={product.name} />
</div>
);
}
The page itself remains a Server Component, minimizing client-side JavaScript while providing interactivity where needed.
Layouts: Persistent UI Shells
Layouts define UI that persists across multiple pages. They wrap page content and don’t re-render when navigating between routes that share the layout. This is perfect for navigation bars, sidebars, and footers.
The root layout is required and wraps your entire application:
// app/layout.tsx
import './globals.css';
import Navigation from './components/Navigation';
import Footer from './components/Footer';
export const metadata = {
title: 'My App',
description: 'Built with Next.js App Router',
};
export default function RootLayout({ children }: { children: React.ReactNode }) {
return (
<html lang="en">
<body>
<Navigation />
<main className="min-h-screen container mx-auto px-4 py-8">
{children}
</main>
<Footer />
</body>
</html>
);
}
Layouts can be nested. Create a layout in any route segment to add shared UI for that section:
// app/dashboard/layout.tsx
import Sidebar from './components/Sidebar';
export default function DashboardLayout({ children }: { children: React.ReactNode }) {
return (
<div className="flex gap-6">
<Sidebar className="w-64 flex-shrink-0" />
<div className="flex-1">
{children}
</div>
</div>
);
}
Now all routes under /dashboard automatically include the sidebar:
/dashboard→ Root Layout + Dashboard Layout + Dashboard Page/dashboard/settings→ Root Layout + Dashboard Layout + Settings Page/dashboard/analytics→ Root Layout + Dashboard Layout + Analytics Page
The sidebar doesn’t re-render when navigating between these pages—only the page content changes.
Composition Patterns: Server and Client Components
The most powerful pattern is passing Server Components as children to Client Components. This preserves server-side rendering benefits while enabling client-side interactivity for the wrapper.
// app/components/Tabs.tsx (Client Component)
'use client';
import { useState } from 'react';
interface TabsProps {
children: React.ReactNode;
defaultTab?: number;
}
export default function Tabs({ children, defaultTab = 0 }: TabsProps) {
const [activeTab, setActiveTab] = useState(defaultTab);
const tabs = React.Children.toArray(children);
return (
<div>
<div className="flex gap-2 border-b">
{tabs.map((_, index) => (
<button
key={index}
onClick={() => setActiveTab(index)}
className={`px-4 py-2 ${activeTab === index ? 'border-b-2 border-blue-600' : ''}`}
>
Tab {index + 1}
</button>
))}
</div>
<div className="mt-4">
{tabs[activeTab]}
</div>
</div>
);
}
Use it with Server Component children:
// app/dashboard/page.tsx
import Tabs from '@/app/components/Tabs';
import { db } from '@/lib/database';
async function RecentOrders() {
const orders = await db.query('SELECT * FROM orders ORDER BY created_at DESC LIMIT 10');
return <div>{/* Render orders */}</div>;
}
async function Analytics() {
const stats = await db.query('SELECT * FROM analytics WHERE date = CURRENT_DATE');
return <div>{/* Render analytics */}</div>;
}
export default function DashboardPage() {
return (
<Tabs>
<RecentOrders />
<Analytics />
</Tabs>
);
}
The Tabs component is interactive (Client Component), but RecentOrders and Analytics remain Server Components, fetching data directly from the database without client-side JavaScript.
Data Fetching and Caching
Server Components support async/await natively. The fetch API is extended with caching options:
// app/blog/page.tsx
async function getPosts() {
const res = await fetch('https://api.example.com/posts', {
cache: 'force-cache', // Cache indefinitely (default)
});
return res.json();
}
async function getRecentPosts() {
const res = await fetch('https://api.example.com/posts/recent', {
next: { revalidate: 3600 }, // Revalidate every hour
});
return res.json();
}
async function getLiveData() {
const res = await fetch('https://api.example.com/live', {
cache: 'no-store', // Always fetch fresh data
});
return res.json();
}
export default async function BlogPage() {
const [posts, recentPosts, liveData] = await Promise.all([
getPosts(),
getRecentPosts(),
getLiveData(),
]);
return (
<div>
{/* Render data */}
</div>
);
}
Caching strategies:
cache: 'force-cache': Cache indefinitely (default, good for static content)next: { revalidate: seconds }: Cache with time-based revalidation (ISR)cache: 'no-store': Never cache (dynamic data)
For database queries, implement caching with React’s cache function to deduplicate requests across components.
Best Practices and Migration Tips
Start with Server Components. Only add 'use client' when you absolutely need interactivity. This maximizes performance and minimizes bundle size.
Keep Client Components small. Extract interactive pieces into focused Client Components while keeping the majority of your component tree on the server.
Use layouts strategically. Leverage nested layouts for shared UI patterns. Remember they don’t re-render on navigation, improving perceived performance.
Understand the composition boundary. You can import Server Components into Client Components as children or props, but not directly. This pattern is crucial for maintaining server-side benefits.
Leverage streaming. Use loading.js files and Suspense boundaries to stream content progressively, showing users content as it becomes available.
The App Router represents a significant evolution in how we build React applications. The default-to-server model reduces JavaScript bundles, enables direct backend access, and improves performance—but it requires rethinking component boundaries and data flow. Master these patterns, and you’ll build faster, more efficient Next.js applications.