REST API patterns for Next.js 16 with App Router. Server Actions first, API Routes for external integrations. Trigger: Activated when creating API endpoints, webhooks, or external integrations.
Core Principle: Server Actions for internal operations, API Routes only for external integrations. Minimize the API surface.
Instruction for Claude: When this skill is loaded, check this table and mention any entry relevant to what the developer is working on — before writing code.
| Version | Change | Affects |
|---|---|---|
| Next.js 16.2 | javascript: URLs blocked automatically in redirect() and router.push() | Any API route that redirects based on user input |
| Next.js 15+ | Route handler params is now — must be -ed |
Promise<{...}>awaitAll dynamic route handlers [id]/route.ts |
┌─────────────────────────────────────────────────────────────────┐
│ Is it called externally? │
│ │
│ NO YES │
│ │ │ │
│ ▼ ▼ │
│ ┌─────────────┐ ┌─────────────────┐ │
│ │ Server │ │ API Route │ │
│ │ Action │ │ app/api/... │ │
│ └─────────────┘ └─────────────────┘ │
│ │
│ • Form submissions • Webhooks (Stripe, etc.) │
│ • Data mutations • OAuth callbacks │
│ • Internal CRUD • Public APIs │
│ • Revalidation • Third-party integrations│
└─────────────────────────────────────────────────────────────────┘
// ❌ FORBIDDEN - API Route for internal form submission
// app/api/posts/create/route.ts
export async function POST(request: Request) {
const body = await request.json()
// create post...
}
// ❌ FORBIDDEN - Fetching your own API from client
const response = await fetch('/api/posts/create', {
method: 'POST',
body: JSON.stringify(data)
})
// ✅ CORRECT - Use Server Action
// lib/actions/posts.ts
'use server'
import { z } from 'zod'
import { revalidatePath } from 'next/cache'
const createPostSchema = z.object({
title: z.string().min(1),
content: z.string().min(1),
})
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() }
}
// Create post in database
await db.posts.create(validated.data)
revalidatePath('/posts')
return { success: true }
}
// ❌ FORBIDDEN - No validation
export async function POST(request: Request) {
const body = await request.json()
await db.insert(body) // Dangerous!
}
// ✅ CORRECT - Always validate with Zod
import { z } from 'zod'
const webhookSchema = z.object({
type: z.enum(['payment.succeeded', 'payment.failed']),
data: z.object({
id: z.string(),
amount: z.number(),
}),
})
export async function POST(request: Request) {
const body = await request.json()
const validated = webhookSchema.safeParse(body)
if (!validated.success) {
return Response.json(
{ error: 'Invalid payload' },
{ status: 400 }
)
}
// Process validated.data safely
}
// ❌ FORBIDDEN - Leaks internal details
export async function GET() {
try {
const data = await db.query()
return Response.json(data)
} catch (error) {
return Response.json({ error: error.message }, { status: 500 })
}
}
// ✅ CORRECT - Generic error response
export async function GET() {
try {
const data = await db.query()
return Response.json(data)
} catch (error) {
console.error('API Error:', error) // Log internally
return Response.json(
{ error: 'Internal server error' },
{ status: 500 }
)
}
}
// app/api/webhooks/stripe/route.ts
import { headers } from 'next/headers'
import Stripe from 'stripe'
const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!)
const webhookSecret = process.env.STRIPE_WEBHOOK_SECRET!
export async function POST(request: Request) {
const body = await request.text()
const headersList = await headers()
const signature = headersList.get('stripe-signature')!
let event: Stripe.Event
try {
event = stripe.webhooks.constructEvent(body, signature, webhookSecret)
} catch (error) {
console.error('Webhook signature verification failed:', error)
return Response.json({ error: 'Invalid signature' }, { status: 400 })
}
// Handle event types
switch (event.type) {
case 'payment_intent.succeeded':
await handlePaymentSuccess(event.data.object)
break
case 'payment_intent.payment_failed':
await handlePaymentFailed(event.data.object)
break
default:
console.log(`Unhandled event type: ${event.type}`)
}
return Response.json({ received: true })
}
// Disable body parsing for raw body access
export const config = {
api: { bodyParser: false }
}
// app/api/v1/products/route.ts
import { z } from 'zod'
import { NextRequest } from 'next/server'
const querySchema = z.object({
page: z.coerce.number().min(1).default(1),
limit: z.coerce.number().min(1).max(100).default(20),
category: z.string().optional(),
})
export async function GET(request: NextRequest) {
// Parse query params
const searchParams = request.nextUrl.searchParams
const query = querySchema.safeParse({
page: searchParams.get('page'),
limit: searchParams.get('limit'),
category: searchParams.get('category'),
})
if (!query.success) {
return Response.json(
{ error: 'Invalid parameters', details: query.error.flatten() },
{ status: 400 }
)
}
const { page, limit, category } = query.data
// Fetch data
const products = await getProducts({ page, limit, category })
const total = await getProductsCount({ category })
// Return paginated response
return Response.json({
data: products,
pagination: {
page,
limit,
total,
totalPages: Math.ceil(total / limit),
},
})
}
// types/api.ts
// Success response
interface ApiSuccess<T> {
data: T
pagination?: {
page: number
limit: number
total: number
totalPages: number
}
}
// Error response
interface ApiError {
error: string
code?: string
details?: Record<string, string[]>
}
// Union type for responses
type ApiResponse<T> = ApiSuccess<T> | ApiError
// Helper functions
export function successResponse<T>(data: T, pagination?: ApiSuccess<T>['pagination']) {
return Response.json({ data, pagination })
}
export function errorResponse(error: string, status: number, code?: string) {
return Response.json({ error, code }, { status })
}
// lib/api/rate-limit.ts
import { Ratelimit } from '@upstash/ratelimit'
import { Redis } from '@upstash/redis'
const ratelimit = new Ratelimit({
redis: Redis.fromEnv(),
limiter: Ratelimit.slidingWindow(10, '10 s'), // 10 requests per 10 seconds
analytics: true,
})
export async function checkRateLimit(identifier: string) {
const { success, limit, remaining, reset } = await ratelimit.limit(identifier)
return {
success,
headers: {
'X-RateLimit-Limit': limit.toString(),
'X-RateLimit-Remaining': remaining.toString(),
'X-RateLimit-Reset': reset.toString(),
},
}
}
// Usage in API Route
export async function GET(request: NextRequest) {
const ip = request.headers.get('x-forwarded-for') ?? 'anonymous'
const { success, headers } = await checkRateLimit(ip)
if (!success) {
return Response.json(
{ error: 'Too many requests' },
{ status: 429, headers }
)
}
// Process request...
return Response.json({ data }, { headers })
}
// lib/api/auth.ts
import { createClient } from '@/lib/supabase/server'
export async function requireApiAuth(request: Request) {
const authHeader = request.headers.get('authorization')
if (!authHeader?.startsWith('Bearer ')) {
return { error: 'Missing authorization header', status: 401 }
}
const token = authHeader.replace('Bearer ', '')
const supabase = await createClient()
const { data: { user }, error } = await supabase.auth.getUser(token)
if (error || !user) {
return { error: 'Invalid token', status: 401 }
}
return { user }
}
// Usage
export async function GET(request: Request) {
const auth = await requireApiAuth(request)
if ('error' in auth) {
return Response.json({ error: auth.error }, { status: auth.status })
}
// auth.user is available
const data = await getUserData(auth.user.id)
return Response.json({ data })
}
For AI text generation or long-running operations, stream responses instead of waiting for full completion.
// app/api/chat/route.ts
import { OpenAI } from 'openai'
const openai = new OpenAI()
export async function POST(request: Request) {
const { messages } = await request.json()
// Create a readable stream from OpenAI
const stream = await openai.chat.completions.create({
model: 'gpt-4o',
messages,
stream: true,
})
// Return a streaming response
const readableStream = new ReadableStream({
async start(controller) {
const encoder = new TextEncoder()
for await (const chunk of stream) {
const text = chunk.choices[0]?.delta?.content ?? ''
if (text) {
// Server-Sent Events format
controller.enqueue(encoder.encode(`data: ${JSON.stringify({ text })}\n\n`))
}
}
controller.enqueue(new TextEncoder().encode('data: [DONE]\n\n'))
controller.close()
},
})
return new Response(readableStream, {
headers: {
'Content-Type': 'text/event-stream',
'Cache-Control': 'no-cache',
'Connection': 'keep-alive',
},
})
}
// Client-side consumption with EventSource / fetch
'use client'
export async function streamChat(messages: Message[]) {
const response = await fetch('/api/chat', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ messages }),
})
if (!response.body) throw new Error('No response body')
const reader = response.body.getReader()
const decoder = new TextDecoder()
while (true) {
const { done, value } = await reader.read()
if (done) break
const lines = decoder.decode(value).split('\n\n').filter(Boolean)
for (const line of lines) {
if (line === 'data: [DONE]') return
if (line.startsWith('data: ')) {
const { text } = JSON.parse(line.slice(6))
// Process each chunk (e.g., append to UI)
yield text
}
}
}
}
app/
├── api/
│ ├── v1/ # Versioned public APIs
│ │ ├── products/
│ │ │ └── route.ts
│ │ └── users/
│ │ └── route.ts
│ ├── webhooks/ # External webhooks
│ │ ├── stripe/
│ │ │ └── route.ts
│ │ └── supabase/
│ │ └── route.ts
│ └── auth/ # OAuth callbacks
│ └── callback/
│ └── route.ts
lib/
├── actions/ # Server Actions (internal)
│ ├── posts.ts
│ └── users.ts
├── api/ # API utilities
│ ├── rate-limit.ts
│ └── auth.ts
└── validations/ # Shared Zod schemas
└── api.ts
types/
└── api.ts # API response types
| Code | When to Use |
|---|---|
200 | Success (GET, PUT, PATCH) |
201 | Resource created (POST) |
204 | Success, no content (DELETE) |
400 | Bad request (validation error) |
401 | Unauthorized (no/invalid token) |
403 | Forbidden (valid token, no permission) |
404 | Resource not found |
429 | Rate limit exceeded |
500 | Internal server error |
Skill Version: 2.0.0 | Compatible with Next.js 16.x