Guide
HTML to PDF in Fastify
Adding PDF generation to a Fastify app does not require a special plugin or a heavy binary dependency. PDFPipe exposes a plain HTTP endpoint: POST some HTML, get a PDF back. Any Fastify route can call it with a single fetch and stream the bytes straight to the client. No temp files, no Chromium process, nothing to keep running alongside your server.
Basic route
A minimal Fastify server with a POST /generate-pdf route. The handler reads the html field from the request body, calls the PDF API, and pipes the response stream back to the client. No intermediate buffering:
// npm install fastify
// Native fetch is available in Node 18+. No extra packages needed.
import Fastify from "fastify";
const server = Fastify();
const PDFPIPE_KEY = process.env.PDFPIPE_API_KEY;
server.post<{ Body: { html: string } }>("/generate-pdf", async (request, reply) => {
const { html } = request.body;
const upstream = await fetch("https://api.pdfpipe.xyz/v1/pdf", {
method: "POST",
headers: {
Authorization: `Bearer ${PDFPIPE_KEY}`,
"Content-Type": "application/json",
},
body: JSON.stringify({ html, options: { format: "A4" } }),
});
if (!upstream.ok) {
const err = await upstream.json().catch(() => ({}));
return reply.status(502).send({ error: (err as any).detail ?? "render failed" });
}
reply.header("Content-Type", "application/pdf");
reply.header("Content-Disposition", 'attachment; filename="document.pdf"');
// Stream the response body directly to the client.
const { Readable } = await import("stream");
return reply.send(Readable.fromWeb(upstream.body as any));
});
await server.listen({ port: 3000 });With store: true
Pass store: true to keep the document on the CDN and receive a retrieval URL in the response headers. Useful for async workflows, background jobs, or any situation where the client should download from a stable link rather than a direct stream:
// Pass store: true to keep the PDF on the CDN and get a URL back.
server.post<{ Body: { html: string; filename?: string } }>(
"/generate-pdf-url",
async (request, reply) => {
const { html, filename = "document.pdf" } = request.body;
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,
}),
});
if (!upstream.ok) return reply.status(502).send({ error: "render failed" });
// Drain the body so the connection is released, then return headers.
await upstream.arrayBuffer();
return reply.send({
document_url: upstream.headers.get("X-PDFPipe-Document-Url"),
document_id: upstream.headers.get("X-PDFPipe-Document-Id"),
expires_at: upstream.headers.get("X-PDFPipe-Document-Expires"),
});
}
);Fastify plugin
For applications with multiple routes that generate PDFs, a reusable plugin keeps the API details in one place. The plugin decorates the server with a server.generatePdf(html, options) method. Routes call that method without knowing anything about the underlying API:
// pdf.plugin.ts — decorates the server with server.generatePdf()
import fp from "fastify-plugin";
import type { FastifyInstance } from "fastify";
import { Readable } from "stream";
interface PdfOptions {
format?: "A4" | "Letter" | "Legal";
landscape?: boolean;
}
interface GeneratePdfResult {
stream: NodeJS.ReadableStream;
contentType: string;
}
async function pdfPlugin(server: FastifyInstance) {
server.decorate(
"generatePdf",
async function (html: string, options: PdfOptions = {}): Promise<GeneratePdfResult> {
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 }),
});
if (!upstream.ok) {
const err = await upstream.json().catch(() => ({}));
throw new Error((err as any).detail ?? "PDF render failed");
}
return {
stream: Readable.fromWeb(upstream.body as any),
contentType: "application/pdf",
};
}
);
}
export default fp(pdfPlugin);
// ---
// In your main server file:
// import pdfPlugin from "./pdf.plugin";
// await server.register(pdfPlugin);
//
// Then any route can call:
// const { stream, contentType } = await server.generatePdf(html, { format: "A4" });
// reply.header("Content-Type", contentType);
// return reply.send(stream);Request validation with Zod
Fastify pairs well with @fastify/type-provider-zod for end-to-end TypeScript type safety. Define the request body schema with Zod, pass it to the route, and Fastify will validate incoming requests and infer the types automatically. No manual casting needed inside the handler:
// npm install zod @fastify/type-provider-zod
import Fastify from "fastify";
import {
serializerCompiler,
validatorCompiler,
type ZodTypeProvider,
} from "@fastify/type-provider-zod";
import { z } from "zod";
import { Readable } from "stream";
const server = Fastify().withTypeProvider<ZodTypeProvider>();
server.setValidatorCompiler(validatorCompiler);
server.setSerializerCompiler(serializerCompiler);
const bodySchema = z.object({
html: z.string().min(1).max(500_000),
filename: z.string().optional().default("document.pdf"),
format: z.enum(["A4", "Letter", "Legal"]).optional().default("A4"),
landscape: z.boolean().optional().default(false),
});
server.post(
"/generate-pdf",
{ schema: { body: bodySchema } },
async (request, reply) => {
const { html, filename, format, landscape } = request.body;
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, landscape } }),
});
if (!upstream.ok) return reply.status(502).send({ error: "render failed" });
reply.header("Content-Type", "application/pdf");
reply.header("Content-Disposition", `attachment; filename="${filename}"`);
return reply.send(Readable.fromWeb(upstream.body as any));
}
);
await server.listen({ port: 3000 });Environment variable setup
Store your API key in a .env file and load it with either dotenv or the @fastify/env plugin. The @fastify/env approach validates the schema at startup so the server refuses to start with a missing key, rather than failing silently at the first PDF request:
# .env
PDFPIPE_API_KEY=your_api_key_here
# ---
# Option A: load with dotenv (npm install dotenv)
# At the top of your entry file, before any imports that read env vars:
import "dotenv/config";
# Option B: load with @fastify/env (npm install @fastify/env)
import Fastify from "fastify";
import fastifyEnv from "@fastify/env";
const server = Fastify();
await server.register(fastifyEnv, {
dotenv: true,
schema: {
type: "object",
required: ["PDFPIPE_API_KEY"],
properties: {
PDFPIPE_API_KEY: { type: "string" },
},
},
});
// server.config.PDFPIPE_API_KEY is now typed and validated.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 →