Skip to content

seiryuu1215/next-api-composer

Repository files navigation

next-api-composer

npm version CI License: MIT

Type-safe middleware composition for Next.js App Router Route Handlers. Zero runtime dependencies.

npm install next-api-composer

Quick Start

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 });
});

Why?

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

API Reference

compose(...middlewares)

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 });
});

withErrorHandler(options?)

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),
})

withAuth(options)

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 }),
})

AuthContext

interface AuthContext {
  userId: string;
  role?: string;
  email?: string | null;
  [key: string]: unknown;
}

withRateLimit(options?)

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.

withValidation(options)

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
});

withPermission(checkFn, errorMsg?)

Permission check middleware. Must be used after withAuth.

const adminOnly = compose(
  withErrorHandler(),
  withAuth({ adapter }),
  withPermission(
    (ctx) => ctx.role === 'admin',
    'Admin access required',
  ),
);

Combining Auth + Validation

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 });
});

Auth Adapters

NextAuth

import { nextAuthAdapter } from 'next-api-composer/adapters/next-auth';
import { authOptions } from '@/lib/auth';

withAuth({ adapter: nextAuthAdapter(authOptions) })

Clerk

import { clerkAdapter } from 'next-api-composer/adapters/clerk';

withAuth({ adapter: clerkAdapter() })

Supabase

import { supabaseAdapter } from 'next-api-composer/adapters/supabase';

withAuth({
  adapter: supabaseAdapter(
    process.env.NEXT_PUBLIC_SUPABASE_URL!,
    process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!,
  ),
})

Custom Adapter

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 };
  },
};

How compose() Works

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

License

MIT

About

Next.js App Router Route Handler向けミドルウェア合成ライブラリ — 認証・レートリミット・バリデーションを型安全に compose() | TypeScript + ゼロ外部依存 + ESM/CJS

Topics

Resources

License

Stars

Watchers

Forks

Releases

No releases published

Packages

 
 
 

Contributors