Production patterns for Next.js 16 with App Router architecture. Trigger: Activated when working with app/ directory, routes, layouts, or server actions.
Core Principle: Server-first architecture. Fetch data on the server, mutate through Server Actions. The client is for interactivity, not data management.
┌─────────────────────────────────────────────────────────────────┐
│ Client (Browser) │
├─────────────────────────────────────────────────────────────────┤
│ Interactive Components │ Forms → Server Actions │
│ (onClick, useState) │ (useFormState, useFormStatus) │
└──────────────────┬─────────┴────────────────┬───────────────────┘
│ │
▼ ▼
┌─────────────────────────────────────────────────────────────────┐
│ Server Components │
│ • Async data fetching • Direct database access │
│ • No useState/useEffect • Streaming with Suspense │
└─────────────────────────────────────────────────────────────────┘
useEffectData fetching in useEffect causes waterfalls, loading spinners, and hydration issues.
// ❌ FORBIDDEN - Client-side fetching anti-pattern
'use client'
import { useEffect, useState } from 'react'
export function UserProfile({ userId }: { userId: string }) {
const [user, setUser] = useState<User | null>(null)
const [loading, setLoading] = useState(true)
useEffect(() => {
fetch(`/api/users/${userId}`)
.then(res => res.json())
.then(setUser)
.finally(() => setLoading(false))
}, [userId])
if (loading) return <Spinner />
return <div>{user?.name}</div>
}
// ✅ CORRECT - Server Component with async fetch
import { getUser } from '@/lib/data/users'
export async function UserProfile({ userId }: { userId: string }) {
const user = await getUser(userId)
if (!user) {
notFound()
}
return <div>{user.name}</div>
}
API routes are for external consumers. Internal data flows through Server Components and Server Actions.
// ❌ FORBIDDEN - API route for internal use
// app/api/users/[id]/route.ts
export async function GET(req: Request, { params }: { params: { id: string } }) {
const user = await db.users.findById(params.id)
return Response.json(user)
}
// ❌ Then fetching it from a component
const user = await fetch('/api/users/123').then(r => r.json())
// ✅ CORRECT - Direct data access in Server Component
// lib/data/users.ts
export async function getUser(id: string): Promise<User | null> {
return db.users.findById(id)
}
// app/users/[id]/page.tsx
import { getUser } from '@/lib/data/users'
export default async function UserPage({ params }: { params: { id: string } }) {
const user = await getUser(params.id)
// ...
}
Server Components are for reading. Mutations go through Server Actions.
// ❌ FORBIDDEN - Mutation in Server Component
export default async function Page() {
// DON'T DO THIS
await db.users.update(userId, { lastVisit: new Date() })
// ...
}
// ✅ CORRECT - Mutations via Server Actions
'use server'
export async function updateLastVisit(userId: string) {
await db.users.update(userId, { lastVisit: new Date() })
revalidatePath('/dashboard')
}
All data fetching happens in async Server Components.
// app/dashboard/page.tsx
import { Suspense } from 'react'
import { getDashboardStats } from '@/lib/data/dashboard'
import { StatsCards, StatsCardsSkeleton } from '@/components/dashboard/stats'
export default async function DashboardPage() {
return (
<div className="space-y-6">
<h1>Dashboard</h1>
<Suspense fallback={<StatsCardsSkeleton />}>
<StatsLoader />
</Suspense>
</div>
)
}
// Separate async component for streaming
async function StatsLoader() {
const stats = await getDashboardStats()
return <StatsCards stats={stats} />
}
Forms and mutations use Server Actions with proper validation.
// lib/actions/user.ts
'use server'
import { z } from 'zod'
import { revalidatePath } from 'next/cache'
import { redirect } from 'next/navigation'
import { createClient } from '@/lib/supabase/server'
const updateProfileSchema = z.object({
name: z.string().min(2).max(100),
bio: z.string().max(500).optional(),
})
export type UpdateProfileState = {
errors?: {
name?: string[]
bio?: string[]
_form?: string[]
}
success?: boolean
}
export async function updateProfile(
prevState: UpdateProfileState,
formData: FormData
): Promise<UpdateProfileState> {
// 1. Validate input
const validatedFields = updateProfileSchema.safeParse({
name: formData.get('name'),
bio: formData.get('bio'),
})
if (!validatedFields.success) {
return {
errors: validatedFields.error.flatten().fieldErrors,
}
}
// 2. Auth check
const supabase = await createClient()
const { data: { user } } = await supabase.auth.getUser()
if (!user) {
return { errors: { _form: ['Unauthorized'] } }
}
// 3. Perform mutation
try {
const { error } = await supabase
.from('profiles')
.update(validatedFields.data)
.eq('id', user.id)
if (error) throw error
} catch (e) {
return { errors: { _form: ['Failed to update profile'] } }
}
// 4. Revalidate and respond
revalidatePath('/settings')
return { success: true }
}
useFormStateUse the new React 19 form hooks for optimal UX.
// components/forms/profile-form.tsx
'use client'
import { useFormState, useFormStatus } from 'react-dom'
import { updateProfile, type UpdateProfileState } from '@/lib/actions/user'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
const initialState: UpdateProfileState = {}
export function ProfileForm({ defaultValues }: { defaultValues: { name: string; bio?: string } }) {
const [state, formAction] = useFormState(updateProfile, initialState)
return (
<form action={formAction} className="space-y-4">
<div>
<Input
name="name"
defaultValue={defaultValues.name}
placeholder="Your name"
/>
{state.errors?.name && (
<p className="text-sm text-destructive mt-1">{state.errors.name[0]}</p>
)}
</div>
<div>
<Textarea
name="bio"
defaultValue={defaultValues.bio}
placeholder="Tell us about yourself"
/>
{state.errors?.bio && (
<p className="text-sm text-destructive mt-1">{state.errors.bio[0]}</p>
)}
</div>
{state.errors?._form && (
<Alert variant="destructive">
<AlertDescription>{state.errors._form[0]}</AlertDescription>
</Alert>
)}
{state.success && (
<Alert>
<AlertDescription>Profile updated successfully!</AlertDescription>
</Alert>
)}
<SubmitButton />
</form>
)
}
function SubmitButton() {
const { pending } = useFormStatus()
return (
<Button type="submit" disabled={pending}>
{pending ? 'Saving...' : 'Save Changes'}
</Button>
)
}
Use route groups to organize without affecting URLs.
// app/(marketing)/page.tsx → /
// app/(marketing)/pricing/page.tsx → /pricing
// app/(app)/dashboard/page.tsx → /dashboard
// app/(app)/settings/page.tsx → /settings
// Separate layouts for different sections
// app/(marketing)/layout.tsx - Marketing header/footer
// app/(app)/layout.tsx - App sidebar, auth check
// app/dashboard/@stats/page.tsx
// app/dashboard/@activity/page.tsx
// app/dashboard/@notifications/page.tsx
// app/dashboard/layout.tsx
export default function DashboardLayout({
children,
stats,
activity,
notifications,
}: {
children: React.ReactNode
stats: React.ReactNode
activity: React.ReactNode
notifications: React.ReactNode
}) {
return (
<div className="grid grid-cols-12 gap-6">
<main className="col-span-8">{children}</main>
<aside className="col-span-4 space-y-6">
{stats}
{activity}
{notifications}
</aside>
</div>
)
}
// ❌ Sequential (slow) - each await blocks the next
async function Page() {
const user = await getUser(id)
const posts = await getPosts(id) // Waits for user
const comments = await getComments() // Waits for posts
}
// ✅ Parallel (fast) - all requests start immediately
async function Page() {
const [user, posts, comments] = await Promise.all([
getUser(id),
getPosts(id),
getComments(),
])
}
// ✅ Streaming (best UX) - show content as it loads
async function Page() {
const userPromise = getUser(id)
return (
<>
<Suspense fallback={<UserSkeleton />}>
<UserProfile userPromise={userPromise} />
</Suspense>
<Suspense fallback={<PostsSkeleton />}>
<UserPosts userId={id} />
</Suspense>
</>
)
}
// lib/data/users.ts
import { cache } from 'react'
// Deduplicated & cached per request
export const getUser = cache(async (id: string) => {
return db.users.findById(id)
})
// Preload for anticipated navigation
export const preloadUser = (id: string) => {
void getUser(id)
}
// Usage in page
import { preloadUser } from '@/lib/data/users'
export default async function UsersPage() {
const users = await getUsers()
return (
<ul>
{users.map(user => (
<li key={user.id} onMouseEnter={() => preloadUser(user.id)}>
<Link href={`/users/${user.id}`}>{user.name}</Link>
</li>
))}
</ul>
)
}
app/
├── (marketing)/ # Public pages
│ ├── layout.tsx
│ ├── page.tsx # Homepage
│ └── pricing/
│ └── page.tsx
├── (app)/ # Authenticated app
│ ├── layout.tsx # With auth check
│ ├── dashboard/
│ │ ├── page.tsx
│ │ ├── loading.tsx
│ │ └── error.tsx
│ └── settings/
│ └── page.tsx
├── api/ # ONLY for webhooks/external APIs
│ └── webhooks/
│ └── stripe/
│ └── route.ts
└── layout.tsx # Root layout
lib/
├── actions/ # Server Actions
│ ├── user.ts
│ └── posts.ts
├── data/ # Data fetching functions
│ ├── users.ts
│ └── posts.ts
└── supabase/
├── client.ts # Browser client
├── server.ts # Server client
└── middleware.ts # Auth middleware
// middleware.ts
import { NextResponse } from 'next/server'
import type { NextRequest } from 'next/server'
import { updateSession } from '@/lib/supabase/middleware'
export async function middleware(request: NextRequest) {
// Refresh auth session
const response = await updateSession(request)
// Add security headers
response.headers.set('X-Frame-Options', 'DENY')
response.headers.set('X-Content-Type-Options', 'nosniff')
return response
}
export const config = {
matcher: [
'/((?!_next/static|_next/image|favicon.ico|public/).*)',
],
}
useEffect for data fetchinguseFormState + useFormStatuserror.tsx)loading.tsx)Skill Version: 2.0.0 | Compatible with Next.js 16.x & React 19