Routes
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 |
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.
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 })
}
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.
$ npm install zod
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-contentRoute Handler - Manually serialize form data to JSON
- Fetch from client component with
fetch() - Parse the response JSON
- Handle loading state with
useState - Separately call
revalidatePathsomehow
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
revalidatePathright inside the function - Use
useFormStatefor loading state - Works without JavaScript (progressive enhancement)
'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!
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.
'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!' }
}
'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>
)
}
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.
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
// }
'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:
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*'],
}
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.
(admin) route group, and read the
session in both Server Components and Server Actions to gate admin operations.