Next.js App Router: Server Actions, Forms & Advanced Patterns (2025)
Next.js App Router has evolved significantly since its introduction. With Next.js 15 and 16, we now have Server Actions, the Form component, advanced caching APIs, and Partial Prerendering. This comprehensive guide covers modern patterns for building production-ready full-stack applications.
Core Architecture: Server and Client Components
Server Components (Default)
Server Components run on the server, enabling direct database access, file system operations, and optimized performance:
// app/dashboard/page.tsx
import { db } from '@/lib/db'
import { auth } from '@/lib/auth'
export default async function DashboardPage() {
const session = await auth()
const stats = await db.user.getStats(session.user.id)
return (
<div>
<h1>Welcome, {session.user.name}!</h1>
<StatsGrid stats={stats} />
</div>
)
}
Client Components (When Needed)
Use 'use client' only for interactivity, browser APIs, or React hooks:
'use client'
import { useState, useTransition } from 'react'
import { refreshStats } from './actions'
function StatsGrid({ initialStats }: { initialStats: Stats }) {
const [stats, setStats] = useState(initialStats)
const [isPending, startTransition] = useTransition()
const handleRefresh = () => {
startTransition(async () => {
const newStats = await refreshStats()
setStats(newStats)
})
}
return (
<div>
<button onClick={handleRefresh} disabled={isPending}>
{isPending ? 'Refreshing...' : 'Refresh Stats'}
</button>
<StatsDisplay stats={stats} />
</div>
)
}
Server Actions: The Game Changer
Server Actions allow you to run server-side code directly from client components without API routes:
// app/actions.ts
'use server'
import { db } from '@/lib/db'
import { revalidatePath } from 'next/cache'
import { redirect } from 'next/navigation'
export async function createPost(formData: FormData) {
const title = formData.get('title') as string
const content = formData.get('content') as string
const post = await db.post.create({
data: { title, content }
})
revalidatePath('/posts')
redirect(`/posts/${post.id}`)
}
export async function toggleLike(postId: string) {
'use server'
await db.like.toggle({ postId })
revalidatePath(`/posts/${postId}`)
}
Using Server Actions in Components
// app/posts/new/page.tsx
import { createPost } from '../actions'
import Form from 'next/form'
export default function NewPostPage() {
return (
<Form action={createPost}>
<input name="title" required />
<textarea name="content" required />
<button type="submit">Create Post</button>
</Form>
)
}
Progressive Enhancement with Forms
The new Form component provides automatic progressive enhancement:
import Form from 'next/form'
function CommentForm({ postId }: { postId: string }) {
async function addComment(formData: FormData) {
'use server'
const content = formData.get('content') as string
await db.comment.create({
data: { postId, content }
})
revalidatePath(`/posts/${postId}`)
}
return (
<Form action={addComment}>
<input name="content" placeholder="Add a comment..." />
<button type="submit">Comment</button>
</Form>
)
}
Advanced Caching and Data Management
Cache Control with cacheLife()
Next.js 16 introduces declarative cache control:
// app/api/market-data/route.ts
import { cacheLife } from 'next/cache'
export async function GET() {
cacheLife('minutes') // Cache for 5 minutes
const data = await fetchMarketData()
return Response.json(data)
}
// Predefined cache profiles
cacheLife('seconds') // 30 seconds
cacheLife('minutes') // 5 minutes
cacheLife('hours') // 1 hour
cacheLife('days') // 1 day
cacheLife('weeks') // 1 week
cacheLife('max') // Maximum cache duration
Granular Cache Invalidation with cacheTag()
// app/actions.ts
'use server'
import { cacheTag } from 'next/cache'
export async function updateUserProfile(userId: string, data: UserData) {
cacheTag(`user-${userId}`, 'user-profiles')
await db.user.update(userId, data)
// Invalidate specific caches
revalidateTag(`user-${userId}`)
revalidateTag('user-profiles')
}
Cache Management APIs
import {
revalidatePath,
revalidateTag,
refresh
} from 'next/cache'
export async function publishPost(postId: string) {
'use server'
await db.post.update(postId, { published: true })
// Revalidate the posts list
revalidatePath('/posts')
// Revalidate specific post cache
revalidateTag(`post-${postId}`)
// Refresh the entire page (client router)
refresh()
redirect('/posts')
}
Partial Prerendering (PPR)
Next.js 15 introduces Partial Prerendering for instant loading experiences:
// app/layout.tsx
import { unstable_noStore as noStore } from 'next/cache'
export default function RootLayout({ children }) {
// Static shell renders immediately
return (
<html>
<body>
<Header />
<main>{children}</main>
<Footer />
</body>
</html>
)
}
// app/dashboard/page.tsx
export default async function DashboardPage() {
// This runs at build time for the static shell
noStore() // Opt out of static generation for dynamic content
const data = await fetchDynamicData()
return <DashboardContent data={data} />
}
Advanced Routing Patterns
Parallel Routes for Complex Layouts
// app/dashboard/layout.tsx
export default function DashboardLayout({
children,
sidebar,
modal
}: {
children: React.ReactNode
sidebar: React.ReactNode
modal: React.ReactNode
}) {
return (
<div className="dashboard">
{sidebar}
<main>{children}</main>
{modal}
</div>
)
}
// app/dashboard/@sidebar/page.tsx
export default function Sidebar() {
return <nav>Dashboard Navigation</nav>
}
// app/dashboard/@modal/(..)login/page.tsx
export default function LoginModal() {
return <LoginForm />
}
Intercepting Routes for Modals
// app/posts/[id]/page.tsx
export default async function PostPage({ params }) {
const post = await getPost(params.id)
return <PostDetail post={post} />
}
// app/posts/[id]/edit/page.tsx (intercepted)
export default function EditPostModal({ params }) {
return (
<Modal>
<EditPostForm postId={params.id} />
</Modal>
)
}
Metadata API Evolution
Next.js 16 enhances the Metadata API with better TypeScript support:
// app/blog/[slug]/page.tsx
import type { Metadata } from 'next'
interface Props {
params: { slug: string }
}
export async function generateMetadata({ params }: Props): Promise<Metadata> {
const post = await getPost(params.slug)
return {
title: post.title,
description: post.excerpt,
authors: [{ name: post.author.name }],
openGraph: {
title: post.title,
description: post.excerpt,
images: [post.coverImage],
},
twitter: {
card: 'summary_large_image',
title: post.title,
description: post.excerpt,
images: [post.coverImage],
},
}
}
Loading and Error States
Route Groups for Conditional Loading
// app/(marketing)/loading.tsx
export default function MarketingLoading() {
return <MarketingSkeleton />
}
// app/(dashboard)/loading.tsx
export default function DashboardLoading() {
return <DashboardSkeleton />
}
Global Error Boundaries
// app/error.tsx
'use client'
export default function GlobalError({
error,
reset,
}: {
error: Error & { digest?: string }
reset: () => void
}) {
return (
<div className="error-boundary">
<h2>Something went wrong!</h2>
<p>{error.message}</p>
<button onClick={reset}>Try again</button>
</div>
)
}
Performance Optimization Patterns
Selective Hydration
// Only hydrate interactive parts
import dynamic from 'next/dynamic'
const InteractiveChart = dynamic(() => import('./InteractiveChart'), {
ssr: false, // Disable SSR for this component
loading: () => <ChartSkeleton />
})
Streaming with Selective Suspense
function PostPage({ params }) {
return (
<div>
{/* Static content renders immediately */}
<PostHeader />
{/* Stream expensive content */}
<Suspense fallback={<PostSkeleton />}>
<PostContent id={params.id} />
</Suspense>
{/* Different loading state for comments */}
<Suspense fallback={<CommentsSkeleton />}>
<PostComments id={params.id} />
</Suspense>
</div>
)
}
Turbopack 2.0 Integration
Next.js 15 uses Turbopack by default for faster development:
# Turbopack is now default
npm run dev
# Explicit Webpack (for compatibility)
npm run dev -- --webpack
# Production build with optimizations
npm run build
Best Practices for 2025
1. Embrace Server Actions Over API Routes
Before (API Routes):
// app/api/posts/route.ts
export async function POST(request: Request) {
const body = await request.json()
const post = await createPost(body)
return Response.json(post)
}
// components/PostForm.tsx
'use client'
function PostForm() {
const handleSubmit = async (data) => {
await fetch('/api/posts', {
method: 'POST',
body: JSON.stringify(data)
})
}
}
After (Server Actions):
// app/actions.ts
'use server'
export async function createPost(formData: FormData) {
const post = await db.post.create(Object.fromEntries(formData))
revalidatePath('/posts')
redirect('/posts')
}
// components/PostForm.tsx
import { createPost } from '@/app/actions'
import Form from 'next/form'
function PostForm() {
return (
<Form action={createPost}>
<input name="title" />
<textarea name="content" />
<button type="submit">Create</button>
</Form>
)
}
2. Use Declarative Caching
// app/api/analytics/route.ts
import { cacheLife } from 'next/cache'
export async function GET() {
cacheLife('hours') // Clear intent
const analytics = await getAnalytics()
return Response.json(analytics)
}
3. Optimize with Partial Prerendering
// app/layout.tsx - Static shell
export default function Layout({ children }) {
return (
<html>
<body>
<StaticHeader />
<main>{children}</main>
<StaticFooter />
</body>
</html>
)
}
// app/dashboard/page.tsx - Dynamic content
export default async function Dashboard() {
unstable_noStore() // Opt out of static generation
const data = await fetchRealtimeData()
return <DynamicDashboard data={data} />
}
4. Progressive Enhancement Strategy
// Forms work without JavaScript
function CommentForm() {
return (
<Form action={addComment}>
<input name="content" required />
<button type="submit">Submit</button>
</Form>
)
}
// Enhanced with JavaScript
'use client'
function EnhancedCommentForm() {
const [optimisticComments, addOptimisticComment] = useOptimistic(
comments,
(state, newComment) => [...state, newComment]
)
return (
<div>
<Form action={addComment}>
<input
name="content"
onChange={(e) => {
addOptimisticComment({
content: e.target.value,
pending: true
})
}}
/>
<button type="submit">Submit</button>
</Form>
<CommentsList comments={optimisticComments} />
</div>
)
}
5. Advanced Error Handling
// app/error.tsx
'use client'
export default function ErrorBoundary({
error,
reset
}: {
error: Error & { digest?: string }
reset: () => void
}) {
// Log to error reporting service
useEffect(() => {
reportError(error)
}, [error])
if (error.digest?.startsWith('NEXT_REDIRECT')) {
return <RedirectError />
}
return (
<div className="error-fallback">
<h2>Application Error</h2>
<p>{error.message}</p>
<button onClick={reset}>Try Again</button>
<ErrorDetails error={error} />
</div>
)
}
Migration from Pages Router
1. Convert API Routes to Server Actions
// Before: pages/api/posts.ts
export default async function handler(req, res) {
if (req.method === 'POST') {
const post = await createPost(req.body)
res.status(201).json(post)
}
}
// After: app/actions.ts
'use server'
export async function createPost(formData: FormData) {
const post = await db.post.create(Object.fromEntries(formData))
revalidatePath('/posts')
}
2. Update getServerSideProps to Server Components
// Before: pages/posts/[id].tsx
export async function getServerSideProps({ params }) {
const post = await getPost(params.id)
return { props: { post } }
}
// After: app/posts/[id]/page.tsx
export default async function PostPage({ params }) {
const post = await getPost(params.id)
return <PostDetail post={post} />
}
Conclusion
Next.js App Router has matured into a powerful full-stack framework. Server Actions eliminate API boilerplate, the Form component provides progressive enhancement, and advanced caching gives you fine-grained control over performance.
The key to mastering Next.js 15/16 is embracing server-first development while progressively enhancing with client-side features. Start migrating your API routes to Server Actions, implement declarative caching, and leverage Partial Prerendering for instant user experiences.
The future of React development is server-centric, and Next.js leads the way. What Server Action pattern are you most excited to implement?