Payload CMS Printable Checklist: One-Click Admin Print
Create a one-click printable checklist from React Hook Form in Payload CMS using a hidden iframe and reusable handler.

📚 Comprehensive Payload CMS Guides
Detailed Payload guides with field configuration examples, custom components, and workflow optimization tips to speed up your CMS development process.
I was wrapping a Payload CMS admin customization for fresh-produce batches when the client asked for a printable checklist. The default admin UI has no print story, and opening a blank window.print() popup just gets blocked. After a few iterations I landed on a pattern that streams form data into a hidden iframe, prints immediately, and cleans itself up. This guide walks you through the exact setup so you can give editors a one-click printable production batch list.
The goal is to render whatever is on the React Hook Form (including unsaved edits) into a print-friendly HTML layout. We fall back to a Payload fetch when the form has no rows yet, but you could extend that to any other collection. Along the way you’ll see how the handler stays isolated from the component tree, so the same code can be reused from other actions or scripts.
1. Centralize printable helpers
Start by moving every formatting concern into a shared handler. This keeps the client component lean and makes it trivial to reuse the logic from other entry points or background jobs.
// File: src/collections/ProductionBatches/handlers/printable.ts
import { toast } from "sonner";
import type { DeliveryDate, Product, ProductionBatch } from "@payload-types";
export type ProductOption = Pick<Product, "id" | "title" | "quantityUnit">;
export type ProductionBatchProduct = NonNullable<
ProductionBatch["products"]
>[number];
export type PrintableProductRow = {
index: number;
title: string;
totalStock?: string;
quantityUnit?: string;
bbeDate?: string;
};
const escapeHtml = (value: string) =>
value
.replace(/&/g, "&")
.replace(/</g, "<")
.replace(/>/g, ">")
.replace(/"/g, """)
.replace(/'/g, "'");
const normalizePrintableStock = (
value: ProductionBatchProduct["totalStock"],
) => {
if (value === undefined || value === null) return undefined;
if (typeof value === "number")
return Number.isFinite(value) ? String(value) : undefined;
const stringValue = String(value).trim();
return stringValue.length > 0 ? stringValue : undefined;
};
export const buildPrintableProducts = (
rows: ProductionBatch["products"],
products: ProductOption[],
): PrintableProductRow[] =>
(rows ?? [])
.map((row, index) => {
if (!row) return null;
const productId =
typeof row.product === "number"
? row.product
: typeof row.product === "object" &&
row.product !== null &&
typeof (row.product as Product).id === "number"
? (row.product as Product).id
: undefined;
const titleCandidate =
typeof row.product === "object" && row.product && "title" in row.product
? String((row.product as Product).title ?? "")
: undefined;
const resolvedTitle = (() => {
if (productId !== undefined) {
const fromOptions = products.find(
(product) => product.id === productId,
)?.title;
return fromOptions?.length
? fromOptions
: (titleCandidate ?? `#${productId}`);
}
return titleCandidate ?? `Vnos ${index + 1}`;
})();
const resolvedUnit = (() => {
if (row.unit) return row.unit;
if (productId !== undefined) {
return (
products.find((product) => product.id === productId)
?.quantityUnit ?? undefined
);
}
return undefined;
})();
return {
index: index + 1,
title: resolvedTitle,
totalStock: normalizePrintableStock(row.totalStock),
quantityUnit: resolvedUnit,
bbeDate: row.bbeDate
? new Date(row.bbeDate).toLocaleDateString("sl-SI")
: undefined,
};
})
.filter((row): row is PrintableProductRow => row !== null);
const createTableRowsHtml = (rows: PrintableProductRow[]) =>
rows
.map(
({ index, title, totalStock, quantityUnit, bbeDate }) => `
<tr>
<td style="padding:8px;border:1px solid #d1d5db;">${index}</td>
<td style="padding:8px;border:1px solid #d1d5db;">${escapeHtml(title)}</td>
<td style="padding:8px;border:1px solid #d1d5db;">${escapeHtml(totalStock ?? "")}</td>
<td style="padding:8px;border:1px solid #d1d5db;">${escapeHtml(quantityUnit ?? "")}</td>
<td style="padding:8px;border:1px solid #d1d5db;">${escapeHtml(bbeDate ?? "")}</td>
</tr>
`,
)
.join("");
export default function handlePrint({
printableProducts,
deliveryDates,
deliveryDateValue,
receivedDateValue,
displayNameValue,
fallbackDisplayName,
}: {
printableProducts: PrintableProductRow[];
deliveryDates: DeliveryDate[];
deliveryDateValue: ProductionBatch["deliveryDate"];
receivedDateValue: ProductionBatch["receivedDate"];
displayNameValue: ProductionBatch["displayName"];
fallbackDisplayName?: ProductionBatch["displayName"];
}) {
if (printableProducts.length === 0) {
toast.error("Dodajte vsaj en izdelek, preden pripravite natis.");
return;
}
const deliveryDateLabel = (() => {
if (!deliveryDateValue) return undefined;
if (typeof deliveryDateValue === "number") {
const match = deliveryDates.find((date) => date.id === deliveryDateValue);
return match
? new Date(match.date).toLocaleDateString("sl-SI")
: `#${deliveryDateValue}`;
}
if (typeof deliveryDateValue === "object" && deliveryDateValue !== null) {
return new Date(
(deliveryDateValue as DeliveryDate).date,
).toLocaleDateString("sl-SI");
}
return undefined;
})();
const receivedDateLabel = receivedDateValue
? new Date(receivedDateValue).toLocaleDateString("sl-SI")
: undefined;
const batchTitle =
typeof displayNameValue === "string" && displayNameValue.trim().length > 0
? displayNameValue
: typeof fallbackDisplayName === "string" &&
fallbackDisplayName.trim().length > 0
? fallbackDisplayName
: "Proizvodna serija";
const printHtml = `
<!DOCTYPE html>
<html lang="sl">
<head>
<meta charset="utf-8" />
<title>Natisni serijo - ${escapeHtml(batchTitle)}</title>
<style>
body { font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; margin: 32px; color: #111827; }
h1 { font-size: 1.75rem; margin-bottom: 0.25rem; }
p { margin: 0.125rem 0; }
table { width: 100%; border-collapse: collapse; margin-top: 24px; font-size: 0.95rem; }
thead th { text-align: left; background: #f3f4f6; border: 1px solid #d1d5db; padding: 8px; }
tbody td { vertical-align: top; }
</style>
</head>
<body>
<header>
<h1>${escapeHtml(batchTitle)}</h1>
<p><strong>Pripravljeno:</strong> ${escapeHtml(new Date().toLocaleString("sl-SI"))}</p>
${receivedDateLabel ? `<p><strong>Datum prejema:</strong> ${escapeHtml(receivedDateLabel)}</p>` : ""}
${deliveryDateLabel ? `<p><strong>Datum dostave:</strong> ${escapeHtml(deliveryDateLabel)}</p>` : ""}
</header>
<main>
<table>
<thead>
<tr>
<th style="width:4rem;">#</th>
<th>Izdelek</th>
<th>Skupna zaloga</th>
<th>Enota</th>
<th>BBE</th>
</tr>
</thead>
<tbody>
${createTableRowsHtml(printableProducts)}
</tbody>
</table>
</main>
</body>
</html>
`;
const iframe = document.createElement("iframe");
iframe.style.position = "fixed";
iframe.style.top = "0";
iframe.style.left = "0";
iframe.style.width = "0";
iframe.style.height = "0";
iframe.style.opacity = "0";
iframe.style.pointerEvents = "none";
iframe.style.border = "0";
iframe.setAttribute("aria-hidden", "true");
iframe.setAttribute("tabindex", "-1");
let cleanupTimeout: number | undefined;
let frameWindowRef: Window | null = null;
const cleanup = () => {
iframe.removeEventListener("load", handleLoad);
if (frameWindowRef) {
frameWindowRef.removeEventListener("afterprint", handleAfterPrint);
frameWindowRef = null;
}
if (iframe.parentNode) {
iframe.parentNode.removeChild(iframe);
}
if (cleanupTimeout !== undefined) {
window.clearTimeout(cleanupTimeout);
cleanupTimeout = undefined;
}
};
const handleAfterPrint = () => cleanup();
const handleLoad = () => {
const frameWindow = iframe.contentWindow;
if (!frameWindow) {
cleanup();
toast.error("Tiskanje ni uspelo: okno ni dostopno.");
return;
}
frameWindowRef = frameWindow;
frameWindowRef.addEventListener("afterprint", handleAfterPrint, {
once: true,
});
frameWindow.setTimeout(() => {
frameWindow.focus();
frameWindow.print();
}, 0);
};
iframe.addEventListener("load", handleLoad);
document.body.appendChild(iframe);
iframe.srcdoc = printHtml;
cleanupTimeout = window.setTimeout(() => {
cleanup();
}, 4000);
}
This handler takes care of formatting product labels, building a tiny HTML table, and handling the hidden iframe lifecycle. The iframe route avoids popup blockers because it stays in the same window context. You’re left with a single entry point (handlePrint) that any React component or background trigger can call.
2. Wire printable rows into the client form
Next, hydrate the handler from the production batch edit form. Build printable rows from whatever React Hook Form currently knows, and keep a summary count for editors so they can spot missing products before printing.
// File: src/collections/ProductionBatches/views/edit/components/client-form.tsx
const watchedProducts = watch("products");
const printableProducts = useMemo(
() => buildPrintableProducts(watchedProducts, products),
[products, watchedProducts],
);
const hasPrintableProducts = printableProducts.length > 0;
const selectedProductCount = useMemo(
() =>
(watchedProducts ?? []).reduce((count, row) => {
if (!row) return count;
const rawProduct = row.product;
if (typeof rawProduct === "number") return count + 1;
if (typeof rawProduct === "object" && rawProduct !== null) {
const candidate = rawProduct as Product;
return typeof candidate?.id === "number" ? count + 1 : count;
}
return count;
}, 0),
[watchedProducts],
);
const handlePrintClick = useCallback(() => {
handlePrint({
printableProducts,
deliveryDates,
deliveryDateValue: watchedDeliveryDate,
receivedDateValue: watchedReceivedDate,
displayNameValue: watchedDisplayName,
fallbackDisplayName: defaultValues.displayName,
});
}, [
defaultValues.displayName,
deliveryDates,
printableProducts,
watchedDeliveryDate,
watchedDisplayName,
watchedReceivedDate,
]);
The buildPrintableProducts helper converts whatever is in the form—numbers, populated relationships, or blank rows—into a list that can be rendered for print. selectedProductCount is optional, but I like surfacing it in the details card so editors see how many items will land in the printout.
3. Hook into the admin toolbar
Finally, expose the print action next to the existing buttons. Disabling the button when there are no rows mirrors the iframe handler’s guard and keeps the UX honest.
// File: src/collections/ProductionBatches/views/edit/components/client-form.tsx
<ProductionBatchToolbar
primary={
<>
<Button
type="button"
buttonStyle="secondary"
onClick={() => reset({ ...defaultValues, intent: defaultIntent })}
>
Ponastavi
</Button>
<Button
type="button"
buttonStyle="secondary"
onClick={handlePrintClick}
disabled={!hasPrintableProducts}
>
Natisni seznam
</Button>
<Button
type="button"
buttonStyle="primary"
disabled={isSubmitting || !isDirty}
onClick={() => {
handleSubmit(onSubmit)();
}}
>
Shrani
</Button>
{/* … */}
</>
}
/* … */
/>
With this in place the button runs entirely on the client. Editors get an instant print dialog, the iframe tears down automatically after afterprint, and unsaved changes are preserved. If you ever need to print straight from a list view—or from a scheduled script—you can call the same handler with data fetched via Payload’s REST or local API.
Wrap-up
The request started with “Can we print this batch?” and ended up with a reusable print pipeline that lives alongside the rest of our Payload admin customizations. We isolated every formatting decision in handlers/printable.ts, transformed live form state into printable rows, and surfaced a single button that opens the browser dialog without juggling extra tabs.
You now have everything you need to extend this pattern to other collections or expand the HTML template with logos and signatures. Let me know in the comments if you have questions, and subscribe for more practical development guides. Thanks, Matija