A simple NextJS API abstraction to enforce validation and conistent HTTP responses in REST API endpoints
- Out-of-the-box validation of both the request body and search params with Zod
- Type safe request body and search params
- Consistent HTTP 400 on validation errors, HTTP 500 on any other runtime error
- Same NextJS API behaviour that you already love
// pages/api/users
export default createHandler((create) => {
return {
post: create(
{
// Invalid body will return a HTTP 400
body: z.object({
firstName: z.string(),
lastName: z.string(),
}),
},
async (req, res) => {
// Validated and type-safe body
const { firstName, lastName } = req.body;
const user = await createUser({ firstName, lastName });
return res.status(200).json(user);
}
),
};
});
An example with a fictional endpoints:
- HTTP GET request with a required
sort
search param - HTTP POST request with a required body
const handler: NextApiHandler = async (req, res) => {
if (req.method === 'POST') {
try {
const validationSchema = z.object({
firstName: z.string(),
lastName: z.string(),
});
let validatedBody: z.infer<typeof validationSchema>;
try {
validatedBody = await validationSchema.parseAsync(req.body);
} catch (error) {
if (error instanceof ZodError) {
return res.status(400).json({
code: 'VALIDATION_FAILED',
details: error.format(),
});
}
return res.status(500).json({
code: 'INTERNAL_SERVER_ERROR',
});
}
const user = await createUser(validatedBody);
return res.status(200).json(user);
} catch (error) {
return res.status(500).json({
code: 'INTERNAL_SERVER_ERROR',
});
}
} else if (req.method === 'GET') {
try {
const validationSchema = z.object({
sort: z.string(),
});
let validatedBody: z.infer<typeof validationSchema>;
try {
validatedBody = await validationSchema.parseAsync(req.query);
} catch (error) {
if (error instanceof ZodError) {
return res.status(400).json({
code: 'VALIDATION_FAILED',
details: error.format(),
});
}
return res.status(500).json({
code: 'INTERNAL_SERVER_ERROR',
});
}
const { sort } = req.query;
const users = await findUsers({ sort });
return res.status(200).json(users);
} catch (error) {
return res.status(500).json({
code: 'INTERNAL_SERVER_ERROR',
});
}
}
};
const handler = createHandler((create) => ({
get: create(
{
params: z.object({
sort: z.string(),
}),
},
async (req, res) => {
const { sort } = req.query;
const users = await findUsers({ sort });
return res.status(200).json(users);
}
),
post: create(
{
// Invalid body will return a HTTP 400
body: z.object({
firstName: z.string(),
lastName: z.string(),
}),
},
async (req, res) => {
// Validated and type-safe body
const { firstName, lastName } = req.body;
const user = await createUser({ firstName, lastName });
return res.status(200).json(user);
}
),
}));
This handler is built upon zod
import type { NextApiHandler, NextApiRequest, NextApiResponse } from 'next';
import { ZodError, type z, type ZodTypeAny } from 'zod';
const formatZodError = (error: ZodError) => {
return error.issues.reduce(
(errors, error) => ({
...errors,
[error.path.join('/')]: error.message,
}),
{}
);
};
const StatusCode = {
VALIDATION_FAILED: 'VALIDATION_FAILED',
INTERNAL_SERVER_ERROR: 'INTERNAL_SERVER_ERROR',
};
type HttpMethod = 'get' | 'post' | 'put' | 'patch' | 'delete';
/** Next API request type extension that types the body and query types */
interface ValidatedNextApiRequest<
RequestBody extends NextApiRequest['body'],
RequestQuery extends NextApiRequest['query']
> extends NextApiRequest {
body: RequestBody;
query: RequestQuery;
}
type CreateAction = <
BodyValidationSchema extends ZodTypeAny,
ParamsValidationSchema extends ZodTypeAny
>(
validationSchema:
| Partial<{ body: BodyValidationSchema; params: ParamsValidationSchema }>
| undefined
| null,
nextApiHandler: (
req: ValidatedNextApiRequest<
z.infer<BodyValidationSchema>,
z.infer<ParamsValidationSchema>
>,
res: NextApiResponse<any>
) => unknown | Promise<unknown>
) => () => void | Promise<void>;
type MakeCreateHandler = (
createAction: CreateAction
) => Partial<Record<HttpMethod, ReturnType<CreateAction>>>;
type CreateHandler = (makeCreateHandler: MakeCreateHandler) => NextApiHandler;
export const createHandler: CreateHandler = (makeCreateHandler) => {
return async (req, res) => {
const createAction: CreateAction =
(validationSchema, nextApiHandler) => async () => {
// Do validation
try {
const validatedBody = validationSchema?.body
? await validationSchema.body?.parseAsync(req.body)
: req.body;
// Overwrite the request body with the coerced body
req.body = validatedBody;
const validatedParams = validationSchema?.params
? await validationSchema.params?.parseAsync(req.query)
: req.query;
// Overwrite the request query with the coerced query
req.query = validatedParams;
} catch (error) {
if (error instanceof ZodError) {
// Validation error
console.warn('Validation error', { error });
return res.status(400).json({
code: StatusCode.VALIDATION_FAILED,
details: formatZodError(error),
});
}
// Something went really wrong
return res.status(500).json({
code: StatusCode.INTERNAL_SERVER_ERROR,
});
}
try {
await nextApiHandler(req, res);
} catch (error) {
console.error('Internal server error', { error });
return res.status(500).json({
code: StatusCode.INTERNAL_SERVER_ERROR,
});
}
};
const actions = makeCreateHandler(createAction);
const action = actions[`${req.method?.toLocaleLowerCase()}` as HttpMethod];
if (!action) {
return res.status(405).end();
}
await action();
};
};