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?

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, no tsconfig.json, no compilation. Write .ts files 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(), and URL — the same APIs you already know from the browser.
  • ES modules and URL imports. Import from npm:, jsr:, or URL specifiers directly. No package.json required.

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 supabase

Initialize Your Project

supabase init my-project
cd my-project

This creates a supabase/ directory with config.toml and an empty functions/ folder.

Create Your First Function

supabase functions new hello-world

This 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:

VariableDescription
SUPABASE_URLYour project's API gateway URL
SUPABASE_ANON_KEYPublic anon key (safe for client-side with RLS)
SUPABASE_SERVICE_ROLE_KEYAdmin key that bypasses RLS (server-side only)
SUPABASE_DB_URLDirect 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.local

For 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 list

Secrets 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:

ClientUse Case
Service role (admin)Webhooks, cron jobs, internal APIs, operations on behalf of system
Anon + user JWTUser-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 = false

This 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's crypto module.
  • 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 synchronous constructEvent).
  • Disable JWT verification for this function in config.toml since 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 serve

Your 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.local

Debugging

The Supabase CLI supports debugging with Chrome DevTools. Run your function with the --debug flag:

supabase functions serve --debug

Then 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 deploy

Your 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?

FeatureSupabase Edge FunctionsNext.js API RoutesCloudflare Workers
RuntimeDenoNode.jsV8 isolates
LanguageTypeScript (native)TypeScript (compiled)TypeScript (compiled)
Cold starts~50-200ms~200-500ms (Lambda)Under 5ms
Deploymentsupabase functions deployvercel deploy (auto)wrangler deploy
DatabaseBuilt-in Supabase PostgresBring your ownD1 / bring your own
AuthBuilt-in Supabase AuthBring your ownBring your own
CORSManualAutomatic (same-origin)Manual
Max execution150s (wall clock)10s-300s (varies)30s (Workers) / 15min (Durable Objects)
Pricing500K invocations free, then $2/millionIncluded in Vercel plan100K requests/day free
Best forSupabase-native appsNext.js appsPerformance-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.

Share: