Skip to content
Chapter 57Lesson 3

The authedRoute twin

Port the authedAction discipline to Next.js route handlers, fencing the HTTP door with the same role and schema checks while speaking status codes and RFC 9457 Problem Details instead of Result types.

Last lesson you built authedAction and closed the missing-role-check bug at the Server Action boundary. The role stopped being a line you could forget and became an argument the compiler counts. But a Server Action is only one door into your app, and it’s the React door: it opens only for callers that submit a form or fire useActionState.

Plenty of callers do neither. A Stripe webhook POSTs JSON to a URL when a payment clears. A partner’s nightly job server pushes a batch of records to your API at 3am. A mobile client, someday, talks to the same backend without ever rendering your React. None of them have a form to submit; they speak raw HTTP to a route.ts. Yet they carry the exact same untrusted input a form does, so they need the exact same three checks before that input touches your database. This lesson ports the wrapper to that other door: authedRoute. It’s the shorter twin of last lesson, because you already did the hard part. The discipline is identical; the one new idea is that what comes back on the wire is different. A form reads a Result; an HTTP client reads a status code. You’ll learn that difference, and you’ll learn how to share one business function across both doors so you never write the mutation twice.

Before any code, hold one picture in mind, because it is the whole lesson: two doors into the same room.

Your app has two server seams that accept a request from the outside 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.

Two doors, but they lead to the same room. A React form comes through authedAction; a partner’s JSON POST comes through authedRoute; both check the same three things at the threshold, and both end up calling the same business function in /lib. That’s the shape to hold onto. Here it is:

React form same-origin, useActionState
JSON POST webhook, partner, mobile
createInvoice(input, ctx) business logic in /lib
Database tenant-scoped tables
Two doors, one room. The caller picks the door; both lead to the same business function, and only the return type differs.

The door you walk through isn’t a matter of taste. It’s decided entirely by the caller’s shape. A route handler is a deliberate reach, not a reflex: you open that door only when the caller forces it. Exactly five things force it, the same threshold you learned earlier in the course: 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). If none of those is true, the React door wins. Server Actions are the default; route handlers are the exception.

This matters because of a common junior instinct worth refusing: exposing /api/whatever for an internal React mutation “so we have an API.” You don’t need one. A button in your own dashboard that removes a member should be a Server Action, and nothing more. Wrapping it in a route handler buys you nothing but a public URL you now have to defend. Reach for the route handler when something outside your React app needs in, and not before.

The point to carry out of this section is that both doors are equally fenced. The route handler is not a back door with weaker locks. It runs the same discipline you already know: resolve the session, authorize the role, parse the input, then do the work. Same four gates, same order, same ctx. The only thing that changes between the two doors is what comes back on the wire. Everything else you already built.

authedRoute(role, schema, fn): the same signature

Section titled “authedRoute(role, schema, fn): the same signature”

Let’s see how little actually changes. As in last lesson, meet the wrapper as something you use before something you build.

The signature is the twin of authedAction. The first two arguments are the same: role, the minimum role allowed, and schema, the Zod shape the input must satisfy. The third argument is where the one difference lives. Instead of fn: (input, ctx) => Promise<Result<T>>, you write fn: (input, ctx) => Promise<Response>. The business function returns a Response, not a Result. That’s the entire divergence at the call site.

Everything inside ctx is byte-for-byte identical to last lesson: { user, orgId, role, db }, where db is tenantDb(orgId) already bound. Nothing about resolving the session or authorizing the role changes; the wrapper still calls the same requireOrgUser helper. The one mechanical adjustment is where it reads the session from. An action reads it from await headers(), but a route handler already has the request in hand, so it reads from request.headers. Same helper, same result, sourced one step earlier.

The call site lives inside a route.ts and gets assigned to a verb export. Here are the two wrappers side by side. This is the figure that carries the whole lesson, so spend a moment on it:

// 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 action’s caller is your own React: useActionState reads state.data on success or state.error.userMessage on failure, and the page never navigates. Same role, same schema, same ctx.

Look at how much is shared. Same 'admin', same createInvoiceSchema, same ctx.db insert. The bodies are nearly line-for-line identical. Exactly two things differ: the return type (Result versus Response), and revalidatePath, which belongs to the React door because there’s no Next.js cache for an HTTP client to revalidate. Hold that picture: same shape, different exit.

One shape note about that POST export. In a route.ts, each HTTP method is its own named export: export const GET = ..., export const POST = ..., export const DELETE = .... This is one of the few files where the framework dictates the export shape, but unlike page.tsx or layout.tsx, which default-export, route.ts exports are named, one per verb. A single route.ts can hold several of them, each its own authedRoute call with its own schema. A GET to read and a POST to create can live in the same file, each fenced independently.

The naming stays consistent with last lesson. The wrapper is named for what it is (authedRoute), but the business function it wraps is named for what it does (createInvoice): verb plus noun, no Route suffix. You don’t rename a mutation just because it came in through a different door.

Parsing from three sources, cheapest first

Section titled “Parsing from three sources, cheapest first”

Now the input side, where the one real mechanical difference lives. An action only ever sees a flat FormData: the wrapper runs Object.fromEntries(formData) and parses that. A route handler doesn’t get that convenience. Its input arrives from up to three places at once, and you have to gather it.

The three sources:

  • Path params: the typed segments of the URL, like the id in /api/invoices/[id]. In Next 16 the handler’s second argument carries them, typed by the global RouteContext<'/api/invoices/[id]'> helper, and params is a Promise you await.
  • Query string: everything after the ?, read off the request URL (new URL(request.url).searchParams). A ?status=paid filter lives here.
  • Request body: the JSON payload, via await request.json() (or request.formData() for the rarer form-encoded POST).

So the schema for a route handler isn’t one flat object. It’s a small object with a sub-schema for each source, { params, query, body }, and the wrapper parses each piece from its own place, then assembles the typed input it hands to fn. This { params, query, body } shape was introduced earlier in the course; here you’re just feeding it to the wrapper.

The principle to apply here is cheapest disqualifier first: run the check that’s cheap to evaluate and most likely to reject before the expensive work. The expensive work, in a route handler, is reading the body off the request stream. The cheap rejections are the gates that come before it: the role check (a forbidden caller never gets parsed at all), and the JSON parse itself (a body that isn’t even valid JSON is a malformed request you turn away before ever validating it against your schema). Each gate is ordered so a doomed request dies at the cheapest point it can.

This sits cleanly inside the four gates you already know. Resolve the session, authorize the role, then parse (read the body and reject malformed JSON, then validate the assembled input), then call. Here’s the wrapper with that order made concrete. It’s the longest code in the lesson, so let it scroll, and walk it gate by gate.

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. The same requireOrgUser from last lesson, with only the source changed. It reads request.headers instead of await headers() because the handler already holds the request. No session means no user; 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. Identical roleAtLeast check. The only difference from the action wrapper is the failure shape: below the floor, return problem(403, …), a 403 Forbidden Response, not an err('forbidden') value. Same gate, HTTP exit.

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 (“malformed”). Then safeParse the assembled { params, query, body }; input that’s well-formed but wrong is a 422 (“fails the schema”), carrying the same fieldErrors shape Zod produced for the form. Two different failures, two different statuses.

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. Everything’s safe now. Build the business ctx, which is { user, orgId, role, db } with db = tenantDb(orgId), the identical payload the action wrapper hands down, and pass it plus the parsed input to fn. Whatever Response it returns passes straight back to the client.

1 / 1

Set this next to last lesson’s authedAction and the parallel is exact. Resolve, authorize, parse, call: same four gates, same order, same ctx. Every line that differs differs for one reason: the failure exits are now HTTP responses, and the input arrives from three places instead of one. The discipline didn’t change. Only the wire did.

This version reads a JSON body, which fits the mutating verbs (POST, PATCH, DELETE) that make up the vast majority of authed handlers. A read-only GET has no body to read, so its wrapper skips that step and parses only params and query; the gates and their order are otherwise identical. Treat the body read as the one part that flexes by verb, not the discipline around it.

The status-code map: 400, 401, 403, 404, 422

Section titled “The status-code map: 400, 401, 403, 404, 422”

Now for that wire, because this is the genuinely new material. When an action fails, it returns an err(code, …), like 'forbidden' or 'validation', and the React form reads that code. When a route fails, there’s no form to read a code; the caller is a program that keys off the HTTP status. So each failure category maps to a specific status, and getting that map right is the core skill of this lesson.

Five statuses cover everything authedRoute and the functions behind it can 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 |

The third column is the point of the table: every HTTP status has a counterpart you already know from the action seam. Porting the wrapper is mostly porting this map. A few rows deserve a closer look, because the distinctions are exactly what a code reviewer will check.

400 versus 422 is the pair people blur, so make it crisp. 400 is for input that’s malformed: the request body isn’t even valid JSON, or a path segment that must be a UUID is the string "banana". The server can’t parse it, so it never reaches your schema. 422 is for input that’s perfectly well-formed but fails the schema: valid JSON, parses fine, but a required field is missing or a string showed up where a number belongs. Parseable-but-wrong is 422; unparseable is 400. The action seam barely has a 400 (a FormData payload is hard to malform), which is why that cell is empty, but on the HTTP 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 cleanest illustration of “same discipline, different exit.” Both wrappers call the same requireOrgUser. In the action, when there’s no session, that helper throws a Next.js redirect and the wrapper lets it fly: the browser navigates to /sign-in, which is exactly right, because there’s a human at a browser who should go sign in. But a route handler has no browser to navigate, and its caller is a program. So the route wrapper turns “no session” into a 401 Response and hands it back; the program reads the 401 and decides what to do. Same helper, same missing-session condition, opposite exit: a navigation for a human, a status code for a machine.

And 404 carries a security posture you met earlier in the course, worth restating because it’s where cross-tenant leaks hide: prefer 404 over 403 on cross-tenant access. When someone with a valid session and a sufficient role asks for an invoice that belongs to another org, the honest-looking answer is 403 (“you’re not allowed”). The secure answer is 404 (“doesn’t exist”). Because ctx.db is tenant-scoped, a read for another org’s row simply comes back empty, and to that caller, “doesn’t exist for you” is the right and leak-free response. A 403 would confirm the row is there, just out of reach; a 404 reveals nothing. So when a tenant-scoped read inside fn finds nothing for a named entity, return 404.

That last point hides the nastiest route-handler trap, so name it directly:

A quick drill to lock the map in. 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.

No valid session on the request
401 Unauthorized
Valid session, but role below the floor
403 Forbidden
Request body isn’t valid JSON
400 Bad Request
Valid JSON, but a required field is missing
422 Unprocessable Entity
Named row not found in this org
404 Not Found

One last thing belongs here, because it’s a posture question the status map raises: don’t reach for redirect() inside a route handler. It technically works, since current Next will serve a 307, but a redirect throws a navigation, and a navigation is the wrong thing to throw at a program. Your partner’s job server didn’t ask to be sent somewhere; it asked for a result. An API handler returns an explicit status Response. Use a redirect (Response.redirect(url, 303)) only on the rare occasion a redirect is genuinely the intent, which it almost never is for a JSON API. The rule isn’t that redirect doesn’t work here; it’s that you don’t throw navigations at programmatic callers.

The status code is half the error. The other half is the body, and you want every error body across every handler to look the same, so that a partner integrating your API writes one error renderer instead of one per endpoint.

There’s a standard for exactly this: RFC 9457 Problem Details . Error responses carry Content-Type: application/problem+json and a body with a fixed set of fields, plus a fieldErrors extension for validation failures. Here’s a 422 from a failed parse:

{
"type": "about:blank",
"title": "Unprocessable Entity",
"status": 422,
"detail": "Validation failed.",
"fieldErrors": {
"amount": ["Expected a positive number."]
}
}

Notice fieldErrors: it’s the same Record<string, string[]> shape z.flattenError produced for the form last lesson. The per-field messages don’t change just because they’re leaving through the HTTP door; only their envelope does. A client renders the message under the amount field exactly like your React form does.

You don’t hand-build this object in every handler. The problem(...) helper you saw in the wrapper builds it for you, and it lives once in src/lib/http/problem.ts, the canonical place the project keeps its Problem Details shape. The schema itself was authored earlier in the course; here you simply consume it, the same way the action wrapper consumes the Result shape. Every failure branch routes through that one helper, so every error your app emits on the wire is identical in form.

Success responses are the easy half. They’re plain application/json via Response.json(data, { status }), with the status chosen by the verb: 200 for a read, 201 for a create, 204 (no body) for a delete, 200 for an update. No special content type, no envelope.

This is also the place for the most common porting mistake, so guard against it directly:

You’ve now seen the full divergence: different return type, different failure exits, different input sources. Go back to that side-by-side a moment ago: the two bodies were nearly identical, both running the same insert. That duplication is the smell. If a mutation needs to be callable from both doors, do you really write its logic twice, once per door?

No. You write it once, in /lib, and let each door wrap it. This is the architectural payoff of the whole lesson, and the most reusable thing you’ll take from it.

The pattern is to lift the actual work out of both bodies into a single pure function in src/lib/invoices/: createInvoice(input, ctx): Promise<Result<Invoice>>. This function has no notion of HTTP and no notion of FormData. It takes validated input and a ctx (with ctx.db already tenant-bound), does the database work, and returns a Result. Now neither door owns the logic; each just wraps the shared function. The Server Action wraps it via authedAction and returns its Result straight to the form. The route handler wraps it via authedRoute, calls the same function, and translates the Result into a Response.

This is Architectural Principle #3 made concrete: pure logic lives in /lib, and side effects (HTTP shapes, form shapes) live at the named boundaries. The boundary translates; the core doesn’t know which boundary called it.

The translation is the one piece worth seeing in code. The route handler gets a Result back from the shared function 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, with 'forbidden' → 403, 'validation' → 422, 'conflict' → 409, 'not-found' → 404. That closes the loop you’ve been circling all lesson. The business function speaks Result. The action door returns that Result as-is. The route door translates it to HTTP. Same value, two presentations.

The part that keeps this clean, and the part juniors get wrong, is where the authorization lives. Authz runs at each door, in the wrapper, not in the shared function. Both authedAction and authedRoute run the role check at the threshold; by the time createInvoice runs, it assumes it’s authorized and tenant-scoped, because it received ctx.db already bound. Don’t put a role check inside the shared function: it doesn’t know which role rule applies, and you’d be checking twice. And don’t call one door from the other: a route handler must never invoke an authedAction, since that’s a different execution context and the wrong tool. When both doors need the same work, the meeting point is the /lib function, never one wrapper reaching into the other.

Here’s that one logical request traced through the pattern, whichever door it came in:

Door-specific authedAction a same-origin React form / useActionState authedRoute a webhook, partner server, or mobile client POSTing JSON
A request arrives at its door. The door is decided by the caller's shape, not by preference — a React form takes authedAction, a partner's JSON POST takes authedRoute.
Identical on both doors resolve → authorize → parse. Same order, same checks, same ctx = { user, orgId, role, db } handed down — whichever door the request came in.
The wrapper runs the same four gates either way — resolve the session, authorize the role, parse the input. This stage is identical on both doors; only the failure exits change shape.
Identical on both doors One pure function in /lib: createInvoice(input, ctx): Promise<Result>. The work is written once — neither door owns it.
The wrapper calls the shared business function — createInvoice(input, ctx). It does the database work and returns a Result. It has no notion of HTTP or FormData, and no idea which door called it.
Door-specific authedAction returns the Result inline — the form reads it as-is authedRoute translates it: okResponse.json, error → problemFrom
Each door presents the same Result differently. The action returns it inline for the form to read; the route translates it — ok to Response.json(data, { status: 201 }), error to problemFrom(error). Same value, two presentations.

Now you write the twin yourself. The exercise below gives you a working shared function in /lib, a deleteCustomer(input, ctx) returning a Result, already wrapped as a Server Action for the React door. Your job is the other door: write the authedRoute-wrapped DELETE export that calls the same shared function and translates its Result into a Response with the right status.

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.

    The seams this wrapper deliberately leaves alone

    Section titled “The seams this wrapper deliberately leaves alone”

    Like authedAction, authedRoute is small on purpose. It does session, role, and schema, the three checks every door owes, and nothing else. Several route-specific concerns look like they belong in the wrapper but don’t; name them once so you know where they actually live.

    • CORS. A same-origin handler needs none. A genuinely public API handler needs Access-Control-Allow-Origin and friends, but that’s a next.config.ts or middleware concern, configured once for a route group, not baked into every authedRoute call.
    • Idempotency. A public handler that accepts retried writes (a webhook that fires twice for one event) honors the Idempotency-Key header so the second delivery is a no-op. The wrapper leaves a seam for it, but the full dedup-table pattern is its own topic, which you’ll build properly with the webhook lesson later in the course.
    • Bearer-token auth. The default identity path is the session cookie: a route handler receives the same cookies an action does, which is why requireOrgUser just works. Authorization: Bearer <token> is a different identity model, for machine-to-machine callers with no cookie. You build exactly that second identity branch at the end of this chapter, in API keys for machine callers. Don’t trust a bearer header without the designed verification path behind it.
    • Streaming. Some handlers stream a response, like a CSV export or an SSE feed, where fn returns a Response whose body is a ReadableStream. The wrapper still runs authz at entry, which is the reassuring part: the role check happens before the first byte goes out. Streaming doesn’t escape the discipline.

    One more point corrects an instinct you might carry from elsewhere. This project runs with Next’s Cache Components on, where everything is dynamic only by opt-in. You might expect to reach for force-dynamic on these handlers, but you don’t have to. The moment a handler reads request.headers (or cookies), Next treats it as dynamic automatically, and authedRoute always reads the session from request.headers. So every authedRoute-wrapped handler is dynamic by construction. There’s no explicit opt-out to remember; reading the session already made the decision.

    You now have both doors. authedAction for the React caller; authedRoute for the HTTP caller. Same three checks at the threshold, same ctx handed down, same /lib functions doing the work, and the only thing that differs between them is the wire format: a Result the form reads inline, or a status code plus a Problem Details body the HTTP client reads. The route handler is not a weaker door. It’s the same room, entered from the other side.

    From here, the discipline goes to work. The next lesson builds out the member-management flows (list members, change a role, remove someone, leave the org, transfer ownership), and every one of them is a Server Action on authedAction, because the dashboard is React and the React door is the right one. Notice the principle in action: route handlers aren’t required there, because no non-React caller forces the reach. You pick the door by the caller, and the caller is your own UI. The lesson after lands the audit-log write, the record of who did what, which, as you learned last lesson, lives inside the business function, not the wrapper, and so flows through whichever door called it.