Supabase Edge Functions in Next.js: Setup to Cron (2026)
Developer Guide

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.

2026-02-25
38 min read
Supabase Edge Functions in Next.js: Setup to Cron (2026)

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:

LimitValue
CPU time per request2 s (actual time on the CPU, not wall clock)
Memory256 MB
Wall-clock duration150 s (Free) / 400 s (paid plans)
Request idle timeout150 s — exceeded requests return 504 Gateway Timeout
Bundled function size20 MB (bundled locally via CLI) / 5 MB (bundled server-side)
Functions per project100 (Free), 500 (Pro), 1,000 (Team)
Secrets100 max, 48 KiB each
Log messages10,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 Timeout for your caller. Always set your own AbortSignal.timeout() on outbound fetch calls 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.serve is 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'), with jsr: and node: built-ins also supported. Pin versions, and manage them per function with a deno.json in the function's directory so two functions can upgrade independently (managing dependencies).
  • Supabase has also introduced a withSupabase handler wrapper (from npm:@supabase/server) in its newest quickstarts that injects pre-built clients and JWT claims. The Deno.serve pattern 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#

bash
npm install supabase --save-dev
npx supabase init
npx supabase login
bash
npx supabase link --project-ref your-project-ref

Create Your First Function#

bash
npx supabase functions new hello-world

This creates: supabase/functions/hello-world/index.ts

2. Basic Edge Function Structure#

Simple HTTP Handler#

typescript
// 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#

typescript
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#

bash
npx supabase functions serve

Test with curl#

bash
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#

typescript
// 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:

bash
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#

typescript
// 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#

typescript
// 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#

sql
-- 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#

typescript
// 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:

sql
-- 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#

typescript
// 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#

typescript
// 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#

bash
npx supabase functions deploy hello-world

Deploy All Functions#

bash
npx supabase functions deploy

Set Environment Variables#

bash
npx supabase secrets set STRIPE_SECRET_KEY=sk_test_...
npx supabase secrets set RESEND_API_KEY=re_...

List Secrets#

bash
npx supabase secrets list

9. Calling from Next.js#

Client-Side Call#

typescript
'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#

typescript
// 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#

typescript
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#

typescript
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.

Frequently Asked Questions

|

Have more questions? Contact us

One email a month — no fluff

RLS gotchas, Next.js cache debugging, and the one Supabase setting that bit me last month.