Guide
HTML to PDF in Hono
Hono is a lightweight, ultra-fast web framework that runs on Cloudflare Workers, Bun, Deno, and Node.js with the same source file. Adding PDF generation is a single fetch call: POST your HTML, stream the bytes back. No binary dependencies, no process to keep warm, no platform-specific configuration. The same route handler works identically whether you deploy to the edge or a Node.js server.
Basic route
A Hono app with a POST /pdf route. The handler reads html and options from the JSON body, calls the PDF API, and returns the bytes using c.body() with the application/pdf content type. The response stream passes through directly with no intermediate buffering:
// npm install hono
// No extra fetch polyfill needed — Hono targets runtimes with native fetch.
import { Hono } from "hono";
const app = new Hono<{ Bindings: { PDFPIPE_API_KEY: string } }>();
app.post("/pdf", async (c) => {
const { html, options } = await c.req.json<{
html: string;
options?: Record<string, unknown>;
}>();
const upstream = await fetch("https://api.pdfpipe.xyz/v1/pdf", {
method: "POST",
headers: {
Authorization: `Bearer ${c.env.PDFPIPE_API_KEY}`,
"Content-Type": "application/json",
},
body: JSON.stringify({ html, options: options ?? { format: "A4" } }),
});
if (!upstream.ok) {
const err = await upstream.json<{ detail?: string }>().catch(() => ({}));
return c.json({ error: err.detail ?? "render failed" }, 502);
}
c.header("Content-Type", "application/pdf");
c.header("Content-Disposition", 'attachment; filename="document.pdf"');
// Return the response body as a stream — no buffering.
return c.body(upstream.body as ReadableStream);
});
export default app;With store: true
Pass store: true to keep the document on the CDN and receive a retrieval URL instead of a byte stream. The route returns JSON via c.json() with the document_url, document ID, and expiry timestamp. Useful for async workflows and situations where the client needs a stable download link:
// Pass store: true to keep the document and return a URL instead of bytes.
app.post("/pdf/url", async (c) => {
const { html, filename = "document.pdf", options } = await c.req.json<{
html: string;
filename?: string;
options?: Record<string, unknown>;
}>();
const upstream = await fetch("https://api.pdfpipe.xyz/v1/pdf", {
method: "POST",
headers: {
Authorization: `Bearer ${c.env.PDFPIPE_API_KEY}`,
"Content-Type": "application/json",
},
body: JSON.stringify({
html,
options: options ?? { format: "A4" },
store: true,
filename,
}),
});
if (!upstream.ok) return c.json({ error: "render failed" }, 502);
// Drain the body so the connection is released, then read response headers.
await upstream.arrayBuffer();
return c.json({
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"),
});
});Middleware for the API key
A custom Hono middleware reads the API key from the environment once and injects it into the context, so individual route handlers never need to access environment variables directly. Register it with app.use("/pdf/*", pdfMiddleware) and any route under that path receives the key via c.get("pdfApiKey"):
// middleware/pdf.ts — injects the API key into the context variable.
import { createMiddleware } from "hono/factory";
type PdfEnv = {
Variables: {
pdfApiKey: string;
};
Bindings: {
PDFPIPE_API_KEY: string;
};
};
export const pdfMiddleware = createMiddleware<PdfEnv>(async (c, next) => {
const key = c.env?.PDFPIPE_API_KEY ?? process.env.PDFPIPE_API_KEY ?? "";
if (!key) {
return c.json({ error: "PDF API key not configured" }, 500);
}
c.set("pdfApiKey", key);
await next();
});
// ---
// In your app file:
// import { pdfMiddleware } from "./middleware/pdf";
//
// app.use("/pdf/*", pdfMiddleware);
//
// Then any route under /pdf/* reads the key from context:
// const key = c.get("pdfApiKey");Multi-runtime deployment
The app object is the same regardless of runtime. Only the entry point differs. Swap it based on your deployment target without touching any route logic:
// The same app object works across every Hono-supported runtime.
// Pick the entry point that matches where you deploy.
// --- Cloudflare Workers (wrangler.toml sets PDFPIPE_API_KEY as a secret) ---
export default app;
// --- Node.js (npm install @hono/node-server) ---
import { serve } from "@hono/node-server";
serve({ fetch: app.fetch, port: 3000 });
// --- Bun ---
import { serve } from "bun";
serve({ fetch: app.fetch, port: 3000 });
// --- Deno ---
Deno.serve(app.fetch);Environment variable setup
On Node.js and Bun, store the key in a .env file and load it with dotenv. On Cloudflare Workers, use wrangler secret put so the key is encrypted at rest and never appears in source control or wrangler.toml:
# .env (Node.js / Bun / Deno)
PDFPIPE_API_KEY=your_api_key_here
# ---
# Load with dotenv on Node.js (npm install dotenv):
# Add at the top of your entry file, before any other imports:
# import "dotenv/config";
# ---
# Cloudflare Workers: store the key as a Wrangler secret instead of .env.
# From your project directory:
# npx wrangler secret put PDFPIPE_API_KEY
#
# Reference it in your app via c.env.PDFPIPE_API_KEY.
# Secrets set via wrangler secret put are never stored in source control.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 →