@mhbdev/next-safe-route
v0.0.38
Published
A safer way to define route handlers in Next.js
Maintainers
Readme
next-safe-route is a utility library for Next.js that provides type-safety and schema validation for Route Handlers, with an experimental server-actions layer. It is compatible with Next.js 15+ (including 16) route handler signatures.
Features
- ✅ Schema Validation: Automatically validate request parameters, query strings, and body content with built-in JSON error responses.
- 🧷 Type-Safe: Work with full TypeScript type safety for parameters, query strings, and body content, including transformation results.
- 🔗 Adapter-Friendly: Ships with a zod (v4+) adapter by default and lazily loads optional adapters for valibot and yup.
- 📦 Next-Ready: Matches the Next.js Route Handler signature (including Next 15/16) and supports middleware-style context extensions.
- ⚡ Server Actions (Experimental): Build safe server actions with input/output validation, middleware chaining, and React hooks.
- 🧪 Fully Tested: Extensive test suite to ensure everything works reliably.
Installation
npm install @mhbdev/next-safe-route zodThe library uses zod v4+ by default. Adapters for valibot and yup are optional and lazy-loaded. Install them only if you plan to use them:
# valibot
npm install valibot
# yup
npm install yupIf you use the hooks subpath (@mhbdev/next-safe-route/hooks), install React in your project:
npm install react react-domIf an optional adapter is invoked without its peer dependency installed, a clear error message will explain what to install.
Usage
// app/api/hello/route.ts
import { createSafeRoute } from '@mhbdev/next-safe-route';
import { z } from 'zod';
const paramsSchema = z.object({
id: z.string(),
});
const querySchema = z.object({
search: z.string().optional(),
});
const bodySchema = z.object({
field: z.string(),
});
export const GET = createSafeRoute()
.params(paramsSchema)
.query(querySchema)
.body(bodySchema)
.handler((request, context) => {
const { id } = context.params;
const { search } = context.query;
const { field } = context.body;
return Response.json({ id, search, field }, { status: 200 });
});To define a route handler in Next.js:
- Import
createSafeRouteand your validation library (e.g.,zod). - Define validation schemas for params, query, and body as needed.
- Use
createSafeRoute()to create a route handler, chainingparams,query, andbodymethods. - Implement your handler function, accessing validated and type-safe params, query, and body through
context. Body validation supportsapplication/json,multipart/form-data, andapplication/x-www-form-urlencoded.
Middleware context
Middlewares receive both the incoming request and the accumulated context.data from baseContext and previous middlewares. Middlewares can return either a context object or a Response (synchronously or asynchronously) to short-circuit execution.
const GET = createSafeRoute({
baseContext: { tenantId: 'tenant-1' },
})
.use((request, data) => {
if (!request.headers.get('authorization')) {
return Response.json({ message: 'Unauthorized' }, { status: 401 });
}
return { userId: 'user-123', tenantId: data.tenantId };
})
.handler((request, context) => {
return Response.json(context.data);
});Parser options
You can customize query/body parsing behavior with parserOptions:
parserOptions.query.arrayStrategy:'auto' | 'always' | 'never'(default:'auto')parserOptions.query.coerce:'none' | 'primitive' | ((value, key) => unknown)(default:'none')parserOptions.body.strictContentType:boolean(default:true)parserOptions.body.allowEmptyBody:boolean(default:true)parserOptions.body.emptyValue: value used when empty body is allowed (default:{})parserOptions.body.coerce:'none' | 'primitive' | ((value, key) => unknown)for form/text values
const POST = createSafeRoute({
parserOptions: {
query: {
arrayStrategy: 'always',
coerce: 'primitive',
},
body: {
strictContentType: false,
allowEmptyBody: false,
coerce: 'primitive',
},
},
})
.query(z.object({ page: z.array(z.number()) }))
.body(z.object({ count: z.number() }))
.handler((request, context) => {
return Response.json({ query: context.query, body: context.body });
});Server Actions (Experimental)
You can build non-throwing typed server actions with createSafeActionClient.
// safe-action.ts
import { createSafeActionClient } from '@mhbdev/next-safe-route';
export const actionClient = createSafeActionClient({
defaultServerError: 'Something went wrong while executing the action.',
});// greet-action.ts
'use server';
import { z } from 'zod';
import { actionClient } from './safe-action';
export const greetAction = actionClient
.inputSchema(
z.object({
name: z.string().min(1),
}),
)
.action(async ({ parsedInput }) => {
return {
message: `Hello, ${parsedInput.name}!`,
};
});Action middleware (next(...) style)
Action middleware receives parsedInput, ctx, metadata, and next.
- Call
next()to continue. - Call
next({ ctx: { ... } })to merge context for downstream middleware/handler. - Return a
SafeActionResultearly to short-circuit. - Calling
next()more than once maps to a safeserverError.
const action = actionClient
.inputSchema(z.object({ amount: z.number() }))
.use(async ({ ctx, next }) => {
if (!ctx.userId) {
return { serverError: 'Unauthorized' };
}
return next({
ctx: {
requestId: crypto.randomUUID(),
},
});
})
.action(async ({ parsedInput, ctx }) => {
return {
ok: true,
amount: parsedInput.amount,
requestId: ctx.requestId as string,
};
});Result contract
Actions always resolve to a non-throwing result envelope:
- Success:
{ data } - Input validation failure:
{ validationErrors: { fieldErrors, formErrors } } - Server failure:
{ serverError }
Validation paths are normalized to dot keys (for example users.0.name).
Hooks (@mhbdev/next-safe-route/hooks)
'use client';
import { useAction } from '@mhbdev/next-safe-route/hooks';
import { greetAction } from './greet-action';
export function Greet() {
const { execute, result, status, reset } = useAction(greetAction);
return (
<div>
<button onClick={() => execute({ name: 'John Doe' })}>Run</button>
<button onClick={reset}>Reset</button>
<pre>{JSON.stringify({ status, result }, null, 2)}</pre>
</div>
);
}Additional hooks:
useOptimisticAction(action, { initialState, updateFn, preserveOnError? })useStateAction(action, { initialState, mapFormData?, onSuccess?, onValidationError?, onServerError? })
useStateAction includes formAction(formData) for direct <form action={formAction}> usage.
Using other validation libraries
The package exports adapters so you can bring your own schema library. Optional adapters can be imported from the main entry or their own subpaths to avoid pulling in unused code:
import { createSafeRoute } from '@mhbdev/next-safe-route';
import { valibotAdapter } from '@mhbdev/next-safe-route/valibot';
import { object, string } from 'valibot';
const querySchema = object({
search: string(),
});
export const GET = createSafeRoute({
validationAdapter: valibotAdapter(),
})
.query(querySchema)
.handler((request, context) => {
return Response.json({ search: context.query.search });
});Tests
Tests are written using Vitest. To run the tests, use the following command:
pnpm testContributing
Contributions are welcome! For major changes, please open an issue first to discuss what you would like to change.
License
This project is licensed under the MIT License - see the LICENSE file for details.
