Next.js 15 Complete Guide 2026: App Router, Server Components, and Performance
Advertisement
Next.js 15 in 2026: The Full-Stack React Framework
Next.js 15 is the most capable version yet — App Router is now the default, Server Components are production-ready, and Turbopack makes development lightning-fast.
- Create a New Project
- React Server Components (RSC)
- Server Actions: Forms Without API Routes
- Caching in Next.js 15
- Streaming with Suspense
- Parallel Routes
- API Routes with Route Handlers
- Metadata and SEO
- Performance Tips
Create a New Project
npx create-next-app@latest my-app --typescript --tailwind --app --src-dir
cd my-app
npm run dev
Project structure:
src/
app/
layout.tsx # Root layout
page.tsx # Home page /
globals.css
dashboard/
layout.tsx # Dashboard layout
page.tsx # /dashboard
loading.tsx # Streaming loading UI
error.tsx # Error boundary
api/
users/
route.ts # API route /api/users
React Server Components (RSC)
Server Components run on the server — zero JS sent to the client:
// app/products/page.tsx — runs on server by default
import { db } from '@/lib/db'
// Direct database access in a component
export default async function ProductsPage() {
const products = await db.product.findMany({
orderBy: { createdAt: 'desc' },
take: 20,
})
return (
<div className="grid grid-cols-3 gap-4">
{products.map(product => (
<ProductCard key={product.id} product={product} />
))}
</div>
)
}
// ProductCard is also a server component
function ProductCard({ product }: { product: Product }) {
return (
<div className="border rounded-lg p-4">
<h2>{product.name}</h2>
<p className="text-gray-600">{product.description}</p>
<span className="font-bold">${product.price}</span>
<AddToCartButton productId={product.id} /> {/* Client component */}
</div>
)
}
// components/AddToCartButton.tsx — Client component for interactivity
'use client'
import { useState } from 'react'
export function AddToCartButton({ productId }: { productId: string }) {
const [added, setAdded] = useState(false)
return (
<button
onClick={() => {
addToCart(productId)
setAdded(true)
}}
className={added ? 'bg-green-500' : 'bg-blue-500'}
>
{added ? 'Added!' : 'Add to Cart'}
</button>
)
}
Server Actions: Forms Without API Routes
// app/actions.ts
'use server'
import { db } from '@/lib/db'
import { revalidatePath } from 'next/cache'
import { z } from 'zod'
const CreatePostSchema = z.object({
title: z.string().min(1).max(100),
content: z.string().min(10),
})
export async function createPost(formData: FormData) {
const validated = CreatePostSchema.safeParse({
title: formData.get('title'),
content: formData.get('content'),
})
if (!validated.success) {
return { error: validated.error.flatten().fieldErrors }
}
await db.post.create({ data: validated.data })
revalidatePath('/posts')
return { success: true }
}
// app/posts/new/page.tsx
import { createPost } from '../actions'
export default function NewPostPage() {
return (
<form action={createPost} className="space-y-4">
<input name="title" placeholder="Post title" className="input" />
<textarea name="content" placeholder="Content" className="textarea" />
<button type="submit" className="btn-primary">Publish</button>
</form>
)
}
Caching in Next.js 15
// 1. Static data — cached indefinitely
const data = await fetch('https://api.example.com/static')
// 2. Revalidate every N seconds
const data = await fetch('https://api.example.com/news', {
next: { revalidate: 3600 } // 1 hour
})
// 3. No caching — always fresh
const data = await fetch('https://api.example.com/live', {
cache: 'no-store'
})
// 4. On-demand revalidation
import { revalidateTag, revalidatePath } from 'next/cache'
// Tag data at fetch time
const data = await fetch('https://api.example.com/posts', {
next: { tags: ['posts'] }
})
// Invalidate by tag (from Server Action or API route)
export async function refreshPosts() {
'use server'
revalidateTag('posts')
}
Streaming with Suspense
// app/dashboard/page.tsx
import { Suspense } from 'react'
export default function DashboardPage() {
return (
<div className="grid grid-cols-3 gap-4">
<Suspense fallback={<StatsSkeleton />}>
<Stats /> {/* Streams in when ready */}
</Suspense>
<Suspense fallback={<ChartSkeleton />}>
<RevenueChart /> {/* Streams in when ready */}
</Suspense>
<Suspense fallback={<TableSkeleton />}>
<RecentOrders /> {/* Streams in when ready */}
</Suspense>
</div>
)
}
// Each component fetches its own data independently
async function Stats() {
const stats = await getStats() // Slow query
return <StatsCard data={stats} />
}
Parallel Routes
// app/dashboard/@analytics/page.tsx
// app/dashboard/@team/page.tsx
// app/dashboard/layout.tsx
export default function DashboardLayout({
children,
analytics,
team,
}: {
children: React.ReactNode
analytics: React.ReactNode
team: React.ReactNode
}) {
return (
<div>
{children}
<div className="grid grid-cols-2 gap-4">
{analytics}
{team}
</div>
</div>
)
}
API Routes with Route Handlers
// app/api/users/route.ts
import { NextRequest, NextResponse } from 'next/server'
import { db } from '@/lib/db'
import { auth } from '@/lib/auth'
export async function GET(request: NextRequest) {
const session = await auth()
if (!session) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
const { searchParams } = new URL(request.url)
const page = parseInt(searchParams.get('page') ?? '1')
const limit = Math.min(parseInt(searchParams.get('limit') ?? '20'), 100)
const [users, total] = await Promise.all([
db.user.findMany({ skip: (page - 1) * limit, take: limit }),
db.user.count(),
])
return NextResponse.json({
users,
pagination: { page, limit, total, pages: Math.ceil(total / limit) },
})
}
export async function POST(request: NextRequest) {
const body = await request.json()
const user = await db.user.create({ data: body })
return NextResponse.json(user, { status: 201 })
}
Metadata and SEO
// app/blog/[slug]/page.tsx
import type { Metadata } from 'next'
export async function generateMetadata({ params }: { params: { slug: string } }): Promise<Metadata> {
const post = await getPost(params.slug)
return {
title: post.title,
description: post.excerpt,
openGraph: {
title: post.title,
description: post.excerpt,
images: [{ url: post.coverImage, width: 1200, height: 630 }],
},
twitter: {
card: 'summary_large_image',
title: post.title,
},
}
}
Performance Tips
| Tip | Impact | Implementation |
|---|---|---|
Use next/image | High | Automatic WebP, lazy loading |
Use next/font | Medium | Zero layout shift fonts |
| Route groups | Medium | Code split by feature |
| Partial Prerendering | High | Static shell + dynamic streams |
unstable_cache | High | Cache expensive DB queries |
Next.js 15 with App Router is now the default choice for production React apps. The combination of Server Components + Server Actions eliminates most API boilerplate.
Advertisement