Next.js 16 App Router: Complete Guide to Server Actions and Streaming 2026
on Next.js, React, Typescript, Frontend, Web development, Server actions
Next.js 16 App Router: Complete Guide to Server Actions and Streaming 2026
Next.js 16 represents a major evolution in full-stack React development. With Partial Prerendering (PPR) now stable, Server Actions production-hardened, and React 19 fully integrated, the App Router has become the undisputed way to build modern web applications.
This guide covers the essential patterns and advanced techniques you need to build fast, scalable apps in 2026.
Photo by Lautaro Andreani on Unsplash
What’s New in Next.js 16
| Feature | Status | Impact |
|---|---|---|
| Partial Prerendering (PPR) | ✅ Stable | Revolutionary performance |
| Server Actions v2 | ✅ Stable | Simplified mutations |
| React 19 | ✅ Integrated | use(), useActionState() |
| Turbopack | ✅ Default | 10x faster builds |
| after() API | ✅ Stable | Background tasks |
connection() API | ✅ Stable | Dynamic data boundaries |
Project Setup
npx create-next-app@latest my-app \
--typescript \
--tailwind \
--app \
--turbopack
cd my-app
// next.config.ts
import type { NextConfig } from 'next'
const nextConfig: NextConfig = {
experimental: {
ppr: true, // Partial Prerendering
dynamicIO: true, // Opt-in dynamic IO patterns
reactCompiler: true, // React 19 compiler
},
}
export default nextConfig
App Router Architecture
app/
├── layout.tsx ← Root layout (Server Component)
├── page.tsx ← Home page
├── globals.css
├── (marketing)/ ← Route group (no URL segment)
│ ├── about/
│ │ └── page.tsx
│ └── blog/
│ ├── page.tsx
│ └── [slug]/
│ └── page.tsx
├── (dashboard)/ ← Protected routes
│ ├── layout.tsx ← Dashboard layout with auth
│ └── settings/
│ └── page.tsx
├── api/ ← API routes
│ └── webhooks/
│ └── route.ts
└── _components/ ← Shared components (not routed)
└── nav.tsx
Server Components vs Client Components
Server Components (Default)
// app/products/page.tsx - Server Component by default
// Runs on server, has access to DB, no bundle size impact
import { db } from '@/lib/db'
import { ProductCard } from './_components/product-card'
interface SearchParams {
category?: string
sort?: string
page?: string
}
// Server Component can be async!
export default async function ProductsPage({
searchParams
}: {
searchParams: Promise<SearchParams>
}) {
const params = await searchParams
const page = Number(params.page) || 1
// Direct database access - no API layer needed!
const { products, total } = await db.product.findMany({
where: {
category: params.category,
published: true,
},
orderBy: params.sort === 'price'
? { price: 'asc' }
: { createdAt: 'desc' },
take: 20,
skip: (page - 1) * 20,
include: {
images: { take: 1 },
category: true,
}
})
return (
<div>
<h1>Products ({total})</h1>
<div className="grid grid-cols-4 gap-4">
{products.map(product => (
<ProductCard key={product.id} product={product} />
))}
</div>
</div>
)
}
Client Components (Opt-in)
'use client'
// app/products/_components/add-to-cart.tsx
import { useState, useTransition } from 'react'
import { addToCart } from '../_actions'
interface Props {
productId: string
price: number
}
export function AddToCartButton({ productId, price }: Props) {
const [isPending, startTransition] = useTransition()
const [added, setAdded] = useState(false)
const handleClick = () => {
startTransition(async () => {
const result = await addToCart(productId)
if (result.success) {
setAdded(true)
setTimeout(() => setAdded(false), 2000)
}
})
}
return (
<button
onClick={handleClick}
disabled={isPending}
className={`px-4 py-2 rounded ${added ? 'bg-green-500' : 'bg-blue-500'}`}
>
{isPending ? 'Adding...' : added ? 'Added! ✓' : `Add to Cart - $${price}`}
</button>
)
}
Server Actions
Server Actions are async functions that run on the server, triggered from the client.
Basic Server Action
// app/products/_actions.ts
'use server'
import { db } from '@/lib/db'
import { auth } from '@/lib/auth'
import { revalidatePath } from 'next/cache'
import { z } from 'zod'
const AddToCartSchema = z.object({
productId: z.string().uuid(),
quantity: z.number().int().min(1).max(100).default(1),
})
export async function addToCart(productId: string, quantity = 1) {
// Auth check
const session = await auth()
if (!session?.user) {
return { success: false, error: 'Not authenticated' }
}
// Validate
const input = AddToCartSchema.safeParse({ productId, quantity })
if (!input.success) {
return { success: false, error: input.error.message }
}
try {
// Direct DB mutation - no API route needed!
await db.cartItem.upsert({
where: {
userId_productId: {
userId: session.user.id,
productId: input.data.productId,
}
},
update: { quantity: { increment: input.data.quantity } },
create: {
userId: session.user.id,
productId: input.data.productId,
quantity: input.data.quantity,
}
})
// Revalidate the cart page
revalidatePath('/cart')
return { success: true }
} catch (error) {
console.error('Failed to add to cart:', error)
return { success: false, error: 'Failed to add item' }
}
}
Forms with Server Actions (React 19 Pattern)
// app/contact/page.tsx
import { useActionState } from 'react'
import { submitContactForm } from './_actions'
type ActionState = {
success?: boolean
error?: string
errors?: Record<string, string>
}
export default function ContactPage() {
const [state, formAction, isPending] = useActionState<ActionState, FormData>(
submitContactForm,
{}
)
return (
<form action={formAction}>
<div>
<label>Name</label>
<input name="name" required />
{state.errors?.name && (
<p className="text-red-500">{state.errors.name}</p>
)}
</div>
<div>
<label>Email</label>
<input name="email" type="email" required />
{state.errors?.email && (
<p className="text-red-500">{state.errors.email}</p>
)}
</div>
<div>
<label>Message</label>
<textarea name="message" required />
</div>
{state.success && (
<p className="text-green-500">Message sent! We'll be in touch.</p>
)}
{state.error && (
<p className="text-red-500">{state.error}</p>
)}
<button type="submit" disabled={isPending}>
{isPending ? 'Sending...' : 'Send Message'}
</button>
</form>
)
}
// app/contact/_actions.ts
'use server'
import { z } from 'zod'
import { sendEmail } from '@/lib/email'
import { after } from 'next/server'
const ContactSchema = z.object({
name: z.string().min(2).max(100),
email: z.string().email(),
message: z.string().min(10).max(5000),
})
export async function submitContactForm(
_prevState: unknown,
formData: FormData
) {
const rawData = {
name: formData.get('name'),
email: formData.get('email'),
message: formData.get('message'),
}
const result = ContactSchema.safeParse(rawData)
if (!result.success) {
return {
errors: result.error.flatten().fieldErrors,
}
}
try {
await sendEmail({
to: 'hello@company.com',
subject: `Contact from ${result.data.name}`,
body: result.data.message,
replyTo: result.data.email,
})
// after() runs AFTER the response is sent (background task!)
after(async () => {
await db.contactSubmission.create({
data: result.data
})
})
return { success: true }
} catch (error) {
return { error: 'Failed to send message. Please try again.' }
}
}
Streaming with Suspense
// app/dashboard/page.tsx
import { Suspense } from 'react'
import { RevenueChart } from './_components/revenue-chart'
import { RecentOrders } from './_components/recent-orders'
import { UserStats } from './_components/user-stats'
import { ChartSkeleton, TableSkeleton, StatsSkeleton } from './_components/skeletons'
export default function DashboardPage() {
// Static shell renders immediately
// Each Suspense boundary streams independently
return (
<div className="dashboard-grid">
<h1>Dashboard</h1>
<Suspense fallback={<StatsSkeleton />}>
<UserStats /> {/* Resolves in ~100ms */}
</Suspense>
<Suspense fallback={<ChartSkeleton />}>
<RevenueChart /> {/* Resolves in ~500ms */}
</Suspense>
<Suspense fallback={<TableSkeleton />}>
<RecentOrders /> {/* Resolves in ~800ms */}
</Suspense>
</div>
)
}
// app/dashboard/_components/revenue-chart.tsx
// This component is a Server Component that fetches data
async function RevenueChart() {
// This query can take 500ms - other components don't wait for it!
const revenue = await db.order.groupBy({
by: ['createdAt'],
_sum: { total: true },
where: {
createdAt: { gte: new Date(Date.now() - 30 * 24 * 60 * 60 * 1000) }
}
})
return <Chart data={revenue} />
}
Partial Prerendering (PPR)
PPR is Next.js 16’s most powerful feature — it combines static and dynamic rendering at the component level.
// app/product/[id]/page.tsx
import { Suspense } from 'react'
import { unstable_noStore as noStore } from 'next/cache'
// Static part - prerendered at build time
async function ProductDetails({ id }: { id: string }) {
const product = await db.product.findUnique({ where: { id } })
return (
<div>
<h1>{product.name}</h1>
<p>{product.description}</p>
<img src={product.image} alt={product.name} />
</div>
)
}
// Dynamic part - rendered at request time
async function ProductInventory({ id }: { id: string }) {
noStore() // Mark as dynamic
const inventory = await db.inventory.findUnique({ where: { productId: id } })
return (
<div>
<span className={inventory.stock > 0 ? 'text-green-500' : 'text-red-500'}>
{inventory.stock > 0 ? `${inventory.stock} in stock` : 'Out of stock'}
</span>
</div>
)
}
async function PersonalizedRecommendations({ userId }: { userId: string }) {
noStore() // User-specific = dynamic
const recs = await getRecommendations(userId)
return <RecommendationList items={recs} />
}
// The page: static shell + dynamic islands
export default async function ProductPage({ params }: { params: { id: string } }) {
return (
<div>
{/* Prerendered at build time → instant */}
<ProductDetails id={params.id} />
{/* Rendered at request time → streamed in */}
<Suspense fallback={<div>Checking availability...</div>}>
<ProductInventory id={params.id} />
</Suspense>
<Suspense fallback={<div>Loading recommendations...</div>}>
<PersonalizedRecommendations userId={userId} />
</Suspense>
</div>
)
}
Data Fetching Patterns
Parallel Fetching
// ✅ Good: Parallel fetching
async function Dashboard() {
const [user, orders, analytics] = await Promise.all([
getUser(),
getOrders(),
getAnalytics(),
])
return <DashboardUI user={user} orders={orders} analytics={analytics} />
}
// ❌ Bad: Sequential fetching (waterfalls)
async function DashboardSlow() {
const user = await getUser()
const orders = await getOrders() // Waits for getUser
const analytics = await getAnalytics() // Waits for getOrders
return <DashboardUI ... />
}
use() for Client-Side Data
'use client'
import { use, Suspense } from 'react'
// New React 19 pattern: pass promises to Client Components
function UserProfile({ userPromise }: { userPromise: Promise<User> }) {
const user = use(userPromise) // Suspends until resolved
return <div>{user.name}</div>
}
// In a Server Component:
function Page() {
const userPromise = getUser() // Start fetch, don't await
return (
<Suspense fallback={<Skeleton />}>
<UserProfile userPromise={userPromise} />
</Suspense>
)
}
Request Deduplication & Caching
// next/cache for fine-grained caching
import { unstable_cache as cache } from 'next/cache'
// Cache with tags for targeted revalidation
const getProductCached = cache(
async (productId: string) => {
return db.product.findUnique({ where: { id: productId } })
},
['product'], // Cache key prefix
{
tags: ['products'], // Tag for revalidation
revalidate: 3600, // Revalidate every hour
}
)
// Revalidate by tag (e.g., when product is updated)
import { revalidateTag } from 'next/cache'
async function updateProduct(id: string, data: UpdateProductInput) {
await db.product.update({ where: { id }, data })
revalidateTag('products') // Invalidates all caches tagged 'products'
}
Middleware: Edge Computing
// middleware.ts
import { NextResponse } from 'next/server'
import type { NextRequest } from 'next/server'
import { verifyToken } from '@/lib/auth'
export async function middleware(request: NextRequest) {
const path = request.nextUrl.pathname
// Auth protection
if (path.startsWith('/dashboard')) {
const token = request.cookies.get('token')?.value
if (!token) {
return NextResponse.redirect(new URL('/login', request.url))
}
try {
const payload = await verifyToken(token)
// Pass user info to downstream
const requestHeaders = new Headers(request.headers)
requestHeaders.set('x-user-id', payload.userId)
requestHeaders.set('x-user-role', payload.role)
return NextResponse.next({ request: { headers: requestHeaders } })
} catch {
return NextResponse.redirect(new URL('/login', request.url))
}
}
// A/B testing at the edge
if (path === '/') {
const bucket = request.cookies.get('ab-bucket')?.value
?? (Math.random() > 0.5 ? 'a' : 'b')
const response = NextResponse.rewrite(
new URL(`/home-${bucket}`, request.url)
)
response.cookies.set('ab-bucket', bucket, { maxAge: 86400 * 30 })
return response
}
return NextResponse.next()
}
export const config = {
matcher: ['/dashboard/:path*', '/']
}
Performance Tips
// 1. Use generateStaticParams for dynamic routes
export async function generateStaticParams() {
const products = await db.product.findMany({
select: { id: true },
where: { featured: true }
})
return products.map(p => ({ id: p.id }))
}
// 2. Optimize images
import Image from 'next/image'
<Image
src={product.imageUrl}
alt={product.name}
width={400}
height={300}
sizes="(max-width: 768px) 100vw, (max-width: 1200px) 50vw, 33vw"
priority={isAboveFold}
/>
// 3. Font optimization
import { Inter, JetBrains_Mono } from 'next/font/google'
const inter = Inter({ subsets: ['latin'], display: 'swap' })
const mono = JetBrains_Mono({ subsets: ['latin'], variable: '--font-mono' })
Conclusion
Next.js 16 with the App Router has fundamentally changed how we build web applications. The key mental shifts:
- Default to Server Components — move data fetching to the server, reduce bundle size
- Use Server Actions for mutations — no need for separate API routes for most operations
- Stream with Suspense — users see content faster, no waterfall loading
- Leverage PPR — combine static performance with dynamic personalization
The full-stack React model is mature in 2026. If you’re still building separate frontend and backend services for new projects, it’s time to reconsider whether Next.js can simplify your stack significantly.
이 글이 도움이 되셨다면 공감 및 광고 클릭을 부탁드립니다 :)
