- Published on
- · 14 min read
Updated
Supabase Edge Functions: Complete Guide to Serverless TypeScript on Deno (2026)
TL;DR: Supabase Edge Functions are serverless TypeScript functions running on Deno Deploy at the edge. They use the Deno.serve() handler pattern with standard Web API Request/Response objects, support TypeScript natively without a build step, and ship with automatic access to your Supabase database, auth, and storage. This guide covers the complete workflow — from scaffolding and CORS handling to database access, JWT validation, webhook handlers, and production deployment.
Table of Contents
- What Are Supabase Edge Functions?
- Getting Started
- The v2 Handler Pattern
- CORS Handling
- Environment Variables and Secrets
- Database Access from Edge Functions
- Authentication and JWT Validation
- Webhook Handler Example
- Error Handling and Logging
- Testing Locally
- Deployment and CI/CD
- Edge Functions vs API Routes
What Are Supabase Edge Functions?
Supabase Edge Functions are serverless TypeScript functions that run on Deno Deploy — a globally distributed edge runtime. Instead of spinning up a Node.js server or configuring Lambda cold starts, you write a TypeScript file that handles HTTP requests using standard Web APIs, and Supabase deploys it across edge nodes worldwide.
The runtime is Deno — the TypeScript-native runtime created by Ryan Dahl (the original creator of Node.js). Deno brings several advantages over Node.js for serverless:
- TypeScript without a build step. No
tsc, notsconfig.json, no compilation. Write.tsfiles and they run directly. - Secure by default. Deno sandboxes execution — no file system, network, or environment access unless explicitly granted.
- Web-standard APIs. Uses
Request,Response,fetch(), andURL— the same APIs you already know from the browser. - ES modules and URL imports. Import from
npm:,jsr:, or URL specifiers directly. Nopackage.jsonrequired.
Edge Functions are designed for short-lived, stateless HTTP handlers: API endpoints, webhook receivers, form processors, scheduled tasks, and server-side logic that needs to run close to your users.
Getting Started
Install the Supabase CLI
If you don't have the Supabase CLI installed:
# macOS
brew install supabase/tap/supabase
# npm (any platform)
npx supabase --version
# Or install globally
npm install -g supabaseInitialize Your Project
supabase init my-project
cd my-projectThis creates a supabase/ directory with config.toml and an empty functions/ folder.
Create Your First Function
supabase functions new hello-worldThis scaffolds supabase/functions/hello-world/index.ts with a starter handler. Your project structure now looks like:
my-project/
├── supabase/
│ ├── config.toml
│ ├── functions/
│ │ ├── _shared/ # Shared utilities (CORS, clients, etc.)
│ │ │ └── cors.ts
│ │ └── hello-world/
│ │ └── index.ts # Function entry point
│ └── migrations/
└── ...The _shared/ directory is a convention for code shared across multiple functions — CORS headers, Supabase client initialization, utility functions. Files here aren't deployed as standalone functions.
The v2 Handler Pattern
Every Supabase Edge Function uses the Deno.serve() pattern — a single function that receives a Request and returns a Response:
Deno.serve(async (req: Request) => {
const { name } = await req.json()
const data = {
message: `Hello ${name}!`,
}
return new Response(JSON.stringify(data), {
headers: { 'Content-Type': 'application/json' },
status: 200,
})
})This is the Deno.serve() API — the standard way to create HTTP servers in Deno. The handler function takes a standard Web API Request and must return a Web API Response (or a Promise<Response>).
Important: Older Supabase Edge Functions tutorials may show an addEventListener('fetch', ...) pattern. This is deprecated. All new functions should use Deno.serve().
Routing Within a Function
A single function can handle multiple routes using URL parsing:
Deno.serve(async (req: Request) => {
const url = new URL(req.url)
const path = url.pathname.split('/').pop()
switch (req.method) {
case 'GET':
if (path === 'users') {
return Response.json({ users: [] })
}
break
case 'POST':
if (path === 'users') {
const body = await req.json()
return Response.json({ created: body }, { status: 201 })
}
break
}
return Response.json({ error: 'Not found' }, { status: 404 })
})For complex routing, consider using Hono — a lightweight web framework that works natively with Supabase Edge Functions.
CORS Handling
When calling Edge Functions from a browser, you must handle CORS manually. Unlike Supabase's REST API, Edge Functions are fully custom server functions — you control the response headers.
The Shared CORS Utility
Create a _shared/cors.ts file that every function can import:
// supabase/functions/_shared/cors.ts
export const corsHeaders = {
'Access-Control-Allow-Origin': '*',
'Access-Control-Allow-Headers': 'authorization, x-client-info, apikey, content-type',
'Access-Control-Allow-Methods': 'GET, POST, PUT, DELETE, OPTIONS',
}For production, replace '*' with your actual domain:
export const corsHeaders = {
'Access-Control-Allow-Origin': 'https://yourdomain.com',
'Access-Control-Allow-Headers': 'authorization, x-client-info, apikey, content-type',
'Access-Control-Allow-Methods': 'GET, POST, PUT, DELETE, OPTIONS',
}SDK-Provided CORS Headers (v2.95.0+)
If you're using @supabase/supabase-js v2.95.0 or later, you can import CORS headers directly from the SDK. This ensures your headers stay in sync as the SDK adds new required headers:
import { corsHeaders } from '@supabase/supabase-js/cors'Applying CORS to a Function
The OPTIONS preflight check must be the first thing in your handler:
import { corsHeaders } from '../_shared/cors.ts'
Deno.serve(async (req: Request) => {
// Handle CORS preflight
if (req.method === 'OPTIONS') {
return new Response('ok', { headers: corsHeaders })
}
try {
const { name } = await req.json()
const data = { message: `Hello ${name}!` }
return new Response(JSON.stringify(data), {
headers: { ...corsHeaders, 'Content-Type': 'application/json' },
status: 200,
})
} catch (error) {
return new Response(JSON.stringify({ error: error.message }), {
headers: { ...corsHeaders, 'Content-Type': 'application/json' },
status: 400,
})
}
})Spread corsHeaders into every response — success responses, error responses, and the OPTIONS preflight. Missing CORS headers on error responses is a common source of confusing browser behavior.
Environment Variables and Secrets
Default Variables
Every Edge Function automatically has access to these environment variables:
| Variable | Description |
|---|---|
SUPABASE_URL | Your project's API gateway URL |
SUPABASE_ANON_KEY | Public anon key (safe for client-side with RLS) |
SUPABASE_SERVICE_ROLE_KEY | Admin key that bypasses RLS (server-side only) |
SUPABASE_DB_URL | Direct Postgres connection string |
Hosted functions also receive SB_REGION (invocation region), SB_EXECUTION_ID (unique function invocation UUID), and DENO_DEPLOYMENT_ID (code version identifier).
Accessing Variables
Use the standard Deno API:
const supabaseUrl = Deno.env.get('SUPABASE_URL')!
const serviceRoleKey = Deno.env.get('SUPABASE_SERVICE_ROLE_KEY')!
const stripeKey = Deno.env.get('STRIPE_SECRET_KEY')!Setting Custom Secrets
For local development, create a .env file at supabase/functions/.env:
STRIPE_SECRET_KEY=sk_test_...
RESEND_API_KEY=re_...
WEBHOOK_SECRET=whsec_...Or specify a custom env file when serving:
supabase functions serve --env-file .env.localFor production, use the CLI to push secrets:
# Set from a file
supabase secrets set --env-file .env
# Set individual secrets
supabase secrets set STRIPE_SECRET_KEY=sk_live_...
# List all remote secrets
supabase secrets listSecrets are available immediately after setting — no redeployment needed. Never check .env files into Git.
Database Access from Edge Functions
Edge Functions have direct access to your Supabase database via the auto-injected environment variables. Initialize a Supabase client and query as usual.
Admin Client (Bypasses RLS)
Use the service role key for server-side operations that need full database access:
import { createClient } from 'npm:@supabase/supabase-js@2'
const supabaseAdmin = createClient(
Deno.env.get('SUPABASE_URL')!,
Deno.env.get('SUPABASE_SERVICE_ROLE_KEY')!
)
Deno.serve(async (req: Request) => {
// This bypasses RLS — use for admin operations, webhooks, cron jobs
const { data, error } = await supabaseAdmin
.from('users')
.select('id, email, plan_type')
.eq('plan_type', 'pro')
if (error) {
return Response.json({ error: error.message }, { status: 500 })
}
return Response.json({ users: data })
})User-Scoped Client (Respects RLS)
Pass the user's JWT to create a client that respects row-level security policies:
import { createClient } from 'npm:@supabase/supabase-js@2'
Deno.serve(async (req: Request) => {
const authHeader = req.headers.get('Authorization')!
const token = authHeader.replace('Bearer ', '')
// This client respects RLS policies using the user's JWT
const supabase = createClient(Deno.env.get('SUPABASE_URL')!, Deno.env.get('SUPABASE_ANON_KEY')!, {
global: { headers: { Authorization: `Bearer ${token}` } },
})
// Only returns rows the user has access to per RLS policies
const { data, error } = await supabase.from('documents').select('*')
if (error) {
return Response.json({ error: error.message }, { status: 500 })
}
return Response.json({ documents: data })
})When to use which client:
| Client | Use Case |
|---|---|
| Service role (admin) | Webhooks, cron jobs, internal APIs, operations on behalf of system |
| Anon + user JWT | User-facing endpoints where RLS should filter results |
Authentication and JWT Validation
Edge Functions can validate the caller's identity by extracting and verifying the JWT from the Authorization header.
Using Supabase Auth
The simplest approach uses the Supabase client to validate the token:
import { createClient } from 'npm:@supabase/supabase-js@2'
const supabase = createClient(Deno.env.get('SUPABASE_URL')!, Deno.env.get('SUPABASE_ANON_KEY')!)
Deno.serve(async (req: Request) => {
const authHeader = req.headers.get('Authorization')
if (!authHeader) {
return Response.json({ error: 'Missing authorization header' }, { status: 401 })
}
const token = authHeader.replace('Bearer ', '')
const { data, error } = await supabase.auth.getUser(token)
if (error || !data.user) {
return Response.json({ error: 'Invalid or expired token' }, { status: 401 })
}
const user = data.user
return Response.json({
message: `Hello ${user.email}`,
userId: user.id,
})
})Disabling Built-in JWT Verification
By default, Edge Functions verify the JWT in the Authorization header before your code runs. For public endpoints or webhook receivers (like Stripe), disable this in config.toml:
# supabase/config.toml
[functions.stripe-webhook]
verify_jwt = falseThis lets unauthenticated requests reach your function — useful for webhooks that use their own signature verification.
Webhook Handler Example
Here's a complete Stripe webhook handler with signature verification — one of the most common Edge Function use cases:
import Stripe from 'https://esm.sh/stripe@17?target=denonext'
const stripe = new Stripe(Deno.env.get('STRIPE_API_KEY')!, {
apiVersion: '2024-11-20',
})
const cryptoProvider = Stripe.createSubtleCryptoProvider()
Deno.serve(async (req: Request) => {
const signature = req.headers.get('Stripe-Signature')
if (!signature) {
return new Response('Missing signature', { status: 400 })
}
// Use .text() — signature verification needs the raw body
const body = await req.text()
let event: Stripe.Event
try {
event = await stripe.webhooks.constructEventAsync(
body,
signature,
Deno.env.get('STRIPE_WEBHOOK_SIGNING_SECRET')!,
undefined,
cryptoProvider
)
} catch (err) {
console.error('Signature verification failed:', err.message)
return new Response(`Webhook Error: ${err.message}`, { status: 400 })
}
// Handle specific event types
switch (event.type) {
case 'checkout.session.completed': {
const session = event.data.object as Stripe.Checkout.Session
console.log(`Checkout completed for customer: ${session.customer}`)
// Grant credits, activate subscription, etc.
break
}
case 'invoice.paid': {
const invoice = event.data.object as Stripe.Invoice
console.log(`Invoice paid: ${invoice.id}, amount: ${invoice.amount_paid}`)
// Record payment, grant recurring credits
break
}
case 'customer.subscription.deleted': {
const subscription = event.data.object as Stripe.Subscription
console.log(`Subscription canceled: ${subscription.id}`)
// Downgrade plan, revoke access
break
}
default:
console.log(`Unhandled event type: ${event.type}`)
}
return Response.json({ received: true }, { status: 200 })
})Key details:
Stripe.createSubtleCryptoProvider()— Required because Deno uses the Web Crypto API instead of Node'scryptomodule.request.text()— Signature verification must use the raw body string, not parsed JSON.constructEventAsync()— The async variant works in Deno's event loop (unlike the synchronousconstructEvent).- Disable JWT verification for this function in
config.tomlsince Stripe doesn't send Supabase JWTs.
Set the required secrets:
supabase secrets set STRIPE_API_KEY=sk_live_...
supabase secrets set STRIPE_WEBHOOK_SIGNING_SECRET=whsec_...Error Handling and Logging
Structured Error Responses
Return consistent JSON error responses with appropriate status codes:
import { corsHeaders } from '../_shared/cors.ts'
function errorResponse(message: string, status: number) {
return Response.json(
{ error: message, status },
{ headers: { ...corsHeaders, 'Content-Type': 'application/json' }, status }
)
}
Deno.serve(async (req: Request) => {
if (req.method === 'OPTIONS') {
return new Response('ok', { headers: corsHeaders })
}
try {
const body = await req.json()
if (!body.email) {
return errorResponse('Email is required', 400)
}
// Business logic here...
return Response.json(
{ success: true },
{ headers: { ...corsHeaders, 'Content-Type': 'application/json' } }
)
} catch (error) {
console.error('Function error:', error)
if (error instanceof SyntaxError) {
return errorResponse('Invalid JSON body', 400)
}
return errorResponse('Internal server error', 500)
}
})Logging
Edge Functions support console.log(), console.error(), and console.warn(). Logs appear in the Supabase Dashboard under Edge Functions → Logs, and in your terminal during local development.
For structured logging, output JSON:
console.log(
JSON.stringify({
level: 'info',
event: 'user_created',
userId: user.id,
timestamp: new Date().toISOString(),
})
)Use console.error() for errors — these are tagged separately in the dashboard logs and are easier to filter.
Testing Locally
Start the Local Runtime
# Start all Supabase services (database, auth, storage, etc.)
supabase start
# Serve all functions with hot reloading
supabase functions serveYour functions are now available at http://localhost:54321/functions/v1/<function-name>.
Test with curl
# Basic POST request
curl -i --request POST \
'http://localhost:54321/functions/v1/hello-world' \
--header 'Authorization: Bearer YOUR_ANON_KEY' \
--header 'Content-Type: application/json' \
--data '{"name": "Developer"}'
# GET request with query params
curl -i 'http://localhost:54321/functions/v1/users?status=active' \
--header 'Authorization: Bearer YOUR_ANON_KEY'The anon key is printed when you run supabase start. You can also find it with supabase status.
Serve a Specific Function
supabase functions serve hello-world --env-file .env.localDebugging
The Supabase CLI supports debugging with Chrome DevTools. Run your function with the --debug flag:
supabase functions serve --debugThen open chrome://inspect in Chrome and connect to the running function.
Deployment and CI/CD
Deploy via CLI
# Deploy a single function
supabase functions deploy hello-world
# Deploy all functions
supabase functions deployYour function is live at https://<project-ref>.supabase.co/functions/v1/hello-world.
GitHub Actions
Automate deployments on push to main:
# .github/workflows/deploy-edge-functions.yml
name: Deploy Edge Functions
on:
push:
branches: [main]
paths:
- 'supabase/functions/**'
jobs:
deploy:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: supabase/setup-cli@v1
with:
version: latest
- name: Link project
run: supabase link --project-ref ${{ secrets.SUPABASE_PROJECT_REF }}
env:
SUPABASE_ACCESS_TOKEN: ${{ secrets.SUPABASE_ACCESS_TOKEN }}
- name: Deploy functions
run: supabase functions deploy
env:
SUPABASE_ACCESS_TOKEN: ${{ secrets.SUPABASE_ACCESS_TOKEN }}Store SUPABASE_ACCESS_TOKEN and SUPABASE_PROJECT_REF as GitHub repository secrets. The paths filter ensures deployments only trigger when function code changes.
Edge Functions vs API Routes
How do Supabase Edge Functions compare to other serverless options?
| Feature | Supabase Edge Functions | Next.js API Routes | Cloudflare Workers |
|---|---|---|---|
| Runtime | Deno | Node.js | V8 isolates |
| Language | TypeScript (native) | TypeScript (compiled) | TypeScript (compiled) |
| Cold starts | ~50-200ms | ~200-500ms (Lambda) | Under 5ms |
| Deployment | supabase functions deploy | vercel deploy (auto) | wrangler deploy |
| Database | Built-in Supabase Postgres | Bring your own | D1 / bring your own |
| Auth | Built-in Supabase Auth | Bring your own | Bring your own |
| CORS | Manual | Automatic (same-origin) | Manual |
| Max execution | 150s (wall clock) | 10s-300s (varies) | 30s (Workers) / 15min (Durable Objects) |
| Pricing | 500K invocations free, then $2/million | Included in Vercel plan | 100K requests/day free |
| Best for | Supabase-native apps | Next.js apps | Performance-critical APIs |
Choose Supabase Edge Functions when your app already uses Supabase for database/auth/storage and you want serverless functions with zero configuration for database access.
Choose Next.js API Routes when you're building a full-stack Next.js app and want your API colocated with your frontend.
Choose Cloudflare Workers when you need the absolute lowest latency, V8 isolate cold starts, and you're comfortable managing your own database connections.
The biggest advantage of Edge Functions is the zero-config integration with the rest of the Supabase platform. Your functions automatically get SUPABASE_URL, SUPABASE_ANON_KEY, and SUPABASE_SERVICE_ROLE_KEY injected — no manual wiring needed.
