The authedRoute twin
Port the authedAction discipline to Next.js route handlers, keeping the same role and schema checks but answering in HTTP status codes and RFC 9457 Problem Details.
Last lesson, authedAction closed the missing-role-check bug by making the role an argument the compiler counts, not a line you could forget. But a Server Action is only the React door: it opens for callers that submit a form or fire useActionState, and plenty of callers do neither.
A Stripe webhook POSTs JSON when a payment clears, a partner’s nightly job pushes records to your API, a mobile client hits the same backend without rendering React. None submit a form; they speak raw HTTP to a route.ts. They carry the same untrusted input a form does, so they need the same three checks before it reaches your database. This lesson ports the wrapper to that door, authedRoute. The discipline is identical, with one new idea: a form reads a Result, but an HTTP client reads a status code on the wire. Both doors call one shared business function, so the mutation is written once.
Two doors, one discipline
Section titled “Two doors, one discipline”Your app has two server seams that accept an outside request and run a mutation. The Server Action is the React caller’s door: a same-origin form submission, a useActionState call, anything inside your own React app. The route handler is everybody else’s door: a webhook , a partner’s server, a mobile client, anything speaking HTTP without a form.
A React form arrives through authedAction, a partner’s JSON POST through authedRoute; both run the same checks at the threshold and call the same /lib function.
createInvoice(input, ctx) business logic in /lib The caller’s shape decides which door, not taste. Open the route handler only when the caller forces it, and exactly five things do: the caller is a non-browser client, the response needs to be cacheable HTTP, the response is a stream, a third party requires a specific URL or status code, or the framework names the file (an OG image, a sitemap). Server Actions are the default; route handlers are the exception.
This refuses a common junior instinct: exposing /api/whatever for an internal React mutation “so we have an API.” A dashboard button that removes a member should be a Server Action; wrapping it in a route handler buys you only a public URL you now have to defend. Reach for the route handler when something outside your React app needs in.
Both doors run the same discipline: resolve the session, authorize the role, parse the input, then do the work. Same four gates, same order, same ctx. Only what comes back on the wire changes.
authedRoute(role, schema, fn): the same signature, a Response body
Section titled “authedRoute(role, schema, fn): the same signature, a Response body”The signature twins authedAction. role and schema are unchanged; only the third argument differs. Instead of fn: (input, ctx) => Promise<Result<T>>, you write fn: (input, ctx) => Promise<Response>: the business function returns a Response, not a Result.
The ctx is identical to last lesson, { user, orgId, role, db } with db already bound to tenantDb(orgId), and the wrapper authorizes the role through the same requireOrgUser. Only the session source changes: an action reads it from await headers(), but a route handler already holds the request, so it reads request.headers.
The call site lives in a route.ts, assigned to a verb export. The two wrappers side by side:
// app/(app)/invoices/actions.ts'use server';
export const createInvoice = authedAction( 'admin', createInvoiceSchema, async (input, ctx) => { const [row] = await ctx.db.insert(invoices).values(input).returning(); revalidatePath('/invoices'); return ok(row); },);Returns a Result the form reads inline. The caller is your own React: useActionState reads state.data on success or state.error.userMessage on failure, and the page never navigates.
export const POST = authedRoute( 'admin', createInvoiceSchema, async (input, ctx) => { const [row] = await ctx.db.insert(invoices).values(input).returning(); return Response.json(row, { status: 201 }); },);Returns a Response the HTTP client reads as a status code plus a body. 201 Created speaks the vocabulary every HTTP client already knows, with no envelope to teach a partner.
Same 'admin', same createInvoiceSchema, same ctx.db insert. Exactly two things differ: the return type (Result versus Response), and revalidatePath, which belongs to the React door alone because an HTTP client has no Next.js cache to revalidate.
The export name carries the HTTP method. A route.ts names one export per method (export const GET, export const POST, export const DELETE), where page.tsx and layout.tsx default-export; one file can hold a GET to read and a POST to create side by side, each its own authedRoute call.
Naming carries over from last lesson: the wrapper is named for what it is (authedRoute), the business function for what it does (createInvoice), no Route suffix.
Parsing three input sources, cheapest first
Section titled “Parsing three input sources, cheapest first”The input side holds the one real difference. An action sees a flat FormData and parses it in one step. A route handler’s input arrives from up to three places at once, which the wrapper gathers itself:
- Path params: the typed URL segments, like the
idin/api/invoices/[id]. In Next 16 the handler’s second argument carries them, typed byRouteContext<'/api/invoices/[id]'>, withparamsa Promise youawait. - Query string: everything after the
?, like a?status=paidfilter, read withnew URL(request.url).searchParams. - Request body: the JSON payload, via
await request.json().
The schema is one object with a sub-schema per source, { params, query, body }. The wrapper parses each piece from its own place, then hands the assembled, typed input to fn.
The four gates are the ones you know: resolve the session, authorize the role, parse, then call. Order them cheapest disqualifier first. Reading the body off the request stream is the expensive step, so the cheap rejections come before it: the role check turns away a forbidden caller, and the JSON parse rejects a malformed body, both before your schema ever runs.
import 'server-only';import { z } from 'zod';import { requireOrgUser } from '@/lib/auth';import { roleAtLeast, type Role } from '@/lib/auth/roles';import { problem } from '@/lib/http/problem';import { tenantDb } from '@/lib/tenant-db';
export const authedRoute = <Schema extends z.ZodType>( role: Role, schema: Schema, fn: (input: z.infer<Schema>, ctx: Ctx) => Promise<Response>, ) => async (request: Request, route: { params: Promise<unknown> }): Promise<Response> => { const { user, orgId, role: actorRole } = await requireOrgUser({ headers: request.headers, });
if (!roleAtLeast(actorRole, role)) { return problem(403, 'You do not have permission to do this.'); }
const url = new URL(request.url); let body: unknown; try { body = await request.json(); } catch { return problem(400, 'Request body is not valid JSON.'); }
const parsed = schema.safeParse({ params: await route.params, query: Object.fromEntries(url.searchParams), body, }); if (!parsed.success) { return problem(422, 'Validation failed.', { fieldErrors: z.flattenError(parsed.error).fieldErrors, }); }
const db = tenantDb(orgId); return fn(parsed.data, { user, orgId, role: actorRole, db }); };Resolve. requireOrgUser now reads request.headers instead of await headers(), since the handler already holds the request. No session means the resolve raises and the wrapper lets it through.
import 'server-only';import { z } from 'zod';import { requireOrgUser } from '@/lib/auth';import { roleAtLeast, type Role } from '@/lib/auth/roles';import { problem } from '@/lib/http/problem';import { tenantDb } from '@/lib/tenant-db';
export const authedRoute = <Schema extends z.ZodType>( role: Role, schema: Schema, fn: (input: z.infer<Schema>, ctx: Ctx) => Promise<Response>, ) => async (request: Request, route: { params: Promise<unknown> }): Promise<Response> => { const { user, orgId, role: actorRole } = await requireOrgUser({ headers: request.headers, });
if (!roleAtLeast(actorRole, role)) { return problem(403, 'You do not have permission to do this.'); }
const url = new URL(request.url); let body: unknown; try { body = await request.json(); } catch { return problem(400, 'Request body is not valid JSON.'); }
const parsed = schema.safeParse({ params: await route.params, query: Object.fromEntries(url.searchParams), body, }); if (!parsed.success) { return problem(422, 'Validation failed.', { fieldErrors: z.flattenError(parsed.error).fieldErrors, }); }
const db = tenantDb(orgId); return fn(parsed.data, { user, orgId, role: actorRole, db }); };Authorize. The same roleAtLeast check, only the failure differs: below the floor, return problem(403, …), a 403 Forbidden Response, rather than an err('forbidden') value.
import 'server-only';import { z } from 'zod';import { requireOrgUser } from '@/lib/auth';import { roleAtLeast, type Role } from '@/lib/auth/roles';import { problem } from '@/lib/http/problem';import { tenantDb } from '@/lib/tenant-db';
export const authedRoute = <Schema extends z.ZodType>( role: Role, schema: Schema, fn: (input: z.infer<Schema>, ctx: Ctx) => Promise<Response>, ) => async (request: Request, route: { params: Promise<unknown> }): Promise<Response> => { const { user, orgId, role: actorRole } = await requireOrgUser({ headers: request.headers, });
if (!roleAtLeast(actorRole, role)) { return problem(403, 'You do not have permission to do this.'); }
const url = new URL(request.url); let body: unknown; try { body = await request.json(); } catch { return problem(400, 'Request body is not valid JSON.'); }
const parsed = schema.safeParse({ params: await route.params, query: Object.fromEntries(url.searchParams), body, }); if (!parsed.success) { return problem(422, 'Validation failed.', { fieldErrors: z.flattenError(parsed.error).fieldErrors, }); }
const db = tenantDb(orgId); return fn(parsed.data, { user, orgId, role: actorRole, db }); };Parse, and the 400/422 split. Read the body first, in a try: unparseable JSON never reaches the schema, so it’s a 400. Then safeParse the assembled { params, query, body }; input that’s well-formed but wrong is a 422 carrying Zod’s fieldErrors.
import 'server-only';import { z } from 'zod';import { requireOrgUser } from '@/lib/auth';import { roleAtLeast, type Role } from '@/lib/auth/roles';import { problem } from '@/lib/http/problem';import { tenantDb } from '@/lib/tenant-db';
export const authedRoute = <Schema extends z.ZodType>( role: Role, schema: Schema, fn: (input: z.infer<Schema>, ctx: Ctx) => Promise<Response>, ) => async (request: Request, route: { params: Promise<unknown> }): Promise<Response> => { const { user, orgId, role: actorRole } = await requireOrgUser({ headers: request.headers, });
if (!roleAtLeast(actorRole, role)) { return problem(403, 'You do not have permission to do this.'); }
const url = new URL(request.url); let body: unknown; try { body = await request.json(); } catch { return problem(400, 'Request body is not valid JSON.'); }
const parsed = schema.safeParse({ params: await route.params, query: Object.fromEntries(url.searchParams), body, }); if (!parsed.success) { return problem(422, 'Validation failed.', { fieldErrors: z.flattenError(parsed.error).fieldErrors, }); }
const db = tenantDb(orgId); return fn(parsed.data, { user, orgId, role: actorRole, db }); };Call. Build the business ctx, { user, orgId, role, db } with db = tenantDb(orgId), the same payload the action wrapper hands down, and pass it with the parsed input to fn. Whatever Response it returns passes straight back to the client.
A read-only GET has no body, so its wrapper skips that read and parses only params and query; the gates and their order are otherwise identical.
The status-code map: 400, 401, 403, 404, 422
Section titled “The status-code map: 400, 401, 403, 404, 422”A failing action returns an err(code, …) like 'forbidden' or 'validation', and the React form reads that code. A route has no form: its caller is a program that keys off the HTTP status. Mapping each failure category to the right status is the core skill of this lesson.
Five statuses cover everything authedRoute and the functions behind it emit:
| Failure | HTTP status | Action-seam analog |
|---|---|---|
| No valid session | 401 Unauthorized | action redirects to /sign-in |
| Valid session, role too low | 403 Forbidden | err('forbidden') |
| Input malformed / unparseable | 400 Bad Request | (n/a; FormData rarely malformed) |
| Input well-formed but fails the schema | 422 Unprocessable Entity | err('validation') |
| Entity doesn’t exist in this org | 404 Not Found | the action’s own not-found |
Every status has a counterpart you already know from the action seam, so porting the wrapper is mostly porting this third column. Three rows reward a closer look.
400 versus 422 is the pair people blur. 400 is malformed input the server can’t parse, so it never reaches your schema: the body isn’t valid JSON, or a path segment that must be a UUID is the string "banana". 422 is input that parses fine but fails the schema: valid JSON with a required field missing, or a string where a number belongs. The action seam barely sees 400, since a FormData payload is hard to malform; but on the wire, where a partner hand-builds a JSON body, malformed input is a real and distinct case.
401 versus the action’s redirect is the same discipline with a different exit. Both wrappers call the same requireOrgUser. In an action, no session makes that helper throw a Next.js redirect, and the wrapper lets it fly: the browser navigates to /sign-in, which is right, because a human should go sign in. A route handler has no browser and its caller is a program, so the wrapper turns “no session” into a 401 Response for the program to act on.
404 is where cross-tenant leaks hide, so prefer it over 403. When a valid, sufficiently privileged session asks for an invoice that belongs to another org, 403 (“you’re not allowed”) confirms the row exists, just out of reach. The secure answer is 404: because ctx.db is tenant-scoped, the read comes back empty, and “doesn’t exist for you” reveals nothing.
Match each failure to the status it produces.
Match each failure cause to the HTTP status authedRoute returns for it. Click an item on the left, then its match on the right. Press Check when done.
401 Unauthorized403 Forbidden400 Bad Request422 Unprocessable Entity404 Not FoundOne last posture rule: don’t call redirect() inside a route handler. It works (Next serves a 307), but a redirect throws a navigation, and your partner’s job server asked for a result, not to be sent somewhere. An API handler returns an explicit status Response; reach for Response.redirect(url, 303) only on the rare occasion a redirect is genuinely the intent.
Response bodies: Problem Details for errors, JSON for success
Section titled “Response bodies: Problem Details for errors, JSON for success”The status code is half the error; the body is the other half. Give every error body the same shape and a partner integrating your API writes one error renderer instead of one per endpoint.
That shape is RFC 9457 Problem Details : errors carry Content-Type: application/problem+json and a fixed set of fields, plus a fieldErrors extension for validation failures. A 422 from a failed parse:
{ "type": "about:blank", "title": "Unprocessable Entity", "status": 422, "detail": "Validation failed.", "fieldErrors": { "amount": ["Expected a positive number."] }}fieldErrors is the same Record<string, string[]> shape z.flattenError produced for the form last lesson. Only the envelope changed, so the client still renders the message under the amount field exactly as before.
You don’t hand-build this object. The problem(...) helper does, living once in src/lib/http/problem.ts. The schema was authored earlier; here you consume it.
Success responses are plain application/json via Response.json(data, { status }), the status chosen by verb: 200 for a read, 201 for a create, 204 (no body) for a delete, 200 for an update.
One business function, both doors
Section titled “One business function, both doors”The two wrappers had near-identical bodies, both running the same insert. That duplication is the smell: a mutation reachable from both doors shouldn’t have its logic written twice. Write it once, in /lib, and let each door wrap it.
Lift the work into one pure function in src/lib/invoices/: createInvoice(input, ctx): Promise<Result<Invoice>>. It has no notion of HTTP or FormData. It takes validated input and a ctx (with ctx.db already tenant-bound), does the database work, and returns a Result. The Server Action wraps it via authedAction and returns the Result straight to the form. The route handler wraps it via authedRoute and translates the Result into a Response.
This is Architectural Principle #3: pure logic lives in /lib, side effects (HTTP shapes, form shapes) live at the named boundaries. The boundary translates; the core doesn’t know which boundary called it.
The route handler gets a Result back and maps it to a status:
export const POST = authedRoute('admin', createInvoiceSchema, async (input, ctx) => { const result = await createInvoice(input, ctx); return result.ok ? Response.json(result.data, { status: 201 }) : problemFrom(result.error);});problemFrom is a tiny mapper: it reads the Result error’s code and returns the matching status — 'forbidden' → 403, 'validation' → 422, 'conflict' → 409, 'not-found' → 404.
The part juniors get wrong is where authorization lives: at each door, in the wrapper, never in the shared function. Both authedAction and authedRoute run the role check at the threshold, so createInvoice assumes it’s authorized and tenant-scoped — it received ctx.db already bound. Checking the role inside would run it twice, and the function doesn’t know which rule applies. Don’t call one door from the other either: a route handler invoking an authedAction is the wrong execution context. The meeting point is the /lib function, never one wrapper reaching into another.
One logical request, traced through the pattern whichever door it entered:
useActionState authedRoute a webhook, partner server, or mobile client POSTing JSON resolve → authorize → parse. Same order, same checks, same
ctx = { user, orgId, role, db } handed down — whichever door the request came in.
/lib: createInvoice(input, ctx): Promise<Result>.
The work is written once — neither door owns it.
Result inline — the form reads it as-is authedRoute translates it: ok → Response.json, error → problemFrom Now write the other door. The exercise hands you a working deleteCustomer(input, ctx) in /lib returning a Result, already wrapped as a Server Action; your job is the authedRoute-wrapped DELETE export that calls the same function and translates its Result into a Response.
The shared deleteCustomer(input, ctx) below lives in /lib and returns a Result — it's already wrapped as a Server Action (the React door, deleteCustomerAction). Write the route-handler twin: an authedRoute-wrapped DELETE that calls the SAME deleteCustomer and translates its Result into a Response. On result.ok return a 204 (no body); otherwise return problemFrom(result.error). The tests feed the handler an admin context and a member context.
Reveal solution
export const DELETE = authedRoute( 'admin', deleteCustomerSchema, async (input, ctx) => { const result = await deleteCustomer(input, ctx); return result.ok ? new Response(null, { status: 204 }) : problemFrom(result.error); },);The wrapper already ran the first three gates — resolve, authorize (the 403 a member gets), and parse (the 422 for bad input). All your body does is call the same deleteCustomer the Server Action calls, then translate the Result it returns into an HTTP exit: result.ok becomes a 204 No Content (the right status for a delete with nothing to return), and any error routes through problemFrom, which maps the Result code to its status — 'not-found' → 404, and so on. That’s the whole port: same business function, same authz at the door, only the return type changed from a Result the form reads to a Response the HTTP client reads. The last test proves the point — both doors leave the store in the identical state, because both call one function in /lib.
What authedRoute leaves out
Section titled “What authedRoute leaves out”Like authedAction, authedRoute stays small: session, role, schema, nothing else. Four route-specific concerns look like they belong in the wrapper but live elsewhere.
- CORS. A same-origin handler needs none; a public API handler’s
Access-Control-Allow-Originbelongs innext.config.tsor middleware, set once per route group rather than per call. - Idempotency. A handler that accepts retried writes, like a webhook that fires twice, honors the
Idempotency-Keyheader so the second delivery is a no-op. The full dedup pattern comes in a later lesson. - Bearer-token auth. The session cookie reaches a route handler just as it reaches an action, so
requireOrgUserworks unchanged.Authorization: Bearer <token>is a different identity model, for machine callers with no cookie, built later this chapter in API keys. - Streaming. Have
fnreturn aResponsewhose body is aReadableStream, for a CSV export or SSE feed. Authz still runs at entry, so the role check passes before the first byte.
You don’t reach for force-dynamic here. Reading request.headers or cookies makes a handler dynamic, and authedRoute always reads the session from request.headers, so under Cache Components every wrapped handler is dynamic by construction.
External resources
Section titled “External resources”The authoritative reference for the HTTP door: verb exports, the params Promise, and the RouteContext helper this lesson leans on.
The standard itself — the type/title/status/detail shape your problem() helper builds, straight from the spec.
The canonical reference for every status in the map, including the 401-vs-403 and 400-vs-422 distinctions the lesson turns on.