The Next.js App Router brought a lot of architectural improvements, but writing raw API Route Handlers (app/api/**/route.ts) still feels remarkably repetitive.
Every time you write an endpoint, you end up repeating the exact same ceremonies:
- Wrapping everything in
try/catch. - Extracting and parsing JSON bodies with
await request.json(). - Running schema validation (like Zod) and manually structuring a 400 response.
- Manually unwrapping asynchronous dynamic parameters in Next.js 15 (
await context.params). - Writing repetitive authentication checks and duplicating TypeScript types.
I got tired of pasting the same 35 lines of setup code for a 3-line database operation. So I built next-routed — a lightweight, zero-dependency, type-safe fluent pipeline for Next.js route handlers.
The Problem: The Native Route Handler Dance
Here is what a standard, validated Next.js 15 route handler typically looks like:
import { NextResponse, type NextRequest } from "next/server";
import { z } from "zod";
import { db } from "@/lib/db";
import { verifyAuth } from "@/lib/auth";
const postSchema = z.object({
title: "z.string().min(3),"
content: z.string(),
});
export async function POST(
request: NextRequest,
context: { params: Promise<{ orgId: string }> }
) {
try {
// 1. Auth check
const user = await verifyAuth(request);
if (!user) {
return NextResponse.json({ id: "UNAUTHORIZED", data: null }, { status: 401 });
}
// 2. Unwrapping Next.js 15 params
const { orgId } = await context.params;
// 3. Body parsing & validation
const rawBody = await request.json().catch(() => ({}));
const parsed = postSchema.safeParse(rawBody);
if (!parsed.success) {
return NextResponse.json(
{ id: "VALIDATION_ERROR", errors: parsed.error.issues },
{ status: 400 }
);
}
// 4. Actual business logic
const post = await db.post.create({
data: { ...parsed.data, orgId, authorId: user.id },
});
return NextResponse.json({ id: "SUCCESS", data: post }, { status: 201 });
} catch (error) {
console.error(error);
return NextResponse.json({ id: "INTERNAL_ERROR", data: null }, { status: 500 });
}
}
Most of this file is transport mechanics and defensive boilerplate. The actual business logic is completely drowned out.
The Solution: A Fluent, Inferred Pipeline
With next-routed, you define your route as a composable pipeline. Types are inferred automatically from your schemas and middlewares — no manual type definitions required.
1. Installation
npm install next-routed
# or
pnpm add next-routed
(Works with Zod or any validator adhering to the Standard Schema v1 specification).
2. The Same Route with next-routed
First, set up your base reusable auth middleware in lib/api.ts:
// lib/api.ts
import { route, RouteResponse, HttpStatus } from "next-routed";
import { verifyAuth } from "./auth";
export const authedRoute = route.use(async ({ request }) => {
const user = await verifyAuth(request);
if (!user) {
return RouteResponse.status(HttpStatus.UNAUTHORIZED).json(
{ message: "Authentication required" },
"UNAUTHORIZED"
);
}
// Merged into the route context downstream
return { user };
});
Now, write your actual endpoint:
// app/api/orgs/[orgId]/posts/route.ts
import { authedRoute } from "@/lib/api";
import { RouteResponse, HttpStatus } from "next-routed";
import { z } from "zod";
import { db } from "@/lib/db";
export const POST = authedRoute
.params(z.object({ orgId: z.string() }))
.body(z.object({ title: z.string().min(3), content: z.string() }))
.handle(async ({ user, params, body }) => {
// - user is guaranteed and typed
// - params.orgId is unwrapped and strictly typed
// - body.title is typed as string
const post = await db.post.create({
data: { ...body, orgId: params.orgId, authorId: user.id },
});
return RouteResponse.status(HttpStatus.CREATED).json(post, "POST_CREATED");
});
Why this approach?
1. Zero Manual TypeScript Annotations
You don't need to define type Context = ... or use z.infer. The chain automatically infers every input (body, query, params, and context from .use()) and presents them in handle().
2. Next.js 14 and 15 Compatibility
Next.js 15 made route params asynchronous (Promise<Params>). next-routed unwraps them under the hood, regardless of whether you're targeting Next 14 or 15.
3. Predictable HTTP Responses with RouteResponse
Instead of manually configuring NextResponse, you get a fluent builder for status codes, headers, cookies, and formats:
return RouteResponse.status(HttpStatus.NO_CONTENT)
.cookie("session", "", { maxAge: 0 })
.empty();
If you don't need custom headers or status codes, simply return an object from .handle() — it will automatically be wrapped into a standardized { id: "SUCCESS", data } payload with status 200.
Try it out & Feedback Welcome
next-routed is fully open-source and has zero runtime dependencies beyond Next.js itself.
- 📦 npm: npmjs.com/package/next-routed
- 🐙 GitHub: github.com/slxca/next-routed
If you're building APIs with the App Router, give it a try and let me know your thoughts or feature requests in the comments!
Top comments (0)