Supabase Edge Functions in Next.js: Setup to Cron (2026)
From supabase functions new to production: Deno runtime gotchas, built-in SUPABASE_URL and SERVICE_ROLE_KEY env vars, webhook triggers and cron jobs.
Photo by Kvistholt Photography on Unsplash
Supabase Edge Functions are server-side TypeScript functions that run on Supabase's open-source, Deno-based edge runtime, distributed globally so they execute close to your users while keeping direct access to your Supabase database. You write them in supabase/functions/<name>/index.ts, serve them locally with npx supabase functions serve, and deploy with npx supabase functions deploy <name> — no server to manage.
Before you build anything on them, know the documented constraints: each request gets at most 2 seconds of actual CPU time and 256 MB of memory, wall-clock time is capped at 150 s on the Free plan / 400 s on paid plans, and a function that hasn't responded within the 150-second request idle timeout returns a 504 Gateway Timeout (Edge Functions limits). This guide covers setup, webhooks, database triggers, cron jobs, and how to call functions from Next.js — with those limits designed in, not discovered in production.
Why Supabase Edge Functions?#
Performance Benefits:
- Deno isolates start fast — no container boot like classic Lambda
- Execute at the edge, close to users
- No server management required
- Automatic scaling
Developer Experience:
- TypeScript/JavaScript support via Deno
- Direct Supabase client access
- Local development and testing
- Simple deployment workflow
Use Cases:
- Webhook handlers (Stripe, GitHub, etc.)
- Database triggers and automation
- Scheduled background jobs
- API integrations
- Custom authentication flows
- Data transformations
The Limits You Must Design Around#
Every number below comes from the official limits page — these are hard caps, not tunables:
| Limit | Value |
|---|---|
| CPU time per request | 2 s (actual time on the CPU, not wall clock) |
| Memory | 256 MB |
| Wall-clock duration | 150 s (Free) / 400 s (paid plans) |
| Request idle timeout | 150 s — exceeded requests return 504 Gateway Timeout |
| Bundled function size | 20 MB (bundled locally via CLI) / 5 MB (bundled server-side) |
| Functions per project | 100 (Free), 500 (Pro), 1,000 (Team) |
| Secrets | 100 max, 48 KiB each |
| Log messages | 10,000 chars max, ~100 events per 10 s |
Practical consequences:
- The 2-second CPU cap is the one that bites. A 400-second wall clock sounds generous, but that budget is for waiting (fetch calls, database queries, streaming). Heavy synchronous work — image resizing, PDF generation, crypto mining your JSON — burns CPU time and gets the worker killed long before the wall clock matters. Offload CPU-heavy processing to a queue or a regular server.
- A hung upstream call turns into a
504 Gateway Timeoutfor your caller. Always set your ownAbortSignal.timeout()on outboundfetchcalls so you can return a real error before the platform kills the request. - The 20 MB bundle cap punishes fat dependencies. Importing a large npm package with
npm:pulls its whole dependency tree into the bundle. Check the deployed size in the CLI output.
The Runtime in 2026: Deno.serve and npm: Imports#
Older Supabase examples (and older versions of this guide) imported serve from deno.land/[email protected]/http/server.ts and packages from esm.sh. Both still work, but the docs have moved on:
Deno.serveis built in — no import needed. All code samples below use it.npm:specifiers are the recommended way to import packages (import { createClient } from 'npm:@supabase/supabase-js@2'), withjsr:andnode:built-ins also supported. Pin versions, and manage them per function with adeno.jsonin the function's directory so two functions can upgrade independently (managing dependencies).- Supabase has also introduced a
withSupabasehandler wrapper (fromnpm:@supabase/server) in its newest quickstarts that injects pre-built clients and JWT claims. TheDeno.servepattern below remains fully supported and is what the vast majority of existing functions use.
Two environment variables are injected automatically into every deployed function: SUPABASE_URL and SUPABASE_SERVICE_ROLE_KEY (plus SUPABASE_ANON_KEY). You never need to set those yourself — npx supabase secrets set is only for your own keys (Stripe, Resend, etc.).
1. Setup and Configuration#
Install Supabase CLI#
npm install supabase --save-dev
npx supabase init
npx supabase loginLink to Your Project#
npx supabase link --project-ref your-project-refCreate Your First Function#
npx supabase functions new hello-worldThis creates: supabase/functions/hello-world/index.ts
2. Basic Edge Function Structure#
Simple HTTP Handler#
// supabase/functions/hello-world/index.ts
Deno.serve(async (req) => {
const { name } = await req.json()
const data = {
message: `Hello ${name}!`,
}
return new Response(
JSON.stringify(data),
{ headers: { "Content-Type": "application/json" } },
)
})With Supabase Client#
import { createClient } from 'npm:@supabase/supabase-js@2'
Deno.serve(async (req) => {
const supabaseClient = createClient(
Deno.env.get('SUPABASE_URL') ?? '',
Deno.env.get('SUPABASE_ANON_KEY') ?? '',
{
global: {
headers: { Authorization: req.headers.get('Authorization')! },
},
}
)
const { data: { user } } = await supabaseClient.auth.getUser()
if (!user) {
return new Response(
JSON.stringify({ error: 'Unauthorized' }),
{ status: 401, headers: { "Content-Type": "application/json" } }
)
}
const { data, error } = await supabaseClient
.from('posts')
.select('*')
.eq('user_id', user.id)
if (error) {
return new Response(
JSON.stringify({ error: error.message }),
{ status: 500, headers: { "Content-Type": "application/json" } }
)
}
return new Response(
JSON.stringify({ data }),
{ headers: { "Content-Type": "application/json" } }
)
})3. Local Development#
Start Local Functions#
npx supabase functions serveTest with curl#
curl -i --location --request POST 'http://localhost:54321/functions/v1/hello-world' \
--header 'Authorization: Bearer YOUR_ANON_KEY' \
--header 'Content-Type: application/json' \
--data '{"name":"World"}'Test from Next.js#
// app/api/test-function/route.ts
export async function POST(request: Request) {
const { name } = await request.json()
const response = await fetch(
`${process.env.NEXT_PUBLIC_SUPABASE_URL}/functions/v1/hello-world`,
{
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY}`,
},
body: JSON.stringify({ name }),
}
)
const data = await response.json()
return Response.json(data)
}4. Webhook Handlers#
First: Disable Supabase JWT Verification for Third-Party Webhooks#
By default every Edge Function requires a valid Supabase JWT in the Authorization header (verify_jwt = true). Stripe and GitHub do not send Supabase JWTs, so with the default setting their webhook deliveries are rejected with 401 Unauthorized before your code ever runs — a classic "the webhook works in my curl test but Stripe shows failed deliveries" bug. Deploy webhook handlers with verification off:
npx supabase functions deploy stripe-webhook --no-verify-jwt(or set verify_jwt = false for that function in supabase/config.toml). You are not weakening security as long as you verify the provider's signature instead — which both handlers below do. Keep verify_jwt = true for every function called by your own authenticated users.
Stripe Webhook#
// supabase/functions/stripe-webhook/index.ts
import { createClient } from 'npm:@supabase/supabase-js@2'
import Stripe from 'npm:stripe@14'
const stripe = new Stripe(Deno.env.get('STRIPE_SECRET_KEY') || '', {
apiVersion: '2023-10-16',
httpClient: Stripe.createFetchHttpClient(),
})
const cryptoProvider = Stripe.createSubtleCryptoProvider()
Deno.serve(async (req) => {
const signature = req.headers.get('Stripe-Signature')
const body = await req.text()
let event
try {
event = await stripe.webhooks.constructEventAsync(
body,
signature!,
Deno.env.get('STRIPE_WEBHOOK_SECRET')!,
undefined,
cryptoProvider
)
} catch (err) {
return new Response(
JSON.stringify({ error: err.message }),
{ status: 400 }
)
}
const supabase = createClient(
Deno.env.get('SUPABASE_URL')!,
Deno.env.get('SUPABASE_SERVICE_ROLE_KEY')!
)
switch (event.type) {
case 'checkout.session.completed': {
const session = event.data.object
await supabase
.from('subscriptions')
.insert({
user_id: session.metadata.user_id,
stripe_customer_id: session.customer,
stripe_subscription_id: session.subscription,
status: 'active',
})
break
}
case 'customer.subscription.updated': {
const subscription = event.data.object
await supabase
.from('subscriptions')
.update({
status: subscription.status,
current_period_end: new Date(subscription.current_period_end * 1000),
})
.eq('stripe_subscription_id', subscription.id)
break
}
case 'customer.subscription.deleted': {
const subscription = event.data.object
await supabase
.from('subscriptions')
.update({ status: 'canceled' })
.eq('stripe_subscription_id', subscription.id)
break
}
}
return new Response(JSON.stringify({ received: true }), {
headers: { 'Content-Type': 'application/json' },
})
})GitHub Webhook#
// supabase/functions/github-webhook/index.ts
import { createClient } from 'npm:@supabase/supabase-js@2'
// crypto.subtle is available globally in the Deno runtime — no import needed
async function verifySignature(
payload: string,
signature: string,
secret: string
): Promise<boolean> {
const encoder = new TextEncoder()
const key = await crypto.subtle.importKey(
"raw",
encoder.encode(secret),
{ name: "HMAC", hash: "SHA-256" },
false,
["sign"]
)
const signed = await crypto.subtle.sign(
"HMAC",
key,
encoder.encode(payload)
)
const expectedSignature = `sha256=${Array.from(new Uint8Array(signed))
.map(b => b.toString(16).padStart(2, '0'))
.join('')}`
return signature === expectedSignature
}
Deno.serve(async (req) => {
const signature = req.headers.get('X-Hub-Signature-256')
const event = req.headers.get('X-GitHub-Event')
const body = await req.text()
const isValid = await verifySignature(
body,
signature!,
Deno.env.get('GITHUB_WEBHOOK_SECRET')!
)
if (!isValid) {
return new Response('Invalid signature', { status: 401 })
}
const payload = JSON.parse(body)
const supabase = createClient(
Deno.env.get('SUPABASE_URL')!,
Deno.env.get('SUPABASE_SERVICE_ROLE_KEY')!
)
if (event === 'push') {
await supabase.from('deployments').insert({
repo: payload.repository.full_name,
branch: payload.ref.replace('refs/heads/', ''),
commit_sha: payload.after,
commit_message: payload.head_commit.message,
author: payload.head_commit.author.name,
})
}
return new Response(JSON.stringify({ success: true }), {
headers: { 'Content-Type': 'application/json' },
})
})5. Database Triggers#
Trigger on Insert#
-- Create function to call edge function
CREATE OR REPLACE FUNCTION trigger_edge_function()
RETURNS TRIGGER AS $$
DECLARE
request_id bigint;
BEGIN
SELECT net.http_post(
url := 'https://your-project.supabase.co/functions/v1/on-user-created',
headers := jsonb_build_object(
'Content-Type', 'application/json',
'Authorization', 'Bearer ' ||
(SELECT decrypted_secret FROM vault.decrypted_secrets WHERE name = 'anon_key')
),
body := jsonb_build_object(
'user_id', NEW.id,
'email', NEW.email
)
) INTO request_id;
RETURN NEW;
END;
$$ LANGUAGE plpgsql;
-- Create trigger
CREATE TRIGGER on_user_created
AFTER INSERT ON auth.users
FOR EACH ROW
EXECUTE FUNCTION trigger_edge_function();Edge Function Handler#
// supabase/functions/on-user-created/index.ts
import { createClient } from 'npm:@supabase/supabase-js@2'
Deno.serve(async (req) => {
const { user_id, email } = await req.json()
const supabase = createClient(
Deno.env.get('SUPABASE_URL')!,
Deno.env.get('SUPABASE_SERVICE_ROLE_KEY')!
)
// Create user profile
await supabase.from('profiles').insert({
id: user_id,
email,
created_at: new Date().toISOString(),
})
// Send welcome email
await fetch('https://api.resend.com/emails', {
method: 'POST',
headers: {
'Authorization': `Bearer ${Deno.env.get('RESEND_API_KEY')}`,
'Content-Type': 'application/json',
},
body: JSON.stringify({
from: '[email protected]',
to: email,
subject: 'Welcome!',
html: '<h1>Welcome to our platform!</h1>',
}),
})
return new Response(JSON.stringify({ success: true }), {
headers: { 'Content-Type': 'application/json' },
})
})6. Scheduled Jobs#
Using pg_cron + pg_net + Vault#
Scheduling an Edge Function needs two extensions: pg_cron (the scheduler) and pg_net (async HTTP from Postgres). The official pattern stores the project URL and API key in Supabase Vault rather than hardcoding them in the cron job — anyone who can read cron.job would otherwise see your key in plain text:
-- Enable both extensions (Dashboard → Database → Extensions also works)
CREATE EXTENSION IF NOT EXISTS pg_cron;
CREATE EXTENSION IF NOT EXISTS pg_net;
-- Store credentials once in Vault
SELECT vault.create_secret('https://your-project.supabase.co', 'project_url');
SELECT vault.create_secret('YOUR_ANON_KEY', 'anon_key');
-- Schedule daily cleanup at 2 AM
SELECT cron.schedule(
'daily-cleanup',
'0 2 * * *',
$$
SELECT net.http_post(
url := (SELECT decrypted_secret FROM vault.decrypted_secrets WHERE name = 'project_url')
|| '/functions/v1/daily-cleanup',
headers := jsonb_build_object(
'Content-Type', 'application/json',
'Authorization', 'Bearer ' ||
(SELECT decrypted_secret FROM vault.decrypted_secrets WHERE name = 'anon_key')
),
body := jsonb_build_object('scheduled_at', now())
) AS request_id;
$$
);Note that net.http_post is fire-and-forget: it returns a request_id immediately and does not wait for (or retry on) a failed function run. Check cron.job_run_details for scheduling failures and the Edge Function logs for execution failures — they are two separate failure domains.
Cleanup Function#
// supabase/functions/daily-cleanup/index.ts
import { createClient } from 'npm:@supabase/supabase-js@2'
Deno.serve(async (req) => {
const supabase = createClient(
Deno.env.get('SUPABASE_URL')!,
Deno.env.get('SUPABASE_SERVICE_ROLE_KEY')!
)
// Delete old sessions
const { data: deletedSessions } = await supabase
.from('sessions')
.delete()
.lt('expires_at', new Date().toISOString())
.select()
// Archive old logs
const thirtyDaysAgo = new Date()
thirtyDaysAgo.setDate(thirtyDaysAgo.getDate() - 30)
const { data: logs } = await supabase
.from('logs')
.select('*')
.lt('created_at', thirtyDaysAgo.toISOString())
if (logs && logs.length > 0) {
await supabase.from('logs_archive').insert(logs)
await supabase
.from('logs')
.delete()
.lt('created_at', thirtyDaysAgo.toISOString())
}
return new Response(
JSON.stringify({
deleted_sessions: deletedSessions?.length || 0,
archived_logs: logs?.length || 0,
}),
{ headers: { 'Content-Type': 'application/json' } }
)
})7. Email Processing#
Send Transactional Emails#
// supabase/functions/send-email/index.ts
Deno.serve(async (req) => {
const { to, subject, html } = await req.json()
const response = await fetch('https://api.resend.com/emails', {
method: 'POST',
headers: {
'Authorization': `Bearer ${Deno.env.get('RESEND_API_KEY')}`,
'Content-Type': 'application/json',
},
body: JSON.stringify({
from: '[email protected]',
to,
subject,
html,
}),
})
const data = await response.json()
return new Response(JSON.stringify(data), {
headers: { 'Content-Type': 'application/json' },
status: response.status,
})
})8. Deployment#
Deploy Single Function#
npx supabase functions deploy hello-worldDeploy All Functions#
npx supabase functions deploySet Environment Variables#
npx supabase secrets set STRIPE_SECRET_KEY=sk_test_...
npx supabase secrets set RESEND_API_KEY=re_...List Secrets#
npx supabase secrets list9. Calling from Next.js#
Client-Side Call#
'use client'
import { createClient } from '@/lib/supabase/client'
export function InvokeFunction() {
const supabase = createClient()
async function callFunction() {
const { data, error } = await supabase.functions.invoke('hello-world', {
body: { name: 'World' },
})
if (error) {
console.error('Error:', error)
return
}
console.log('Response:', data)
}
return <button onClick={callFunction}>Call Function</button>
}Server-Side Call#
// app/api/invoke/route.ts
import { createClient } from '@/lib/supabase/server'
export async function POST(request: Request) {
const supabase = createClient()
const { name } = await request.json()
const { data, error } = await supabase.functions.invoke('hello-world', {
body: { name },
})
if (error) {
return Response.json({ error: error.message }, { status: 500 })
}
return Response.json(data)
}10. Error Handling and Logging#
Structured Logging#
function log(level: string, message: string, meta?: any) {
console.log(JSON.stringify({
timestamp: new Date().toISOString(),
level,
message,
...meta,
}))
}
Deno.serve(async (req) => {
try {
log('info', 'Function invoked', { method: req.method })
const { data } = await req.json()
// Process data
log('info', 'Function completed successfully')
return new Response(JSON.stringify({ success: true }), {
headers: { 'Content-Type': 'application/json' },
})
} catch (error) {
log('error', 'Function failed', { error: error.message })
return new Response(
JSON.stringify({ error: 'Internal server error' }),
{ status: 500, headers: { 'Content-Type': 'application/json' } }
)
}
})11. Best Practices#
Security#
- Never expose service role key to clients
- Validate all inputs
- Use environment variables for secrets
- Implement rate limiting
- Verify webhook signatures
Performance#
- Keep functions small and focused
- Use connection pooling for database
- Cache responses when possible
- Minimize cold start time
- Use streaming for large responses
Monitoring#
- Log all errors with context
- Track function invocations
- Monitor execution time
- Set up alerts for failures
- Use structured logging
12. Common Use Cases#
Image Processing#
import { createClient } from 'npm:@supabase/supabase-js@2'
Deno.serve(async (req) => {
const { imageUrl } = await req.json()
// Download image
const response = await fetch(imageUrl)
const imageBuffer = await response.arrayBuffer()
// Process image (resize, compress, etc.)
// ... image processing logic ...
// Upload to Supabase Storage
const supabase = createClient(
Deno.env.get('SUPABASE_URL')!,
Deno.env.get('SUPABASE_SERVICE_ROLE_KEY')!
)
const fileName = `processed-${Date.now()}.jpg`
const { data, error } = await supabase.storage
.from('images')
.upload(fileName, imageBuffer, {
contentType: 'image/jpeg',
})
if (error) {
return new Response(
JSON.stringify({ error: error.message }),
{ status: 500 }
)
}
const { data: { publicUrl } } = supabase.storage
.from('images')
.getPublicUrl(fileName)
return new Response(
JSON.stringify({ url: publicUrl }),
{ headers: { 'Content-Type': 'application/json' } }
)
})Frequently Asked Questions (FAQ)#
What are Supabase Edge Functions?#
Supabase Edge Functions are server-side TypeScript functions that run on Supabase's open-source, Deno-based edge runtime, distributed globally close to your users. They provide fast cold starts, direct Supabase database access, and seamless integration with your Next.js applications without managing servers.
How do Supabase Edge Functions differ from Next.js API routes?#
Supabase Edge Functions run on Supabase's globally distributed Deno runtime, while Next.js API routes run on your deployment platform (Vercel, AWS, etc.). Edge Functions are better for webhooks, database triggers, and scheduled jobs, while API routes are better for application-specific logic that needs tight integration with your Next.js app.
Can I use npm packages in Supabase Edge Functions?#
Yes — and since the runtime added Node compatibility, the officially recommended way is the npm: specifier: import Stripe from 'npm:stripe@14'. JSR (jsr:@std/[email protected]), node: built-ins (import process from 'node:process'), and legacy esm.sh / deno.land URLs are also supported. Pin versions in a per-function deno.json.
How do I test Edge Functions locally?#
Use the Supabase CLI: npx supabase functions serve to start a local development server. Then test with curl or from your Next.js app pointing to http://localhost:54321/functions/v1/your-function.
What's the cold start time for Supabase Edge Functions?#
Supabase does not publish a cold-start SLA, but the runtime boots V8 isolates rather than containers, so cold starts are typically far shorter than container-based serverless platforms. What is documented is the per-request budget once running: 2 s CPU time and 256 MB memory.
How do I handle secrets in Edge Functions?#
Use the Supabase CLI to set secrets: npx supabase secrets set API_KEY=value. Access them in your function with Deno.env.get('API_KEY'). Never hardcode secrets in your function code.
Can Edge Functions access my Supabase database?#
Yes, Edge Functions have full access to your Supabase database using the Supabase client. You can use either the anon key (with RLS) or service role key (bypasses RLS) depending on your needs.
How much do Supabase Edge Functions cost?#
Supabase Edge Functions are included in all plans with generous limits. The free tier includes 500K function invocations per month. Pro plan includes 2M invocations. Additional invocations cost $2 per 1M requests.
Can I use Edge Functions for scheduled jobs?#
Yes, combine Edge Functions with PostgreSQL's pg_cron extension to schedule jobs. Create a cron job that calls your Edge Function via HTTP at specified intervals (hourly, daily, etc.).
How do I deploy Edge Functions to production?#
Use the Supabase CLI: npx supabase functions deploy function-name. This deploys your function to Deno Deploy globally. Set environment variables with npx supabase secrets set KEY=value.
Can Edge Functions handle file uploads?#
Yes, Edge Functions can receive file uploads and store them in Supabase Storage. Parse the multipart form data, then use the Supabase client to upload to storage buckets.
What's the maximum execution time for Edge Functions?#
Three separate documented caps apply: wall-clock duration of 150 s (Free plan) or 400 s (paid plans), a request idle timeout of 150 s (exceed it and the caller gets a 504 Gateway Timeout), and — most restrictive in practice — 2 s of actual CPU time per request. Waiting on I/O doesn't consume CPU time; synchronous computation does.
Conclusion#
Supabase Edge Functions provide a powerful serverless platform for extending your Next.js applications. With global distribution, seamless database integration, and simple deployment, they're perfect for webhooks, background jobs, and API integrations.
Start with simple HTTP handlers, then add database triggers, scheduled jobs, and complex workflows as your application grows.
Related Articles#
- Complete Guide to Building SaaS with Next.js and Supabase
- Deploying Next.js + Supabase to Production
- Supabase Authentication & Authorization Patterns
- Background Jobs and Async Task Patterns with Next.js and Supabase
- GraphQL Integration with Next.js and Supabase Guide
- Supabase Edge Function BOOT_ERROR: Fix "Failed to Bootstrap"
Frequently Asked Questions
One email a month — no fluff
RLS gotchas, Next.js cache debugging, and the one Supabase setting that bit me last month.
Related Guides
Next.js + Supabase Background Jobs & Async Patterns
Background jobs and async patterns for Next.js + Supabase: database queues, pg_cron, and Edge Functions — no external services.
GraphQL Integration with Next.js and Supabase Guide
Integrate GraphQL with Next.js and Supabase: schema generation, resolvers, authentication, and real-time subscriptions for production apps.
Real-Time Collaboration with Next.js + Supabase: Build Guide
Build real-time collaborative apps with Next.js and Supabase: presence tracking, live cursors, collaborative editing, conflict resolution.