Skip to content

Overview & Quick Start

Typed Payload application operations

@nexload-sdk/payload-operations

v1.0.0 Stable Released

Typed custom operations for Payload CMS with the native Payload REST SDK.

Payload CMSNode.jsBrowserESM

@nexload-sdk/payload-operations defines a single shared Zod contract, mounts it as secure Payload CMS REST endpoints, and exposes matching, fully typed RPC client methods beside the native PayloadSDK.

Use it for application commands, domain actions, and multi-step workflows that do not fit Payload’s default collection CRUD API. Keep ordinary document reads and writes on cms.payload; put project-owned business operations under cms.operations.


import { createCMSClient, defineCMSOperations, operation } from "@nexload-sdk/payload-operations";
import { z } from "zod";
// 1. Define shared contract
export const operations = defineCMSOperations({
inventory: {
check: operation({
input: z.object({ sku: z.string() }),
output: z.object({ available: z.number().int() }),
}),
},
});
// 2. Consume from frontend or server action with full type inference
const cms = createCMSClient({
operations,
payload: { baseURL: "https://cms.example.com/api" },
});
const stock = await cms.operations.inventory.check({ sku: "TSHIRT-BLK-M" });
console.log(stock.available); // typed as number

  • Single Source of Truth: Define input, output, and domain error schemas once; TypeScript infers both client and server types automatically.
  • Hierarchical Operation Trees: Group operations logically (auth.login, orders.create, inventory.reserve) without flat route chaos.
  • Asynchronous Zod Parsing: Full support for input transformations, client-side pre-flight sanitization, and output projections.
  • Discriminated Domain Errors: Explicit error definitions with typed data payloads, status codes, and deterministic client pattern matching.
  • Native Payload Integration: Handlers receive the authentic PayloadRequest, providing full access to req.payload.find, req.payload.create, and user session context.
  • Shared Transport Pipeline: A unified fetch client with plugin support (timeouts, custom Bearer authentication, distributed tracing) shared between custom operations and the official Payload SDK.

Terminal window
pnpm add @nexload-sdk/payload-operations payload @payloadcms/sdk zod

With alternative package managers:

Terminal window
# npm
npm install @nexload-sdk/payload-operations payload @payloadcms/sdk zod
# bun
bun add @nexload-sdk/payload-operations payload @payloadcms/sdk zod
Package / RuntimeSupported RangeNotes
Node.js>=20.9.0Required for server runtime and AbortSignal support
Payload CMS>=3.85.0 <4.0.0Server endpoints & Local API engine
@payloadcms/sdk>=3.85.0 <4.0.0Native REST SDK companion
Zod>=4.0.0 <5.0.0Contract schema validation

Keep payload and @payloadcms/sdk pinned to the exact same version. The package is ESM-only and side-effect free.


1. The Contract Model (@nexload-sdk/payload-operations/contract)

Section titled “1. The Contract Model (@nexload-sdk/payload-operations/contract)”

The contract is a shared, platform-neutral module containing only Zod schemas and metadata. It has zero dependencies on Payload server internals, making it 100% safe to import in browser bundles, Next.js Server Components, mobile clients, and worker threads.

  • Client Boundary: Validates input before sending a network request. If invalid, fails immediately with INPUT_VALIDATION_FAILED without unnecessary HTTP traffic.
  • Server Boundary: Re-validates input inside the Payload endpoint before executing the handler, protecting backend business logic. Output is validated and serialized back safely.

3. Server Endpoints Mount (@nexload-sdk/payload-operations/server)

Section titled “3. Server Endpoints Mount (@nexload-sdk/payload-operations/server)”

createPayloadEndpoints transforms your contract into Payload-compatible Endpoint definitions. It registers:

  • POST handlers for each operation route (e.g. /api/operations/inventory/reserve).
  • OPTIONS handlers to support CORS pre-flight across frontend origins.
  • Default RBAC protection requiring an authenticated session (Boolean(req.user)), with granular per-operation access overrides.

Create src/operations/contract.ts (shared between client and server):

import { defineCMSOperations, operation } from "@nexload-sdk/payload-operations";
import { z } from "zod";
export const cmsOperations = defineCMSOperations({
orders: {
create: operation({
input: z.object({
sku: z.string().min(1),
quantity: z.number().int().positive(),
}),
output: z.object({
orderId: z.string(),
createdAt: z.string(),
totalAmount: z.number(),
}),
errors: {
OUT_OF_STOCK: {
status: 409,
message: "Selected product is out of stock.",
data: z.object({ available: z.number().int().nonnegative() }),
},
},
}),
},
});

Step 2: Implement Handlers in Payload Config

Section titled “Step 2: Implement Handlers in Payload Config”

In src/payload.config.ts, mount the operations into Payload’s endpoints array:

import { buildConfig } from "payload";
import { createPayloadEndpoints } from "@nexload-sdk/payload-operations/server";
import { cmsOperations } from "./operations/contract";
const operationEndpoints = createPayloadEndpoints({
operations: cmsOperations,
basePath: "/api/operations",
access: {
// Default: Must be logged in
default: ({ req }) => Boolean(req.user),
},
handlers: {
orders: {
create: async ({ input, errors, req }) => {
// Query database via Payload Local API
const item = await req.payload.find({
collection: "inventory",
where: { sku: { equals: input.sku } },
limit: 1,
});
const stock = item.docs[0]?.stock ?? 0;
if (stock < input.quantity) {
throw errors.OUT_OF_STOCK({ data: { available: stock } });
}
// Create order document
const order = await req.payload.create({
collection: "orders",
data: {
sku: input.sku,
quantity: input.quantity,
customer: req.user?.id,
},
});
return {
orderId: String(order.id),
createdAt: new Date().toISOString(),
totalAmount: input.quantity * 25,
};
},
},
},
});
export default buildConfig({
// Mount custom operations endpoints
endpoints: [...operationEndpoints],
// ... collections, db adapter, etc.
});

In your frontend application or Next.js App Router:

import { createCMSClient, safe, isDefinedError } from "@nexload-sdk/payload-operations";
import { cmsOperations } from "./operations/contract";
const cms = createCMSClient({
operations: cmsOperations,
basePath: "/api/operations",
payload: {
baseURL: "https://cms.example.com",
baseInit: { credentials: "include" },
},
});
export async function submitOrder() {
const [error, order, isDefined] = await safe(
cms.operations.orders.create({
sku: "ITEM-100",
quantity: 2,
})
);
if (isDefined && isDefinedError(error, "OUT_OF_STOCK")) {
alert(`Only ${error.data.available} items left in stock.`);
return;
}
if (error) {
alert("Unexpected order failure.");
return;
}
console.log("Order confirmed:", order.orderId);
}