codelynx.dev
🇫🇷🇬🇧

Back 12/01/2025

The Next Safe Action but for API Routes

Written by Melvyn Malherbe on 12/01/2025


Are you looking for a tool that works like Next Safe Action but for API Routes? It exists!

Let me introduce Next-Zod-Route, a library that allows you to transform your API Routes from this:

TS
const BodySchema = z.object({
  name: z.string(),
});

const SearchParamsSchema = z.object({
  query: z.string(),
});

export const POST = async (
  req: Request,
  context: { searchParams: Promise<Record<string, string>> }
) => {
  const safeBody = BodySchema.safeParse(await req.json());
  const baseSearchParams = await context.searchParams;
  const safeSearchParams = SearchParamsSchema.safeParse(baseSearchParams);

  if (!safeBody.success) {
    return NextResponse.json({ error: safeBody.error.message }, { status: 400 });
  }

  if (!safeSearchParams.success) {
    return NextResponse.json(
      { error: safeSearchParams.error.message },
      { status: 400 }
    );
  }

  const body = safeBody.data;
  const searchParams = safeSearchParams.data;

  return NextResponse.json({ body, searchParams });
};

To this:

TS
export const POST = route
  .body(
    z.object({
      name: z.string(),
    })
  )
  .query(
    z.object({
      q: z.string(),
    })
  )
  .handler(async (req, { body, query }) => {
    return NextResponse.json({ body, query });
  });

Much simpler, right?

And that's not all because next-zod-route allows you to add:

  • middlewares that can modify the response
  • validations for body, params, and search params
  • return a simple JavaScript object instead of a NextResponse
  • have custom errors

Parameter Validation

With next-zod-route, you can easily validate route parameters, query parameters, and request body. Let's see how it works:

TS
const route = createZodRoute()
  .params(
    z.object({
      id: z.string().uuid(),
    })
  )
  .query(
    z.object({
      search: z.string().min(1),
    })
  )
  .body(
    z.object({
      field: z.string(),
    })
  )
  .handler(async (req, { params, query, body }) => {
    // params.id is correctly typed as string
    // query.search is correctly typed as string
    // body.field is correctly typed as string

    return NextResponse.json({ params, query, body });
  });

The typing is automatically inferred from your Zod schemas, making your code safer and more readable.

Middlewares

You can easily add middlewares to modify or validate requests before they reach your handler:

TS
const authMiddleware: MiddlewareFunction = async ({ next, request }) => {
  // Code executed before the handler
  const startTime = performance.now();

  // Passing data to the context (here you can get auth based on the request)
  const result = await next({ ctx: { user: { id: 'user-123', role: 'admin' } } });

  // Code executed after the handler
  const endTime = performance.now();
  console.log(`Request took ${Math.round(endTime - startTime)}ms`);

  return result;
};

export const GET = createZodRoute()
  .use(authMiddleware)
  .handler((request, context) => {
    // Access middleware data
    const { user } = context.ctx;
    return Response.json({ user });
  });

Middlewares can:

  • Execute code before and after the handler
  • Share data via context
  • Short-circuit the middleware chain (for example, for authentication checks)
  • Modify the response

Custom Error Handling

You can customize error handling to handle specific cases:

TS
class CustomError extends Error {
  statusCode: number;
  constructor(message: string, statusCode: number) {
    super(message);
    this.name = 'CustomError';
    this.statusCode = statusCode;
  }
}

const handleServerError = (error: Error) => {
  if (error instanceof CustomError) {
    return new Response(
      JSON.stringify({ message: error.name, details: error.message }),
      { status: error.statusCode }
    );
  }

  return new Response(JSON.stringify({ message: 'Something went wrong' }), {
    status: 500,
  });
};

export const GET = createZodRoute({
  handleServerError,
}).handler(() => {
  throw new CustomError('Not Found', 404);
});

Metadata with Validation

The metadata feature allows you to associate validated data with your routes, particularly useful for authorization checks:

TS
const permissionsMetadataSchema = z.object({
  permissions: z.array(z.string()).optional(),
});

const permissionMiddleware: MiddlewareFunction = async ({
  next,
  metadata,
  request,
}) => {
  const isAuthorized = hasPermission(request, metadata.permissions);

  if (!hasAllPermissions) {
    // Short-circuit with a 403 Forbidden response
    return new Response(
      JSON.stringify({
        error: 'Forbidden',
        message: 'You do not have the required permissions',
      }),
      {
        status: 403,
        headers: { 'Content-Type': 'application/json' },
      }
    );
  }

  // Continue with authorized context
  return next({ ctx: { authorized: true } });
};

export const GET = createZodRoute()
  .defineMetadata(permissionsMetadataSchema)
  .use(permissionMiddleware)
  // Static
  .metadata({ requiredPermissions: ['read:users'] })
  .handler((request, context) => {
    const { authorized } = context.ctx;
    return Response.json({ success: true, authorized });
  });

Flexible Return

You can return either a standard Response object or a simple JavaScript object that will be automatically converted to a JSON response:

TS
// Direct return of a Response object
export const GET = createZodRoute().handler(() => {
  return new Response(JSON.stringify({ custom: 'response' }), {
    status: 201,
    headers: { 'X-Custom-Header': 'test' },
  });
});

// Return a JavaScript object (automatically converted to JSON)
export const GET = createZodRoute().handler(() => {
  return { data: 'value' }; // Default status 200
});

Advanced Middleware Chain

Middlewares can work together to build complex functionalities:

TS
export const GET = createZodRoute()
  .use(async ({ next }) => {
    // First middleware
    const result = await next({ ctx: { user: { id: 'user-123' } } });
    return result;
  })
  .use(async ({ next, ctx }) => {
    // Second middleware, access to first middleware context
    const user = ctx.user;

    const result = await next({ ctx: { permissions: ['read', 'write'] } });
    return result;
  })
  .handler((request, context) => {
    // Access to complete context from both middlewares
    const { user, permissions } = context.ctx;
    return Response.json({ user, permissions });
  });

Conclusion

next-zod-route is a complete solution to handle your API Routes in Next.js with:

  • Input validation (params, query, body) with Zod
  • Flexible middleware system
  • Custom error handling
  • Validated metadata for authorization
  • Compatibility with different body formats (JSON, form-data)

If you're developing API routes in Next.js and looking for an alternative to Next Safe Action, this library is made for you!

To install it:

BASH
npm install next-zod-route
# or
yarn add next-zod-route
# or
pnpm add next-zod-route

Find the source code and complete documentation on GitHub.