@tinystack/next-middleware
v0.2.0
Published
Composable, type-safe middleware chains for Next.js route handlers — middleware ordering enforced at compile time.
Downloads
247
Maintainers
Readme
@tinystack/next-middleware
Composable, type-safe middleware chains for Next.js route handlers — with middleware ordering enforced at compile time.
// app/api/projects/route.ts
import {
createMiddlewareChain,
withQuery,
} from "@tinystack/next-middleware"
import { z } from "zod"
export const GET = createMiddlewareChain()
.use(withAuth) // adds ctx.user
.use(withQuery(z.object({ page: z.coerce.number().default(1) })))
.handle(async (req, ctx) => {
// ^ ctx is fully typed: { user: User; query: { page: number } }
return Response.json(await listProjects(ctx.user, ctx.query.page))
})Why
Every Next.js codebase grows a hand-rolled use(...) chain eventually. Most
of them get two things wrong:
- Ordering is unchecked. A middleware that reads
ctx.usercan run before the one that sets it — and nothing complains until production. - Responses get lost. A middleware that forgets
return next()silently swallows the handler's response.
This library fixes both:
use()rejects, at compile time, any middleware whose requirements the chain doesn't yet provide:const needsUser = createTypedMiddleware<{ org: string }, { user: string }>( async (req, ctx, next) => next({ org: await orgOf(ctx.user) }) ) createMiddlewareChain().use(needsUser) // ^^^^^^^^^ ✖ Type error: chain provides {}, // middleware requires { user: string } createMiddlewareChain().use(withUser).use(needsUser) // ✔ compilesThe runtime guarantees exactly one response per request: a dropped response (
await next()withoutreturn) is rescued and reported viaonDroppedResponse; a chain that produces nothing returns a JSON 500 (or yourfallbackResponse); callingnext()twice throws.
Install
pnpm add @tinystack/next-middlewarenext (≥14) and zod (≥3.25, if you use the validation middlewares) are peer
dependencies.
Writing middleware
A middleware declares what it adds to ctx and what it requires from middlewares before it:
import { createTypedMiddleware } from "@tinystack/next-middleware"
// Adds ─────────┐ Requires ──┐
const withOrg = createTypedMiddleware<{ org: Org }, { user: User }>(
async (req, ctx, next) => {
const org = await findOrg(ctx.user)
if (!org) return Response.json({ error: "No org" }, { status: 403 })
return next({ org }) // always *return* next()
}
)Short-circuit by returning a Response without calling next(). Anything a
middleware throws propagates out of the chain — wrap it with
withErrorBoundary if you want throws converted to
responses.
Included middlewares
The validation middlewares parse one request surface with a zod schema, add
the typed result to ctx, and respond with a JSON 400 (including the zod
issues) on invalid input. Override that via onInvalid — return your own
Response or throw into your error boundary.
| Middleware | Adds | Notes |
| ---------------------------- | ------------- | ----------------------------------------------------------------------------------- |
| withQuery(schema, opts?) | ctx.query | Empty values become undefined; repeated keys become arrays |
| withBody(schema, opts?) | ctx.body | Malformed JSON fails validation like any other bad input; consumes the request body |
| withParams(schema, opts?) | ctx.params | Awaits the Next.js 15+ params promise before validating |
| withHeaders(schema, opts?) | ctx.headers | Header names are lowercased; write schema keys in lowercase |
export const POST = createMiddlewareChain()
.use(withParams(z.object({ projectId: z.uuid() })))
.use(withBody(z.object({ name: z.string().min(1) })))
.handle(async (req, ctx) =>
Response.json(await rename(ctx.params.projectId, ctx.body.name))
)Error boundary
withErrorBoundary(onError) catches anything thrown by later middlewares or
the handler and converts it to a response. Add it as the first use() so it
wraps the whole chain; the error-to-response mapping is entirely yours:
export const GET = createMiddlewareChain()
.use(
withErrorBoundary((error, req) => {
logger.error("unhandled", { error, path: req.nextUrl.pathname })
return Response.json({ error: "Internal error" }, { status: 500 })
})
)
.use(withAuth)
.handle(async (req, ctx) => Response.json(await riskyThing(ctx.user)))It adds nothing to ctx, so it composes anywhere in the chain — but it only sees throws from middlewares after it.
Chain options
createMiddlewareChain({
// Log dropped responses with your logger (default: console.warn)
onDroppedResponse: ({ method, pathname }) =>
logger.error("dropped", { method, pathname }),
// Response when nothing in the chain produced one (default: JSON 500)
fallbackResponse: (req) =>
Response.json({ error: "No response" }, { status: 500 }),
})Chains are immutable — use() returns a new chain — so you can fork a shared
base chain safely:
const authed = createMiddlewareChain().use(withAuth)
export const GET = authed.use(withQuery(listSchema)).handle(listProjects)
export const POST = authed.use(withBody(createSchema)).handle(createProject)When not to use it
Boring JSON APIs are the sweet spot. Streaming responses, webhooks with raw body signatures, and file uploads are often clearer as plain route handlers.
License
MIT
