PDFPipe

Guide

HTML to PDF in Astro

Astro is primarily a static site builder, but its SSR mode and API endpoints make server-side PDF generation straightforward. There are no PDF libraries on npm that fit Astro's edge-friendly model. PDFPipe is a single fetch call from any API route or SSR page, with no binary dependencies.

Astro API endpoint

Add a .ts file under src/pages/api/ and export a named HTTP method handler. Requires output: "server" or prerender = false in hybrid mode:

typescript
// src/pages/api/pdf.ts
// POST /api/pdf — Astro API endpoint (requires SSR or hybrid output)
// astro.config.mjs: output: "server"  or  export const prerender = false
import type { APIRoute } from "astro";

const API_URL = "https://api.pdfpipe.xyz/v1/pdf";

export const POST: APIRoute = async ({ request }) => {
  const apiKey = import.meta.env.PDFPIPE_API_KEY;
  if (!apiKey) {
    return new Response(JSON.stringify({ error: "API key not configured" }), {
      status: 500,
      headers: { "Content-Type": "application/json" },
    });
  }

  let body: { html?: string; filename?: string };
  try {
    body = await request.json();
  } catch {
    return new Response(JSON.stringify({ error: "Invalid JSON body" }), {
      status: 400,
      headers: { "Content-Type": "application/json" },
    });
  }

  const { html, filename = "document.pdf" } = body;
  if (!html) {
    return new Response(JSON.stringify({ error: "html is required" }), {
      status: 400,
      headers: { "Content-Type": "application/json" },
    });
  }

  const upstream = await fetch(API_URL, {
    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(() => ({})) as Record<string, unknown>;
    return new Response(JSON.stringify({ error: String(err.detail ?? "render failed") }), {
      status: 502,
      headers: { "Content-Type": "application/json" },
    });
  }

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

SSR route generating a PDF

An SSR page with a .pdf.ts suffix handles GET requests for a specific document. Navigating to /invoices/42.pdf triggers a browser download directly:

typescript
// src/pages/invoices/[id].pdf.ts
// GET /invoices/42.pdf — SSR route that generates the PDF on request.
// Add  export const prerender = false  at the top for hybrid mode.
import type { APIRoute } from "astro";

const API_URL = "https://api.pdfpipe.xyz/v1/pdf";

export const prerender = false;

export const GET: APIRoute = async ({ params }) => {
  const { id } = params;
  const apiKey = import.meta.env.PDFPIPE_API_KEY;

  if (!apiKey) {
    return new Response("Server misconfigured", { status: 500 });
  }

  const html = buildInvoiceHtml(id!);

  const upstream = await fetch(API_URL, {
    method: "POST",
    headers: {
      Authorization: `Bearer ${apiKey}`,
      "Content-Type": "application/json",
    },
    body: JSON.stringify({
      html,
      options: {
        format: "A4",
        margin: { top: "20mm", bottom: "20mm", left: "15mm", right: "15mm" },
      },
    }),
  });

  if (!upstream.ok) {
    return new Response("PDF generation failed", { status: 502 });
  }

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

function buildInvoiceHtml(id: string): string {
  return `
    <!DOCTYPE html>
    <html>
    <body style="font-family: system-ui; padding: 40px">
      <h1>Invoice #${id}</h1>
      <p>Thank you for your order.</p>
    </body>
    </html>
  `;
}

// Link from any .astro page:
// <a href={`/invoices/${id}.pdf`} download>Download PDF</a>

Store and return a CDN URL

Pass store: true to persist the PDF on the CDN. The endpoint returns JSON with the document ID, URL, and expiry so your client-side island or script can open it directly without routing through your server:

typescript
// src/pages/api/pdf/store.ts
// POST /api/pdf/store — stores the PDF and returns the CDN metadata as JSON.
import type { APIRoute } from "astro";

const API_URL = "https://api.pdfpipe.xyz/v1/pdf";

export const prerender = false;

export const POST: APIRoute = async ({ request }) => {
  const apiKey = import.meta.env.PDFPIPE_API_KEY;
  if (!apiKey) {
    return new Response(JSON.stringify({ error: "API key not configured" }), {
      status: 500, headers: { "Content-Type": "application/json" },
    });
  }

  const { html, filename = "document.pdf", format = "A4" } =
    await request.json() as { html: string; filename?: string; format?: string };

  if (!html) {
    return new Response(JSON.stringify({ error: "html is required" }), {
      status: 400, headers: { "Content-Type": "application/json" },
    });
  }

  const upstream = await fetch(API_URL, {
    method: "POST",
    headers: {
      Authorization: `Bearer ${apiKey}`,
      "Content-Type": "application/json",
    },
    body: JSON.stringify({
      html,
      options: { format },
      store: true,
      filename,
    }),
  });

  if (!upstream.ok) {
    return new Response(JSON.stringify({ error: "PDF render failed" }), {
      status: 502, headers: { "Content-Type": "application/json" },
    });
  }

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

  return new Response(JSON.stringify({ docId, docUrl, expiresAt }), {
    headers: { "Content-Type": "application/json" },
  });
};

// Client-side usage in an Astro island or vanilla script:
// const res = await fetch("/api/pdf/store", {
//   method: "POST",
//   headers: { "Content-Type": "application/json" },
//   body: JSON.stringify({ html, filename: "report.pdf" }),
// });
// const { docUrl } = await res.json();
// window.open(docUrl, "_blank");

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 →