Module 4: Backend Patterns
Choose the right API pattern for your needs.
API Pattern Comparison
| Pattern | Best For | Type Safety | Complexity |
|---|---|---|---|
| REST | Public APIs, simple CRUD | Manual | Low |
| tRPC | Full-stack TypeScript apps | Automatic | Medium |
| GraphQL | Complex data requirements | Schema-based | High |
| Server Actions | Form submissions, mutations | Automatic | Low |
REST with Next.js
Route Handlers
typescript
// app/api/users/route.ts
import { db } from '@/lib/db'
import { NextRequest, NextResponse } from 'next/server'
// GET /api/users
export async function GET(request: NextRequest) {
const { searchParams } = new URL(request.url)
const page = parseInt(searchParams.get('page') || '1')
const limit = 10
const users = await db.user.findMany({
skip: (page - 1) * limit,
take: limit,
})
return NextResponse.json(users)
}
// POST /api/users
export async function POST(request: NextRequest) {
const body = await request.json()
const user = await db.user.create({
data: body,
})
return NextResponse.json(user, { status: 201 })
}Dynamic Routes
typescript
// app/api/users/[id]/route.ts
import { db } from '@/lib/db'
import { NextRequest, NextResponse } from 'next/server'
// GET /api/users/123
export async function GET(
request: NextRequest,
{ params }: { params: { id: string } }
) {
const user = await db.user.findUnique({
where: { id: params.id },
})
if (!user) {
return NextResponse.json({ error: 'Not found' }, { status: 404 })
}
return NextResponse.json(user)
}
// PATCH /api/users/123
export async function PATCH(
request: NextRequest,
{ params }: { params: { id: string } }
) {
const body = await request.json()
const user = await db.user.update({
where: { id: params.id },
data: body,
})
return NextResponse.json(user)
}
// DELETE /api/users/123
export async function DELETE(
request: NextRequest,
{ params }: { params: { id: string } }
) {
await db.user.delete({
where: { id: params.id },
})
return new NextResponse(null, { status: 204 })
}tRPC (Recommended for TypeScript)
End-to-end type safety without code generation.
Setup
bash
npm install @trpc/server @trpc/client @trpc/react-query @trpc/next @tanstack/react-query zodServer Setup
typescript
// server/trpc.ts
import { initTRPC, TRPCError } from '@trpc/server'
import { auth } from '@clerk/nextjs/server'
const t = initTRPC.create()
export const router = t.router
export const publicProcedure = t.procedure
export const protectedProcedure = t.procedure.use(async ({ next }) => {
const { userId } = auth()
if (!userId) {
throw new TRPCError({ code: 'UNAUTHORIZED' })
}
return next({ ctx: { userId } })
})Define Router
typescript
// server/routers/user.ts
import { z } from 'zod'
import { router, protectedProcedure, publicProcedure } from '../trpc'
import { db } from '@/lib/db'
export const userRouter = router({
// Public: Get user by ID
getById: publicProcedure
.input(z.string())
.query(async ({ input }) => {
return db.user.findUnique({ where: { id: input } })
}),
// Protected: Update current user
update: protectedProcedure
.input(z.object({
name: z.string().optional(),
email: z.string().email().optional(),
}))
.mutation(async ({ ctx, input }) => {
return db.user.update({
where: { id: ctx.userId },
data: input,
})
}),
// Protected: List all users
list: protectedProcedure
.input(z.object({
page: z.number().default(1),
limit: z.number().default(10),
}))
.query(async ({ input }) => {
const { page, limit } = input
return db.user.findMany({
skip: (page - 1) * limit,
take: limit,
})
}),
})Root Router
typescript
// server/routers/index.ts
import { router } from '../trpc'
import { userRouter } from './user'
import { postRouter } from './post'
export const appRouter = router({
user: userRouter,
post: postRouter,
})
export type AppRouter = typeof appRouterAPI Route
typescript
// app/api/trpc/[trpc]/route.ts
import { fetchRequestHandler } from '@trpc/server/adapters/fetch'
import { appRouter } from '@/server/routers'
const handler = (req: Request) =>
fetchRequestHandler({
endpoint: '/api/trpc',
req,
router: appRouter,
createContext: () => ({}),
})
export { handler as GET, handler as POST }Client Usage
tsx
'use client'
import { trpc } from '@/lib/trpc'
export function UserProfile() {
// Queries
const { data: user, isLoading } = trpc.user.getById.useQuery('user_123')
// Mutations
const updateUser = trpc.user.update.useMutation({
onSuccess: () => {
// Invalidate and refetch
utils.user.getById.invalidate('user_123')
},
})
if (isLoading) return <div>Loading...</div>
return (
<div>
<h1>{user?.name}</h1>
<button onClick={() => updateUser.mutate({ name: 'New Name' })}>
Update Name
</button>
</div>
)
}Server Actions
Built into Next.js - simplest for forms.
typescript
// app/actions.ts
'use server'
import { db } from '@/lib/db'
import { auth } from '@clerk/nextjs/server'
import { revalidatePath } from 'next/cache'
import { redirect } from 'next/navigation'
import { z } from 'zod'
const createPostSchema = z.object({
title: z.string().min(1).max(100),
content: z.string().min(1),
})
export async function createPost(formData: FormData) {
const { userId } = auth()
if (!userId) throw new Error('Unauthorized')
const rawData = {
title: formData.get('title'),
content: formData.get('content'),
}
const validatedData = createPostSchema.parse(rawData)
await db.post.create({
data: {
...validatedData,
authorId: userId,
},
})
revalidatePath('/posts')
redirect('/posts')
}tsx
// app/posts/new/page.tsx
import { createPost } from '@/app/actions'
export default function NewPostPage() {
return (
<form action={createPost}>
<input name="title" placeholder="Title" required />
<textarea name="content" placeholder="Content" required />
<button type="submit">Create Post</button>
</form>
)
}With useFormState
tsx
'use client'
import { useFormState, useFormStatus } from 'react-dom'
import { createPost } from '@/app/actions'
function SubmitButton() {
const { pending } = useFormStatus()
return (
<button type="submit" disabled={pending}>
{pending ? 'Creating...' : 'Create Post'}
</button>
)
}
export function CreatePostForm() {
const [state, action] = useFormState(createPost, null)
return (
<form action={action}>
{state?.error && <p className="text-red-500">{state.error}</p>}
<input name="title" />
<textarea name="content" />
<SubmitButton />
</form>
)
}Error Handling
typescript
// lib/errors.ts
export class AppError extends Error {
constructor(
message: string,
public statusCode: number = 500,
public code?: string
) {
super(message)
}
}
// Usage in API route
export async function POST(request: Request) {
try {
// ... your logic
} catch (error) {
if (error instanceof AppError) {
return Response.json(
{ error: error.message, code: error.code },
{ status: error.statusCode }
)
}
console.error(error)
return Response.json(
{ error: 'Internal server error' },
{ status: 500 }
)
}
}Summary
- REST - Simple, universal, good for public APIs
- tRPC - Best DX for TypeScript full-stack apps
- Server Actions - Simplest for forms, built into Next.js
- GraphQL - When you need flexible queries (usually overkill)