A typed, onion-model middleware system for Next.js route handlers, server actions, form actions, and pages — plus a generated, fully typed client for your routes.
Requires Next.js ≥ 16 (App Router), React ≥ 19, TypeScript ≥ 5, Node ≥ 22. zod v4 is an optional peer dependency, needed only by the validation middlewares.
Early release (0.x): the API is still settling and minor versions may contain breaking changes.
The Next.js App Router gives you four server entry points — route handlers, server actions, form actions, and pages — and no middleware system for any of them. So every file re-implements the same prologue by hand: check the session, parse and validate the input, load the entity or 404, shape the error response. next-pipe replaces that with one onion-model middleware system that works identically across all four surfaces, on top of the native Next.js primitives (your route.ts stays a plain HTTP endpoint, your actions stay server actions). Because middlewares are typed end to end — including their short-circuit responses — a generated client can read back the exact response union of every route.
export const POST = routePipe<RouteContext<"/api/notes/[id]/like">>()
.use(AuthMiddleware) // → 401
.use(BodyValidationMiddleware, z.object({ like: z.boolean() })) // → 400
.handle(async ({ ctx, input, user }) => {
const { id } = await ctx.params;
await db.setLike(id, user.username, input.like);
return json(200, { liked: input.like });
});The 401 and 400 paths you didn't write are still there — handled by the middlewares, turned into real Responses, and included in this route's response type, which the generated client gets for free.
- next-safe-action / zsa — excellent action builders, but they cover server actions only. next-pipe applies the same middleware model to route handlers, form actions, and pages too, and adds the generated typed client for routes.
- tRPC — replaces your HTTP surface with an RPC router that the client must import. next-pipe keeps native
route.tsendpoints (callable by anything — webhooks, mobile apps, curl); client types come from codegen overimport type, so no server code approaches the client bundle. - ts-rest / Hono — contract-first or bring-your-own-framework: you describe the API separately, then implement it. next-pipe goes the other way and derives the contract from the handlers you already wrote.
pnpm add @flefebvre/next-pipe
# optional, for the validation middlewares and actionPipe(schema)/formActionPipe(schema):
pnpm add zodA middleware is a class with a before step (and optionally an after step). before either merges typed values into the handler's input with next(...), or short-circuits with interrupt(...):
// lib/middlewares/auth-middleware.ts
import { next, interrupt } from "@flefebvre/next-pipe/server";
import { BeforeMiddleware } from "@flefebvre/next-pipe/middlewares";
import { getSessionUser } from "@/lib/auth"; // your own session logic
export class AuthMiddleware extends BeforeMiddleware {
async before() {
const user = await getSessionUser();
if (!user) {
// Short-circuits the handler. The interrupt travels back through the
// `after` chain, becomes a real 401 Response, and joins the route's
// response union.
return interrupt({ status: 401, json: { error: "Not signed in" } } as const);
}
// Merged into the handler's input, fully typed.
return next({ user });
}
}routePipe() wires in ResponseMiddleware for you: handlers return plain { status, json } objects (the json() helper keeps them precisely typed) and they come out as real Responses:
// app/api/notes/[id]/like/route.ts
import z from "zod";
import { routePipe } from "@flefebvre/next-pipe/pipes";
import { json } from "@flefebvre/next-pipe/server";
import { BodyValidationMiddleware } from "@flefebvre/next-pipe/middlewares/routes";
import { AuthMiddleware } from "@/lib/middlewares/auth-middleware";
import { db } from "@/lib/db";
export const POST = routePipe<RouteContext<"/api/notes/[id]/like">>()
.use(AuthMiddleware)
.use(BodyValidationMiddleware, z.object({ like: z.boolean() }))
.handle(async ({ ctx, input, user }) => {
// input is { like: boolean }, user comes from AuthMiddleware — all typed.
const { id } = await ctx.params;
await db.setLike(id, user.username, input.like);
return json(200, { liked: input.like });
});$ npx next-pipe gen
✓ api/notes/[id]/like → src/generated/routes/api/notes/[id]/like.ts (POST)
✓ barrel → src/generated/routes/index.ts
The generator walks your app/ directory with the TypeScript type checker and emits one tiny module per route plus a routes barrel. Generated files use import type only, so no server code can leak into the client bundle. Tip: add "gen": "next-pipe gen" to your package.json scripts and re-run it when routes change.
import { callRoute } from "@flefebvre/next-pipe/client";
import { routes } from "@/generated/routes";
const res = await callRoute(routes.api.notes(id).like.post(), { json: { like: true } });
// res: { status: 200; json: { liked: boolean } }
// | { status: 401; json: { error: string } }
// | { status: 400; json: … } ← schema failure, with typed field errors
if (res.status === 200) {
res.json.liked; // boolean
}callRoute requires json (and query) exactly when the route declares them, and returns the union of everything the route can answer — handler returns and middleware interrupts. In client components, useApiCall(builder) wraps this with useTransition pending state — see Client & hooks.
| Import | Exports | Docs |
|---|---|---|
@flefebvre/next-pipe/pipes |
routePipe, actionPipe, formActionPipe, pagePipe |
route · action · form · page |
@flefebvre/next-pipe/server |
Pipe, entry, createPipe, next, interrupt, success, error, json, merge |
Core concepts |
@flefebvre/next-pipe/client |
callRoute, defineRoute, useApiCall, getActionError |
Client & hooks |
@flefebvre/next-pipe/middlewares |
Middleware, BeforeMiddleware, AfterMiddleware, OutputTypeMiddleware + authoring types |
Custom middlewares |
@flefebvre/next-pipe/middlewares/routes |
ResponseMiddleware, BodyValidationMiddleware, QuerystringMiddleware |
Built-in middlewares |
@flefebvre/next-pipe/middlewares/actions |
InputValidationMiddleware |
Built-in middlewares |
@flefebvre/next-pipe/middlewares/form-actions |
FormValidationMiddleware |
Built-in middlewares |
@flefebvre/next-pipe/middlewares/pages |
SearchParamsMiddleware |
Built-in middlewares |
next-pipe (bin) |
next-pipe gen |
Codegen |
- Core concepts — the onion model:
before/afterphases,nextvsinterrupt, how context merges across middlewares, and why interrupts flow back through theafters of earlier middlewares. - routePipe — route handlers: response unions,
RouteContextparams, status/json typing. - actionPipe — typed server actions: zod input validation, the
success/errorresult shape, reading errors withgetActionError. - formActionPipe —
<form>actions:FormDataparsing, schema validation, typed field errors back to the form. - pagePipe — run the same middlewares in front of a page or server component (e.g. redirect guests to
/login). - Built-in middlewares — body, querystring, input and form validation; response shaping; output typing.
- Write your own middleware —
BeforeMiddleware,AfterMiddleware, parametrized constructors (.use(M, ...args)), interrupts, and theconfigtype channel that feeds the generated client. - Codegen — how
next-pipe genanalyzes routes through the TypeScript checker, every CLI flag, and CI setup. - Client & hooks —
defineRoute,callRoutesemantics (declared statuses return, everything else throws), and theuseApiCallhook.
If you use an AI coding agent (Claude Code, Cursor, Codex, …), this repo ships an installable skill that teaches the agent next-pipe's pipes, middlewares, codegen, and client:
npx skills add flolefebvre/next-pipe(Uses the skills CLI; or just copy skills/next-pipe/ into your agent's skills directory, e.g. .claude/skills/.)