PDFPipe

Guide

HTML to PDF in Remix

Remix runs on Node and exposes native fetch in every loader and action. There is nothing to install: call PDFPipe, pipe the response body through, and Remix handles the rest. Avoid headless-browser packages that add hundreds of megabytes and break in edge runtimes.

Loader returning a PDF

The simplest pattern is a route whose loader fetches from PDFPipe and returns the stream as the response. Navigating to the URL triggers an immediate browser download:

typescript
// app/routes/invoices.$id.pdf.tsx
// GET /invoices/42/pdf  — streams the PDF to the browser
import type { LoaderFunctionArgs } from "@remix-run/node";

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

export async function loader({ params }: LoaderFunctionArgs) {
  const apiKey = process.env.PDFPIPE_API_KEY;
  if (!apiKey) throw new Response("PDFPIPE_API_KEY not configured", { status: 500 });

  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 ${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>;
    throw new Response(String(err.detail ?? "PDF render failed"), { status: 502 });
  }

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

Resource route for PDF generation

A resource route with only an action export acts as a lightweight API endpoint. POST your HTML from any client-side form or fetch call:

typescript
// app/routes/api.pdf.tsx
// POST /api/pdf — resource route that accepts arbitrary HTML from the client
import type { ActionFunctionArgs } from "@remix-run/node";
import { json } from "@remix-run/node";

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

export async function action({ request }: ActionFunctionArgs) {
  if (request.method !== "POST") {
    return json({ error: "Method not allowed" }, { status: 405 });
  }

  const apiKey = process.env.PDFPIPE_API_KEY;
  if (!apiKey) return json({ error: "API key not configured" }, { status: 500 });

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

  if (!html || typeof html !== "string") {
    return json({ error: "html is required" }, { status: 400 });
  }

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

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

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

Store and redirect to a download URL

Pass store: true to keep the PDF on the CDN. The response headers contain the URL, document ID, and expiry. Redirect the user straight to the CDN link so your server never buffers the file:

typescript
// app/routes/reports.$id.tsx
// Action that stores the PDF and redirects to its CDN URL.
import type { ActionFunctionArgs } from "@remix-run/node";
import { redirect } from "@remix-run/node";

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

export async function action({ params }: ActionFunctionArgs) {
  const apiKey = process.env.PDFPIPE_API_KEY!;
  const { id } = params;
  const html = await buildReportHtml(id!);

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

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

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

  if (!docUrl) throw new Response("No document URL returned", { status: 502 });

  // You could also persist docId + expiresAt to your database here
  // before redirecting the user to the CDN URL.
  return redirect(docUrl);
}

async function buildReportHtml(id: string): Promise<string> {
  // Fetch your data and render to an HTML string.
  return `<html><body><h1>Report ${id}</h1></body></html>`;
}

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 →