Type-safe middleware composition for Next.js App Router Route Handlers. Zero runtime dependencies.
npm install next-api-composer
import { compose, withErrorHandler, withAuth, withRateLimit } from 'next-api-composer';
import { nextAuthAdapter } from 'next-api-composer/adapters/next-auth';
import { authOptions } from '@/lib/auth';
import { NextResponse } from 'next/server';
const secured = compose(
withErrorHandler({ label: 'users' }),
withRateLimit({ max: 30 }),
withAuth({ adapter: nextAuthAdapter(authOptions) }),
);
export const GET = secured(async (req, ctx) => {
return NextResponse.json({ userId: ctx.userId });
});Next.js App Router Route Handlers have no built-in middleware composition. You end up with deeply nested wrappers or duplicated try/catch + auth + rate-limit logic in every route.
next-api-composer gives you:
compose()— Combine any number of middlewares into a single wrapper- Type-safe context — Each middleware injects typed context that flows through the chain
- Auth adapter system — First-class support for NextAuth, Clerk, and Supabase
- Zero external runtime dependencies — In-memory rate limiting, no Redis required
Composes multiple middlewares into a single wrapper. Middlewares are applied left-to-right (leftmost is outermost, runs first).
const handler = compose(
withErrorHandler(), // 1st: catches errors
withRateLimit(), // 2nd: rate limiting
withAuth({ adapter }) // 3rd: authentication
)(async (req, ctx) => {
// ctx contains AuthContext
return NextResponse.json({ ok: true });
});Wraps the handler in try/catch. Logs errors and returns a 500 JSON response.
| Option | Type | Default | Description |
|---|---|---|---|
label |
string |
'API' |
Label for console.error log |
onError |
(error, req) => void |
— | Custom error callback (e.g., Sentry) |
withErrorHandler({
label: 'users-api',
onError: (err, req) => Sentry.captureException(err),
})Authenticates the request using the provided adapter. Injects AuthContext into handler context.
| Option | Type | Description |
|---|---|---|
adapter |
AuthAdapter |
Authentication adapter (required) |
onUnauthorized |
(req) => NextResponse |
Custom 401 response |
withAuth({
adapter: nextAuthAdapter(authOptions),
onUnauthorized: () => NextResponse.json({ error: 'Please log in' }, { status: 401 }),
})interface AuthContext {
userId: string;
role?: string;
email?: string | null;
[key: string]: unknown;
}In-memory rate limiter. No external dependencies.
| Option | Type | Default | Description |
|---|---|---|---|
windowMs |
number |
60000 |
Time window in ms |
max |
number |
60 |
Max requests per window |
keyFn |
(req) => string |
IP-based | Custom key extraction |
withRateLimit({ max: 30, windowMs: 60_000 })Note: The rate limiter uses in-memory storage. In serverless environments (Vercel, AWS Lambda), each cold start creates a fresh store, so limits are per-instance rather than global. For global rate limiting, use an external store like Upstash Redis.
Validates request body and/or query parameters using Zod schemas. Injects validated into context.
Requires zod as a peer dependency.
| Option | Type | Description |
|---|---|---|
body |
ZodType |
Schema for JSON body |
query |
ZodType |
Schema for query params |
import { z } from 'zod';
const createUser = compose(
withErrorHandler(),
withValidation({
body: z.object({
name: z.string(),
email: z.string().email(),
}),
}),
);
export const POST = createUser(async (req, ctx) => {
const { name, email } = ctx.validated.body;
// name: string, email: string — fully typed
});Permission check middleware. Must be used after withAuth.
const adminOnly = compose(
withErrorHandler(),
withAuth({ adapter }),
withPermission(
(ctx) => ctx.role === 'admin',
'Admin access required',
),
);Middlewares stack naturally. Each one adds to the context:
const createPost = compose(
withErrorHandler({ label: 'posts' }),
withRateLimit({ max: 10 }),
withAuth({ adapter: nextAuthAdapter(authOptions) }),
withValidation({ body: z.object({ title: z.string(), content: z.string() }) }),
);
export const POST = createPost(async (req, ctx) => {
// ctx.userId — from withAuth
// ctx.role — from withAuth
// ctx.validated — from withValidation
return NextResponse.json({ ok: true });
});import { nextAuthAdapter } from 'next-api-composer/adapters/next-auth';
import { authOptions } from '@/lib/auth';
withAuth({ adapter: nextAuthAdapter(authOptions) })import { clerkAdapter } from 'next-api-composer/adapters/clerk';
withAuth({ adapter: clerkAdapter() })import { supabaseAdapter } from 'next-api-composer/adapters/supabase';
withAuth({
adapter: supabaseAdapter(
process.env.NEXT_PUBLIC_SUPABASE_URL!,
process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!,
),
})import type { AuthAdapter } from 'next-api-composer';
const myAdapter: AuthAdapter = {
async getUser(req) {
const token = req.headers.get('authorization')?.replace('Bearer ', '');
if (!token) return null;
const user = await verifyToken(token);
return { userId: user.id, role: user.role, email: user.email };
},
};compose(a, b, c)(handler) is equivalent to a(b(c(handler))).
The leftmost middleware is the outermost — it runs first on the way in and last on the way out:
Request → a → b → c → handler → c → b → a → Response
Each middleware can:
- Short-circuit by returning a response without calling
next - Inject context by passing additional data to the inner handler
- Post-process the response from inner handlers
MIT