PDFPipe

Guide

HTML to PDF in Bun

Bun has no native PDF library, and the npm-compatible packages that wrap Puppeteer or headless Chrome add a 150 MB binary that slows down your Docker builds and clashes with Bun's fast startup model. PDFPipe is a single HTTP call. Bun's fetch is built in and fast, so there is nothing to install.

Bun.serve handler

Build the HTML, POST it to PDFPipe, and stream the response body straight to the client:

typescript
// server.ts — run with: PDFPIPE_API_KEY=pp_live_... bun run server.ts
const apiKey = process.env.PDFPIPE_API_KEY!;

Bun.serve({
  port: 3000,
  async fetch(req) {
    const url = new URL(req.url);

    if (req.method === "GET" && url.pathname.startsWith("/invoices/")) {
      const id = url.pathname.split("/").pop();
      const html = `
        <html>
        <body style="font-family: system-ui; padding: 40px">
          <h1>Invoice #${id}</h1>
          <p>Thank you for your order.</p>
        </body>
        </html>
      `;

      const upstream = await fetch("https://api.pdfpipe.xyz/v1/pdf", {
        method: "POST",
        headers: {
          Authorization: `Bearer ${apiKey}`,
          "Content-Type": "application/json",
        },
        body: JSON.stringify({ html, options: { format: "A4" } }),
      });

      if (!upstream.ok) {
        const err = await upstream.json().catch(() => ({}));
        return Response.json({ error: (err as any).detail ?? "render failed" }, { status: 502 });
      }

      // Pipe the upstream body directly to the client — no buffering.
      return new Response(upstream.body, {
        headers: {
          "Content-Type": "application/pdf",
          "Content-Disposition": `attachment; filename="invoice-${id}.pdf"`,
        },
      });
    }

    return new Response("Not found", { status: 404 });
  },
});

With Elysia

Elysia is the most popular Bun-native framework. The same pattern works directly as a route handler:

typescript
// Elysia framework — npm install elysia  (or bun add elysia)
import { Elysia } from "elysia";

const app = new Elysia()
  .get("/invoices/:id.pdf", async ({ params }) => {
    const html = buildInvoiceHtml(params.id); // your template function

    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" } }),
    });

    if (!upstream.ok) throw new Error("PDF render failed");

    return new Response(upstream.body, {
      headers: {
        "Content-Type": "application/pdf",
        "Content-Disposition": `attachment; filename="invoice-${params.id}.pdf"`,
      },
    });
  })
  .listen(3000);

Store the PDF and return a URL

Pass store: true to persist the document on the CDN. The response headers contain the retrieval URL, document ID, and expiry time:

typescript
// Store the PDF and return a download URL.
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: `invoice-${orderId}.pdf`,
  }),
});

if (!upstream.ok) throw new Error("PDF render failed");

const docId  = upstream.headers.get("X-PDFPipe-Document-Id");
const docUrl = upstream.headers.get("X-PDFPipe-Document-Url");
const expiresAt = upstream.headers.get("X-PDFPipe-Document-Expires");

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 →