PDFPipe

Guide

HTML to PDF in SvelteKit

SvelteKit has no built-in PDF output. The common workaround of running a headless browser inside the server process adds a large binary dependency and complicates containerisation. PDFPipe is a single fetch call from a +server.ts or form action, using the same $env/dynamic/private pattern you already use for secrets.

Server route returning a PDF

A +server.ts file exports a GET handler that calls PDFPipe and pipes the response stream back to the browser. Navigation to the route triggers a download:

typescript
// src/routes/invoices/[id]/pdf/+server.ts
// GET /invoices/42/pdf
import type { RequestHandler } from "./$types";
import { error } from "@sveltejs/kit";
import { env } from "$env/dynamic/private";

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

export const GET: RequestHandler = async ({ params }) => {
  const { id } = params;

  const html = `
    <!DOCTYPE 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(API_URL, {
    method: "POST",
    headers: {
      Authorization: `Bearer ${env.PDFPIPE_API_KEY}`,
      "Content-Type": "application/json",
    },
    body: JSON.stringify({ html, options: { format: "A4" } }),
  });

  if (!upstream.ok) {
    const body = await upstream.json().catch(() => ({})) as Record<string, unknown>;
    throw error(502, String(body.detail ?? "PDF render failed"));
  }

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

Form action that generates a PDF

A form action in +page.server.ts reads form data, calls PDFPipe with store: true, and redirects the user to the CDN URL. This avoids buffering the binary on your server:

typescript
// src/routes/reports/+page.server.ts
// Form action that generates a PDF and redirects to its download URL.
import type { Actions } from "./$types";
import { fail, redirect } from "@sveltejs/kit";
import { env } from "$env/dynamic/private";

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

export const actions: Actions = {
  generate: async ({ request }) => {
    const data = await request.formData();
    const title = String(data.get("title") ?? "Untitled");

    const html = `
      <!DOCTYPE html>
      <html>
      <body style="font-family: system-ui; padding: 40px">
        <h1>${title}</h1>
        <p>Generated at ${new Date().toLocaleString()}.</p>
      </body>
      </html>
    `;

    const upstream = await fetch(API_URL, {
      method: "POST",
      headers: {
        Authorization: `Bearer ${env.PDFPIPE_API_KEY}`,
        "Content-Type": "application/json",
      },
      body: JSON.stringify({
        html,
        options: { format: "A4" },
        store: true,
        filename: `${title.toLowerCase().replace(/\s+/g, "-")}.pdf`,
      }),
    });

    if (!upstream.ok) {
      return fail(502, { error: "PDF generation failed. Please try again." });
    }

    const docUrl = upstream.headers.get("X-PDFPipe-Document-Url");
    if (!docUrl) return fail(502, { error: "No document URL returned." });

    throw redirect(303, docUrl);
  },
};

// src/routes/reports/+page.svelte (corresponding form)
// <form method="POST" action="?/generate">
//   <input name="title" placeholder="Report title" required />
//   <button type="submit">Generate PDF</button>
// </form>

Shared storage utility

For apps that generate PDFs from multiple routes, extract the storage logic into a $lib/server utility. Returns a typed object with the document ID, URL, and expiry that you can persist to a database or return as JSON:

typescript
// src/lib/server/pdf.ts
// Shared utility: store a PDF and return the CDN metadata.
import { env } from "$env/dynamic/private";
import { error } from "@sveltejs/kit";

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

export interface StoredDocument {
  id: string;
  url: string;
  expiresAt: string;
}

export async function storePdf(
  html: string,
  filename: string,
  options: { format?: string } = {}
): Promise<StoredDocument> {
  const upstream = await fetch(API_URL, {
    method: "POST",
    headers: {
      Authorization: `Bearer ${env.PDFPIPE_API_KEY}`,
      "Content-Type": "application/json",
    },
    body: JSON.stringify({
      html,
      options: { format: options.format ?? "A4" },
      store: true,
      filename,
    }),
  });

  if (!upstream.ok) {
    const body = await upstream.json().catch(() => ({})) as Record<string, unknown>;
    throw error(502, String(body.detail ?? "PDF render failed"));
  }

  const id = upstream.headers.get("X-PDFPipe-Document-Id") ?? "";
  const url = upstream.headers.get("X-PDFPipe-Document-Url") ?? "";
  const expiresAt = upstream.headers.get("X-PDFPipe-Document-Expires") ?? "";

  return { id, url, expiresAt };
}

// Usage in any +page.server.ts or +server.ts:
// import { storePdf } from "$lib/server/pdf";
// const doc = await storePdf(html, "report.pdf");
// return json({ downloadUrl: doc.url, expiresAt: doc.expiresAt });

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 →