Edge Functions: A Practical Guide with Real Examples
Edge functions are small pieces of server code that run on request, without a server for you to manage. On StitchDB they are TypeScript or JavaScript modules on the Deno runtime, served at https://<project-ref>.p.stitchdb.com/functions/v1/<name> and run on the same server as your database. You use them for the parts of an app that cannot live in the browser: anything that needs a secret, a webhook receiver, a call to an AI provider, or logic you want in one place for web and mobile clients.
This guide covers the patterns you will actually use, with code that runs as is.
#When to reach for a function
Most reads and writes do not need one. The REST API is generated from your tables and Row Level Security authorizes each request. A function earns its place when:
- the work needs a secret (Stripe, an e-mail API, an AI key);
- a third party calls you (Stripe, GitHub, a payment provider webhook);
- several steps must happen together and you do not want to trust the client to do them in order;
- you want to call it on a schedule from SQL.
If the logic is pure data, a Postgres function called with rpc() is often simpler and faster, because it runs inside the database.
#The shape of a function
Deno.serve(async (req) => {
const { name = 'world' } = await req.json().catch(() => ({}))
return Response.json({ message: `Hello ${name}!` })
})That is the whole contract: call Deno.serve once with a handler from Request to Response. You get the web platform APIs (fetch, Web Crypto, streams) plus packages from npm and JSR. Create it in Studio from a template, or with the CLI:
stitchdb functions new send-email # stitchdb/functions/send-email/index.ts
stitchdb functions serve # local, live reload
stitchdb functions deploy send-emailCall it from any client. The JavaScript SDK sends the signed-in user's token automatically, or the anon key when nobody is signed in:
const { data, error } = await stitchdb.functions.invoke('hello-world', {
body: { name: 'Ada' },
})#Several routes in one function
The request path starts with the function name, so one function can serve a small API:
import { Hono } from 'jsr:@hono/hono@4'
// The request path starts with the function name: /my-api/...
const app = new Hono().basePath('/my-api')
app.get('/items/:id', (c) => c.json({ id: c.req.param('id') }))
app.post('/items', async (c) => c.json(await c.req.json(), 201))
Deno.serve(app.fetch)Pin package versions (jsr:@hono/hono@4, npm:stripe@17) so a new release cannot change your function under you. A function can have up to 200 files, and a deno.json next to the entrypoint works as an import map.
#Secrets and built-in variables
Every function already has STITCHDB_URL, STITCHDB_ANON_KEY, STITCHDB_SERVICE_ROLE_KEY, STITCHDB_DB_URL (the transaction pooler, as the project owner), STITCHDB_PROJECT_REF and STITCHDB_FUNCTION_SLUG. Add your own:
stitchdb secrets set STRIPE_KEY=sk_live_… RESEND_KEY=re_…
stitchdb secrets listconst stripeKey = Deno.env.get('STRIPE_SECRET_KEY')
if (!stripeKey) return new Response('STRIPE_SECRET_KEY is not set', { status: 500 })Secrets are shared by every function in the project, stored encrypted and never shown again; the list shows a SHA-256 digest so you can tell which value is set. Changes apply from the next request without a redeploy. Up to 100 secrets per project, 64 KB each.
#Know who is calling
By default a function only runs for requests with a valid JWT of your project: the anon key, the service role key or a signed-in user's access token. The anon key only proves the request came from your app. To act as the user, forward their token so Row Level Security applies:
import { createClient } from 'https://stitchdb.com/sdk/stitchdb-js.mjs'
Deno.serve(async (req) => {
// Queries run as the caller: Row Level Security applies.
const db = createClient(Deno.env.get('STITCHDB_URL')!, Deno.env.get('STITCHDB_ANON_KEY')!, {
global: { headers: { Authorization: req.headers.get('Authorization') ?? '' } },
auth: { persistSession: false },
})
const { data: { user } } = await db.auth.getUser()
if (!user) return Response.json({ error: 'Sign in first' }, { status: 401 })
const { data } = await db.from('todos').select('*')
return Response.json(data)
})The middleware package does the same with less code, and adds rate limits and JSON errors:
import { pipeline, withClaims, withRequiredClaims, withStitchClient } from 'https://stitchdb.com/sdk/stitchdb-middleware-2.1.0.mjs'
Deno.serve(pipeline([withClaims(), withRequiredClaims(), withStitchClient()], async (req, ctx) => {
// ctx.claims: the signed-in user. ctx.stitch: a client acting as them, so Row Level Security applies.
const { data, error } = await ctx.stitch.from('todos').select('*')
if (error) throw error
return Response.json({ user: ctx.claims.sub, todos: data })
}))withRateLimit({ limit, windowMs }) adds a per-user (or per-IP) limit with a 429 and Retry-After. Throw new HttpError(404, 'No such todo') to answer with that status and a JSON body. See Middleware.
#Receive a webhook
Stripe and GitHub cannot send your project keys, so turn JWT verification off for that function and check the sender's signature yourself:
# stitchdb/config.toml
[functions.stripe-webhook]
verify_jwt = falseimport Stripe from 'npm:stripe@17'
const stripe = new Stripe(Deno.env.get('STRIPE_SECRET_KEY')!)
const crypto = Stripe.createSubtleCryptoProvider()
Deno.serve(async (req) => {
const body = await req.text()
try {
const event = await stripe.webhooks.constructEventAsync(
body, req.headers.get('stripe-signature')!, Deno.env.get('STRIPE_WEBHOOK_SECRET')!, undefined, crypto)
console.log('Stripe event', event.type)
return Response.json({ received: true })
} catch (e) {
return new Response(`Bad signature: ${(e as Error).message}`, { status: 400 })
}
})Read the body as text before parsing; signatures are computed over the raw bytes. If all you want is Stripe data in your database, the built-in Stripe sync removes the need for this function.
#Answer now, keep working
Webhook senders time out quickly. Return first and finish in the background with EdgeRuntime.waitUntil():
Deno.serve(async (req) => {
const payload = await req.json()
// Answer right away; the work continues after the response (within the wall-clock limit).
EdgeRuntime.waitUntil(fetch('https://hooks.example.com/ingest', { method: 'POST', body: JSON.stringify(payload) }))
return new Response('accepted', { status: 202 })
})The background work still counts against the wall-clock limit. Responses can also stream, so server-sent events work for AI output or progress updates.
#Run on a schedule
Combine pg_cron and pg_net to call a function from SQL. Store the service role key in Vault first:
-- Call the function every night at 03:00 UTC (pg_cron + pg_net).
select cron.schedule('nightly-report', '0 3 * * *', $$
select net.http_post(
url := 'https://<project-ref>.p.stitchdb.com/functions/v1/nightly-report',
headers := jsonb_build_object(
'Authorization', 'Bearer ' || (select decrypted_secret from vault.decrypted_secrets where name = 'service_role_key'),
'Content-Type', 'application/json'),
body := '{}'::jsonb)
$$);Cron schedules are in UTC. A project can have up to 20 cron jobs.
#Limits, and how to stay inside them
| Plan | Functions | Invocations / month | CPU per request | Memory | Wall clock |
|---|---|---|---|---|---|
| Free | 10 | 500,000 | 2,000 ms | 128 MB | 60 s |
| Pro | 100 | 2,000,000 | 2,000 ms | 150 MB | 150 s |
| Scale | 250 | 5,000,000 | 4,000 ms | 256 MB | 400 s |
These numbers come from the live plans API; check function limits and pricing for the current values. CPU time counts only the time your code computes. Waiting on fetch or the database does not count, which is why calling an AI API for 30 seconds is fine while resizing a large image in JavaScript is not.
When a request goes over CPU or memory, it is stopped and gets 546 WORKER_LIMIT. Other status codes worth knowing: 503 BOOT_ERROR means a syntax or import error, 500 WORKER_ERROR means the function threw before answering, and 429 QUOTA_EXCEEDED means the month's invocations are used up. Request bodies can be up to 20 MB.
For work that runs longer than the wall clock (imports, batch e-mails, an AI call per document), use background jobs: a function wrapped in job() that runs named steps, saves each result, retries failures and can sleep for days. From Pro, job mode runs one long call for up to 15 minutes (60 on Scale) with progress and checkpoints.
#What functions cannot do
- Start processes (
Deno.Command) or load native libraries. - Keep files: each request may write under
/tmp, but nothing is kept. - Reach private addresses or send SMTP on port 25. Send e-mail through an HTTPS API such as Resend.
CORS is handled for you: every response carries Access-Control-Allow-Origin: * and OPTIONS preflights are answered without running your code. CORS headers you set yourself are replaced, so you cannot accidentally break browser access. To restrict callers, require a JWT or check the request in code.
#Deploy from CI and roll back
Every deploy with changed code is a new version; the previous ones stay under Settings → Roll back. In CI, set STITCHDB_ACCESS_TOKEN and run stitchdb functions deploy, or call the management API directly. Logs (console.log, errors, limit stops) appear in Studio within about 10 seconds; never log secrets or personal data, since anyone with project access can read them.
#FAQ
#Are edge functions compatible with Supabase Edge Functions?
StitchDB runs Deno functions with Deno.serve, and functions also get the project URL and keys under the variable names that stack uses, so existing functions deploy as they are.
#Can a function query Postgres directly?
Yes. STITCHDB_DB_URL connects through the transaction pooler as the project owner. Use postgres(url, { prepare: false }) with postgres.js, and remember Row Level Security does not apply to the owner.
#Do I pay for requests rejected before my code runs?
No. Requests with a missing or invalid JWT, or to an unknown function, are not counted as invocations.
#Where do they run geographically?
On your project's server, in StitchDB's EU data centers. That keeps them close to the database, which matters more than distance to the user when a function makes several queries.
Write your first function in Studio: start for free, then follow the functions quickstart.