The Complete Guide to Building with Next.js App Router in 2026: Server Components, Streaming, and More
Next.js App Router became the stable default in late 2025, with Server Components, Server Actions, and Streaming changing how we build React applications. The shift from client-side rendering to server-first is now the standard approach.
Here's the practical guide.
App Router Basics
app/
├── layout.tsx # Root layout (persists across pages)
├── page.tsx # Home page (/)
├── about/
│ └── page.tsx # /about
├── posts/
│ ├── page.tsx # /posts
│ └── [id]/
│ └── page.tsx # /posts/:id
└── api/
└── posts/
└── route.ts # /api/posts
Server Components
// app/users/page.tsx — Server Component by default
import { prisma } from "@/lib/prisma";
export default async function UsersPage() {
// Direct database access, no API needed
const users = await prisma.user.findMany({
orderBy: { createdAt: "desc" },
});
return (
<main>
<h1>Users</h1>
<ul>
{users.map((user) => (
<li key={user.id}>
{user.name} ({user.email})
</li>
))}
</ul>
</main>
);
}
Client Components
// app/counter.tsx — Add "use client" for interactivity
"use client";
import { useState } from "react";
export function Counter({ initial = 0 }) {
const [count, setCount] = useState(initial);
return (
<div>
<p>Count: {count}</p>
<button onClick={() => setCount(count + 1)}>Increment</button>
<button onClick={() => setCount(count - 1)}>Decrement</button>
</div>
);
}
Dynamic Routes
// app/posts/[id]/page.tsx
import { prisma } from "@/lib/prisma";
interface Props {
params: { id: string };
}
export default async function PostPage({ params }: Props) {
const post = await prisma.post.findUnique({
where: { id: params.id },
include: { author: true },
});
if (!post) notFound();
return (
<article>
<h1>{post.title}</h1>
<p>By {post.author.name}</p>
<div>{post.content}</div>
</article>
);
}
Server Actions
// app/actions.ts
"use server";
import { prisma } from "@/lib/prisma";
import { revalidatePath } from "next/cache";
export async function createPost(formData: FormData) {
const title = formData.get("title") as string;
const content = formData.get("content") as string;
await prisma.post.create({
data: { title, content },
});
revalidatePath("/posts");
}
// In a component:
import { createPost } from "@/app/actions";
export function NewPostForm() {
return (
<form action={createPost}>
<input name="title" type="text" required />
<textarea name="content" required />
<button type="submit">Create Post</button>
</form>
);
}
Streaming with Suspense
import { Suspense } from "react";
export default function DashboardPage() {
return (
<div>
<h1>Dashboard</h1>
<Suspense fallback={<div>Loading metrics...</div>}>
<Metrics />
</Suspense>
<Suspense fallback={<div>Loading users...</div>}>
<UserList />
</Suspense>
</div>
);
}
async function Metrics() {
// Slow query
const metrics = await fetchMetrics();
return <div>Metrics: {JSON.stringify(metrics)}</div>;
}
Middleware
// middleware.ts
import { NextResponse } from "next/server";
import type { NextRequest } from "next/server";
export function middleware(request: NextRequest) {
// Redirect unauthenticated users
const token = request.cookies.get("auth-token");
if (!token && request.nextUrl.pathname.startsWith("/dashboard")) {
return NextResponse.redirect(new URL("/login", request.url));
}
return NextResponse.next();
}
export const config = {
matcher: ["/dashboard/:path*", "/profile/:path*"],
};
This article contains affiliate links. If you sign up through the links above, I may earn a commission at no additional cost to you.
Ready to Build Your Online Business?
Get started with Systeme.io for free — All-in-one platform for building your online business with AI tools.
Top comments (0)