Skip to content
CastPDF

Generate PDFs in a Supabase Edge Function

To generate a PDF with Supabase, write an Edge Function that calls CastPDF with fetch inside Deno.serve, upload the bytes to a Storage bucket and return a signed link. The API key lives in function secrets, never in your app. Edge Functions cannot run a browser, so the PDF generation API does the rendering for them.

  • Updated October 2026
  • 100 free PDFs a month

What works for PDFs on Supabase

Supabase gives you Postgres, auth, storage and Edge Functions, but nothing that renders documents. Edge Functions run TypeScript on a Deno based runtime built from lightweight isolates. That design starts fast and scales well, and it also rules out anything that needs to launch a separate program, such as a headless browser.

Ways to produce PDFs in a Supabase project
ApproachRuns whereLimits
Puppeteer or PlaywrightNot inside an Edge FunctionThere is no way to start a Chromium process in the isolate, so you would need a separate server
A pure JavaScript PDF library such as pdf-libInside the Edge FunctionGood for stamping or merging existing PDFs; you place text by coordinates and handle page breaks yourself
Generating in the browserThe user’s deviceResults vary by browser, the file never reaches Storage unless the client uploads it, and the data is exposed client side
CastPDFCalled from the Edge FunctionOne HTTPS request per document; a plan above the free allowance

In practice, a Supabase app that needs real documents (tickets, invoices, reports) ends up calling a rendering service from a function. The function holds the secret, reads the rows the user is allowed to see, sends them to the template and parks the file in Storage. Your frontend only ever handles a link.

Create the function and set its secrets

Scaffold a function with the Supabase CLI, then store your CastPDF key and the ticket template ID as secrets. Start with a test key (it begins with cpdf_test_): test tickets are free and watermarked. For local development, put the same names in supabase/functions/.env and the CLI loads them when you run supabase functions serve. Create a private bucket called tickets in the dashboard or with SQL before the first run.

Terminal
supabase functions new ticket-pdf

supabase secrets set CASTPDF_API_KEY="$CASTPDF_API_KEY" TICKET_TEMPLATE_ID="$TICKET_TEMPLATE_ID"

supabase functions deploy ticket-pdf

SUPABASE_URL, SUPABASE_ANON_KEY and SUPABASE_SERVICE_ROLE_KEY are available to every deployed function without setting them yourself, so the code below can create both a user scoped client and an admin client.

The Edge Function: render, upload, sign

The function receives a booking ID from a signed in user. It reads the booking through a client that carries the caller’s JWT, so your row level security policies decide whether this user may see it. Only after that check does it call CastPDF, upload the ticket with the service role client and return a signed URL that is valid for one hour.

supabase/functions/ticket-pdf/index.ts
import { createClient } from 'jsr:@supabase/supabase-js@2';

const SUPABASE_URL = Deno.env.get('SUPABASE_URL')!;

Deno.serve(async (req: Request) => {
  const authHeader = req.headers.get('Authorization');
  if (req.method !== 'POST' || !authHeader) {
    return Response.json({ message: 'Sign in and POST a bookingId' }, { status: 400 });
  }

  // Acts as the caller, so row level security applies to this query.
  const asUser = createClient(SUPABASE_URL, Deno.env.get('SUPABASE_ANON_KEY')!, {
    global: { headers: { Authorization: authHeader } },
  });
  const { bookingId } = await req.json();
  const { data: booking, error } = await asUser
    .from('bookings')
    .select('id, code, holder_name, gate, section, row_label, seat_label, price, events(name, venue, starts_on, doors)')
    .eq('id', bookingId)
    .single();
  if (error || !booking) {
    return Response.json({ message: 'Booking not found' }, { status: 404 });
  }

  const pdfRes = await fetch('https://api.castpdf.com/v1/pdf', {
    method: 'POST',
    headers: {
      Authorization: `Bearer ${Deno.env.get('CASTPDF_API_KEY')}`,
      'Content-Type': 'application/json',
      'Idempotency-Key': `ticket-${booking.code}`,
    },
    body: JSON.stringify({
      template_id: Deno.env.get('TICKET_TEMPLATE_ID'),
      data: {
        currency: 'EUR',
        locale: 'en-IE',
        event: { name: booking.events.name, venue: booking.events.venue, date: booking.events.starts_on, doors: booking.events.doors },
        ticket: { type: 'Standard', gate: booking.gate, section: booking.section, row: booking.row_label, seat: booking.seat_label, price: booking.price },
        holder: { name: booking.holder_name },
        booking: { code: booking.code },
      },
      filename: `ticket-${booking.code}`,
    }),
    signal: AbortSignal.timeout(60_000),
  });
  if (!pdfRes.ok) {
    const { error: apiError } = await pdfRes.json();
    console.error('CastPDF', apiError.code, apiError.message);
    return Response.json({ message: 'Ticket could not be created' }, { status: 502 });
  }
  const pdf = new Uint8Array(await pdfRes.arrayBuffer());

  const admin = createClient(SUPABASE_URL, Deno.env.get('SUPABASE_SERVICE_ROLE_KEY')!);
  const path = `${booking.id}/${booking.code}.pdf`;
  const { error: uploadError } = await admin.storage
    .from('tickets')
    .upload(path, pdf, { contentType: 'application/pdf', upsert: true });
  if (uploadError) {
    return Response.json({ message: uploadError.message }, { status: 500 });
  }

  const { data: signed } = await admin.storage.from('tickets').createSignedUrl(path, 60 * 60);
  return Response.json({ path, url: signed?.signedUrl });
});

Two clients is the important pattern here. The user scoped client cannot read bookings that belong to someone else, so a guessed ID simply returns nothing. The service role client bypasses row level security, which is why it is used only for the upload, after the ownership check has passed. Uploading with upsert: true and an idempotency key based on the booking code makes a repeated call harmless: the same document comes back and simply replaces the stored copy.

Call it from your app with functions.invoke

In the browser or a mobile app, supabase.functions.invoke sends the request with the signed in user’s access token attached, which is what the function reads as its Authorization header. The CastPDF key never leaves the server.

Client code (supabase-js v2)
const { data, error } = await supabase.functions.invoke('ticket-pdf', {
  body: { bookingId: booking.id },
});
if (error) {
  console.error('Ticket PDF failed', error.message);
} else {
  window.open(data.url, '_blank');
}

If you would rather not store tickets at all, add response: 'url' to the CastPDF request and return the link CastPDF gives you instead of uploading. That link works without a key until it expires with your plan’s retention period. Storing in your own bucket is the better choice when tickets must stay available for months, or when you want to serve them under your own access rules.

Design the ticket once, send JSON forever

The ticket starter template already has the layout: event name, date and doors, gate and row details, the holder and the booking code. Copy it in the dashboard, change the colours and fonts in the HTML and CSS editor with a live preview, and the function only has to send data. The request it builds looks like this:

Ticket data posted by the function
{
  "template_id": "9b1d3f5a-7c2e-4a6b-8d0f-3e5a7c9b1d2f",
  "data": {
    "currency": "EUR",
    "locale": "en-IE",
    "event": { "name": "Harbourside Developer Summit", "venue": "Dock Hall 3, Dublin", "date": "2026-11-12", "doors": "08:30" },
    "ticket": { "type": "Standard", "gate": "C", "section": "Main hall", "row": "K", "seat": "22", "price": 189 },
    "holder": { "name": "Siobhan Keane" },
    "booking": { "code": "HDS-5R8T2W" }
  },
  "filename": "ticket-HDS-5R8T2W"
}

The template sets its own page size (a ticket strip rather than A4) and renders in print mode, so you do not pass a format. The data you send replaces the sample data entirely; include every field the template prints, or adjust the template to drop the ones you do not use.

Production checklist for Supabase

  • Keep the bucket private and hand out signed URLs with a short lifetime. A public bucket would let anyone with the path download any ticket.
  • Never use the service role key in client code, and never pass it to CastPDF. It belongs only inside functions, as in the example above.
  • Leave JWT verification switched on for the function (the default), so only signed in users can trigger renders that count towards your plan.
  • Waiting on fetch does not use the function’s CPU budget, but it does use wall clock time. A render can take up to 30 seconds, and the 60 second abort signal leaves room for queue time.
  • Swap the test key for a cpdf_live_ key with supabase secrets set before launch; the function picks up the new value without a code change.
  • Keep each ticket or report within 50 pages and 40 MB. Remember the size limits on your Storage bucket too.

Troubleshooting the Edge Function

Supabase specific errors and their fixes
SymptomLikely causeFix
invalid_api_key (401) from CastPDFThe secret was set locally but not in the project, or has a typoRun supabase secrets list and set it again
Booking not found for a real bookingRow level security blocks the query for this userCheck the policy on bookings and that the client sent its session
new row violates row-level security policy on uploadThe upload used the user client instead of the admin clientUpload with the service role client after the ownership check
A 401 from the function itselfJWT verification is on and the request had no valid sessionCall it with functions.invoke from a signed in client
template_render_error (422)A field the template reads is missing from dataRead details.line and add the field or a Liquid default
rate_limited (429) during testingTest keys allow 20 requests per minuteWait for Retry-After seconds before calling again

The quickstart explains keys and starter templates, and the API reference documents every field the function sends. Comparing platforms? The Firebase page does the same job with a Cloud Function and Cloud Storage, the AWS Lambda page with S3, and the Node.js page covers plain server code.

FAQ

Common questions

Can I run Puppeteer in a Supabase Edge Function?

No. Edge Functions run in isolates that cannot start a browser process. Call a rendering API from the function instead, or run Puppeteer on a separate server you manage.

Why use two Supabase clients in one function?

The client created with the caller’s token respects row level security, so it proves the user may see the booking. The service role client is needed for the upload. Keeping them apart limits what a bug could expose.

Should I store PDFs in Supabase Storage or use the CastPDF link?

Use Storage when documents must stay available for a long time or follow your own access rules. Use the CastPDF link when a temporary download is enough. Both approaches keep your API key private.

Can a database trigger create the PDF automatically?

Yes. A Postgres trigger using the pg_net extension can call the Edge Function when a row is inserted, for example a new booking. Make the function idempotent, because a call may be repeated.

Does the CastPDF key need to be in my Supabase client code?

Never. The key stays in function secrets. Your frontend calls the function with the user’s session, and only the function talks to CastPDF.

How long does a Supabase Edge Function have to finish the render?

Typical documents come back in a few seconds, well within the wall clock limit. Waiting on the network does not count as CPU time. Keep an abort signal of about 60 seconds so a slow call fails cleanly.

Make your first PDF in 5 minutes

Pick a template, add your details and download your PDF. You get 100 free PDFs every month, and you don’t need a card.