Next.js 15 App Router: A Complete Guide
Everything you need to know about the App Router, Server Components, and the latest Next.js features.
Next.js has evolved from a simple React framework into the backbone of modern web development. With the App Router now stable and battle-tested, Next.js 15 introduces a paradigm shift in how we think about building web applications. At Vaarak, we've migrated over 30 production projects to the App Router, and this guide distills everything we've learned along the way.
This guide covers the fundamental architecture changes, practical migration patterns, performance optimizations, and real-world patterns we've battle-tested across projects ranging from small marketing sites to enterprise platforms serving millions of users.
Understanding the App Router Architecture
The App Router replaces the traditional Pages Router with a file-system based routing mechanism built on React Server Components (RSC). Unlike the Pages Router where every component was a client component by default, the App Router defaults to Server Components — meaning your component code runs on the server and only the HTML is sent to the client.
This is a fundamental shift. In the Pages Router world, you'd fetch data in getServerSideProps or getStaticProps, then pass it as props to your component. With the App Router, your components can directly await data fetches, database queries, or file system operations — because they run on the server.
// Server Component — runs on the server, zero JS sent to client
export default async function BlogPost({ params }: { params: Promise<{ slug: string }> }) {
const { slug } = await params;
const post = await db.posts.findUnique({ where: { slug } });
if (!post) notFound();
return (
<article>
<h1>{post.title}</h1>
<div dangerouslySetInnerHTML={{ __html: post.content }} />
</article>
);
}Layouts, Templates, and Loading States
One of the most powerful features of the App Router is the layout system. Layouts persist across navigations, meaning shared UI like sidebars and navigation bars don't re-render when users navigate between pages. This creates an app-like experience with instant navigations.
export default function DashboardLayout({
children,
}: {
children: React.ReactNode;
}) {
return (
<div className="flex min-h-screen">
<Sidebar />
<main className="flex-1 p-8">
<Suspense fallback={<DashboardSkeleton />}>
{children}
</Suspense>
</main>
</div>
);
}Templates, on the other hand, create a new instance on every navigation. They're useful for enter/exit animations, features that rely on useEffect running on each page change, or resetting state between child routes.
Use loading.tsx files to create instant loading states. Next.js automatically wraps your page in a Suspense boundary with the loading component as the fallback.
Server Components vs. Client Components
The mental model is straightforward: Server Components run on the server and send HTML to the client. Client Components run on both the server (for SSR) and the client (for interactivity). You opt into client components with the "use client" directive.
The key insight is that you should push client components as far down the tree as possible. Instead of making an entire page a client component because one button needs onClick, extract just the interactive parts into small client components and keep the rest as server components.
- Server Components: Data fetching, accessing backend resources, keeping sensitive data on the server, reducing client-side JavaScript
- Client Components: Interactivity (onClick, onChange), browser APIs (localStorage, geolocation), state management (useState, useReducer), lifecycle effects (useEffect)
- Composition Pattern: Server Components can import and render Client Components, but Client Components cannot import Server Components — they can only receive them as children props
Data Fetching Patterns
Next.js 15 extended the native fetch API with automatic request deduplication and caching controls. When multiple components in a single render request the same URL, Next.js automatically deduplicates them into a single network request.
// Parallel data fetching — both requests run simultaneously
async function ProductPage() {
const productsPromise = fetch('/api/products');
const categoriesPromise = fetch('/api/categories');
const [productsRes, categoriesRes] = await Promise.all([
productsPromise,
categoriesPromise,
]);
const products = await productsRes.json();
const categories = await categoriesRes.json();
return <ProductGrid products={products} categories={categories} />;
}Next.js 15 no longer caches fetch requests by default. You must explicitly opt into caching with { cache: 'force-cache' } or use the unstable_cache function for database queries.
Streaming and Suspense
One of the most impactful features is streaming with Suspense. Instead of waiting for all data to load before showing anything, you can progressively stream UI to the client as data becomes available. This dramatically improves perceived performance.
We've seen Time to First Byte (TTFB) improvements of 40-60% on data-heavy pages by implementing streaming. The shell of the page loads instantly while slower data sources stream in progressively.
import { Suspense } from 'react';
export default function Dashboard() {
return (
<div className="grid grid-cols-3 gap-6">
{/* Instant — no data fetching */}
<WelcomeBanner />
{/* Streams in as data loads */}
<Suspense fallback={<ChartSkeleton />}>
<RevenueChart />
</Suspense>
<Suspense fallback={<TableSkeleton />}>
<RecentOrders />
</Suspense>
</div>
);
}Migration Strategy: Pages Router to App Router
You don't have to migrate all at once. Next.js supports running both the Pages Router and App Router simultaneously. Our recommended approach is to migrate page by page, starting with the simplest routes and working toward more complex ones.
- Start with static pages (about, contact, FAQ) — they have no data fetching complexity
- Migrate layout components (header, footer, sidebar) into the App Router layout system
- Convert data-fetching pages one at a time, replacing getServerSideProps with async Server Components
- Move interactive features to Client Components with the "use client" directive
- Update API routes from pages/api to app/api route handlers
- Remove the old Pages Router files once all routes are migrated
“The migration to App Router reduced our bundle size by 35% and improved our Lighthouse performance score from 72 to 96. The investment paid for itself within the first month.”
— Vaarak Engineering Team
Performance Best Practices
After migrating dozens of projects, we've identified the patterns that consistently deliver the best performance results. These aren't theoretical — they're battle-tested optimizations that have measurably improved our clients' Core Web Vitals.
- Use React.lazy and dynamic imports for heavy client components (charts, editors, maps)
- Implement route-level code splitting with the loading.tsx convention
- Leverage Partial Prerendering (PPR) for pages that mix static and dynamic content
- Use the Image component with proper sizing and priority hints for LCP images
- Minimize the use of useEffect — most data fetching belongs in Server Components
- Co-locate data fetching with the components that need it instead of fetching at the page level
Conclusion
The App Router represents the biggest architectural shift in Next.js history, and it's worth the investment. Server Components reduce client-side JavaScript, streaming improves perceived performance, and the file-based conventions make complex routing patterns simple. Whether you're starting a new project or migrating an existing one, the App Router is the future of React web development.
At Vaarak, we're building every new project on the App Router and actively migrating existing ones. If you need help with a migration or want to discuss your project's architecture, reach out to our engineering team.
Amar Singh
Founder & Lead Engineer