elementsinteractive/payload-zodapi(opens in a new tab)
Language: TypeScript
- stars
- 4
- watchers
- 0
- forks
- 1
- open issues
- 0
- Last updated
- 10/27/2026
Language: TypeScript
A Payload CMS plugin that generates a unified OpenAPI spec for both Payload's built-in collection/global endpoints and your custom endpoints, with full Zod-based request/response validation and TypeScript type inference.
Payload gives you a powerful API out of the box, but there's no built-in way to document it or validate custom endpoints consistently. This plugin bridges that gap:
/api/openapi.json.defineEndpoint wraps Payload endpoint handlers with Zod validation for path params, query params, and request/response bodies. You get full type inference with zero boilerplate.generate:schemas bin command converts the Payload JSON schema into TypeScript Zod schemas, which you can use for response validation, data transformation, or anywhere else you need runtime schema checks.pnpm add @elementsinteractive/payload-zodapi
# or
npm install @elementsinteractive/payload-zodapiRegister the plugin in your payload.config.ts:
import { zodApiPlugin } from "@elementsinteractive/payload-zodapi"; export default buildConfig({ plugins: [ zodApiPlugin({ openapiVersion: "3.0", metadata: { title: "My API", version: "1.0.0" }, }), ], custom: { zodApiPluginOpts: { schemasPath: "src/payload-schemas.ts", // output path for generated Zod schemas }, }, });
The OpenAPI spec is served at /api/openapi.json and interactive docs at /api/docs.
Use defineEndpoint instead of a raw Payload endpoint object. It handles validation, type inference, and OpenAPI registration automatically.
import type { CollectionConfig } from "payload"; import { z } from "zod"; import { defineEndpoint } from "@elementsinteractive/payload-zodapi"; const getPost = defineEndpoint({ path: "/:id", method: "get", openapi: { summary: "Get post by ID", tags: ["Posts"], params: z.object({ id: z.string().uuid() }), query: z.object({ include: z.string().optional() }), response: z.object({ id: z.string(), title: z.string() }), }, handler: async (req, { pathParams, queryParams }) => { const { id } = pathParams; // { id: string } const { include } = queryParams; // { include?: string } return Response.json({ id, title: "Hello world" }); }, }); export const Posts: CollectionConfig = { slug: "posts", endpoints: [getPost], fields: [{ name: "title", type: "text" }], };
Omit params and path params are inferred directly from the :param segments in path — both at the type level and at runtime.
defineEndpoint({ path: "/posts/:slug/:version", method: "get", handler: async (req, { pathParams }) => { // pathParams: { slug: string; version: string } const { slug, version } = pathParams; }, });
Provide an explicit params schema when you need stricter validation:
defineEndpoint({ path: "/posts/:id/:status", method: "get", openapi: { params: z.object({ id: z.string().uuid(), status: z.enum(["draft", "published"]), }), }, handler: async (req, { pathParams }) => { // pathParams: { id: string; status: "draft" | "published" } }, });
Use the queryArray helper to normalise query params that may appear multiple times (e.g. ?tag=a&tag=b):
import { defineEndpoint, queryArray } from "@elementsinteractive/payload-zodapi"; defineEndpoint({ path: "/items", method: "get", openapi: { query: z.object({ tag: queryArray(z.string()), // string[] — handles ?tag=a&tag=b page: z.string().optional(), // string — single value }), }, handler: async (req, { queryParams }) => { const { tag, page } = queryParams; // tag: string[], page: string | undefined }, });
Guards are async functions that run before the handler. Return a Response to short-circuit the request, or return nothing to allow it through. They're useful for authentication, authorization, and other pre-handler checks.
defineEndpoint({ path: "/:id", method: "get", guards: [ async (req) => { if (!req.user) { return Response.json({ error: "Not authenticated" }, { status: 401 }); } }, ], handler: async (req, { pathParams }) => { return Response.json({ id: pathParams.id }); }, });
Multiple guards run in order and each can independently short-circuit.
Response bodies are validated against the response schema. Behaviour is controlled per-endpoint via responseValidation:
| Mode | Behaviour |
|---|---|
"enforce" |
Logs error and returns 500 (default) |
"log" |
Logs a warning but returns the original response |
"off" |
Skips response validation entirely |
defineEndpoint({ openapi: { response: z.object({ id: z.string() }), responseValidation: "log", }, handler: async (req, input) => { /* ... */ }, });
Generate Zod schemas from your Payload config with:
pnpm payload generate:schemas
This reads the Payload config, converts it to JSON schema via configToJSONSchema, and outputs TypeScript Zod schemas to the path configured in custom.zodApiPluginOpts.schemasPath.
import { PostSchema } from "@/payload-schemas"; const myEndpoint = defineEndpoint({ path: "/posts", method: "get", openapi: { response: z.array(PostSchema), }, handler: async (req) => { /* ... */ }, });
If your JSON schema contains structures that confuse quicktype, supply additional pre-processors via the schemaPreProcessors plugin option. Each pre-processor receives a JSONSchema4 and returns a transformed one.
import type { SchemaPreProcessor } from "@elementsinteractive/payload-zodapi"; // Flatten a deeply-nested or circular property so quicktype can handle it const flattenCircularRefs: SchemaPreProcessor = (schema) => { const definitions = { ...(schema.definitions ?? {}) }; for (const [key, def] of Object.entries(definitions)) { if (def.properties?.metadata) { definitions[key] = { ...def, properties: { ...def.properties, metadata: { type: "object" } }, }; } } return { ...schema, definitions }; }; zodApiPlugin({ metadata: { title: "My API", version: "1.0.0" }, schemaPreProcessors: [flattenCircularRefs], });
MIT
Thanks goes to these wonderful people (emoji key):
Sacha Brouté 💻 🤔 🚧 |
Sergio Castillo 💻 🚧 |
|||||
|
|
||||||
This project follows the all-contributors specification. Contributions of any kind welcome!