Guide
HTML to PDF in Payload CMS
Payload CMS is a TypeScript-native headless CMS that runs alongside your Next.js app. It exposes both a REST and a GraphQL API, and its local API lets you query any collection directly inside custom endpoints and hooks without going over HTTP. That makes PDF generation straightforward: you can trigger it from a custom POST /api/export-pdf endpoint on demand, or wire it to an afterOperation hook so a PDF is generated automatically every time an article is published.
Overview
Both approaches use the same pattern: fetch the document from Payload's local API, build an HTML string from its fields, and POST that HTML to the PDF API. The API returns either a byte stream or a stable download URL depending on whether you pass store: true. All configuration lives in payload.config.ts for the endpoint, or in the collection file for the hook.
// Payload CMS runs inside your Next.js app.
// payload.config.ts is the single source of truth for collections,
// custom endpoints, hooks, and plugins.
//
// Install the PDF API client:
// npm install node-fetch # or use native fetch in Node 18+
//
// Set your API key in .env:
// PDFPIPE_API_KEY=your_api_key_hereCustom REST endpoint
Add a custom endpoint to payload.config.ts using the endpoints array. The handler reads a contentId from the request body, fetches the article via req.payload.findByID, builds HTML from its fields, calls the PDF API with store: true, and returns the document_url. The endpoint is reachable at POST /api/export-pdf once Payload is running:
// payload.config.ts
import { buildConfig } from "payload/config";
export default buildConfig({
// ... your existing config
endpoints: [
{
path: "/export-pdf",
method: "post",
handler: async (req, res) => {
const { contentId } = req.body as { contentId: string };
if (!contentId) {
return res.status(400).json({ error: "contentId is required" });
}
// Fetch the article from Payload's local API.
// req.payload gives you access to the full Payload API without
// going through HTTP — no extra auth needed.
const article = await req.payload.findByID({
collection: "articles",
id: contentId,
});
if (!article) {
return res.status(404).json({ error: "Article not found" });
}
// Build an HTML string from the article fields.
const html = `<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8" />
<style>
body { font-family: Georgia, serif; max-width: 720px; margin: 40px auto; color: #1d1812; }
h1 { font-size: 2rem; margin-bottom: 0.5rem; }
.meta { color: #6b6560; font-size: 0.875rem; margin-bottom: 2rem; }
</style>
</head>
<body>
<h1>${article.title}</h1>
<p class="meta">Published ${new Date(article.publishedAt).toDateString()}</p>
${article.contentHtml ?? ""}
</body>
</html>`;
const upstream = await fetch("https://api.pdfpipe.xyz/v1/pdf", {
method: "POST",
headers: {
Authorization: `Bearer ${process.env.PDFPIPE_API_KEY}`,
"Content-Type": "application/json",
},
body: JSON.stringify({
html,
options: { format: "A4" },
store: true,
filename: `${article.slug ?? contentId}.pdf`,
}),
});
if (!upstream.ok) {
const err = await upstream.json().catch(() => ({}));
return res.status(502).json({ error: (err as any).detail ?? "render failed" });
}
// store: true returns the URL in the response body.
const result = await upstream.json();
return res.status(200).json({ document_url: result.document_url });
},
},
],
});afterOperation hook
For fully automatic PDF generation, attach an afterOperation hook to your collection. The hook fires after every create or update operation. It checks whether the document was published, generates the PDF, and writes the resulting URL back to the document using req.payload.update. Editors see the pdfUrl field populate automatically in the admin UI after saving:
// collections/Articles.ts
import type { CollectionConfig } from "payload/types";
export const Articles: CollectionConfig = {
slug: "articles",
fields: [
{ name: "title", type: "text", required: true },
{ name: "slug", type: "text" },
{ name: "status", type: "select", options: ["draft", "published"] },
{ name: "publishedAt", type: "date" },
{ name: "content", type: "richText" },
// Stores the generated PDF URL after publishing.
{ name: "pdfUrl", type: "text", admin: { readOnly: true } },
],
hooks: {
afterOperation: [
async ({ operation, result, req }) => {
// Run only on create/update when the doc is being published.
if (
(operation === "create" || operation === "update") &&
result.status === "published"
) {
const html = `<!DOCTYPE html>
<html>
<head><meta charset="utf-8" /></head>
<body>
<h1>${result.title}</h1>
${result.contentHtml ?? ""}
</body>
</html>`;
const upstream = await fetch("https://api.pdfpipe.xyz/v1/pdf", {
method: "POST",
headers: {
Authorization: `Bearer ${process.env.PDFPIPE_API_KEY}`,
"Content-Type": "application/json",
},
body: JSON.stringify({
html,
options: { format: "A4" },
store: true,
filename: `${result.slug ?? result.id}.pdf`,
}),
});
if (upstream.ok) {
const { document_url } = await upstream.json();
// Write the PDF URL back to the document.
await req.payload.update({
collection: "articles",
id: result.id,
data: { pdfUrl: document_url },
});
// Return the updated result so callers see the new field.
return { ...result, pdfUrl: document_url };
}
}
return result;
},
],
},
};Rendering rich text
Payload stores rich text as structured JSON, not as raw HTML. Before passing content to the PDF API, serialize it to an HTML string. Payload ships a convertLexicalToHTML helper in the @payloadcms/richtext-lexical package for projects using the Lexical editor (the default since Payload v2):
// utils/lexicalToHtml.ts
// Payload ships a helper that converts its Lexical editor state to HTML.
// Import it from the @payloadcms/richtext-lexical package.
import { convertLexicalToHTML } from "@payloadcms/richtext-lexical";
// article.content is the raw Lexical JSON stored by Payload.
export async function serializeRichText(
lexicalState: Record<string, unknown>
): Promise<string> {
return convertLexicalToHTML({ data: lexicalState });
}
// ---
// Then in your endpoint or hook, replace the static contentHtml reference:
//
// import { serializeRichText } from "../utils/lexicalToHtml";
//
// const contentHtml = await serializeRichText(article.content);
//
// const html = `...
// <body>
// <h1>${article.title}</h1>
// ${contentHtml}
// </body>
// ...`;For older Payload projects that still use Slate, import from @payloadcms/richtext-slate instead:
// If your Payload project was created before v2 and uses Slate editor:
// npm install @payloadcms/richtext-slate
import { slateToHtml } from "@payloadcms/richtext-slate";
// article.content is an array of Slate nodes.
const contentHtml = slateToHtml(article.content);Environment variable
Store your API key in a .env file at the project root. Payload loads it automatically in development. In production, set it through your hosting environment so it is available as process.env.PDFPIPE_API_KEY at runtime:
# .env (project root, alongside payload.config.ts)
PDFPIPE_API_KEY=your_api_key_here
# Payload reads .env automatically in development via dotenv.
# In production, inject this variable through your host's secret manager
# (Vercel env vars, Railway variables, etc.) — never commit the value.Getting an API key
The Hobby plan gives 500 free documents a month with no credit card required. Paid plans start at $19 and include a longer document archive, higher limits, and email support.
Full API reference on the docs page. Try it now in the live playground with no signup.
Get an API key →