Generate PDFs in a Supabase Edge Function

Generate PDFs in a Supabase Edge Function

Generate PDFs in a Supabase Edge Function by sending HTML to a PDF API with fetch, then save the file to Supabase Storage.

Supabase Edge Functions are a natural place for "generate the invoice when an order is paid" or "export this report as PDF". The hard part is rendering. Good HTML-to-PDF output needs a real browser engine, and edge functions can't run one.

Quick answer. Store your key with supabase secrets set HTML_PDF_KEY=..., then fetch the hosted renderer from your function. This is the same request as curl:

curl https://html-pdf.com/v1/pdf -H "Authorization: Bearer $HTML_PDF_KEY" \
  -d '{"html": "<h1>Invoice</h1>", "format": "A4"}' -o invoice.pdf

Why you can't run Chromium in an Edge Function

This section explains why the usual Puppeteer approach fails here. Edge Functions run on the Supabase Edge Runtime, a Deno-based isolate environment.

The practical answer is to keep the function as glue and send the HTML to a hosted Chromium renderer over HTTP.

Set your API key as a secret

This step makes your key available to the function without putting it in code. Get a key at https://html-pdf.com/login (100 free credits, and 1 credit = 1 PDF page), then run this.

supabase secrets set HTML_PDF_KEY=your_key_here

For local development, put HTML_PDF_KEY=... in supabase/functions/.env and run supabase functions serve. Inside the function, read it with Deno.env.get("HTML_PDF_KEY").

Option 1: return the PDF directly

This option streams the PDF straight back to the caller. Create the function with supabase functions new render-pdf and replace index.ts.

// supabase/functions/render-pdf/index.ts
Deno.serve(async (req) => {
  if (req.method !== "POST") {
    return new Response("Method not allowed", { status: 405 });
  }

  const { html } = await req.json();

  const res = await fetch("https://html-pdf.com/v1/pdf", {
    method: "POST",
    headers: {
      Authorization: `Bearer ${Deno.env.get("HTML_PDF_KEY")}`,
      "Content-Type": "application/json",
    },
    body: JSON.stringify({ html, format: "A4", margin: "15mm", print_background: true }),
  });

  if (!res.ok) {
    // Errors are JSON: { error: { code, message, hint } }
    return new Response(await res.text(), {
      status: res.status,
      headers: { "Content-Type": "application/json" },
    });
  }

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

Passing res.body streams the PDF back without buffering it in memory. Deploy with supabase functions deploy render-pdf. Call it from your app with supabase.functions.invoke("render-pdf", { body: { html } }) or a plain fetch that includes the user's JWT.

Option 2: upload to Supabase Storage

This option saves the PDF so you can link it to a row and serve it later. Create a bucket (for example a private invoices bucket) and upload from the function with supabase-js.

// supabase/functions/invoice-pdf/index.ts
import { createClient } from "npm:@supabase/supabase-js@2";

const supabase = createClient(
  Deno.env.get("SUPABASE_URL")!,
  Deno.env.get("SUPABASE_SERVICE_ROLE_KEY")!,
);

Deno.serve(async (req) => {
  const { orderId, html } = await req.json();

  const res = await fetch("https://html-pdf.com/v1/pdf", {
    method: "POST",
    headers: {
      Authorization: `Bearer ${Deno.env.get("HTML_PDF_KEY")}`,
      "Content-Type": "application/json",
    },
    body: JSON.stringify({ html, format: "A4", prefer_css_page_size: true }),
  });
  if (!res.ok) return new Response(await res.text(), { status: res.status });

  const pdf = new Uint8Array(await res.arrayBuffer());
  const path = `orders/${orderId}.pdf`;

  const { error } = await supabase.storage
    .from("invoices")
    .upload(path, pdf, { contentType: "application/pdf", upsert: true });
  if (error) return new Response(error.message, { status: 500 });

  const { data } = await supabase.storage
    .from("invoices")
    .createSignedUrl(path, 60 * 60); // 1 hour

  return Response.json({
    path,
    signedUrl: data?.signedUrl,
    pages: res.headers.get("X-Pages"),
  });
});

SUPABASE_URL and SUPABASE_SERVICE_ROLE_KEY are available to deployed functions by default. The service role key bypasses Row Level Security. Keep this function server-side (check the caller's JWT or call it from a database webhook) and never return the key.

If you don't need to keep the file, skip Storage and send "response": "url". The API then returns JSON { "url": "...", "expires_at": "..." } with a link valid for 24 hours.

Building the HTML

This section covers where the HTML comes from. Most projects keep an HTML template and fill it with row data from Postgres.

Escape user-supplied values before you insert them, and use print CSS (@page, break-inside: avoid) for clean pages. A worked invoice template is at https://html-pdf.com/generate-invoice-pdf, and more starting points are in https://html-pdf.com/templates.

You can also send {"url": "https://..."} to render a public page, such as a report route in your app, instead of HTML.

Triggering it

These are the common ways to run the function.

Limits and options

This section lists the limits and the options you will reach for most. Requests can be up to 20 MB, each render has 30 seconds, and you're billed per page.

The X-Credits-Remaining header shows your balance, and 402 insufficient_credits means you're out. Useful options include format, landscape, margin, header_html/footer_html with page numbers, wait_for_expression for pages that render charts, and max_pages as a safety cap.

The full reference is at https://html-pdf.com/docs. Other runtimes are covered in https://html-pdf.com/html-to-pdf-nodejs.

FAQ

Can I run Puppeteer in a Supabase Edge Function?

No. Edge Functions run in Deno isolates that can't spawn a Chromium process or ship a ~100+ MB browser. Call a hosted renderer like https://html-pdf.com/v1/pdf with fetch instead.

How do I save a generated PDF to Supabase Storage?

Read the API response with arrayBuffer(), then upload it with supabase.storage.from("invoices").upload(path, pdf, { contentType: "application/pdf" }). Use createSignedUrl to share it from a private bucket.

Yes. Send "response": "url" and the API returns JSON with a download link that lasts 24 hours. Your HTML is not stored.

How much does PDF generation cost?

One credit equals one PDF page. You get 100 free credits without a card, then packs cost $0.01 to $0.005 per page, purchased credits never expire, and failed renders cost nothing.

This page as markdown · text