Edge Functions run on Deno isolates with a small size budget, so Chromium is off the table. Supabase's own Puppeteer example connects to a hosted browser over WebSocket. A screenshot API is the simpler version of the same idea: one HTTPS request, one image back.
200 renders a month, no card.
Supabase Edge Functions are Deno Deploy isolates. There is no filesystem to unpack a browser into, no way to spawn a Chromium process, and the bundle size restriction rules out shipping one. The official Supabase example for Puppeteer says as much: it uses puppeteer-core to connect to Browserless over WebSocket rather than launching a browser locally.
That approach works, but it means holding a WebSocket open to a remote browser for the entire render, managing page lifecycle from inside a function with a wall-clock limit, and paying for browser minutes on a second vendor. If all you need is an image of a URL, a single GET request that returns the finished PNG is easier to reason about and easier to retry.
The function below reads the API key from a Supabase secret, calls the screenshot endpoint, and returns the image. The second version uploads it to a Storage bucket and returns the public URL, which is the pattern most apps want when a screenshot is attached to a database row.
References: Supabase docs: Puppeteer in Edge Functions (Browserless example).
Call GET https://app.snap-render.com/v1/screenshot with the target in the url parameter and your key in the X-API-Key header. The response body is the finished image (or PDF when format=pdf). Ads and cookie banners are blocked by default.
supabase secrets set SNAPRENDER_API_KEY=sk_live_your_key_here
# Local development: add the same line to supabase/functions/.env
Deno.serve(async (req: Request) => {
const { searchParams } = new URL(req.url);
const target = searchParams.get('url');
if (!target) {
return Response.json({ error: 'url query parameter is required' }, { status: 400 });
}
const params = new URLSearchParams({
url: target,
format: 'png',
full_page: 'true',
block_cookie_banners: 'true',
cache: 'true',
cache_ttl: '3600',
});
const upstream = await fetch(`https://app.snap-render.com/v1/screenshot?${params}`, {
headers: { 'X-API-Key': Deno.env.get('SNAPRENDER_API_KEY') ?? '' },
});
if (!upstream.ok) {
const body = await upstream.json().catch(() => ({ error: { code: 'UNKNOWN', message: upstream.statusText } }));
// 429 is either RATE_LIMITED (burst) or QUOTA_EXCEEDED (monthly)
return Response.json({ error: body.error }, { status: upstream.status });
}
return new Response(await upstream.arrayBuffer(), {
headers: {
'Content-Type': 'image/png',
'Cache-Control': 'public, max-age=3600',
},
});
});
SUPABASE_URL and SUPABASE_SERVICE_ROLE_KEY are injected into every deployed function automatically. Create the screenshots bucket once in the dashboard (public if you want getPublicUrl to work without signing).
import { createClient } from 'npm:@supabase/supabase-js@2';
Deno.serve(async (req: Request) => {
const { url, siteId } = await req.json();
const upstream = await fetch(
`https://app.snap-render.com/v1/screenshot?url=${encodeURIComponent(url)}&format=webp&width=1200&height=630`,
{ headers: { 'X-API-Key': Deno.env.get('SNAPRENDER_API_KEY') ?? '' } },
);
if (!upstream.ok) {
return Response.json({ error: 'capture failed', status: upstream.status }, { status: 502 });
}
const supabase = createClient(
Deno.env.get('SUPABASE_URL')!,
Deno.env.get('SUPABASE_SERVICE_ROLE_KEY')!,
);
const path = `sites/${siteId}/${Date.now()}.webp`;
const { error } = await supabase.storage
.from('screenshots')
.upload(path, await upstream.arrayBuffer(), { contentType: 'image/webp', upsert: true });
if (error) {
return Response.json({ error: error.message }, { status: 500 });
}
const { data } = supabase.storage.from('screenshots').getPublicUrl(path);
return Response.json({ url: data.publicUrl });
});
Caching is off by default. Pass cache=true and a cache_ttl in seconds (default 86400, maximum depends on plan). A repeat request for the same URL and parameters inside that window is served from the cache and does not count against your monthly quota. The X-Cache response header reports HIT or MISS, and X-Remaining-Credits shows what is left this month.
Every error body is { "error": { "code", "message", "status" } }.
Full parameter list, POST with raw HTML, signed URLs, and batch jobs are in the API reference. The OpenAPI spec is at /openapi.json.
Because a Chromium binary cannot be bundled into or launched from an Edge Function, the official example connects puppeteer-core to a remote browser over WebSocket. A screenshot API replaces that WebSocket session with one HTTPS request and returns the finished image, so there is no page lifecycle to manage from inside the function.
Yes. Point a Database Webhook at the function URL, read the new row from the request body, capture the URL stored on it, and write the Storage path back with a second query. The function stays under a second of CPU because the render happens elsewhere.
No. With cache=true, a repeat request for the same URL and parameters inside the cache_ttl window returns the stored image and is not billed as a render. The X-Cache response header tells you whether a request was a hit.
WebP at the default quality is usually the smallest file with no visible loss, which matters for Storage egress. Use PNG when you need lossless output, and pdf when you want a document instead of an image.
Yes. The free plan includes 200 renders a month with every feature enabled, and no card is required.
Language quickstarts: Node.js, Python, cURL. Cost comparison: Puppeteer on Lambda vs an API.
Or skip the setup entirely
Everything in this guide is one GET request with SnapRender. No browser to babysit, no timeouts to tune.
200 screenshots a month free. First render in under a minute.
Get a free API key, 200 renders a month, no card.