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.
| Approach | Runs where | Limits |
|---|---|---|
| Puppeteer or Playwright | Not inside an Edge Function | There 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-lib | Inside the Edge Function | Good for stamping or merging existing PDFs; you place text by coordinates and handle page breaks yourself |
| Generating in the browser | The user’s device | Results vary by browser, the file never reaches Storage unless the client uploads it, and the data is exposed client side |
| CastPDF | Called from the Edge Function | One 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.
supabase functions new ticket-pdf
supabase secrets set CASTPDF_API_KEY="$CASTPDF_API_KEY" TICKET_TEMPLATE_ID="$TICKET_TEMPLATE_ID"
supabase functions deploy ticket-pdfSUPABASE_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.
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.
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:
{
"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
fetchdoes 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 withsupabase secrets setbefore 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
| Symptom | Likely cause | Fix |
|---|---|---|
invalid_api_key (401) from CastPDF | The secret was set locally but not in the project, or has a typo | Run supabase secrets list and set it again |
Booking not found for a real booking | Row level security blocks the query for this user | Check the policy on bookings and that the client sent its session |
new row violates row-level security policy on upload | The upload used the user client instead of the admin client | Upload with the service role client after the ownership check |
A 401 from the function itself | JWT verification is on and the request had no valid session | Call it with functions.invoke from a signed in client |
template_render_error (422) | A field the template reads is missing from data | Read details.line and add the field or a Liquid default |
rate_limited (429) during testing | Test keys allow 20 requests per minute | Wait 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.