route-handlers
This skill provides detailed guidance for building Next.js App Router API routes. It covers HTTP methods, request/response handling, streaming, error management, and includes practical examples like a blog API with Prisma and Zod validation. It addresses common needs like authentication and rate limiting.
Packaged view
This page reorganizes the original catalog entry around fit, installability, and workflow context first. The original raw source lives below.
Install command
npx @skill-hub/cli install davepoon-buildwithclaude-route-handlers
Repository
Skill path: plugins/nextjs-expert/skills/route-handlers
This skill provides detailed guidance for building Next.js App Router API routes. It covers HTTP methods, request/response handling, streaming, error management, and includes practical examples like a blog API with Prisma and Zod validation. It addresses common needs like authentication and rate limiting.
Open repositoryBest for
Primary workflow: Ship Full Stack.
Technical facets: Backend, Frontend.
Target audience: Next.js developers building backend APIs with the App Router, particularly those needing structured examples for REST endpoints, database operations, and request validation..
License: Unknown.
Original source
Catalog source: SkillHub Club.
Repository owner: davepoon.
This is still a mirrored public skill entry. Review the repository before installing into production workflows.
What it helps with
- Install route-handlers into Claude Code, Codex CLI, Gemini CLI, or OpenCode workflows
- Review https://github.com/davepoon/buildwithclaude before adding route-handlers to shared team environments
- Use route-handlers for backend workflows
Works across
Favorites: 0.
Sub-skills: 0.
Aggregator: No.
Original source / Raw SKILL.md
---
name: route-handlers
description: This skill should be used when the user asks to "create an API route", "add an endpoint", "build a REST API", "handle POST requests", "create route handlers", "stream responses", or needs guidance on Next.js API development in the App Router.
version: 1.0.0
---
# Next.js Route Handlers
## Overview
Route Handlers allow you to create API endpoints using the Web Request and Response APIs. They're defined in `route.ts` files within the `app` directory.
## Basic Structure
### File Convention
Route handlers use `route.ts` (or `route.js`):
```
app/
├── api/
│ ├── users/
│ │ └── route.ts # /api/users
│ └── posts/
│ ├── route.ts # /api/posts
│ └── [id]/
│ └── route.ts # /api/posts/:id
```
### HTTP Methods
Export functions named after HTTP methods:
```tsx
// app/api/users/route.ts
import { NextResponse } from 'next/server'
export async function GET() {
const users = await db.user.findMany()
return NextResponse.json(users)
}
export async function POST(request: Request) {
const body = await request.json()
const user = await db.user.create({ data: body })
return NextResponse.json(user, { status: 201 })
}
```
Supported methods: `GET`, `POST`, `PUT`, `PATCH`, `DELETE`, `HEAD`, `OPTIONS`
## Request Handling
### Reading Request Body
```tsx
export async function POST(request: Request) {
// JSON body
const json = await request.json()
// Form data
const formData = await request.formData()
const name = formData.get('name')
// Text body
const text = await request.text()
return NextResponse.json({ received: true })
}
```
### URL Parameters
Dynamic route parameters:
```tsx
// app/api/posts/[id]/route.ts
interface RouteContext {
params: Promise<{ id: string }>
}
export async function GET(
request: Request,
context: RouteContext
) {
const { id } = await context.params
const post = await db.post.findUnique({ where: { id } })
if (!post) {
return NextResponse.json(
{ error: 'Not found' },
{ status: 404 }
)
}
return NextResponse.json(post)
}
```
### Query Parameters
```tsx
export async function GET(request: Request) {
const { searchParams } = new URL(request.url)
const page = searchParams.get('page') ?? '1'
const limit = searchParams.get('limit') ?? '10'
const posts = await db.post.findMany({
skip: (parseInt(page) - 1) * parseInt(limit),
take: parseInt(limit),
})
return NextResponse.json(posts)
}
```
### Request Headers
```tsx
export async function GET(request: Request) {
const authHeader = request.headers.get('authorization')
if (!authHeader?.startsWith('Bearer ')) {
return NextResponse.json(
{ error: 'Unauthorized' },
{ status: 401 }
)
}
const token = authHeader.split(' ')[1]
// Validate token...
return NextResponse.json({ authenticated: true })
}
```
## Response Handling
### JSON Response
```tsx
import { NextResponse } from 'next/server'
export async function GET() {
return NextResponse.json(
{ message: 'Hello' },
{ status: 200 }
)
}
```
### Setting Headers
```tsx
export async function GET() {
return NextResponse.json(
{ data: 'value' },
{
headers: {
'Cache-Control': 'max-age=3600',
'X-Custom-Header': 'custom-value',
},
}
)
}
```
### Setting Cookies
```tsx
import { cookies } from 'next/headers'
export async function POST(request: Request) {
const cookieStore = await cookies()
// Set cookie
cookieStore.set('session', 'abc123', {
httpOnly: true,
secure: process.env.NODE_ENV === 'production',
sameSite: 'lax',
maxAge: 60 * 60 * 24 * 7, // 1 week
})
return NextResponse.json({ success: true })
}
```
### Redirects
```tsx
import { redirect } from 'next/navigation'
import { NextResponse } from 'next/server'
export async function GET() {
// Option 1: redirect function (throws)
redirect('/login')
// Option 2: NextResponse.redirect
return NextResponse.redirect(new URL('/login', request.url))
}
```
## Streaming Responses
### Text Streaming
```tsx
export async function GET() {
const encoder = new TextEncoder()
const stream = new ReadableStream({
async start(controller) {
for (let i = 0; i < 10; i++) {
controller.enqueue(encoder.encode(`data: ${i}\n\n`))
await new Promise(resolve => setTimeout(resolve, 100))
}
controller.close()
},
})
return new Response(stream, {
headers: {
'Content-Type': 'text/event-stream',
'Cache-Control': 'no-cache',
'Connection': 'keep-alive',
},
})
}
```
### AI/LLM Streaming
```tsx
export async function POST(request: Request) {
const { prompt } = await request.json()
const response = await openai.chat.completions.create({
model: 'gpt-4',
messages: [{ role: 'user', content: prompt }],
stream: true,
})
const stream = new ReadableStream({
async start(controller) {
for await (const chunk of response) {
const text = chunk.choices[0]?.delta?.content || ''
controller.enqueue(new TextEncoder().encode(text))
}
controller.close()
},
})
return new Response(stream, {
headers: { 'Content-Type': 'text/plain' },
})
}
```
## CORS Configuration
```tsx
export async function OPTIONS() {
return new Response(null, {
status: 204,
headers: {
'Access-Control-Allow-Origin': '*',
'Access-Control-Allow-Methods': 'GET, POST, PUT, DELETE',
'Access-Control-Allow-Headers': 'Content-Type, Authorization',
},
})
}
export async function GET() {
return NextResponse.json(
{ data: 'value' },
{
headers: {
'Access-Control-Allow-Origin': '*',
},
}
)
}
```
## Caching
### Static (Default for GET)
```tsx
// Cached by default
export async function GET() {
const data = await fetch('https://api.example.com/data')
return NextResponse.json(await data.json())
}
```
### Opt-out of Caching
```tsx
export const dynamic = 'force-dynamic'
export async function GET() {
// Always fresh
}
// Or use cookies/headers (auto opts out)
import { cookies } from 'next/headers'
export async function GET() {
const cookieStore = await cookies()
// Now dynamic
}
```
## Error Handling
```tsx
export async function GET(request: Request) {
try {
const data = await riskyOperation()
return NextResponse.json(data)
} catch (error) {
console.error('API Error:', error)
if (error instanceof ValidationError) {
return NextResponse.json(
{ error: error.message },
{ status: 400 }
)
}
return NextResponse.json(
{ error: 'Internal Server Error' },
{ status: 500 }
)
}
}
```
## Resources
For detailed patterns, see:
- `references/http-methods.md` - Complete HTTP method guide
- `references/streaming-responses.md` - Advanced streaming patterns
- `examples/crud-api.md` - Full CRUD API example
---
## Referenced Files
> The following files are referenced in this skill and included for context.
### references/http-methods.md
```markdown
# HTTP Methods in Route Handlers
## Supported Methods
Export async functions with HTTP method names:
```tsx
// app/api/posts/route.ts
export async function GET(request: Request) {}
export async function POST(request: Request) {}
export async function PUT(request: Request) {}
export async function PATCH(request: Request) {}
export async function DELETE(request: Request) {}
export async function HEAD(request: Request) {}
export async function OPTIONS(request: Request) {}
```
## GET Requests
### Basic GET
```tsx
// app/api/posts/route.ts
import { NextResponse } from 'next/server'
export async function GET() {
const posts = await db.post.findMany()
return NextResponse.json(posts)
}
```
### GET with Query Parameters
```tsx
// app/api/search/route.ts
import { NextRequest, NextResponse } from 'next/server'
export async function GET(request: NextRequest) {
const searchParams = request.nextUrl.searchParams
const query = searchParams.get('q')
const page = parseInt(searchParams.get('page') || '1')
const limit = parseInt(searchParams.get('limit') || '10')
const results = await db.post.findMany({
where: {
OR: [
{ title: { contains: query || '' } },
{ content: { contains: query || '' } },
],
},
skip: (page - 1) * limit,
take: limit,
})
return NextResponse.json({
results,
page,
limit,
query,
})
}
```
### GET with Dynamic Segment
```tsx
// app/api/posts/[id]/route.ts
import { NextRequest, NextResponse } from 'next/server'
export async function GET(
request: NextRequest,
{ params }: { params: Promise<{ id: string }> }
) {
const { id } = await params
const post = await db.post.findUnique({
where: { id },
})
if (!post) {
return NextResponse.json(
{ error: 'Post not found' },
{ status: 404 }
)
}
return NextResponse.json(post)
}
```
## POST Requests
### JSON Body
```tsx
// app/api/posts/route.ts
import { NextRequest, NextResponse } from 'next/server'
export async function POST(request: NextRequest) {
const body = await request.json()
const post = await db.post.create({
data: {
title: body.title,
content: body.content,
authorId: body.authorId,
},
})
return NextResponse.json(post, { status: 201 })
}
```
### Form Data
```tsx
// app/api/upload/route.ts
import { NextRequest, NextResponse } from 'next/server'
export async function POST(request: NextRequest) {
const formData = await request.formData()
const name = formData.get('name') as string
const email = formData.get('email') as string
// Process form data
const user = await db.user.create({
data: { name, email },
})
return NextResponse.json(user, { status: 201 })
}
```
### File Upload
```tsx
// app/api/upload/route.ts
import { NextRequest, NextResponse } from 'next/server'
import { writeFile } from 'fs/promises'
import path from 'path'
export async function POST(request: NextRequest) {
const formData = await request.formData()
const file = formData.get('file') as File
if (!file) {
return NextResponse.json(
{ error: 'No file provided' },
{ status: 400 }
)
}
const bytes = await file.arrayBuffer()
const buffer = Buffer.from(bytes)
const filename = `${Date.now()}-${file.name}`
const filepath = path.join(process.cwd(), 'public/uploads', filename)
await writeFile(filepath, buffer)
return NextResponse.json({
url: `/uploads/${filename}`,
})
}
```
## PUT Requests
Full resource replacement:
```tsx
// app/api/posts/[id]/route.ts
import { NextRequest, NextResponse } from 'next/server'
export async function PUT(
request: NextRequest,
{ params }: { params: Promise<{ id: string }> }
) {
const { id } = await params
const body = await request.json()
const post = await db.post.update({
where: { id },
data: {
title: body.title,
content: body.content,
published: body.published,
},
})
return NextResponse.json(post)
}
```
## PATCH Requests
Partial update:
```tsx
// app/api/posts/[id]/route.ts
import { NextRequest, NextResponse } from 'next/server'
export async function PATCH(
request: NextRequest,
{ params }: { params: Promise<{ id: string }> }
) {
const { id } = await params
const body = await request.json()
// Only update provided fields
const post = await db.post.update({
where: { id },
data: body,
})
return NextResponse.json(post)
}
```
## DELETE Requests
```tsx
// app/api/posts/[id]/route.ts
import { NextRequest, NextResponse } from 'next/server'
export async function DELETE(
request: NextRequest,
{ params }: { params: Promise<{ id: string }> }
) {
const { id } = await params
await db.post.delete({
where: { id },
})
return new NextResponse(null, { status: 204 })
}
```
## Request Headers
```tsx
// app/api/protected/route.ts
import { NextRequest, NextResponse } from 'next/server'
export async function GET(request: NextRequest) {
// Get specific header
const authHeader = request.headers.get('authorization')
// Get all headers
const contentType = request.headers.get('content-type')
const userAgent = request.headers.get('user-agent')
if (!authHeader?.startsWith('Bearer ')) {
return NextResponse.json(
{ error: 'Unauthorized' },
{ status: 401 }
)
}
const token = authHeader.split(' ')[1]
// Validate token...
return NextResponse.json({ message: 'Authenticated' })
}
```
## Response Headers
```tsx
// app/api/data/route.ts
import { NextResponse } from 'next/server'
export async function GET() {
const data = { message: 'Hello' }
return NextResponse.json(data, {
status: 200,
headers: {
'Cache-Control': 'max-age=3600, s-maxage=3600',
'X-Custom-Header': 'custom-value',
},
})
}
```
## Cookies
### Reading Cookies
```tsx
// app/api/user/route.ts
import { NextRequest, NextResponse } from 'next/server'
import { cookies } from 'next/headers'
export async function GET(request: NextRequest) {
// Method 1: From request
const token = request.cookies.get('token')?.value
// Method 2: Using cookies() function
const cookieStore = await cookies()
const sessionId = cookieStore.get('sessionId')?.value
return NextResponse.json({ token, sessionId })
}
```
### Setting Cookies
```tsx
// app/api/login/route.ts
import { NextRequest, NextResponse } from 'next/server'
export async function POST(request: NextRequest) {
const body = await request.json()
const token = await authenticate(body.email, body.password)
const response = NextResponse.json({ success: true })
response.cookies.set('token', token, {
httpOnly: true,
secure: process.env.NODE_ENV === 'production',
sameSite: 'strict',
maxAge: 60 * 60 * 24 * 7, // 1 week
path: '/',
})
return response
}
```
### Deleting Cookies
```tsx
// app/api/logout/route.ts
import { NextResponse } from 'next/server'
export async function POST() {
const response = NextResponse.json({ success: true })
response.cookies.delete('token')
// Or set with expired date
response.cookies.set('token', '', {
expires: new Date(0),
})
return response
}
```
## URL Handling
```tsx
// app/api/info/route.ts
import { NextRequest, NextResponse } from 'next/server'
export async function GET(request: NextRequest) {
const { pathname, searchParams, origin } = request.nextUrl
return NextResponse.json({
pathname, // /api/info
origin, // http://localhost:3000
query: Object.fromEntries(searchParams),
})
}
```
## Redirects
```tsx
// app/api/old-endpoint/route.ts
import { NextRequest, NextResponse } from 'next/server'
import { redirect } from 'next/navigation'
export async function GET(request: NextRequest) {
// Method 1: Using redirect()
redirect('/api/new-endpoint')
// Method 2: Using NextResponse.redirect()
return NextResponse.redirect(new URL('/api/new-endpoint', request.url))
// Method 3: Redirect with status
return NextResponse.redirect(
new URL('/api/new-endpoint', request.url),
{ status: 301 } // Permanent redirect
)
}
```
```
### references/streaming-responses.md
```markdown
# Streaming Responses in Route Handlers
## Basic Streaming
### Using ReadableStream
```tsx
// app/api/stream/route.ts
export async function GET() {
const encoder = new TextEncoder()
const stream = new ReadableStream({
async start(controller) {
for (let i = 0; i < 10; i++) {
const chunk = encoder.encode(`Chunk ${i}\n`)
controller.enqueue(chunk)
await new Promise(resolve => setTimeout(resolve, 500))
}
controller.close()
},
})
return new Response(stream, {
headers: {
'Content-Type': 'text/plain; charset=utf-8',
'Transfer-Encoding': 'chunked',
},
})
}
```
### Streaming JSON Lines
```tsx
// app/api/stream-json/route.ts
export async function GET() {
const encoder = new TextEncoder()
const stream = new ReadableStream({
async start(controller) {
const items = await fetchLargeDataset()
for (const item of items) {
const json = JSON.stringify(item) + '\n'
controller.enqueue(encoder.encode(json))
}
controller.close()
},
})
return new Response(stream, {
headers: {
'Content-Type': 'application/x-ndjson',
},
})
}
```
## Server-Sent Events (SSE)
### Basic SSE
```tsx
// app/api/sse/route.ts
export async function GET() {
const encoder = new TextEncoder()
const stream = new ReadableStream({
async start(controller) {
let count = 0
const interval = setInterval(() => {
const data = `data: ${JSON.stringify({ count: count++, time: new Date().toISOString() })}\n\n`
controller.enqueue(encoder.encode(data))
if (count >= 10) {
clearInterval(interval)
controller.close()
}
}, 1000)
},
})
return new Response(stream, {
headers: {
'Content-Type': 'text/event-stream',
'Cache-Control': 'no-cache',
'Connection': 'keep-alive',
},
})
}
```
### SSE with Event Types
```tsx
// app/api/events/route.ts
export async function GET() {
const encoder = new TextEncoder()
const stream = new ReadableStream({
async start(controller) {
// Send a named event
const sendEvent = (eventType: string, data: unknown) => {
const message = `event: ${eventType}\ndata: ${JSON.stringify(data)}\n\n`
controller.enqueue(encoder.encode(message))
}
// Initial connection event
sendEvent('connected', { status: 'ok' })
// Simulate notifications
const notifications = await getNotifications()
for (const notification of notifications) {
sendEvent('notification', notification)
await delay(500)
}
// Final event
sendEvent('complete', { total: notifications.length })
controller.close()
},
})
return new Response(stream, {
headers: {
'Content-Type': 'text/event-stream',
'Cache-Control': 'no-cache',
'Connection': 'keep-alive',
},
})
}
```
### Client-Side SSE Consumer
```tsx
// components/sse-consumer.tsx
'use client'
import { useEffect, useState } from 'react'
interface Event {
type: string
data: unknown
}
export function SSEConsumer() {
const [events, setEvents] = useState<Event[]>([])
const [status, setStatus] = useState('disconnected')
useEffect(() => {
const eventSource = new EventSource('/api/events')
eventSource.onopen = () => {
setStatus('connected')
}
eventSource.addEventListener('notification', (event) => {
const data = JSON.parse(event.data)
setEvents(prev => [...prev, { type: 'notification', data }])
})
eventSource.addEventListener('complete', (event) => {
const data = JSON.parse(event.data)
setEvents(prev => [...prev, { type: 'complete', data }])
eventSource.close()
})
eventSource.onerror = () => {
setStatus('error')
eventSource.close()
}
return () => {
eventSource.close()
}
}, [])
return (
<div>
<p>Status: {status}</p>
<ul>
{events.map((event, i) => (
<li key={i}>{JSON.stringify(event)}</li>
))}
</ul>
</div>
)
}
```
## Streaming with AI/LLM
### OpenAI Streaming
```tsx
// app/api/chat/route.ts
import OpenAI from 'openai'
const openai = new OpenAI()
export async function POST(request: Request) {
const { messages } = await request.json()
const response = await openai.chat.completions.create({
model: 'gpt-4',
messages,
stream: true,
})
const encoder = new TextEncoder()
const stream = new ReadableStream({
async start(controller) {
for await (const chunk of response) {
const content = chunk.choices[0]?.delta?.content || ''
controller.enqueue(encoder.encode(content))
}
controller.close()
},
})
return new Response(stream, {
headers: {
'Content-Type': 'text/plain; charset=utf-8',
},
})
}
```
### Vercel AI SDK
```tsx
// app/api/chat/route.ts
import { openai } from '@ai-sdk/openai'
import { streamText } from 'ai'
export async function POST(request: Request) {
const { messages } = await request.json()
const result = streamText({
model: openai('gpt-4'),
messages,
})
return result.toDataStreamResponse()
}
```
## File Downloads
### Streaming Large Files
```tsx
// app/api/download/[filename]/route.ts
import { createReadStream } from 'fs'
import { stat } from 'fs/promises'
import path from 'path'
import { Readable } from 'stream'
export async function GET(
request: Request,
{ params }: { params: Promise<{ filename: string }> }
) {
const { filename } = await params
const filepath = path.join(process.cwd(), 'files', filename)
const stats = await stat(filepath)
const fileStream = createReadStream(filepath)
// Convert Node.js stream to Web ReadableStream
const stream = Readable.toWeb(fileStream) as ReadableStream
return new Response(stream, {
headers: {
'Content-Type': 'application/octet-stream',
'Content-Disposition': `attachment; filename="${filename}"`,
'Content-Length': stats.size.toString(),
},
})
}
```
### Range Requests (Video Streaming)
```tsx
// app/api/video/[id]/route.ts
import { createReadStream } from 'fs'
import { stat } from 'fs/promises'
import path from 'path'
export async function GET(
request: Request,
{ params }: { params: Promise<{ id: string }> }
) {
const { id } = await params
const filepath = path.join(process.cwd(), 'videos', `${id}.mp4`)
const stats = await stat(filepath)
const fileSize = stats.size
const range = request.headers.get('range')
if (range) {
const parts = range.replace(/bytes=/, '').split('-')
const start = parseInt(parts[0], 10)
const end = parts[1] ? parseInt(parts[1], 10) : fileSize - 1
const chunkSize = end - start + 1
const fileStream = createReadStream(filepath, { start, end })
const stream = Readable.toWeb(fileStream) as ReadableStream
return new Response(stream, {
status: 206,
headers: {
'Content-Range': `bytes ${start}-${end}/${fileSize}`,
'Accept-Ranges': 'bytes',
'Content-Length': chunkSize.toString(),
'Content-Type': 'video/mp4',
},
})
}
const fileStream = createReadStream(filepath)
const stream = Readable.toWeb(fileStream) as ReadableStream
return new Response(stream, {
headers: {
'Content-Length': fileSize.toString(),
'Content-Type': 'video/mp4',
},
})
}
```
## Progress Tracking
### Upload Progress
```tsx
// app/api/upload-progress/route.ts
export async function POST(request: Request) {
const contentLength = parseInt(request.headers.get('content-length') || '0')
const reader = request.body?.getReader()
if (!reader) {
return new Response('No body', { status: 400 })
}
let receivedLength = 0
const chunks: Uint8Array[] = []
while (true) {
const { done, value } = await reader.read()
if (done) break
chunks.push(value)
receivedLength += value.length
const progress = Math.round((receivedLength / contentLength) * 100)
console.log(`Progress: ${progress}%`)
}
// Combine chunks
const data = new Uint8Array(receivedLength)
let position = 0
for (const chunk of chunks) {
data.set(chunk, position)
position += chunk.length
}
return new Response(JSON.stringify({ received: receivedLength }))
}
```
## Async Iteration
### Database Cursor Streaming
```tsx
// app/api/export/route.ts
export async function GET() {
const encoder = new TextEncoder()
const stream = new ReadableStream({
async start(controller) {
// Header row
controller.enqueue(encoder.encode('id,name,email\n'))
// Stream results from database cursor
const cursor = db.user.findMany({
cursor: { id: 'start' },
take: 100,
})
for await (const batch of cursor) {
for (const user of batch) {
const row = `${user.id},${user.name},${user.email}\n`
controller.enqueue(encoder.encode(row))
}
}
controller.close()
},
})
return new Response(stream, {
headers: {
'Content-Type': 'text/csv',
'Content-Disposition': 'attachment; filename="users.csv"',
},
})
}
```
## Error Handling in Streams
```tsx
// app/api/stream-safe/route.ts
export async function GET() {
const encoder = new TextEncoder()
const stream = new ReadableStream({
async start(controller) {
try {
for await (const item of fetchItems()) {
controller.enqueue(encoder.encode(JSON.stringify(item) + '\n'))
}
controller.close()
} catch (error) {
// Send error as part of stream before closing
controller.enqueue(
encoder.encode(JSON.stringify({ error: 'Stream failed' }) + '\n')
)
controller.close()
}
},
cancel(reason) {
console.log('Stream cancelled:', reason)
// Cleanup resources
},
})
return new Response(stream, {
headers: {
'Content-Type': 'application/x-ndjson',
},
})
}
```
## TransformStream
### Transform Data On-the-Fly
```tsx
// app/api/transform/route.ts
export async function POST(request: Request) {
const { readable, writable } = new TransformStream({
transform(chunk, controller) {
// Transform each chunk (e.g., uppercase text)
const text = new TextDecoder().decode(chunk)
const transformed = text.toUpperCase()
controller.enqueue(new TextEncoder().encode(transformed))
},
})
// Pipe input through transform
request.body?.pipeTo(writable)
return new Response(readable, {
headers: {
'Content-Type': 'text/plain',
},
})
}
```
```
### examples/crud-api.md
```markdown
# Complete CRUD API Example
## File Structure
```
app/
├── api/
│ └── posts/
│ ├── route.ts # GET all, POST new
│ └── [id]/
│ └── route.ts # GET one, PUT, PATCH, DELETE
├── lib/
│ ├── db.ts # Database client
│ └── validations.ts # Zod schemas
└── types/
└── post.ts # Type definitions
```
## Types
```tsx
// types/post.ts
export interface Post {
id: string
title: string
content: string
published: boolean
authorId: string
createdAt: Date
updatedAt: Date
}
export interface CreatePostInput {
title: string
content: string
authorId: string
published?: boolean
}
export interface UpdatePostInput {
title?: string
content?: string
published?: boolean
}
```
## Validations
```tsx
// lib/validations.ts
import { z } from 'zod'
export const createPostSchema = z.object({
title: z.string().min(1, 'Title is required').max(200),
content: z.string().min(1, 'Content is required'),
authorId: z.string().uuid('Invalid author ID'),
published: z.boolean().optional().default(false),
})
export const updatePostSchema = z.object({
title: z.string().min(1).max(200).optional(),
content: z.string().min(1).optional(),
published: z.boolean().optional(),
})
export const paginationSchema = z.object({
page: z.coerce.number().int().positive().optional().default(1),
limit: z.coerce.number().int().positive().max(100).optional().default(10),
sort: z.enum(['newest', 'oldest', 'title']).optional().default('newest'),
})
export type CreatePostInput = z.infer<typeof createPostSchema>
export type UpdatePostInput = z.infer<typeof updatePostSchema>
```
## Database Client
```tsx
// lib/db.ts
import { PrismaClient } from '@prisma/client'
const globalForPrisma = globalThis as unknown as {
prisma: PrismaClient | undefined
}
export const prisma = globalForPrisma.prisma ?? new PrismaClient()
if (process.env.NODE_ENV !== 'production') {
globalForPrisma.prisma = prisma
}
```
## Collection Route (GET all, POST)
```tsx
// app/api/posts/route.ts
import { NextRequest, NextResponse } from 'next/server'
import { prisma } from '@/lib/db'
import {
createPostSchema,
paginationSchema,
} from '@/lib/validations'
// GET /api/posts
export async function GET(request: NextRequest) {
try {
const searchParams = request.nextUrl.searchParams
// Parse and validate query parameters
const params = paginationSchema.safeParse({
page: searchParams.get('page'),
limit: searchParams.get('limit'),
sort: searchParams.get('sort'),
})
if (!params.success) {
return NextResponse.json(
{ error: 'Invalid parameters', details: params.error.flatten() },
{ status: 400 }
)
}
const { page, limit, sort } = params.data
const skip = (page - 1) * limit
// Determine sort order
const orderBy = {
newest: { createdAt: 'desc' as const },
oldest: { createdAt: 'asc' as const },
title: { title: 'asc' as const },
}[sort]
// Fetch posts and total count in parallel
const [posts, total] = await Promise.all([
prisma.post.findMany({
skip,
take: limit,
orderBy,
include: {
author: {
select: { id: true, name: true },
},
},
}),
prisma.post.count(),
])
return NextResponse.json({
data: posts,
pagination: {
page,
limit,
total,
totalPages: Math.ceil(total / limit),
hasMore: skip + posts.length < total,
},
})
} catch (error) {
console.error('GET /api/posts error:', error)
return NextResponse.json(
{ error: 'Failed to fetch posts' },
{ status: 500 }
)
}
}
// POST /api/posts
export async function POST(request: NextRequest) {
try {
const body = await request.json()
// Validate request body
const validatedData = createPostSchema.safeParse(body)
if (!validatedData.success) {
return NextResponse.json(
{
error: 'Validation failed',
details: validatedData.error.flatten(),
},
{ status: 400 }
)
}
// Verify author exists
const author = await prisma.user.findUnique({
where: { id: validatedData.data.authorId },
})
if (!author) {
return NextResponse.json(
{ error: 'Author not found' },
{ status: 404 }
)
}
// Create post
const post = await prisma.post.create({
data: validatedData.data,
include: {
author: {
select: { id: true, name: true },
},
},
})
return NextResponse.json(post, { status: 201 })
} catch (error) {
console.error('POST /api/posts error:', error)
return NextResponse.json(
{ error: 'Failed to create post' },
{ status: 500 }
)
}
}
```
## Single Resource Route (GET one, PUT, PATCH, DELETE)
```tsx
// app/api/posts/[id]/route.ts
import { NextRequest, NextResponse } from 'next/server'
import { prisma } from '@/lib/db'
import { updatePostSchema } from '@/lib/validations'
type Params = { params: Promise<{ id: string }> }
// GET /api/posts/[id]
export async function GET(request: NextRequest, { params }: Params) {
try {
const { id } = await params
const post = await prisma.post.findUnique({
where: { id },
include: {
author: {
select: { id: true, name: true, email: true },
},
},
})
if (!post) {
return NextResponse.json(
{ error: 'Post not found' },
{ status: 404 }
)
}
return NextResponse.json(post)
} catch (error) {
console.error('GET /api/posts/[id] error:', error)
return NextResponse.json(
{ error: 'Failed to fetch post' },
{ status: 500 }
)
}
}
// PUT /api/posts/[id] - Full replacement
export async function PUT(request: NextRequest, { params }: Params) {
try {
const { id } = await params
const body = await request.json()
// For PUT, all fields are required
const fullUpdateSchema = updatePostSchema.required()
const validatedData = fullUpdateSchema.safeParse(body)
if (!validatedData.success) {
return NextResponse.json(
{
error: 'Validation failed',
details: validatedData.error.flatten(),
},
{ status: 400 }
)
}
// Check if post exists
const existing = await prisma.post.findUnique({ where: { id } })
if (!existing) {
return NextResponse.json(
{ error: 'Post not found' },
{ status: 404 }
)
}
const post = await prisma.post.update({
where: { id },
data: {
...validatedData.data,
updatedAt: new Date(),
},
include: {
author: {
select: { id: true, name: true },
},
},
})
return NextResponse.json(post)
} catch (error) {
console.error('PUT /api/posts/[id] error:', error)
return NextResponse.json(
{ error: 'Failed to update post' },
{ status: 500 }
)
}
}
// PATCH /api/posts/[id] - Partial update
export async function PATCH(request: NextRequest, { params }: Params) {
try {
const { id } = await params
const body = await request.json()
const validatedData = updatePostSchema.safeParse(body)
if (!validatedData.success) {
return NextResponse.json(
{
error: 'Validation failed',
details: validatedData.error.flatten(),
},
{ status: 400 }
)
}
// Check if there's anything to update
if (Object.keys(validatedData.data).length === 0) {
return NextResponse.json(
{ error: 'No fields to update' },
{ status: 400 }
)
}
// Check if post exists
const existing = await prisma.post.findUnique({ where: { id } })
if (!existing) {
return NextResponse.json(
{ error: 'Post not found' },
{ status: 404 }
)
}
const post = await prisma.post.update({
where: { id },
data: {
...validatedData.data,
updatedAt: new Date(),
},
include: {
author: {
select: { id: true, name: true },
},
},
})
return NextResponse.json(post)
} catch (error) {
console.error('PATCH /api/posts/[id] error:', error)
return NextResponse.json(
{ error: 'Failed to update post' },
{ status: 500 }
)
}
}
// DELETE /api/posts/[id]
export async function DELETE(request: NextRequest, { params }: Params) {
try {
const { id } = await params
// Check if post exists
const existing = await prisma.post.findUnique({ where: { id } })
if (!existing) {
return NextResponse.json(
{ error: 'Post not found' },
{ status: 404 }
)
}
await prisma.post.delete({ where: { id } })
// Return 204 No Content
return new NextResponse(null, { status: 204 })
} catch (error) {
console.error('DELETE /api/posts/[id] error:', error)
return NextResponse.json(
{ error: 'Failed to delete post' },
{ status: 500 }
)
}
}
```
## Error Handler Utility
```tsx
// lib/api-utils.ts
import { NextResponse } from 'next/server'
import { ZodError } from 'zod'
import { Prisma } from '@prisma/client'
export function handleApiError(error: unknown) {
console.error('API Error:', error)
if (error instanceof ZodError) {
return NextResponse.json(
{
error: 'Validation failed',
details: error.flatten(),
},
{ status: 400 }
)
}
if (error instanceof Prisma.PrismaClientKnownRequestError) {
if (error.code === 'P2025') {
return NextResponse.json(
{ error: 'Record not found' },
{ status: 404 }
)
}
if (error.code === 'P2002') {
return NextResponse.json(
{ error: 'Duplicate entry' },
{ status: 409 }
)
}
}
return NextResponse.json(
{ error: 'Internal server error' },
{ status: 500 }
)
}
```
## Usage Examples
### Fetch All Posts
```tsx
// Client-side fetch
const response = await fetch('/api/posts?page=1&limit=10&sort=newest')
const { data, pagination } = await response.json()
```
### Create a Post
```tsx
const response = await fetch('/api/posts', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
title: 'My New Post',
content: 'Post content here...',
authorId: 'user-uuid',
}),
})
const newPost = await response.json()
```
### Update a Post (Partial)
```tsx
const response = await fetch('/api/posts/post-id', {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
published: true,
}),
})
const updatedPost = await response.json()
```
### Delete a Post
```tsx
const response = await fetch('/api/posts/post-id', {
method: 'DELETE',
})
if (response.status === 204) {
console.log('Post deleted successfully')
}
```
## Adding Authentication
```tsx
// app/api/posts/route.ts
import { auth } from '@/auth'
export async function POST(request: NextRequest) {
// Check authentication
const session = await auth()
if (!session?.user) {
return NextResponse.json(
{ error: 'Unauthorized' },
{ status: 401 }
)
}
// Use authenticated user's ID
const body = await request.json()
body.authorId = session.user.id
// ... rest of POST logic
}
```
## Rate Limiting
```tsx
// lib/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'),
analytics: true,
})
// app/api/posts/route.ts
export async function POST(request: NextRequest) {
const ip = request.headers.get('x-forwarded-for') ?? '127.0.0.1'
const { success, limit, reset, remaining } = await ratelimit.limit(ip)
if (!success) {
return NextResponse.json(
{ error: 'Too many requests' },
{
status: 429,
headers: {
'X-RateLimit-Limit': limit.toString(),
'X-RateLimit-Remaining': remaining.toString(),
'X-RateLimit-Reset': reset.toString(),
},
}
)
}
// ... rest of handler
}
```
```