Next.js 16 App Router: Complete Guide to Server Actions and Streaming 2026



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.

Next.js Development Photo by Lautaro Andreani on Unsplash


What’s New in Next.js 16

FeatureStatusImpact
Partial Prerendering (PPR)✅ StableRevolutionary performance
Server Actions v2✅ StableSimplified mutations
React 19✅ Integrateduse(), useActionState()
Turbopack✅ Default10x faster builds
after() API✅ StableBackground tasks
connection() API✅ StableDynamic 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:

  1. Default to Server Components — move data fetching to the server, reduce bundle size
  2. Use Server Actions for mutations — no need for separate API routes for most operations
  3. Stream with Suspense — users see content faster, no waterfall loading
  4. 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.

이 글이 도움이 되셨다면 공감 및 광고 클릭을 부탁드립니다 :)