Next.js 13+ introduced the App Router, a fundamentally different way to think about routing compared to the Pages Router. Instead of files representing routes, folders become routes, and special files dictate what role each segment plays. Once you internalize the App Router mental model, building complex nested layouts and managing state across routes becomes intuitive rather than confusing.
Special files — File vs role
The App Router revolves around five core special files, each serving a distinct purpose in your application. Rather than thinking "this file is a route," think "this file handles a specific responsibility within this segment." Understanding the difference between each file's role is the foundation of mastering the App Router.
layout.tsx defines shared UI that persists across route changes within that segment. When you navigate from /dashboard/analytics to /dashboard/settings, the dashboard layout stays mounted. This is powerful for maintaining state, keeping WebSocket connections alive, or preserving scroll position in a sidebar without prop drilling.
page.tsx is the unique UI for that specific route. Every route needs a page file at its end, or Next.js won't know what to render. Think of it as the endpoint—the actual content users see when they visit that URL.
loading.tsx automatically wraps your page in a Suspense boundary with a fallback. While the page component streams in, users see your loading UI. No manual Suspense wiring needed.
error.tsx creates an error boundary for that segment and its children. Unhandled errors bubble up until caught by an error boundary, then render gracefully instead of crashing the entire app.
route.ts is where you define API endpoints. Instead of a separate pages/api folder, API routes live alongside your UI in the app directory using HTTP method exports.
Here's how these files coexist in a real dashboard structure:
// app/dashboard/layout.tsx
// Rendered for every dashboard route
export default function DashboardLayout({ children }) {
return (
<div className="flex">
<Sidebar />
<main>{children}</main>
</div>
);
}
// app/dashboard/page.tsx
// Renders at /dashboard
export default function DashboardHome() {
return <h1>Dashboard overview</h1>;
}
// app/dashboard/loading.tsx
// Shown while page.tsx is loading
export default function Loading() {
return <div>Loading dashboard...</div>;
}
// app/dashboard/error.tsx
// Catches errors in dashboard and its children
'use client';
export default function Error({ error, reset }) {
return (
<div>
<p>Something went wrong: {error.message}</p>
<button onClick={reset}>Try again</button>
</div>
);
}
// app/dashboard/analytics/page.tsx
// Renders at /dashboard/analytics
export default function AnalyticsPage() {
return <h1>Analytics</h1>;
}
// app/api/metrics/route.ts
// API endpoint at /api/metrics
export async function GET() {
return Response.json({ data: 'metrics' });
}
Each file has one job. Mixing concerns—trying to handle loading, errors, and UI in one component—leads to unnecessary complexity.
Layouts persist — Navigating between pages keeps layout state
This is where the App Router shines compared to traditional file-based routing. When users navigate between /dashboard/analytics and /dashboard/reports, the dashboard layout component never unmounts. Its state persists. Its effects don't re-run.
I learned this the hard way while building a CRM platform where the sidebar maintained an expanded menu state. With the Pages Router, navigating between pages would remount everything, collapsing the menu. With the App Router, the layout stayed intact—no state loss, no janky UI reset.
Here's why this matters in practice:
- WebSocket connections established in a layout don't reconnect on every page change.
- Global filters, search input, or scroll position in the layout persist without extra work.
- Performance improves because you're not destroying and recreating shared UI.
Consider a real-world e-commerce dashboard where the layout holds user preferences, a shopping cart state, and a real-time notification connection:
// app/admin/layout.tsx
'use client';
import { useEffect, useState } from 'react';
import { CartProvider } from '@/context/cart';
import { NotificationSocket } from '@/lib/socket';
let socketConnection: NotificationSocket | null = null;
export default function AdminLayout({ children }) {
const [prefs, setPrefs] = useState({ theme: 'dark' });
useEffect(() => {
// This runs ONCE when the layout mounts, not on every page change
if (!socketConnection) {
socketConnection = new NotificationSocket();
socketConnection.connect();
}
return () => {
// Cleanup only happens when the layout truly unmounts
};
}, []);
return (
<CartProvider>
<div style={{ background: prefs.theme === 'dark' ? '#000' : '#fff' }}>
<Header />
<main>{children}</main>
</div>
</CartProvider>
);
}
// app/admin/inventory/page.tsx
// Navigating here keeps the layout mounted
export default function InventoryPage() {
return <h1>Inventory</h1>;
}
// app/admin/orders/page.tsx
// Navigating here also keeps the layout mounted
export default function OrdersPage() {
return <h1>Orders</h1>;
}
The WebSocket connection, the cart provider, the theme preference—all survive navigation. This is one of the most underrated benefits of the App Router mental model.
Nested routes nest UI — Each segment can add its own layout and loading state
Folders nest. UI nests. It's that simple. Every folder in your app directory can have its own layout, and they compose together like Russian dolls.
When you navigate to /admin/products/123/reviews, Next.js renders:
- Root layout (
app/layout.tsx) - Admin layout (
app/admin/layout.tsx) - Products layout (
app/admin/products/layout.tsx) - Product detail layout (
app/admin/products/[id]/layout.tsx) - Reviews page (
app/admin/products/[id]/reviews/page.tsx)
Each layout can have its own loading state, error boundary, and shared UI. On a trading platform I worked on, we used this to nest multiple levels of filters and real-time data providers without prop drilling:
// app/trading/layout.tsx
// Root trading layout with global market data
export default function TradingLayout({ children }) {
return (
<MarketDataProvider>
<div>{children}</div>
</MarketDataProvider>
);
}
// app/trading/[symbol]/layout.tsx
// Symbol-specific layout with this symbol's data
export default function SymbolLayout({ children, params }) {
return (
<SymbolDataProvider symbol={params.symbol}>
<div className="grid">
<SymbolChart />
<div>{children}</div>
</div>
</SymbolDataProvider>
);
}
// app/trading/[symbol]/loading.tsx
// Show fallback while symbol page loads
export default function Loading() {
return <div>Loading {params.symbol} data...</div>;
}
// app/trading/[symbol]/page.tsx
export default function SymbolPage({ params }) {
return <h1>{params.symbol}</h1>;
}
// app/trading/[symbol]/orders/page.tsx
// Nests under symbol layout
export default function OrdersPage() {
return <h1>Orders</h1>;
}
Notice how each level adds its own responsibility. The root provides market data. The symbol level filters that data by symbol. The orders page consumes both. No prop drilling. Clean separation.
Server by default — Every component is a Server Component unless marked otherwise
This is the biggest mindset shift for developers coming from the Pages Router. In the App Router, every component you write is a Server Component by default. No getServerSideProps. No fetching on the client. It just happens on the server.
Server Components give you:
- Direct database access without API routes.
- Sensitive environment variables stay on the server.
- Smaller JavaScript bundles—no component code ships to the browser.
- No waterfall requests. Data fetching happens before rendering.
To use client-side features like hooks, event handlers, or browser APIs, mark the component with 'use client'. I typically do this at the smallest possible scope—a form or an interactive widget—rather than entire pages.
// app/products/page.tsx
// Server Component by default
// Can fetch data directly
import { db } from '@/lib/db';
export default async function ProductsPage() {
const products = await db.products.findMany();
return (
<div>
{products.map(p => (
<ProductCard key={p.id} product={p} />
))}
</div>
);
}
// app/products/product-card.tsx
// Server Component – no interactivity needed
export function ProductCard({ product }) {
return (
<div>
<h2>{product.name}</h2>
<p>${product.price}</p>
</div>
);
}
// app/products/add-to-cart-button.tsx
// Client Component – needs useState, onClick
'use client';
import { useCart } from '@/context/cart';
export function AddToCartButton({ productId }) {
const { addItem } = useCart();
return (
<button onClick={() => addItem(productId)}>
Add to cart
</button>
);
}
Keep Server Components as your default. Mark components 'use client' only when necessary. This keeps your bundle small and your data fetching clean.
Common mistakes
Marking entire page layouts as 'use client'. This defeats the purpose of Server Components. Only the interactive bits need to be client-side. A layout with only static UI should stay a Server Component.
Trying to use useRouter inside a Server Component. useRouter is client-only. Either import it in a 'use client' component or use the server-only redirect() function instead.
Fetching data in multiple nested layouts. This creates waterfalls. If a child needs data from a parent's query, fetch it in the parent and pass it down, or restructure so the child can fetch independently.
Forgetting that layout state persists. If you're debugging why state isn't resetting on navigation, remember—it shouldn't. Layouts persist by design. Reset state manually in a useEffect if needed.
Putting all routes in one deeply nested folder structure. While nesting is powerful, over-nesting makes navigation harder and file organization confusing. Keep your app folder reasonably flat unless nesting serves a real UI need.
Key takeaways
- Folders are routes. Each folder segment can contain layout, page, loading, error, and route files.
- Each special file has one role: layout persists UI, page is the endpoint, loading wraps in Suspense, error catches exceptions, route defines API.
- Layouts don't remount on navigation between sibling routes—state and connections persist naturally.
- Nested layouts compose together, allowing you to avoid prop drilling and keep data fetching localized to the segment that needs it.
- Server Components are the default. Use 'use client' sparingly for interactive widgets and forms.
- Think in terms of responsibilities per segment, not routes per file. This mindset makes the App Router feel natural.






Comments
Loading comments…