Writing and Invoking a Function
The idea in one sentence
Section titled “The idea in one sentence”An Edge Function is a plain Deno.serve handler that receives an HTTP request and returns a response, invoked from a client with supabase.functions.invoke() — but because it runs on Deno, writing one correctly means importing dependencies, and handling cross-origin requests, the Deno way.
A minimal real function
Section titled “A minimal real function”Deno.serve is a global provided by the Deno runtime — there is nothing to import for it. You hand it a callback that receives the incoming Request and returns a Response, exactly like the Fetch API you already know from the browser:
Deno.serve((_req) => { return new Response(JSON.stringify({ message: 'Hello from the edge' }), { headers: { 'Content-Type': 'application/json' }, })})This file lives at supabase/functions/edge-hello/index.ts. Once deployed, it is reachable at https://<project-ref>.supabase.co/functions/v1/edge-hello, but you rarely construct that URL by hand — the client library does it for you. deno check confirms this file type-checks on its own before you ever run it — covered at the end of this lesson.
Import specifiers: this is Deno, not Node
Section titled “Import specifiers: this is Deno, not Node”The single most common way AI-generated Edge Function code breaks: importing a package the way you would in a Node.js or Vite project.
// @expect-error worker fails to boot: bare specifiers are not resolvable in Denoimport { createClient } from '@supabase/supabase-js'
Deno.serve((_req) => { const client = createClient('http://x', 'y') return new Response(JSON.stringify({ ok: !!client }))})Deployed as-is under supabase functions serve, every request to this function returns a real 503, and the actual boot error, captured from the local runtime’s own logs, is unambiguous about what to do instead:
HTTP/1.1 503{"code":"BOOT_ERROR","message":"Worker failed to boot (please check logs)"}
worker boot error: failed to bootstrap runtime: failed to create the graph:Relative import path "@supabase/supabase-js" not prefixed with / or ./ or ../ hint: If you want to use a JSR or npm package, try running `deno add jsr:@supabase/supabase-js` or `deno add npm:@supabase/supabase-js` at file:///.../supabase/functions/edge-bare-import-demo/index.ts:1:30Deno has no node_modules-style bare-specifier resolution by default — every import must be a relative path (./foo.ts), a full URL, or one of two package-registry specifiers:
jsr:— the JSR registry, Deno’s own, and where@supabase/supabase-jsis published for Deno consumption.npm:— resolves an ordinary npm package through Deno’s npm compatibility layer.
Fixed, both specifier styles working for real against this local stack in the same function:
import { createClient } from 'jsr:@supabase/supabase-js@2'import { nanoid } from 'npm:nanoid@5'
Deno.serve((_req) => { const client = createClient('http://example.com', 'anon-key-placeholder') return new Response(JSON.stringify({ jsrClientOk: !!client, npmId: nanoid(8) }), { headers: { 'Content-Type': 'application/json' }, })})$ curl "$FUNCTIONS_URL/edge-import-specifiers" -H "Authorization: Bearer $ANON_KEY" -H "apikey: $ANON_KEY"{"jsrClientOk":true,"npmId":"kR-o8HyM"}deno.json: a per-function import map
Section titled “deno.json: a per-function import map”A bare specifier can still work if you give Deno a mapping for it — a deno.json sitting next to a function’s index.ts acts as that function’s own import map, scoped to just that function (each function gets its own module graph, so nothing here leaks to a sibling function):
{ "imports": { "supabase": "jsr:@supabase/supabase-js@2" }}import { createClient } from 'supabase'
Deno.serve((_req) => { const client = createClient('http://example.com', 'anon-key-placeholder') return new Response(JSON.stringify({ ok: !!client }), { headers: { 'Content-Type': 'application/json' }, })})$ curl "$FUNCTIONS_URL/edge-import-map" -H "Authorization: Bearer $ANON_KEY" -H "apikey: $ANON_KEY"{"ok":true}The bare specifier 'supabase' resolves only because deno.json’s imports map redirects it to the real jsr: specifier — remove that file and this exact function fails with the same BOOT_ERROR shown above.
CORS: headers your function must set itself
Section titled “CORS: headers your function must set itself”Every browser-issued cross-origin request first checks whether the server explicitly allows it — for a non-trivial request (a POST with a JSON body, say), the browser sends an OPTIONS preflight first and only proceeds with the real request if the preflight response says it may. A function that never sets CORS headers works perfectly from curl (curl does not enforce CORS — only browsers do) and then fails silently for real browser clients from a different origin. Handled correctly:
const corsHeaders = { 'Access-Control-Allow-Origin': '*', 'Access-Control-Allow-Headers': 'authorization, x-client-info, apikey, content-type', 'Access-Control-Allow-Methods': 'POST, OPTIONS',}
Deno.serve(async (req) => { if (req.method === 'OPTIONS') { return new Response(null, { headers: corsHeaders }) }
const { name } = await req.json() return new Response(JSON.stringify({ message: `Hello ${name}!` }), { headers: { ...corsHeaders, 'Content-Type': 'application/json' }, })})A real preflight against it, and the real headers the response actually carries:
curl -i -X OPTIONS "$FUNCTIONS_URL/edge-cors" \ -H "Origin: https://example.com" \ -H "Access-Control-Request-Method: POST" \ -H "Access-Control-Request-Headers: authorization, content-type"HTTP/1.1 200 OKAccess-Control-Allow-Origin: *Access-Control-Allow-Headers: authorization, content-typeAccess-Control-Allow-Methods: GET,HEAD,PUT,PATCH,POST,DELETE,OPTIONS,TRACE,CONNECTThe real request, with and without the function setting its own CORS headers — curl shows both as 200, because curl never enforces CORS; a browser reading the headers is what would differ:
$ curl -i -X POST "$FUNCTIONS_URL/edge-cors" -H "Origin: https://example.com" \ -H "Authorization: Bearer $ANON_KEY" -H "apikey: $ANON_KEY" -H "Content-Type: application/json" -d '{"name":"world"}'HTTP/1.1 200 OKaccess-control-allow-origin: *access-control-allow-headers: authorization, x-client-info, apikey, content-typeaccess-control-allow-methods: POST, OPTIONS{"message":"Hello world!"}
$ curl -i -X POST "$FUNCTIONS_URL/edge-no-cors" -H "Origin: https://example.com" \ -H "Authorization: Bearer $ANON_KEY" -H "apikey: $ANON_KEY" -H "Content-Type: application/json" -d '{"name":"world"}'HTTP/1.1 200 OKAccess-Control-Allow-Origin: *{"message":"Hello world!"}Both come back 200 with a body — the difference a real browser’s fetch() would act on is that edge-cors’s response carries the specific Allow-Headers/Allow-Methods a real cross-origin POST with custom headers needs, while edge-no-cors only has whatever this local proxy added on top; deployed and called from a genuinely different origin with custom headers, only the function that sets its own headers is guaranteed to work.
Invoking it from a client
Section titled “Invoking it from a client”supabase-js exposes a dedicated functions.invoke() method rather than a plain fetch, because it does useful work on your behalf: serializing body to JSON, and automatically attaching an Authorization header — the currently signed-in user’s access token if someone is logged in, or the project’s anon key otherwise.
// @runimport { createClient } from '@supabase/supabase-js'
declare const process: { env: Record<string, string | undefined> }
const supabase = createClient(process.env.SUPABASE_URL!, process.env.SUPABASE_ANON_KEY!)
const { data, error } = await supabase.functions.invoke('edge-hello')
if (error) { throw new Error(`invoke failed: ${error.message}`)}
console.log('invoke succeeded, function responded:', data)This is a real, executed call — supabase.functions.invoke('edge-hello') reaches the edge-hello function from earlier in this lesson, served locally by this module’s own supabase functions serve process, and the harness runs it with tsx against the live stack exactly as shown, not a mocked client.
verify_jwt: who is allowed to call at all
Section titled “verify_jwt: who is allowed to call at all”Before your function’s code runs at all, the Edge Runtime can require a valid Supabase JWT on every request — controlled per-function with verify_jwt (true by default; disable it for a function meant to be called by something other than a signed-in client, like a webhook receiver in the next lesson):
[functions.edge-hello]verify_jwt = falseWith the default (verify_jwt = true, no flag), the real response bodies for a missing vs. a malformed Authorization header — captured from this local stack, calling edge-hello:
$ curl "$FUNCTIONS_URL/edge-hello"{"code":"UNAUTHORIZED_NO_AUTH_HEADER","message":"Missing authorization header","msg":"Missing authorization header"}HTTP 401
$ curl "$FUNCTIONS_URL/edge-hello" -H "Authorization: Bearer not-a-real-jwt" -H "apikey: $ANON_KEY"{"code":"UNAUTHORIZED_INVALID_JWT_FORMAT","message":"Invalid JWT format","msg":"Invalid JWT format"}HTTP 401
$ curl "$FUNCTIONS_URL/edge-hello" -H "Authorization: Bearer $ANON_KEY" -H "apikey: $ANON_KEY"{"message":"Hello from the edge"}HTTP 200The project’s own anon key is itself a valid JWT (signed for the anon role), which is exactly why it works as a bearer token above — verify_jwt only checks that the token is a real, validly signed Supabase JWT, not that a specific human user is signed in. Passing the caller’s actual user access token instead (covered fully in the next lesson) is what lets the function’s own database queries run as that user rather than as anon. With --no-verify-jwt (equivalent to verify_jwt = false), the same request with no Authorization header at all succeeds:
$ supabase functions serve --no-verify-jwt$ curl "$FUNCTIONS_URL/edge-hello"{"message":"Hello from the edge"}HTTP 200Secrets: .env locally, supabase secrets set when deployed
Section titled “Secrets: .env locally, supabase secrets set when deployed”A function frequently needs a credential it should never expose to the client. Locally, supabase functions serve --env-file <path> loads a plain .env file, read inside the function with Deno.env.get exactly the same way in both environments:
Deno.serve((_req) => { const secret = Deno.env.get('EDGE_DEMO_SECRET') ?? null return new Response(JSON.stringify({ secret }), { headers: { 'Content-Type': 'application/json' }, })})$ echo "EDGE_DEMO_SECRET=from-env-file" > .env.local$ supabase functions serve --env-file .env.local$ curl "$FUNCTIONS_URL/edge-secrets" -H "Authorization: Bearer $ANON_KEY" -H "apikey: $ANON_KEY"{"secret":"from-env-file"}supabase secrets set is the deployed-project equivalent — and, run against this local stack with no cloud project linked, it fails with exactly the error that gives away it is a hosted-only command:
$ supabase secrets set EDGE_DEMO_SECRET2=from-secrets-set{"_tag":"Error","error":{"code":"LegacyPlatformAuthRequiredError","message":"Access token not provided. Supply an access token by running `supabase login` or setting the SUPABASE_ACCESS_TOKEN environment variable."}}There is no local emulation of supabase secrets set — .env/--env-file is the local story; supabase secrets set only ever talks to a real, logged-in, deployed project.
deno check as a pre-deploy gate
Section titled “deno check as a pre-deploy gate”Every real ts-edge fence in this course is already run through deno check before it is trusted — the exact command a CI pipeline (or you, before supabase functions deploy) should run on a function directory:
$ cd supabase/functions/edge-typecheck-demo && deno check index.tsCheck index.tsTS2345 [ERROR]: Argument of type 'string' is not assignable to parameter of type 'number'. const total = addToCount(5, "3") ~~~ at file:///.../edge-typecheck-demo/index.ts:6:31
error: Type checking failed.A real type error (addToCount(5, "3") passing a string where a number is declared) caught before deploy, not discovered as a runtime 503 after — deno check is Deno’s own compiler doing exactly what tsc --noEmit does for a Node/browser project, scoped to one function’s own module graph.
flowchart LR
client["supabase.functions.invoke('edge-hello')"] -->|"HTTP request + Authorization header"| edge["Deno.serve function (jsr:/npm: imports resolved)"]
edge -->|"Response"| client Reading AI-generated code
Section titled “Reading AI-generated code”An AI assistant asked to “call the internal notifications service” from an Edge Function produced this:
🐛 Spot the bug
import axios from 'axios'import { v4 as uuidv4 } from 'uuid'
Deno.serve(async (req) => {const { userId, message } = await req.json()
const response = await axios.post('https://notifications.internal/send', { id: uuidv4(), userId, message,})
return new Response(JSON.stringify(response.data), { headers: { 'Content-Type': 'application/json' },})})Show the answer
Both imports are bare specifiers — import axios from 'axios' and import { v4 as uuidv4 } from 'uuid' — exactly the pattern that fails to boot with the real BOOT_ERROR/“Relative import path … not prefixed with / or ./ or ../” shown earlier in this lesson, because the AI generated this the way it would for a Node.js/Express backend, where bare specifiers resolve through node_modules automatically. Every request to this function would 503 before a single line of the handler ever runs. The fix is mechanical, not a redesign: import axios from 'npm:axios@1' and import { v4 as uuidv4 } from 'npm:uuid@9' (or drop both entirely — crypto.randomUUID() is a Deno/Web-standard global that needs no import at all, and a plain fetch() replaces axios for a simple POST with no dependency required).
Mental Model
Section titled “Mental Model”Writing an Edge Function is writing a standalone Deno program with its own module graph, not adding a route to an existing Node.js app. Every import is resolved fresh, per function, the moment the isolate boots — which is exactly why a bare specifier fails at boot (before your handler code runs at all) rather than merely at the one line that uses it, and why a deno.json sitting next to one function’s index.ts has zero effect on any other function. CORS and verify_jwt sit at that same “before your code runs” boundary: verify_jwt is checked by the runtime before your handler is even invoked (proved by the exact, structured 401 body, not something your own code produced), while CORS headers are the opposite — nothing checks them for you, which is precisely why a function that never sets them “works” everywhere curl or a same-origin browser call is tried, and breaks silently the first time a genuinely cross-origin browser client calls it.
Trade-offs
Section titled “Trade-offs”jsr:/npm:specifiers give you the entire npm/JSR ecosystem inside a function — at the cost of remembering to pin a version explicitly, since an unpinned specifier resolves to “whatever is latest right now” with no lockfile the way a Node project’spackage-lock.jsonwould give you.- A per-function
deno.jsonimport map keeps a function’s dependencies fully scoped to itself — at the cost of duplication: two functions that both need the same package each declare and pin it independently, with no shared rootpackage.jsonto fall back on. verify_jwt = true(the default) rejects unauthenticated requests before your code runs, for free — at the cost of being unusable for anything that is not a signed-in Supabase client: a Database Webhook or third-party callback (next lesson) has no Supabase JWT to present, and needsverify_jwt = falseplus its own authentication scheme instead.
Common Misconceptions
Section titled “Common Misconceptions”- “I can import any npm package the way I would in a Node.js file.” Only with an explicit
npm:(orjsr:, or a URL, or a mapped bare specifier viadeno.json) — a bare specifier is a boot-time503, not a runtime surprise on first use. - “If curl gets a 200 with the right JSON, my CORS headers are fine.” curl never enforces CORS — only browsers do, by refusing to expose a cross-origin response to page JavaScript when the headers don’t allow it. A
200from curl proves the function ran; it proves nothing about whether a real cross-origin browser client can read the result. - “
verify_jwt = falsemeans anyone can do anything.” It only removes the built-in JWT check before your handler runs — a function withverify_jwt = falseis fully responsible for its own authentication and authorization from that point on (a shared secret header, an HMAC signature check, or nothing at all if it is genuinely meant to be public). - “
supabase secrets setworks locally once I’m testing with the CLI.” It is a hosted-project-only command that requiressupabase login; the local equivalent is always.env/--env-file, proved above by the realLegacyPlatformAuthRequiredError.