docs: migrate Edge Functions guides to @supabase/server (COM-269) (#46656)

## What

Migrates the Edge Functions **documentation** from the legacy
`Deno.serve` + manual `createClient` pattern to the
[`@supabase/server`](https://github.com/supabase/server) `withSupabase`
wrapper. This is the part of
[COM-269](https://linear.app/supabase/issue/COM-269) that AI coding
assistants index, so it's split out to ship first; the standalone
`examples/` functions follow in a second PR.

## Canonical pattern

```ts
import { withSupabase } from 'npm:@supabase/server@1'

export default {
  fetch: withSupabase({ auth: 'user' }, async (req, ctx) => {
    const { data } = await ctx.supabase.from('countries').select('*')
    return Response.json({ data })
  }),
}
```

- `export default { fetch }` object shape (not `Deno.serve`, not a bare
default export), versioned `npm:@supabase/server@1`.
- `auth` mode picks the caller: `user` → `ctx.supabase` (RLS);
`secret`/`publishable`/`none` → set `verify_jwt = false`, `secret` uses
`ctx.supabaseAdmin`.
- `Response.json(...)` over `new Response(JSON.stringify(...))`.

## Changes

- **AI prompt** (`examples/prompts/edge-functions.md`) — rewritten to
lead with `withSupabase` as the default; `auth`-mode table;
`@supabase/server@1`. Highest AI-indexing impact.
- **connect-to-postgres** — "Using supabase-js" now uses `ctx.supabase`
(+ its CodeSample deps `postgres-on-the-edge`, `drizzle`).
- **Example pages** — semantic-search, push-notifications,
amazon-bedrock, cloudflare-turnstile, og-image, send-emails,
slack-bot-mention, auth-send-email-hook.
- **Guides** — ai-models, background-tasks, routing (+ `restful-tasks`
dep), kysely-postgres, sentry-monitoring, upstash-redis, elevenlabs ×2,
websockets, cors (reframed: CORS is automatic with `withSupabase`).

## Notable fixes

- **websockets**: the JWT-auth examples had a latent bug — handler
wasn't `async` and called `getClaims()` without the extracted token. Now
`await supabase.auth.getUser(jwt)`. (`withSupabase` can't authenticate
WebSocket clients since they can't send headers — noted in the page.)
- **restful-tasks**: fixed a broken `npm:supabase-js` import →
`npm:@supabase/supabase-js`.

## Follow-ups (not in this PR)

- The ~42 standalone `examples/` edge functions → second PR.
- A dedicated `withSupabase` intro page (today it's only documented
inside the auth-framed "Securing Edge Functions" page).
- `.claude/skills/supabase-server/SKILL.md` is stale (`allow:` vs
`auth:`).

<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->
## Summary by CodeRabbit

* **Documentation**
* Updated Edge Function examples to the modern withSupabase + exported
fetch handler pattern across guides and examples.
* Standardized JSON response/error handling (uses built-in JSON helpers)
and preserved streaming/SSE behaviors where applicable.
* Clarified auth modes, context clients (user vs admin), and automatic
CORS handling; removed manual preflight boilerplate.
* Updated local serve/deploy instructions to include --no-verify-jwt for
relevant examples.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->
This commit is contained in:
Tomás Pozo authored and GitHub committed 2026-06-08 12:32:15 -05:00
1 parent 4af431da00
commit adbd3c3d22
23 files changed
+1013 -934

No files matched your search

@@ -1,5 +1,6 @@
import { drizzle } from 'drizzle-orm/postgres-js'
import postgres from 'postgres'
import { countries } from '../_shared/schema.ts'
const connectionString = Deno.env.get('SUPABASE_DB_URL')!
@@ -7,8 +8,10 @@ const connectionString = Deno.env.get('SUPABASE_DB_URL')!
const client = postgres(connectionString, { prepare: false })
const db = drizzle(client)
Deno.serve(async (_req) => {
const allCountries = await db.select().from(countries)
export default {
fetch: async (_req) => {
const allCountries = await db.select().from(countries)
return Response.json(allCountries)
})
return Response.json(allCountries)
},
}
@@ -13,36 +13,38 @@ const pool = new Pool(
1
)
Deno.serve(async (_req) => {
try {
// Grab a connection from the pool
const connection = await pool.connect()
export default {
fetch: async (_req) => {
try {
// Run a query
const result = await connection.queryObject`SELECT * FROM animals`
const animals = result.rows // [{ id: 1, name: "Lion" }, ...]
// Grab a connection from the pool
const connection = await pool.connect()
// Encode the result as pretty printed JSON
const body = JSON.stringify(
animals,
(_key, value) => (typeof value === 'bigint' ? value.toString() : value),
2
)
try {
// Run a query
const result = await connection.queryObject`SELECT * FROM animals`
const animals = result.rows // [{ id: 1, name: "Lion" }, ...]
// Return the response with the correct content type header
return new Response(body, {
status: 200,
headers: {
'Content-Type': 'application/json; charset=utf-8',
},
})
} finally {
// Release the connection back into the pool
connection.release()
// Encode the result as pretty printed JSON
const body = JSON.stringify(
animals,
(_key, value) => (typeof value === 'bigint' ? value.toString() : value),
2
)
// Return the response with the correct content type header
return new Response(body, {
status: 200,
headers: {
'Content-Type': 'application/json; charset=utf-8',
},
})
} finally {
// Release the connection back into the pool
connection.release()
}
} catch (err) {
console.error(err)
return new Response(String(err?.message ?? err), { status: 500 })
}
} catch (err) {
console.error(err)
return new Response(String(err?.message ?? err), { status: 500 })
}
})
},
}
@@ -2,15 +2,8 @@
// https://deno.land/manual/getting_started/setup_your_environment
// This enables autocomplete, go to definition, etc.
import { createClient, SupabaseClient } from 'npm:supabase-js@2'
// New approach (v2.95.0+)
import { corsHeaders } from 'jsr:@supabase/supabase-js@2/cors'
// For older versions, use hardcoded headers:
// const corsHeaders = {
// 'Access-Control-Allow-Origin': '*',
// 'Access-Control-Allow-Headers': 'authorization, x-client-info, apikey, content-type',
// 'Access-Control-Allow-Methods': 'POST, GET, OPTIONS, PUT, DELETE',
// }
import { withSupabase } from 'npm:@supabase/server@^1'
import type { SupabaseClient } from 'npm:@supabase/supabase-js@2'
interface Task {
name: string
@@ -21,109 +14,76 @@ async function getTask(supabaseClient: SupabaseClient, id: string) {
const { data: task, error } = await supabaseClient.from('tasks').select('*').eq('id', id)
if (error) throw error
return new Response(JSON.stringify({ task }), {
headers: { ...corsHeaders, 'Content-Type': 'application/json' },
status: 200,
})
return Response.json({ task })
}
async function getAllTasks(supabaseClient: SupabaseClient) {
const { data: tasks, error } = await supabaseClient.from('tasks').select('*')
if (error) throw error
return new Response(JSON.stringify({ tasks }), {
headers: { ...corsHeaders, 'Content-Type': 'application/json' },
status: 200,
})
return Response.json({ tasks })
}
async function deleteTask(supabaseClient: SupabaseClient, id: string) {
const { error } = await supabaseClient.from('tasks').delete().eq('id', id)
if (error) throw error
return new Response(JSON.stringify({}), {
headers: { ...corsHeaders, 'Content-Type': 'application/json' },
status: 200,
})
return Response.json({})
}
async function updateTask(supabaseClient: SupabaseClient, id: string, task: Task) {
const { error } = await supabaseClient.from('tasks').update(task).eq('id', id)
if (error) throw error
return new Response(JSON.stringify({ task }), {
headers: { ...corsHeaders, 'Content-Type': 'application/json' },
status: 200,
})
return Response.json({ task })
}
async function createTask(supabaseClient: SupabaseClient, task: Task) {
const { error } = await supabaseClient.from('tasks').insert(task)
if (error) throw error
return new Response(JSON.stringify({ task }), {
headers: { ...corsHeaders, 'Content-Type': 'application/json' },
status: 200,
})
return Response.json({ task })
}
Deno.serve(async (req) => {
const { url, method } = req
export default {
fetch: withSupabase({ auth: 'user' }, async (req, ctx) => {
const { url, method } = req
// This is needed if you're planning to invoke your function from a browser.
if (method === 'OPTIONS') {
return new Response('ok', { headers: corsHeaders })
}
try {
// ctx.supabase is scoped to the calling user, so your row-level-security
// (RLS) policies are applied.
const supabaseClient = ctx.supabase
try {
const SUPABASE_PUBLISHABLE_KEYS = JSON.parse(Deno.env.get('SUPABASE_PUBLISHABLE_KEYS')!)
// Create a Supabase client with the Auth context of the logged in user.
const supabaseClient = createClient(
// Supabase API URL - env var exported by default.
Deno.env.get('SUPABASE_URL') ?? '',
// Supabase publishable key - env var exported by default.
SUPABASE_PUBLISHABLE_KEYS['default'] ?? '',
// Create client with Auth context of the user that called the function.
// This way your row-level-security (RLS) policies are applied.
{
global: {
headers: { Authorization: req.headers.get('Authorization')! },
},
// For more details on URLPattern, check https://developer.mozilla.org/en-US/docs/Web/API/URL_Pattern_API
const taskPattern = new URLPattern({ pathname: '/restful-tasks/:id' })
const matchingPath = taskPattern.exec(url)
const id = matchingPath ? matchingPath.pathname.groups.id : null
let task = null
if (method === 'POST' || method === 'PUT') {
const body = await req.json()
task = body.task
}
)
// For more details on URLPattern, check https://developer.mozilla.org/en-US/docs/Web/API/URL_Pattern_API
const taskPattern = new URLPattern({ pathname: '/restful-tasks/:id' })
const matchingPath = taskPattern.exec(url)
const id = matchingPath ? matchingPath.pathname.groups.id : null
// call relevant method based on method and id
switch (true) {
case id && method === 'GET':
return getTask(supabaseClient, id as string)
case id && method === 'PUT':
return updateTask(supabaseClient, id as string, task)
case id && method === 'DELETE':
return deleteTask(supabaseClient, id as string)
case method === 'POST':
return createTask(supabaseClient, task)
case method === 'GET':
return getAllTasks(supabaseClient)
default:
return getAllTasks(supabaseClient)
}
} catch (error) {
console.error(error)
let task = null
if (method === 'POST' || method === 'PUT') {
const body = await req.json()
task = body.task
return Response.json({ error: error.message }, { status: 400 })
}
// call relevant method based on method and id
switch (true) {
case id && method === 'GET':
return getTask(supabaseClient, id as string)
case id && method === 'PUT':
return updateTask(supabaseClient, id as string, task)
case id && method === 'DELETE':
return deleteTask(supabaseClient, id as string)
case method === 'POST':
return createTask(supabaseClient, task)
case method === 'GET':
return getAllTasks(supabaseClient)
default:
return getAllTasks(supabaseClient)
}
} catch (error) {
console.error(error)
return new Response(JSON.stringify({ error: error.message }), {
headers: { ...corsHeaders, 'Content-Type': 'application/json' },
status: 400,
})
}
})
}),
}
+96 -31
View File
@@ -10,47 +10,115 @@ You're an expert in writing TypeScript and Deno JavaScript runtime. Generate **h
## Guidelines
1. Try to use Web APIs and Deno’s core APIs instead of external dependencies (eg: use fetch instead of Axios, use WebSockets API instead of node-ws)
1. Try to use Web APIs and Deno's core APIs instead of external dependencies (eg: use fetch instead of Axios, use WebSockets API instead of node-ws)
2. If you are reusing utility methods between Edge Functions, add them to `supabase/functions/_shared` and import using a relative path. Do NOT have cross dependencies between Edge Functions.
3. Do NOT use bare specifiers when importing dependencies. If you need to use an external dependency, make sure it's prefixed with either `npm:` or `jsr:`. For example, `@supabase/supabase-js` should be written as `npm:@supabase/supabase-js`.
4. For external imports, always define a version. For example, `npm:@express` should be written as `npm:express@4.18.2`.
5. For external dependencies, importing via `npm:` and `jsr:` is preferred. Minimize the use of imports from @`deno.land/x` , `esm.sh` and @`unpkg.com` . If you have a package from one of those CDNs, you can replace the CDN hostname with `npm:` specifier.
6. You can also use Node built-in APIs. You will need to import them using `node:` specifier. For example, to import Node process: `import process from "node:process". Use Node APIs when you find gaps in Deno APIs.
7. Do NOT use `import { serve } from "https://deno.land/std@0.168.0/http/server.ts"`. Instead use the built-in `Deno.serve`.
8. Following environment variables (ie. secrets) are pre-populated in both local and hosted Supabase environments. Users don't need to manually set them:
4. For external imports, always define a version. For example, `npm:express` should be written as `npm:express@4.18.2`.
5. For external dependencies, importing via `npm:` and `jsr:` is preferred. Minimize the use of imports from `deno.land/x`, `esm.sh` and `unpkg.com`. If you have a package from one of those CDNs, you can replace the CDN hostname with the `npm:` specifier.
6. You can also use Node built-in APIs. You will need to import them using the `node:` specifier. For example, to import Node process: `import process from "node:process"`. Use Node APIs when you find gaps in Deno APIs.
7. Do NOT use `import { serve } from "https://deno.land/std@0.168.0/http/server.ts"`, and do NOT use `Deno.serve`. Instead, export a default object with a `fetch` handler:
```ts
export default {
fetch: async (req: Request) => {
return Response.json({ message: 'Hello world' })
},
}
```
This is the request handler contract for Supabase Edge Functions, and it also runs unchanged on Cloudflare Workers and Bun. Always wrap this handler with `withSupabase` to secure and configure it (see guideline 8).
8. Write your handler with `withSupabase` from `npm:@supabase/server@^1`. One wrapper gives you:
- Authentication: verifies the caller's credentials.
- Authorization: only lets through callers that match the `auth` mode you declare.
- Pre-configured clients on `ctx`: `ctx.supabase` (scoped to the caller's RLS) and `ctx.supabaseAdmin` (bypasses RLS).
- CORS handling, including preflight requests.
Your one decision is the `auth` mode:
```ts
import { withSupabase } from 'npm:@supabase/server@^1'
export default {
fetch: withSupabase({ auth: 'user' }, async (req, ctx) => {
const { data, error } = await ctx.supabase.from('countries').select('*')
if (error) throw error
return Response.json({ data })
}),
}
```
Choose the `auth` mode by who calls the function:
| Caller | `auth` | `verify_jwt` | Client |
| ---------------------------------------------------- | --------------- | ---------------------- | ---------------------------------- |
| Signed-in user (JWT on `Authorization`) | `'user'` | `true` (default, omit) | `ctx.supabase` (RLS-scoped) |
| Cron, worker, `pg_net`, or another function | `'secret'` | `false` | `ctx.supabaseAdmin` (bypasses RLS) |
| Public client | `'publishable'` | `false` | `ctx.supabase` |
| Public endpoint or external webhook (verify in code) | `'none'` | `false` | `ctx.supabaseAdmin` if needed |
For any mode other than `'user'`, set `verify_jwt = false` for that function in `supabase/config.toml`:
```toml
[functions.my-function]
verify_jwt = false
```
`ctx.userClaims` holds the verified user identity. To accept only one named key, use `auth: 'secret:<name>'` or `auth: 'publishable:<name>'`. For a public endpoint, use `auth: 'none'`; you still get CORS handling and `ctx.supabaseAdmin`.
9. The following environment variables (ie. secrets) are pre-populated in both local and hosted Supabase environments. Users don't need to manually set them:
- SUPABASE_URL
- SUPABASE_PUBLISHABLE_KEYS
- SUPABASE_SECRET_KEYS
- SUPABASE_DB_URL
You then need to parse them with `JSON.parse(Deno.env.get('SUPABASE_SECRET_KEYS')!)` or `JSON.parse(Deno.env.get('SUPABASE_PUBLISHABLE_KEYS')!)` to access the actual keys in the code. For example, assign the parsed map first with `const SUPABASE_SECRET_KEYS = JSON.parse(Deno.env.get('SUPABASE_SECRET_KEYS')!)` and then index it with `SUPABASE_SECRET_KEYS['default']` to access the default secret key. 9. To set other environment variables (ie. secrets) users can put them in a env file and run the `supabase secrets set --env-file path/to/env-file` 10. A single Edge Function can handle multiple routes. It is recommended to use a library like Express or Hono to handle the routes as it's easier for developer to understand and maintain. Each route must be prefixed with `/function-name` so they are routed correctly. 11. File write operations are ONLY permitted on `/tmp` directory. You can use either Deno or Node File APIs. 12. Use `EdgeRuntime.waitUntil(promise)` static method to run long-running tasks in the background without blocking response to a request. Do NOT assume it is available in the request / execution context.
`withSupabase` reads these for you, so prefer it over reading keys by hand. If you must read a key without the SDK, parse the JSON map and index it by name: `const SUPABASE_SECRET_KEYS = JSON.parse(Deno.env.get('SUPABASE_SECRET_KEYS')!)`, then `SUPABASE_SECRET_KEYS['default']` for the default secret key. The publishable keys work the same way through `SUPABASE_PUBLISHABLE_KEYS`.
10. To set other environment variables (ie. secrets) users can put them in an env file and run `supabase secrets set --env-file path/to/env-file`.
11. A single Edge Function can handle multiple routes. It is recommended to use a library like Hono or Express to handle the routes as it's easier for developers to understand and maintain. Each route must be prefixed with `/function-name` so they are routed correctly. For per-route Supabase auth with Hono, use the adapter from `npm:@supabase/server@^1/adapters/hono`.
12. File write operations are ONLY permitted on the `/tmp` directory. You can use either Deno or Node File APIs.
13. Use the `EdgeRuntime.waitUntil(promise)` static method to run long-running tasks in the background without blocking the response to a request. Do NOT assume it is available in the request / execution context.
## Example Templates
### Recommended: Edge Function with `withSupabase`
```ts
import { withSupabase } from 'npm:@supabase/server@^1'
export default {
fetch: withSupabase({ auth: 'user' }, async (req, ctx) => {
const { data, error } = await ctx.supabase.from('countries').select('*')
if (error) throw error
return Response.json({ data })
}),
}
```
### Simple Hello World Function
```tsx
```ts
interface reqPayload {
name: string
}
console.info('server started')
Deno.serve(async (req: Request) => {
const { name }: reqPayload = await req.json()
const data = {
message: `Hello ${name} from foo!`,
}
export default {
fetch: async (req: Request) => {
const { name }: reqPayload = await req.json()
const data = {
message: `Hello ${name} from foo!`,
}
return new Response(JSON.stringify(data), {
headers: { 'Content-Type': 'application/json', Connection: 'keep-alive' },
})
})
return Response.json(data)
},
}
```
### Example Function using Node built-in API
```tsx
```ts
import { randomBytes } from 'node:crypto'
import { createServer } from 'node:http'
import process from 'node:process'
@@ -73,7 +141,7 @@ server.listen(9999)
### Using npm packages in Functions
```tsx
```ts
import express from 'npm:express@4.18.2'
const app = express()
@@ -87,18 +155,15 @@ app.listen(8000)
### Generate embeddings using built-in @Supabase.ai API
```tsx
```ts
const model = new Supabase.ai.Session('gte-small')
Deno.serve(async (req: Request) => {
const params = new URL(req.url).searchParams
const input = params.get('text')
const output = await model.run(input, { mean_pool: true, normalize: true })
return new Response(JSON.stringify(output), {
headers: {
'Content-Type': 'application/json',
Connection: 'keep-alive',
},
})
})
export default {
fetch: async (req: Request) => {
const params = new URL(req.url).searchParams
const input = params.get('text')
const output = await model.run(input, { mean_pool: true, normalize: true })
return Response.json(output)
},
}
```