PDFPipe

Guide

HTML to PDF in Svelte

Browser-based PDF libraries run in the client, which means fonts load inconsistently, page breaks are unreliable, and the output depends on the user's browser version. Moving generation server-side is better, but bundling a native binary into a SvelteKit deployment is painful on most hosts. The alternative is one fetch call from a +server.ts route.

Server route that streams the PDF back

Create a POST route that accepts an HTML string, forwards it to the rendering API, and pipes the binary response directly to the browser. No buffering, no temp files:

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

export const POST: RequestHandler = async ({ request }) => {
  const { html, filename = "document.pdf" } = await request.json();

  const upstream = await fetch("https://api.pdfpipe.xyz/v1/pdf", {
    method: "POST",
    headers: {
      Authorization: `Bearer ${env.PDFPIPE_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="${filename}"`,
    },
  });
};

The route streams the PDF body straight from the upstream response, so memory usage stays flat regardless of document size. A specific error is returned when the render fails rather than a silent 500.

Calling the route from a Svelte 5 component

The component below uses Svelte 5 runes: $props() for typed inputs and $state() for loading and error state. The click handler posts to the server route and triggers a browser download:

svelte
<!-- src/lib/PdfButton.svelte (Svelte 5 runes) -->
<script lang="ts">
  let { html, filename = "document.pdf" }: {
    html: string;
    filename?: string;
  } = $props();

  let loading = $state(false);
  let errorMsg = $state("");

  async function downloadPdf() {
    loading = true;
    errorMsg = "";

    try {
      const res = await fetch("/pdf", {
        method: "POST",
        headers: { "Content-Type": "application/json" },
        body: JSON.stringify({ html, filename }),
      });

      if (!res.ok) {
        errorMsg = "PDF generation failed. Please try again.";
        return;
      }

      const blob = await res.blob();
      const url = URL.createObjectURL(blob);
      const a = document.createElement("a");
      a.href = url;
      a.download = filename;
      a.click();
      URL.revokeObjectURL(url);
    } catch {
      errorMsg = "An unexpected error occurred.";
    } finally {
      loading = false;
    }
  }
</script>

<button onclick={downloadPdf} disabled={loading}>
  {loading ? "Generating..." : "Download PDF"}
</button>

{#if errorMsg}
  <p class="error">{errorMsg}</p>
{/if}

Drop PdfButton anywhere in your app and pass in the HTML string you want rendered. The component handles the full lifecycle: fetch, blob conversion, anchor click, and URL cleanup.

Storing the API key

Add the key to .env at the project root. Do not prefix it with VITE_ or PUBLIC_: SvelteKit only exposes unprefixed variables to server-side code accessed via $env/dynamic/private.

dotenv
# .env
# Never prefix with VITE_ or PUBLIC_ — this key stays server-side only.
PDFPIPE_KEY=your_key_here

Getting a hosted URL for email sharing

Pass store: true in the request body and the API returns a JSON envelope with a hosted document URL instead of a raw binary stream. Use this when you need to email the PDF, save the link in a database, or open it in a new tab without creating a blob URL on the client:

typescript
// src/routes/pdf/+server.ts
// Pass store: true to get back a JSON envelope with a hosted URL.
import type { RequestHandler } from "./$types";
import { error, json } from "@sveltejs/kit";
import { env } from "$env/dynamic/private";

export const POST: RequestHandler = async ({ request }) => {
  const { html, filename = "document.pdf" } = await request.json();

  const upstream = await fetch("https://api.pdfpipe.xyz/v1/pdf", {
    method: "POST",
    headers: {
      Authorization: `Bearer ${env.PDFPIPE_KEY}`,
      "Content-Type": "application/json",
    },
    body: JSON.stringify({
      html,
      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 documentUrl = upstream.headers.get("X-PDFPipe-Document-Url") ?? "";
  const documentId  = upstream.headers.get("X-PDFPipe-Document-Id")  ?? "";

  return json({ documentUrl, documentId });
};

// In your component, call the route and read the URL:
// const { documentUrl } = await res.json();
// — then email it, persist it, or open it in a new tab.

Pricing

The Hobby plan gives 500 free documents per month with no credit card required. Paid plans add higher limits, a longer document archive, and email support:

Plan       Documents/month   Price
────────────────────────────────
Hobby      500               $0
Starter    5,000             $19 / mo
Growth     20,000            $49 / mo
Scale      50,000            $149 / mo
Business   Unlimited         $499 / mo

PDFPipe is the API used above. Try it in the live playground with no signup, or browse the full API reference.

See pricing →