Guide
HTML to PDF in Directus
Directus wraps any SQL database with a REST and GraphQL API plus a visual data studio. Its built-in Flows automation engine and server-side Hook extensions both give you convenient places to trigger PDF generation whenever a collection item is created or updated. You can also fetch items directly via the Directus JS SDK and generate a PDF on demand from any Node.js script or serverless function.
Overview
All three approaches share the same core pattern: retrieve the item fields from Directus, build an HTML string, and POST it to the PDF API. The API returns either a raw byte stream or a stable download URL depending on whether you pass store: true. The URL variant is particularly convenient with Directus because you can write it straight back to the item as a field value.
// Directus is an open-source headless CMS and data platform.
// It wraps any SQL database with a REST and GraphQL API,
// a visual data studio, and a built-in automation engine (Flows).
//
// Install the Directus JS SDK:
// npm install @directus/sdk
//
// Set your credentials in .env:
// DIRECTUS_URL=https://your-directus.example.com
// DIRECTUS_TOKEN=your_static_token_here
// PDFPIPE_API_KEY=your_api_key_hereUsing Directus Flows
Flows is the visual automation builder built into Directus. You can configure a flow entirely from the admin UI without touching any code: set the trigger to the items.create event on your collection, add a Webhook operation pointing at your server, and Directus will POST the full item payload there whenever a new item is saved. The handler below shows what that endpoint looks like on the receiving end:
// Using Directus Flows (the visual automation builder)
//
// 1. Open your Directus instance → Settings → Flows → Create Flow.
// 2. Add a trigger: "Event Hook" → Action: "items.create" (or items.update)
// → Collection: your collection slug (e.g. "articles").
// 3. Add an operation: "Webhook / Request URL".
// - Method: POST
// - URL: https://your-api-server.example.com/webhooks/directus-pdf
// - Headers: { "Content-Type": "application/json" }
// - Request body: {{ $trigger }} (passes the full trigger payload)
//
// The server-side handler below receives that payload and generates the PDF.
// webhooks/directus-pdf.ts (e.g. an Express route or a serverless function)
import type { Request, Response } from "express";
interface DirectusTriggerPayload {
collection: string;
key: string | number; // the primary key of the created/updated item
payload: Record<string, unknown>;
}
export async function handleDirectusPdfWebhook(
req: Request,
res: Response
): Promise<void> {
const body = req.body as DirectusTriggerPayload;
const { key, payload } = body;
const title = String(payload.title ?? "Untitled");
const content = String(payload.content ?? "");
const slug = String(payload.slug ?? key);
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; }
</style>
</head>
<body>
<h1>${title}</h1>
${content}
</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: `${slug}.pdf`,
}),
});
if (!upstream.ok) {
const err = await upstream.json().catch(() => ({}));
res.status(502).json({ error: (err as { detail?: string }).detail ?? "render failed" });
return;
}
const result = await upstream.json() as { document_url: string };
res.status(200).json({ document_url: result.document_url });
}Custom Hook extension
Hook extensions run inside the Directus process and have direct access to the Knex database instance, so there is no HTTP round-trip to fetch the item. Use npx create-directus-extension@latest to scaffold a TypeScript hook, then register your action listeners for items.create and items.update. After building the extension and restarting Directus, it activates automatically. The PDF URL is written back to the item via the exposed Knex instance:
// extensions/hooks/pdf-on-publish/index.ts
//
// A Directus Hook extension fires server-side on database events.
// It has direct access to the Directus services layer — no HTTP round-trip.
//
// Scaffold: npx create-directus-extension@latest
// Choose: hook → TypeScript → name: pdf-on-publish
//
// Place this file at:
// extensions/hooks/pdf-on-publish/src/index.ts
//
// Then build and restart Directus to activate the extension.
import type { HookExtensionContext } from "@directus/extensions";
export default ({ action }: HookExtensionContext) => {
// Fire after an item is created in the "articles" collection.
action("items.create", async ({ collection, key, payload }, { database, schema }) => {
if (collection !== "articles") return;
// payload contains the fields written to the database.
const title = String(payload.title ?? "Untitled");
const content = String(payload.content ?? "");
const slug = String(payload.slug ?? key);
// Only generate when the item is being published.
if (payload.status !== "published") return;
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; }
</style>
</head>
<body>
<h1>${title}</h1>
${content}
</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: `${slug}.pdf`,
}),
});
if (!upstream.ok) {
console.error("[pdf-on-publish] render failed", await upstream.text());
return;
}
const { document_url } = await upstream.json() as { document_url: string };
// Write the PDF URL back to the item using the Knex instance Directus exposes.
await database("articles")
.where({ id: key })
.update({ pdf_url: document_url });
});
// Also fire on updates, using the same logic.
action("items.update", async ({ collection, keys, payload }, { database }) => {
if (collection !== "articles") return;
if (payload.status !== "published") return;
for (const key of keys) {
// Re-fetch the full item so we have all fields, not just the diff.
const item = await database("articles").where({ id: key }).first() as {
title?: string;
content?: string;
slug?: string;
id: string | number;
} | undefined;
if (!item) continue;
const title = String(item.title ?? "Untitled");
const content = String(item.content ?? "");
const slug = String(item.slug ?? item.id);
const html = `<!DOCTYPE html>
<html>
<head><meta charset="utf-8" /></head>
<body>
<h1>${title}</h1>
${content}
</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: `${slug}.pdf`,
}),
});
if (upstream.ok) {
const { document_url } = await upstream.json() as { document_url: string };
await database("articles").where({ id: key }).update({ pdf_url: document_url });
}
}
});
};Direct API usage with the JS SDK
For on-demand generation from a script, a serverless function, or a custom endpoint, use the official Directus JS SDK to fetch the item and then call the PDF API directly. The example below returns raw PDF bytes, which you can pipe to a file, an HTTP response, or cloud storage:
// generate-pdf.ts
// Fetch a Directus item via the JS SDK, build HTML from its fields,
// and POST to the PDF API to get a byte stream back.
import { createDirectus, staticToken, rest, readItem } from "@directus/sdk";
interface Article {
id: string;
title: string;
slug: string;
status: string;
published_at: string;
content: string; // plain HTML or serialized rich text
}
const directus = createDirectus<{ articles: Article }>(
process.env.DIRECTUS_URL!
).with(staticToken(process.env.DIRECTUS_TOKEN!)).with(rest());
async function generatePdfForArticle(articleId: string): Promise<Uint8Array> {
// 1. Fetch the item from Directus.
const article = await directus.request(
readItem("articles", articleId)
);
// 2. Build an HTML document from the item 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.published_at).toDateString()}</p>
${article.content ?? ""}
</body>
</html>`;
// 3. Send to the PDF API — no store flag, returns the PDF as a byte stream.
const response = 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" },
}),
});
if (!response.ok) {
const err = await response.json().catch(() => ({})) as { detail?: string };
throw new Error(err.detail ?? "PDF render failed");
}
// Returns raw PDF bytes — pipe to a file, an HTTP response, or cloud storage.
const buffer = await response.arrayBuffer();
return new Uint8Array(buffer);
}Storing the URL back in Directus
Pass store: true to receive a stable download URL instead of a byte stream. You can then write it back to a text field on the Directus item using updateItem from the SDK. Add a pdf_url field of type String to your collection schema in the Directus data studio first:
// store-pdf-url.ts
// Use store: true to get a stable download URL back,
// then write it to the Directus item using the SDK.
import { createDirectus, staticToken, rest, readItem, updateItem } from "@directus/sdk";
interface Article {
id: string;
title: string;
slug: string;
content: string;
pdf_url?: string; // the field where we store the generated URL
}
const directus = createDirectus<{ articles: Article }>(
process.env.DIRECTUS_URL!
).with(staticToken(process.env.DIRECTUS_TOKEN!)).with(rest());
async function storePdfUrl(articleId: string): Promise<string> {
// 1. Fetch the item.
const article = await directus.request(
readItem("articles", articleId)
);
const html = `<!DOCTYPE html>
<html>
<head><meta charset="utf-8" /></head>
<body>
<h1>${article.title}</h1>
${article.content ?? ""}
</body>
</html>`;
// 2. Generate the PDF and request a stored URL.
const response = 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 ?? articleId}.pdf`,
}),
});
if (!response.ok) {
const err = await response.json().catch(() => ({})) as { detail?: string };
throw new Error(err.detail ?? "PDF render failed");
}
const { document_url } = await response.json() as { document_url: string };
// 3. Write the URL back to the Directus item.
// The pdf_url field must exist in the collection schema.
await directus.request(
updateItem("articles", articleId, { pdf_url: document_url })
);
return document_url;
}Environment variables
Store your credentials in a .env file at the project root. Hook extensions read environment variables from the .env file at the root of your Directus installation. In production, inject them through your hosting environment so they are available as process.env.* at runtime:
# .env (project root)
DIRECTUS_URL=https://your-directus.example.com
DIRECTUS_TOKEN=your_static_token_here
PDFPIPE_API_KEY=your_api_key_here
# In Directus Hook extensions, environment variables are read from the
# .env file at the root of your Directus installation.
# In production, inject them through your host's secret manager
# (Railway variables, Render env vars, etc.) — never commit the values.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 →