Skip to content

Module 4: Backend Patterns

Choose the right API pattern for your needs.

API Pattern Comparison

PatternBest ForType SafetyComplexity
RESTPublic APIs, simple CRUDManualLow
tRPCFull-stack TypeScript appsAutomaticMedium
GraphQLComplex data requirementsSchema-basedHigh
Server ActionsForm submissions, mutationsAutomaticLow

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 })
}

End-to-end type safety without code generation.

Setup

bash
npm install @trpc/server @trpc/client @trpc/react-query @trpc/next @tanstack/react-query zod

Server 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 appRouter

API 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

  1. REST - Simple, universal, good for public APIs
  2. tRPC - Best DX for TypeScript full-stack apps
  3. Server Actions - Simplest for forms, built into Next.js
  4. GraphQL - When you need flexible queries (usually overkill)

Next: Module 5: Database & PostgreSQL →