Payload Engineering
Payload's Generated Types Are the Contract: The Zod + Server Action Pattern We Ship Instead of DTOs
Payload generates the types. Zod derives from them. Server Actions accept the same shape the Local API returns. Here is the pattern, the four failure modes it removes, and the one place we still validate by hand.

*Three copies of the same field shape is two copies too many — and by month three, one of them is lying.*
Every Payload + Next.js codebase we inherit has the same drift. The collection config declares a field. Someone writes a Zod schema for the form because Server Actions want validated input. Someone else types the Server Action signature by hand because the Zod inference felt awkward. Three copies of the same shape, in three files, maintained by three different pull requests. By month three, one of them is lying — usually the Zod one, because the collection config got a new field and nobody updated the form schema.
The failure mode is not dramatic. It is a silent one: a Server Action accepts a payload that Payload's Local API then rejects with a validation error the user never sees, or worse, accepts a payload with a stale field name that gets silently dropped on write. We have debugged this on three Payload projects in the last eighteen months. The fix is not more schemas. The fix is fewer.
Payload already generates a TypeScript file that describes every collection, every field, every relationship, every locale. It is the closest thing to a source of truth the stack has. We stopped writing DTOs on top of it. This is the pattern we ship instead — the generated types anchor the contract, Zod derives from them where runtime validation is needed, and Server Actions accept the same shape the Local API returns.
The drift problem: three places a field shape lives
On a typical Payload + Next.js App Router project, a single `Post.title` field ends up described in three separate files:
Collection config — `collections/Posts.ts`, where Payload owns the field definition (type, required, minLength, localized, access rules).
Form schema — `lib/schemas/post.ts`, a hand-written Zod object the client-side form imports for `react-hook-form` resolvers.
Server Action signature — `app/actions/posts.ts`, where the function accepts either the Zod-parsed shape, an untyped `FormData`, or a hand-rolled interface someone wrote in a hurry.
Any one of these three can change independently. Rename `title` to `headline` in the collection config, run `payload generate:types`, and TypeScript will yell at every consumer of the generated `Post` interface. Good. But the Zod schema in `lib/schemas/post.ts` still references `title` — TypeScript does not know it is supposed to be a mirror. The form compiles, the Server Action compiles, and at runtime Payload's Local API silently drops the unknown key or throws a validation error the form was not built to display.
Why we stopped writing DTOs
The `payload generate:types` command produces a `payload-types.ts` file with an interface for every collection and global, keyed by slug. Relationships are typed as either the ID union or the populated object, localized fields become per-locale records, and blocks get a discriminated union. This is more than enough to anchor a contract.
// payload-types.ts — generated, do not edit
export interface Post {
id: string;
title: string;
slug: string;
status: 'draft' | 'published';
author: string | User;
category?: (string | Category) | null;
content: {
root: {
type: string;
children: unknown[];
};
};
publishedAt?: string | null;
updatedAt: string;
createdAt: string;
}
export interface Config {
collections: {
posts: Post;
users: User;
categories: Category;
};
}The instinct at this point is to duplicate this into a Zod schema so Server Actions can validate. Do not. The generated interface is the shape you want your Server Actions to speak. What you actually need Zod for is a narrower job: parsing untrusted input from a form submission into a subset of that shape.
The pattern in 200 lines
Here is the full shape. Collection config on top, generated types below, a derived Zod schema for the form input, and a Server Action that hands the parsed value directly to the Local API.
// collections/Posts.ts
import type { CollectionConfig } from 'payload';
export const Posts: CollectionConfig = {
slug: 'posts',
admin: { useAsTitle: 'title' },
fields: [
{ name: 'title', type: 'text', required: true, minLength: 3 },
{ name: 'slug', type: 'text', required: true, unique: true },
{
name: 'status',
type: 'select',
required: true,
defaultValue: 'draft',
options: [
{ label: 'Draft', value: 'draft' },
{ label: 'Published', value: 'published' },
],
},
{ name: 'author', type: 'relationship', relationTo: 'users', required: true },
{ name: 'category', type: 'relationship', relationTo: 'categories' },
{ name: 'content', type: 'richText' },
{ name: 'publishedAt', type: 'date' },
],
};// lib/schemas/post.ts
import { z } from 'zod';
import type { Post } from '@/payload-types';
// The shape a form submits — a strict subset of Post, with IDs for relationships.
// This is the ONLY place we hand-write field constraints, and only for fields
// that need runtime validation the browser will not perform for us.
export const postFormSchema = z.object({
title: z.string().min(3).max(200),
slug: z.string().regex(/^[a-z0-9-]+$/, 'lowercase, digits and hyphens only'),
status: z.enum(['draft', 'published']),
author: z.string().min(1), // relationship ID
category: z.string().nullish(), // optional relationship ID
publishedAt: z.string().datetime().nullish(),
}) satisfies z.ZodType<PostFormInput>;
// Compile-time guarantee: the Zod schema output is assignable to a Post subset.
type PostFormInput = Pick<
Post,
'title' | 'slug' | 'status'
> & {
author: string;
category?: string | null;
publishedAt?: string | null;
};
export type PostFormValues = z.infer<typeof postFormSchema>;The `satisfies z.ZodType<PostFormInput>` line is where the whole pattern earns its keep. If someone renames `title` to `headline` in the collection config and regenerates types, `PostFormInput` no longer compiles, which means the `satisfies` clause no longer compiles, which means the Zod schema fails to build. TypeScript catches the drift at the exact moment it is introduced.
// app/actions/posts.ts
'use server';
import { getPayload } from 'payload';
import config from '@payload-config';
import { postFormSchema, type PostFormValues } from '@/lib/schemas/post';
import { revalidatePath } from 'next/cache';
type ActionResult =
| { ok: true; id: string }
| { ok: false; fieldErrors: Partial<Record<keyof PostFormValues, string>> };
export async function createPost(input: PostFormValues): Promise<ActionResult> {
const parsed = postFormSchema.safeParse(input);
if (!parsed.success) {
return {
ok: false,
fieldErrors: flattenZodErrors(parsed.error),
};
}
const payload = await getPayload({ config });
try {
const doc = await payload.create({
collection: 'posts',
data: parsed.data,
});
revalidatePath('/admin/posts');
return { ok: true, id: doc.id };
} catch (err) {
// Payload's own validation caught something Zod did not.
return { ok: false, fieldErrors: mapPayloadErrors(err) };
}
}Two things to notice. First, `parsed.data` is handed straight to `payload.create` — no re-mapping, no DTO conversion. The Zod output shape and the Local API input shape are the same shape, because we designed them to be. Second, we still catch Payload's own validation errors and merge them into the same `fieldErrors` structure the Zod branch produces. Payload enforces things Zod cannot — cross-collection uniqueness, access rules, hook-driven constraints — and we let it.
Deriving Zod from generated interfaces
There are two ways to do this and we have shipped both. The first is what the snippet above uses: hand-write the Zod schema, then use `satisfies` to prove its inferred type is assignable to a `Pick<>` of the generated interface. The second is `z.custom<Post>()`, which skips runtime validation entirely and treats the generated type as gospel.
`satisfies` + `Pick` — use this when the form actually needs runtime validation (public forms, admin UI where a bad input should show a specific field error). You get real Zod parsing and a compile-time drift guard.
`z.custom<Post>()` — use this for internal Server Actions that take input from trusted callers (e.g. another Server Action, a webhook handler that has already validated). Zero runtime cost, full type safety, no drift because there is no second schema to drift from.
We default to the first for anything user-facing. The `satisfies` clause is the mechanism that makes this whole pattern hold up over time — without it, you are back to two schemas that happen to agree today.
Handling relationships and depth
Payload's generated relationship fields are union types: `string | User` for a populated single relation, `(string | User)[]` for `hasMany`. This is honest — a query with `depth: 0` returns IDs, a query with `depth: 1+` returns populated objects. But it is annoying on the write side, because you always want to send IDs, never populated objects.
We handle this with a small helper type that narrows the generated interface to its write shape:
// lib/types/write-shape.ts
type Primitive = string | number | boolean | null | undefined | Date;
// Recursively narrow relationship unions to just their ID form.
export type WriteShape<T> = {
[K in keyof T]: T[K] extends Primitive
? T[K]
: T[K] extends Array<infer U>
? Array<U extends { id: infer ID } ? ID : WriteShape<U>>
: T[K] extends { id: infer ID } | infer S
? S extends string ? ID | S : WriteShape<T[K]>
: WriteShape<T[K]>;
};
// Usage:
// type PostWrite = WriteShape<Post>;
// PostWrite.author is now `string` instead of `string | User`.
// PostWrite.category is `string | null | undefined`.This is 12 lines of type gymnastics and it removes the entire class of "why is this typed as a full User object when I just want to send an ID" bugs. Ship it once, import it everywhere.
Locale-aware forms
Localized fields change shape in the generated types depending on whether you passed `locale: 'all'` or a single locale to the query. Payload's default is single-locale, so the generated field is a plain `string`. Under `locale: 'all'`, it becomes `Partial<Record<LocaleCode, string>>`. This matters for Server Actions that write translations.
// One wrapper we add to keep locale-aware Server Actions honest.
type LocalizedInput<T, K extends keyof T> = Omit<T, K> & {
[P in K]: Partial<Record<Locale, T[P]>>;
};
export type PostLocalizedWrite = LocalizedInput<
WriteShape<Post>,
'title' | 'content'
>;
// Server Action signature is now honest about which fields carry locale keys.
export async function updatePostTranslations(
id: string,
data: Pick<PostLocalizedWrite, 'title' | 'content'>,
) { /* ... */ }The failure mode this removes
We hit this one on a Payload + Next.js project where a content collection got a required `excerpt` field added mid-sprint. The engineer updated the collection config, ran `generate:types`, updated the admin UI, and shipped. The public-facing submission form on the marketing site — which used its own hand-written Zod schema — kept accepting posts without an excerpt. Payload's Local API rejected them. The Server Action swallowed the error and returned a generic "something went wrong". Two days of "the form is broken" tickets before someone traced it. With the `satisfies` pattern, the Zod schema would have failed to compile the moment the required field was added, and the build would have refused to ship until the form was updated.
Where we still hand-write validation
Payload's field validation covers a lot — required, min/max, unique, regex on text fields, custom validate functions per field. It does not cover:
Cross-field constraints — e.g. `publishedAt` must be after `createdAt`, or `status: 'published'` requires a non-empty `content`. These live in Zod's `.refine()` or in a `beforeValidate` collection hook.
Conditional required — e.g. `category` is required only when `status === 'published'`. Payload's field-level `required` is boolean, not conditional; we express this in Zod with `.superRefine()`.
External uniqueness — e.g. a slug that must not collide with a route in an external system. This is a Server Action guard, not a schema concern.
Business rules with side effects — e.g. "only one post per author per day". This is a Postgres unique index plus a caught error, not a schema at all.
The rule of thumb: if Payload can express it, Payload owns it. If it needs data from another collection or another system, it lives in the Server Action. Zod is for form-shape parsing, nothing more.
Migration path
You do not need a big-bang rewrite to introduce this pattern into an existing codebase. Order of operations we use:
Add the CI step — regenerate types on every build, fail on `tsc` errors. This tells you where drift already exists.
Add the `WriteShape` helper — one file, no consumers yet.
Pick the noisiest Server Action — usually the one with the most "why is this typed as `any`" comments — and refactor it first. Use `satisfies` to prove the Zod schema matches the generated type.
Delete the DTO — the interface that used to describe the input can go. The generated type plus `WriteShape` replaces it.
Repeat per Server Action — one per PR. Do not batch. Each conversion is a self-contained diff.
On the projects where we have done this, the delta is typically 30–60 lines deleted per Server Action and one class of bug eliminated per collection touched. It is not a glamorous refactor. It is a compounding one.
What we would not do
A few adjacent patterns we have deliberately rejected, and why:
tRPC on top of Payload — adds a second contract layer between the client and the Local API. The Local API already runs in the same Node process as your Server Actions. There is nothing to marshal over the wire. tRPC solves a problem we do not have.
GraphQL codegen — Payload exposes a GraphQL endpoint, and generating a typed client from it is a valid path. But it produces a third set of types (alongside the collection config and the REST-style generated interfaces), and the shapes do not always match. Two sources of truth is worse than one.
A second schema library — Valibot, ArkType, Effect Schema, whatever. Fine libraries, all of them, but adding one to a Payload project means every future engineer has to learn which one owns which shape. Zod is already in most Next.js codebases. Stop there.
This is one of a handful of default patterns we now wire on every Payload + Next.js engagement. See how we ship Payload builds end-to-end — from collection design through Server Action wiring to the deploy pipeline.
If you are staring at a Payload project where the Zod schemas, the form types, and the collection configs have quietly stopped agreeing, we have seen it. Send us the schema, we will tell you what we would change
On every Payload + Next.js project we ship, this is now the default from day one: `payload generate:types` in CI, `WriteShape` in the types folder, Zod schemas guarded by `satisfies`, and Server Actions that hand parsed input straight to the Local API. It is not a framework, it is not a library, it is a discipline. The reward is that six months in, when a junior engineer renames a field, the build tells them exactly which three files to update — before anything ships to a customer.
// After the call
Questions operators ask next
Does this pattern work with Payload's REST and GraphQL APIs too, or only the Local API?
It works with all three, but the ergonomics are best with the Local API because there is no serialization boundary — the Zod-parsed value goes straight to `payload.create` in the same process. For REST or GraphQL callers, you still get the compile-time drift guard, but you also need to handle serialization of dates and relationships at the boundary.
How does `satisfies` behave under TypeScript strict mode with `exactOptionalPropertyTypes`?
It gets stricter, which is what you want. Under `exactOptionalPropertyTypes: true`, an optional field in the generated `Post` interface will not accept `undefined` as an explicit value — only absence. Your Zod schema needs to use `.optional()` rather than `.nullish()` for those fields, or explicitly allow `null` if the collection field is nullable. We recommend running with strict mode on; this pattern surfaces the mismatches instead of hiding them.
What happens when a Payload collection uses `blocks` fields — does the generated discriminated union still work with Zod?
Yes, but you write a `z.discriminatedUnion('blockType', [...])` that mirrors the generated union. This is one place where `satisfies` really earns its keep — if you add a new block type to the collection config, the Zod discriminated union stops satisfying the generated type and TypeScript tells you exactly which case is missing.
Should we regenerate types on every commit, or only when collection configs change?
Every build in CI, unconditionally. The generation is fast (usually under a second on projects with 20–30 collections), and running it conditionally invites the exact drift the pattern is designed to prevent. We also commit the generated file so code review can see the diff when a field shape changes.
Does this replace the need for Payload access control in Server Actions?
No, and it should not. Type safety is not authorization. Server Actions still need to check the authenticated user and Payload's access rules still fire on the Local API call. The pattern makes sure the shape of the data is correct; access control decides whether this user is allowed to write it. Two different problems, two different layers.
How does this hold up on a monorepo where the Next.js app and Payload live in different packages?
Fine, but you need to export the generated types from the Payload package and import them into the Next.js app as a workspace dependency. The `payload-types.ts` file becomes a build artifact of the Payload package. We usually add a `prebuild` script that regenerates it before the Next.js app builds, so the dependency graph stays honest.
Pull quote
If your Zod schema and your Payload collection can disagree at runtime, one of them is a lie. We stopped letting Zod be the lie.