Next.js 15 brings the App Router to full maturity. React Server Components are the default, server actions are stable, and partial pre-rendering has moved out of experimental. This article covers the fundamentals of the App Router — RSC, streaming, loading states, server actions — with production-grade code examples.
File System Routing
app/
├── layout.tsx # root layout (all pages)
├── page.tsx # / route
├── about/
│ └── page.tsx # /about
├── blog/
│ ├── layout.tsx # blog layout
│ ├── page.tsx # /blog
│ └── [slug]/
│ └── page.tsx # /blog/:slug
├── (marketing)/ # Route group (does not affect the URL)
│ ├── pricing/page.tsx
│ └── contact/page.tsx
└── api/
└── users/route.ts # /api/users (Route Handler)
React Server Components (RSC)
In the App Router, components are Server Components by default. They do not render on the client, do not ship in the bundle, and can hit the database directly. To add interactivity, add a 'use client' directive.
// app/blog/page.tsx — Server Component (default)
import { db } from '@/lib/db';
export default async function BlogPage() {
const posts = await db.posts.findMany({ orderBy: { createdAt: 'desc' } });
return (
<ul>
{posts.map(p => <li key={p.id}>{p.title}</li>)}
</ul>
);
}
// Hits the DB directly, ships HTML + JSON to the client
// The 'db' library never enters the bundle
// app/components/Counter.tsx — Client Component
'use client';
import { useState } from 'react';
export default function Counter() {
const [n, setN] = useState(0);
return <button onClick={() => setN(n + 1)}>{n}</button>;
}
Layouts and Templates
// app/layout.tsx — root layout (the HTML document)
export default function RootLayout({ children }) {
return (
<html lang="en">
<body>
<nav>{/* ... */}</nav>
{children}
<footer>{/* ... */}</footer>
</body>
</html>
);
}
// app/blog/layout.tsx — nested layout
export default function BlogLayout({ children }) {
return (
<div className="blog-container">
<aside>Sidebar</aside>
<main>{children}</main>
</div>
);
}
Streaming and Suspense
import { Suspense } from 'react';
export default function DashboardPage() {
return (
<div>
<h1>Dashboard</h1>
{/* Slow parts are wrapped in Suspense */}
<Suspense fallback={<Skeleton />}>
<RecentOrders />
</Suspense>
<Suspense fallback={<Skeleton />}>
<Stats />
</Suspense>
</div>
);
}
async function RecentOrders() {
const orders = await db.orders.findMany({ take: 10 });
return <OrderList orders={orders} />;
}
// HTML streaming shows the skeleton immediately
// Each section is swapped in as it's ready
loading.tsx and error.tsx
// app/blog/[slug]/loading.tsx — automatic Suspense
export default function Loading() {
return <div className="skeleton">Loading...</div>;
}
// app/blog/[slug]/error.tsx — automatic ErrorBoundary
'use client';
export default function Error({ error, reset }) {
return (
<div>
<h2>Something went wrong</h2>
<p>{error.message}</p>
<button onClick={reset}>Try again</button>
</div>
);
}
// app/not-found.tsx — 404
export default function NotFound() {
return <h1>Page not found</h1>;
}
Data Fetching
// Cached fetch (default, behaves like ISR)
const res = await fetch('https://api.example.com/data');
// No cache
const res = await fetch('https://api.example.com/data', { cache: 'no-store' });
// Revalidate every 60s
const res = await fetch('https://api.example.com/data', { next: { revalidate: 60 } });
// Tag-based revalidation
const res = await fetch('...', { next: { tags: ['products'] } });
// Later:
import { revalidateTag } from 'next/cache';
revalidateTag('products');
Server Actions
// app/actions.ts
'use server';
import { revalidatePath } from 'next/cache';
export async function createPost(formData: FormData) {
const title = formData.get('title') as string;
const post = await db.posts.create({ data: { title } });
revalidatePath('/blog');
return post;
}
// app/new/page.tsx — form submit runs directly on the server
import { createPost } from '../actions';
export default function NewPost() {
return (
<form action={createPost}>
<input name="title" />
<button>Save</button>
</form>
);
}
Route Handlers (API Routes)
// app/api/users/route.ts
import { NextResponse } from 'next/server';
export async function GET(request: Request) {
const users = await db.users.findMany();
return NextResponse.json(users);
}
export async function POST(request: Request) {
const body = await request.json();
const user = await db.users.create({ data: body });
return NextResponse.json(user, { status: 201 });
}
// Dynamic route: app/api/users/[id]/route.ts
export async function GET(
request: Request,
{ params }: { params: { id: string } }
) {
const user = await db.users.findUnique({ where: { id: params.id } });
if (!user) return new Response('Not found', { status: 404 });
return Response.json(user);
}
Parallel Routes
app/dashboard/
├── @analytics/page.tsx # /dashboard → analytics slot
├── @team/page.tsx # /dashboard → team slot
└── layout.tsx # lays out the slots
// layout.tsx
export default function Layout({ children, analytics, team }) {
return (
<div>
{children}
<div className="grid grid-cols-2">
{analytics}
{team}
</div>
</div>
);
}
Metadata API
// Static
export const metadata = {
title: 'Blog — KEYDAL',
description: 'Technical blog posts'
};
// Dynamic
export async function generateMetadata({ params }) {
const post = await getPost(params.slug);
return {
title: post.title,
description: post.description,
openGraph: { images: [post.coverImage] }
};
}
Conclusion
The App Router is the crystallized form of React's server-first philosophy. RSC shrinks the bundle, streaming speeds up FCP, and server actions process forms without ever writing an API route. The learning curve is steeper than the Pages Router, but the productivity payoff is disproportionately high.
Reach out to KEYDAL to build modern React applications with the Next.js App Router. Contact us