DEV Community

Cover image for Tired of Boilerplate in Next.js 15 Route Handlers? Meet next-routed
slxca
slxca

Posted on

Tired of Boilerplate in Next.js 15 Route Handlers? Meet next-routed

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:

  1. Wrapping everything in try/catch.
  2. Extracting and parsing JSON bodies with await request.json().
  3. Running schema validation (like Zod) and manually structuring a 400 response.
  4. Manually unwrapping asynchronous dynamic parameters in Next.js 15 (await context.params).
  5. 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 });
  }
}
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

(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 };
});
Enter fullscreen mode Exit fullscreen mode

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");
  });
Enter fullscreen mode Exit fullscreen mode

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();
Enter fullscreen mode Exit fullscreen mode

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.

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)