Routes

Chapter 7 — API Routes & Server Actions | Next.js Course
Next.js Rebuild Course Chapter 7 of 10

API Routes & Server Actions

Two ways to run server-side code in Next.js: Route Handlers (HTTP endpoints any client can call) and Server Actions (async functions called directly from components with zero API wiring). We'll build both, validate with Zod, and learn when to use each.

🏁

Chapter milestone: The search box on the site sends a Server Action. The admin can POST to /api/revalidate to clear the ISR cache. All form submissions are validated server-side with Zod before touching the database.

Route Handlers vs Server Actions — which should you use?

This is the most common source of confusion in Next.js 14. Both run on the server. The difference is who calls them and how they're invoked.

Question Route Handler app/api/…/route.ts Server Action 'use server'
Who calls it? Any HTTP client — browser fetch, mobile app, curl, another service Only your own Next.js components/forms — not externally accessible
How invoked? fetch('/api/revalidate', { method: 'POST' }) Called like a normal function: await saveContent(formData)
URL Yes — has a real URL you can test with curl No URL — compiled into a POST to a special internal endpoint
Form progressive enhancement? No — requires JavaScript to use Yes — <form action={serverAction}> works even without JS
Can revalidate cache directly? Yes (call revalidatePath inside the handler) Yes (call revalidatePath inside the action)
Use for osztromok.com External webhook, the /api/revalidate endpoint from Ch.6 Admin CRUD (Ch.9), search, contact form
Simple rule: if an external system needs to call it, use a Route Handler. If only your own site calls it, use a Server Action — less boilerplate, automatic CSRF protection, and direct cache revalidation without a round-trip.

Route Handlers — HTTP endpoints in the App Router

A Route Handler lives in a route.ts file inside app/api/. It exports named functions matching HTTP methods: GET, POST, PUT, PATCH, DELETE.

GET
Read data. Cacheable. No body.
POST
Create. Sends a JSON body.
PUT / PATCH
Replace / partial update.
DELETE
Remove a resource.
app/api/subjects/route.ts — GET list + POST create TypeScript · Route Handler
import { NextRequest, NextResponse } from 'next/server'
import { prisma } from '@/lib/prisma'
import { z }      from 'zod'

// ── GET /api/subjects ─────────────────────────────────────────
// Returns all subjects as JSON. Results cached for 60 seconds.
export async function GET() {
  const subjects = await prisma.subject.findMany({
    orderBy: { position: 'asc' },
    select:  { id: true, name: true, slug: true, description: true },
  })
  return NextResponse.json(subjects, {
    headers: { 'Cache-Control': 'public, s-maxage=60, stale-while-revalidate=300' },
  })
}

// ── Zod schema for creating a subject ─────────────────────────
const CreateSubjectSchema = z.object({
  name:        z.string().min(1, 'Name is required').max(100),
  slug:        z.string().regex(/^[a-z0-9-]+$/, 'Slug: lowercase letters, numbers, hyphens only'),
  description: z.string().max(500).optional(),
  position:    z.number().int().min(0).default(0),
})

// ── POST /api/subjects ────────────────────────────────────────
// Creates a new subject. Requires admin token.
export async function POST(req: NextRequest) {
  // Auth check
  const token = req.headers.get('x-admin-token')
  if (token !== process.env.ADMIN_TOKEN) {
    return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
  }

  // Parse body
  let body: unknown
  try {
    body = await req.json()
  } catch {
    return NextResponse.json({ error: 'Invalid JSON' }, { status: 400 })
  }

  // Validate with Zod
  const result = CreateSubjectSchema.safeParse(body)
  if (!result.success) {
    return NextResponse.json(
      { error: 'Validation failed', issues: result.error.flatten().fieldErrors },
      { status: 422 }
    )
  }

  // Save to DB
  const subject = await prisma.subject.create({ data: result.data })
  return NextResponse.json(subject, { status: 201 })
}
app/api/subjects/[id]/route.ts — GET one + PATCH + DELETE TypeScript · Route Handler
import { NextRequest, NextResponse } from 'next/server'
import { prisma }  from '@/lib/prisma'
import { z }       from 'zod'
import { revalidateSubject } from '@/lib/revalidate'

interface Context { params: { id: string } }

export async function GET(_req: NextRequest, { params }: Context) {
  const subject = await prisma.subject.findUnique({ where: { id: Number(params.id) } })
  if (!subject) return NextResponse.json({ error: 'Not found' }, { status: 404 })
  return NextResponse.json(subject)
}

const PatchSchema = z.object({
  name:        z.string().min(1).optional(),
  description: z.string().optional(),
  position:    z.number().int().optional(),
})

export async function PATCH(req: NextRequest, { params }: Context) {
  const parsed = PatchSchema.safeParse(await req.json())
  if (!parsed.success) {
    return NextResponse.json({ error: parsed.error.flatten() }, { status: 422 })
  }
  const subject = await prisma.subject.update({
    where: { id: Number(params.id) },
    data:  parsed.data,
  })
  revalidateSubject(subject.slug)   // clear ISR cache for this subject
  return NextResponse.json(subject)
}

export async function DELETE(_req: NextRequest, { params }: Context) {
  const subject = await prisma.subject.delete({ where: { id: Number(params.id) } })
  revalidateSubject(subject.slug)
  return new Response(null, { status: 204 })   // 204 No Content
}

Zod — type-safe validation for all server input

Never trust data that arrives from outside your app — not from forms, not from API clients, not from URL params. Zod lets you declare the shape you expect and get back either clean typed data or a structured list of errors.

Install Zod Terminal
$ npm install zod
lib/schemas.ts — all Zod schemas in one place TypeScript
import { z } from 'zod'

// ── Slug validator — reused in multiple schemas ────────────────
const slugField = z
  .string()
  .min(1)
  .max(100)
  .regex(/^[a-z0-9-]+$/, 'Lowercase letters, numbers, and hyphens only')

// ── Page content section ───────────────────────────────────────
export const PageContentSchema = z.object({
  heading:  z.string().min(1).max(200),
  body:     z.string().min(1),          // TinyMCE HTML — no max, can be long
  position: z.number().int().min(0),
})

// ── Full page ──────────────────────────────────────────────────
export const PageSchema = z.object({
  title:       z.string().min(1).max(200),
  slug:        slugField,
  description: z.string().max(500).optional(),
  position:    z.number().int().min(0).default(0),
  content:     z.array(PageContentSchema).min(1, 'At least one content section required'),
})

// ── Search query (URL param) ───────────────────────────────────
export const SearchSchema = z.object({
  q: z.string().min(2, 'Search term must be at least 2 characters').max(100),
})

// ── Derive TypeScript type from the Zod schema ─────────────────
// This gives you a type WITHOUT having to declare it separately.
export type PageInput        = z.infer<typeof PageSchema>
export type PageContentInput = z.infer<typeof PageContentSchema>
export type SearchInput      = z.infer<typeof SearchSchema>
z.infer<typeof Schema> derives a TypeScript type from your Zod schema automatically. This means you define the shape once and both the runtime validator and the type system stay in sync — no possibility of them drifting apart.

Server Actions — the modern alternative

A Server Action is an async function marked with 'use server'. You can call it directly from a Server Component, pass it as a form's action prop, or call it from a Client Component via startTransition. No fetch. No API endpoint. No serialisation boilerplate.

The old way — Route Handler

  • Write a POST /api/save-content Route Handler
  • Manually serialize form data to JSON
  • Fetch from client component with fetch()
  • Parse the response JSON
  • Handle loading state with useState
  • Separately call revalidatePath somehow

The new way — Server Action

  • Write a function marked 'use server'
  • Pass it as <form action={saveContent}>
  • Form data arrives as FormData — already typed
  • Call revalidatePath right inside the function
  • Use useFormState for loading state
  • Works without JavaScript (progressive enhancement)
actions/search.ts — a simple Server Action TypeScript · Server Action
'use server'   // marks every export in this file as a Server Action

import { redirect }  from 'next/navigation'
import { prisma }    from '@/lib/prisma'
import { SearchSchema } from '@/lib/schemas'

// ── Search action ──────────────────────────────────────────────
// Called by the search form. Validates the query, then redirects
// to /search?q=... where the results page picks it up.
export async function searchAction(formData: FormData) {
  const raw = { q: formData.get('q') }
  const result = SearchSchema.safeParse(raw)

  if (!result.success) {
    // Redirect with an error param the results page can display
    redirect(`/search?error=too-short`)
  }

  redirect(`/search?q=${encodeURIComponent(result.data.q)}`)
}

// Usage in a Server Component:
// <form action={searchAction}>
//   <input name="q" type="text" />
//   <button type="submit">Search</button>
// </form>
// No JavaScript required for this form to submit!
app/search/page.tsx — the results page reads the URL param TypeScript · Server Component
import { prisma } from '@/lib/prisma'
import Link       from 'next/link'
import { searchAction } from '@/actions/search'

interface Props { searchParams: { q?: string; error?: string } }

export default async function SearchPage({ searchParams }: Props) {
  const q = searchParams.q?.trim() ?? ''

  const results = q.length >= 2
    ? await prisma.page.findMany({
        where: {
          OR: [
            { title:       { contains: q } },
            { description: { contains: q } },
          ],
        },
        include: { subtopic: { include: { subject: true } } },
        take: 20,
      })
    : []

  return (
    <>
      <h1 className="text-2xl font-bold mb-6">Search</h1>

      {/* Search form — uses the Server Action */}
      <form action={searchAction} className="flex gap-2 mb-8">
        <input
          name="q"
          defaultValue={q}
          placeholder="Search lessons…"
          className="flex-1 bg-site-deep border border-site-border rounded px-3 py-2
                     text-sm text-site-text focus:outline-none focus:border-site-accent"
        />
        <button type="submit" className="badge-accent px-4 py-2 rounded text-sm">
          Search
        </button>
      </form>

      {searchParams.error === 'too-short' && (
        <p className="text-sm text-orange-400 mb-4">
          Please enter at least 2 characters.
        </p>
      )}

      {q && (
        <p className="text-sm text-site-muted mb-4">
          {results.length} result{results.length !== 1 ? 's' : ''} for <strong className="text-site-text">"{q}"</strong>
        </p>
      )}

      <ul className="space-y-2">
        {results.map((page) => (
          <li key={page.id}>
            <Link
              href={`/${page.subtopic.subject.slug}/${page.subtopic.slug}/${page.slug}`}
              className="card p-4 block hover:border-site-accent transition-colors group"
            >
              <p className="font-medium text-site-text group-hover:text-site-accent transition-colors">
                {page.title}
              </p>
              <p className="text-xs text-site-dim mt-1">
                {page.subtopic.subject.name} → {page.subtopic.name}
              </p>
            </Link>
          </li>
        ))}
      </ul>
    </>
  )
}

useFormState — inline validation errors without a redirect

The search action above redirects on error because it's simple. For a richer form where you want field-level error messages to appear inline (like an admin edit form), use useFormState from react-dom. This requires the form component to be a Client Component.

actions/content.ts — Server Action that returns state TypeScript · Server Action
'use server'

import { revalidatePath }    from 'next/cache'
import { prisma }            from '@/lib/prisma'
import { PageContentSchema } from '@/lib/schemas'

// ── Return type for the action ─────────────────────────────────
export type ActionState = {
  status: 'idle' | 'success' | 'error'
  message?: string
  errors?: Record<string, string[]>
}

export async function saveContentSection(
  _prevState: ActionState,
  formData:   FormData
): Promise<ActionState> {
  const raw = {
    heading:  formData.get('heading'),
    body:     formData.get('body'),
    position: Number(formData.get('position')),
  }
  const pageId = Number(formData.get('pageId'))

  const result = PageContentSchema.safeParse(raw)
  if (!result.success) {
    return {
      status: 'error',
      message: 'Please fix the errors below.',
      errors:  result.error.flatten().fieldErrors,
    }
  }

  await prisma.pageContent.create({
    data: { ...result.data, pageId },
  })

  // Revalidate the content page so the live site reflects the change
  const path = formData.get('pagePath') as string
  if (path) revalidatePath(path)

  return { status: 'success', message: 'Section saved!' }
}
components/admin/AddSectionForm.tsx — Client Component with useFormState TypeScript · Client Component
'use client'

import { useFormState, useFormStatus } from 'react-dom'
import { saveContentSection, ActionState } from '@/actions/content'

const initialState: ActionState = { status: 'idle' }

// ── Submit button — knows if the form is pending ───────────────
// Must be a separate component so it can use useFormStatus.
function SubmitButton() {
  const { pending } = useFormStatus()
  return (
    <button
      type="submit"
      disabled={pending}
      className="badge-accent px-4 py-2 rounded text-sm disabled:opacity-50"
    >
      {pending ? 'Saving…' : 'Save section'}
    </button>
  )
}

interface Props { pageId: number; pagePath: string }

export default function AddSectionForm({ pageId, pagePath }: Props) {
  const [state, formAction] = useFormState(saveContentSection, initialState)

  return (
    <form action={formAction} className="card p-6 space-y-4">
      {/* Hidden fields pass non-user data to the action */}
      <input type="hidden" name="pageId"   value={pageId}   />
      <input type="hidden" name="pagePath" value={pagePath} />

      <div>
        <label className="block text-sm text-site-muted mb-1">Heading</label>
        <input
          name="heading"
          className="w-full bg-site-deep border border-site-border rounded px-3 py-2
                     text-sm text-site-text focus:outline-none focus:border-site-accent"
        />
        {state.errors?.heading && (
          <p className="text-xs text-red-400 mt-1">{state.errors.heading[0]}</p>
        )}
      </div>

      <div>
        <label className="block text-sm text-site-muted mb-1">Body (HTML)</label>
        <textarea
          name="body"
          rows={8}
          className="w-full bg-site-deep border border-site-border rounded px-3 py-2
                     text-sm text-site-text font-mono focus:outline-none focus:border-site-accent"
        />
        {state.errors?.body && (
          <p className="text-xs text-red-400 mt-1">{state.errors.body[0]}</p>
        )}
      </div>

      <input type="hidden" name="position" value="0" />

      <div className="flex items-center gap-4">
        <SubmitButton />
        {state.status === 'success' && (
          <p className="text-sm text-green-400">✓ {state.message}</p>
        )}
        {state.status === 'error' && !state.errors && (
          <p className="text-sm text-red-400">{state.message}</p>
        )}
      </div>
    </form>
  )
}
Why is SubmitButton a separate component? useFormStatus reads the status of the nearest parent <form>, but it only works inside a component that is a child of the form — not in the same component that renders the form. The separate component pattern is the official workaround.

Error handling in Route Handlers and Server Actions

Unhandled errors in a Route Handler crash the request with a 500. Always wrap database calls in try/catch and return structured JSON errors. Server Actions that throw will trigger the nearest error.tsx boundary.

lib/api-helpers.ts — shared error wrapper for Route Handlers TypeScript
import { NextResponse } from 'next/server'

// Wrap any Route Handler to catch unexpected DB errors
export function withErrorHandler<T>(
  handler: () => Promise<T>
): Promise<T | NextResponse> {
  return handler().catch((err: unknown) => {
    console.error('[API Error]', err)
    return NextResponse.json(
      { error: 'Internal server error' },
      { status: 500 }
    )
  })
}

// Usage:
// export function GET() {
//   return withErrorHandler(async () => {
//     const data = await prisma.subject.findMany()
//     return NextResponse.json(data)
//   })
// }

// ── Prisma unique constraint error ─────────────────────────────
// If you try to create a subject with a slug that already exists,
// Prisma throws a P2002 error. Catch it specifically:
import { Prisma } from '@prisma/client'

export function isPrismaUniqueError(err: unknown): boolean {
  return err instanceof Prisma.PrismaClientKnownRequestError
    && err.code === 'P2002'
}

// In a route handler:
// } catch (err) {
//   if (isPrismaUniqueError(err)) {
//     return NextResponse.json({ error: 'Slug already exists' }, { status: 409 })
//   }
//   throw err   // re-throw unknowns
// }
app/error.tsx — catches errors thrown in Server Actions TypeScript · Client Component
'use client'   // error boundaries must be Client Components

export default function GlobalError({
  error,
  reset,
}: {
  error: Error & { digest?: string }
  reset: () => void
}) {
  return (
    <div className="flex flex-col items-center justify-center min-h-[50vh] gap-6 text-center">
      <h2 className="text-2xl font-bold text-site-text">Something went wrong</h2>
      <p className="text-site-muted text-sm max-w-sm">
        {error.message ?? 'An unexpected error occurred.'}
      </p>
      {error.digest && (
        <p className="text-site-dim text-xs font-mono">Error ID: {error.digest}</p>
      )}
      <button
        onClick={reset}
        className="badge-accent px-5 py-2 rounded text-sm"
      >
        Try again
      </button>
    </div>
  )
}
error.digest is a short hash Next.js assigns to each server-side error. In development it's the same as the message; in production it's an opaque ID. Log it on the server (it appears in your Next.js logs automatically) so you can correlate what users see with what crashed on the server.

Protecting API routes with middleware

Checking the admin token inside every Route Handler works, but it's error-prone — forget the check once and the route is open. Middleware runs before any route and can block requests centrally. We'll use it fully in the auth chapter, but here's the pattern for protecting the /api/admin/* namespace:

middleware.ts — lives at the project root (same level as app/) TypeScript · Edge Runtime
import { NextRequest, NextResponse } from 'next/server'

export function middleware(req: NextRequest) {
  const { pathname } = req.nextUrl

  // Protect all /api/admin/* routes
  if (pathname.startsWith('/api/admin')) {
    const token = req.headers.get('x-admin-token')
    if (token !== process.env.ADMIN_TOKEN) {
      return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
    }
  }

  return NextResponse.next()
}

// Only run middleware on these paths — skip static files / images
export const config = {
  matcher: ['/api/:path*', '/(admin)/:path*'],
}
Middleware runs on the Edge Runtime — the same V8 isolate technology as Cloudflare Workers. It has no access to Node.js APIs (no filesystem, no Prisma). Keep it lean: just read headers/cookies, check tokens, redirect, rewrite. Heavy logic goes in Route Handlers or Server Actions.

What we've built and what to test

Search form works without JavaScript

Disable JS in DevTools. Type a query in the search box and submit. You should land on /search?q=… with results — the Server Action handled the form entirely server-side.

GET /api/subjects returns JSON

Open http://localhost:3000/api/subjects in the browser. You should see a JSON array of subjects. Check the response headers — Cache-Control: public, s-maxage=60 should be present.

POST with missing fields returns 422 with field errors

Use curl or Insomnia: curl -X POST /api/subjects -H "x-admin-token: …" -d '{"name":""}'. Response should be 422 with issues.name explaining the validation failure.

useFormState shows inline errors

Render <AddSectionForm> in a test page. Submit with an empty heading. The error message should appear below the input without a page reload.

Error boundary catches a thrown Server Action

Temporarily throw throw new Error('test') in a Server Action. The error.tsx boundary should catch it and show the "Try again" button instead of a white crash screen.

Up next — Chapter 8: Authentication with NextAuth.js. We'll add a login page backed by a credentials provider (no OAuth required for a personal site), protect the (admin) route group, and read the session in both Server Components and Server Actions to gate admin operations.