JSON in Supabase Edge Functions: Response.json(), Request Body, Supabase Client & Deno

Last updated:

Supabase Edge Functions run on Deno at the edge — globally distributed across 40+ regions with under 100 ms cold starts. Response.json(data) returns any JavaScript object as a JSON response with Content-Type: application/json set automatically. const body = await req.json() reads and parses the JSON request body. The Supabase client (createClient) accesses the database from inside the function: const { data } = await supabase.from("users").select("*") returns typed JSON matching your database schema. Environment variables (SUPABASE_URL, SUPABASE_ANON_KEY) are injected automatically in deployed functions and set locally via .env.local. Edge Functions support any Deno-compatible code — Zod validation, custom headers, CORS, and JWT verification via the Supabase Auth helpers. This guide covers returning JSON responses, reading JSON request bodies, calling the Supabase database, handling CORS, JWT authentication, and deploying edge functions.

Returning JSON Responses with Response.json()

Response.json(data) is the idiomatic way to return JSON from a Supabase Edge Function. It is a static method on the Web API Response class, native to Deno — no import required. It serializes the JavaScript value using JSON.stringify internally and sets Content-Type: application/json; charset=UTF-8 automatically. The optional second argument accepts a ResponseInit object for setting the HTTP status code, additional headers, and status text.

// supabase/functions/hello-json/index.ts
import { serve } from "https://deno.land/std@0.168.0/http/server.ts"

serve(async (req: Request) => {
  // ── Simplest JSON response ─────────────────────────────────────
  // Response.json() sets Content-Type: application/json automatically
  return Response.json({ message: "Hello from Supabase Edge!" })
  // HTTP/1.1 200 OK
  // Content-Type: application/json; charset=UTF-8
  // {"message":"Hello from Supabase Edge!"}
})

// ── Equivalent manual form (more verbose) ──────────────────────
// return new Response(
//   JSON.stringify({ message: "Hello from Supabase Edge!" }),
//   { headers: { "Content-Type": "application/json" } }
// )

// ── With explicit HTTP status code ────────────────────────────
serve(async (_req: Request) => {
  const data = {
    users: [
      { id: "1", name: "Alice", role: "admin" },
      { id: "2", name: "Bob",   role: "viewer" },
    ],
    total: 2,
    page:  1,
  }

  return Response.json(data, { status: 200 })
})

// ── Error JSON response ────────────────────────────────────────
serve(async (req: Request) => {
  if (req.method !== "POST") {
    return Response.json(
      { error: "Method not allowed", allowed: ["POST"] },
      { status: 405 }
    )
  }

  return Response.json({ success: true })
})

// ── JSON response with custom headers ─────────────────────────
serve(async (_req: Request) => {
  return Response.json(
    { data: "sensitive" },
    {
      status: 200,
      headers: {
        "Cache-Control":           "no-store",
        "X-Content-Type-Options":  "nosniff",
        "X-Request-ID":            crypto.randomUUID(),
      },
    }
  )
})

// ── Serialization rules ────────────────────────────────────────
// Response.json() uses JSON.stringify internally:
//   undefined field values → omitted from output
//   Date objects          → ISO 8601 strings ("2026-05-28T10:00:00.000Z")
//   Circular references   → throws TypeError
//   BigInt values         → throws TypeError (not JSON-serializable)
//   null, arrays, strings → serialized as-is

const example = {
  id:        crypto.randomUUID(),    // string
  createdAt: new Date(),             // → "2026-05-28T10:00:00.000Z"
  active:    true,
  score:     42.5,
  tags:      ["deno", "supabase"],
  missing:   undefined,              // → omitted from JSON output
}
// Response.json(example) → valid JSON object with 5 keys (not 6)

The Response.json() static method was added to the Fetch API specification and is available in Deno 1.28+ and all modern browsers. It is the Web Platform standard — the same API works in Cloudflare Workers, Vercel Edge Functions, and Deno Deploy without any adapter. The content type header includes charset=UTF-8 automatically, which ensures JSON parsers on the receiving end handle Unicode correctly. Every Supabase Edge Function must return a Response object — returning nothing or a plain value causes the runtime to respond with an empty 200.

Reading JSON Request Bodies with req.json()

req.json() is the async method on the Request object that reads the entire request body and parses it as JSON. It returns a Promise<any> — the parsed value is untyped by default. Always await it, always wrap in a try/catch, and always validate the result shape with Zod or a manual check before using the data.

import { serve } from "https://deno.land/std@0.168.0/http/server.ts"

serve(async (req: Request) => {
  // ── Basic body parsing ─────────────────────────────────────────
  // req.json() throws if:
  //   1. The body is not valid JSON
  //   2. The body is empty (GET, HEAD requests)
  //   3. Content-Type is missing (browser may still work, but spec requires it)

  let body: unknown
  try {
    body = await req.json()
  } catch (err) {
    return Response.json(
      { error: "Invalid JSON body", detail: (err as Error).message },
      { status: 400 }
    )
  }

  return Response.json({ received: body })
})

// ── Only parse body for methods that have one ──────────────────
serve(async (req: Request) => {
  if (req.method === "GET" || req.method === "HEAD") {
    // No body — read from URL search params instead
    const url    = new URL(req.url)
    const userId = url.searchParams.get("userId")
    return Response.json({ userId })
  }

  const body = await req.json()
  return Response.json({ body })
})

// ── TypeScript type assertion after parsing ────────────────────
interface CreateUserBody {
  name:  string
  email: string
  role?: "admin" | "viewer"
}

serve(async (req: Request) => {
  const body = await req.json() as CreateUserBody
  // TypeScript now treats body as CreateUserBody
  // But this is UNSAFE — the cast does not validate at runtime
  const name = body.name   // typed as string, but could be undefined
  return Response.json({ name })
})

// ── Reading body only once ─────────────────────────────────────
// The Request body is a ReadableStream and can only be consumed once.
// Calling req.json() twice throws "body already consumed".
// To read body as text AND parse as JSON, use:

serve(async (req: Request) => {
  const text = await req.text()               // read as string
  const body = JSON.parse(text) as unknown    // parse manually

  // Now you have both the raw text (for logging/HMAC) and parsed JSON
  console.log("Raw body:", text.slice(0, 200))
  return Response.json({ parsed: body })
})

// ── Checking Content-Type before parsing ──────────────────────
serve(async (req: Request) => {
  const contentType = req.headers.get("Content-Type") ?? ""
  if (!contentType.includes("application/json")) {
    return Response.json(
      { error: "Content-Type must be application/json" },
      { status: 415 }  // Unsupported Media Type
    )
  }
  const body = await req.json()
  return Response.json({ ok: true, body })
})

The Request.body is a ReadableStream that can only be consumed once — calling req.json(), req.text(), or req.arrayBuffer() marks the body as consumed. Subsequent calls throw a TypeError: body already consumed error. If you need the raw body string for HMAC webhook signature verification alongside JSON parsing, call req.text() first and then use JSON.parse(text). The req.clone() method creates a copy of the request with a fresh unconsumed body, but this doubles memory usage — avoid it for large payloads.

Querying Supabase Database for JSON Data

The Supabase JavaScript client works inside Edge Functions — import it from https://esm.sh/@supabase/supabase-js@2 and create a client using environment variables that Supabase injects automatically. The client returns typed JSON from .select() calls matching your database schema. Use the TypeScript types generated by supabase gen types typescript for full type safety across database queries.

import { serve }        from "https://deno.land/std@0.168.0/http/server.ts"
import { createClient } from "https://esm.sh/@supabase/supabase-js@2"

// ── Create Supabase client ─────────────────────────────────────
// SUPABASE_URL and SUPABASE_ANON_KEY are injected automatically
// in deployed functions. For local dev, set them in .env.local.
serve(async (req: Request) => {
  const supabase = createClient(
    Deno.env.get("SUPABASE_URL")!,
    Deno.env.get("SUPABASE_ANON_KEY")!
  )

  // ── Basic SELECT — returns JSON array ─────────────────────────
  const { data, error } = await supabase
    .from("users")
    .select("id, name, email, created_at")
    .order("created_at", { ascending: false })
    .limit(20)

  if (error) {
    return Response.json({ error: error.message }, { status: 500 })
  }

  // data is typed as Array<{ id: string; name: string; email: string; created_at: string }>
  return Response.json({ users: data, count: data.length })
})

// ── Passing JWT for RLS enforcement ──────────────────────────
serve(async (req: Request) => {
  // Pass the user's Authorization header to the client
  // so Row Level Security policies apply to all queries
  const supabase = createClient(
    Deno.env.get("SUPABASE_URL")!,
    Deno.env.get("SUPABASE_ANON_KEY")!,
    {
      global: {
        headers: { Authorization: req.headers.get("Authorization") ?? "" },
      },
    }
  )

  // This query only returns rows the authenticated user can access
  const { data: posts, error } = await supabase
    .from("posts")
    .select("id, title, content, author_id")
    .eq("published", true)

  if (error) {
    return Response.json({ error: error.message }, { status: 500 })
  }

  return Response.json({ posts })
})

// ── INSERT and return the new row as JSON ─────────────────────
serve(async (req: Request) => {
  const supabase = createClient(
    Deno.env.get("SUPABASE_URL")!,
    Deno.env.get("SUPABASE_ANON_KEY")!
  )

  const body = await req.json() as { title: string; content: string }

  const { data: post, error } = await supabase
    .from("posts")
    .insert({ title: body.title, content: body.content })
    .select()        // return the inserted row with server-generated id and created_at
    .single()        // expect exactly one row

  if (error) {
    return Response.json({ error: error.message }, { status: 500 })
  }

  return Response.json({ post }, { status: 201 })
})

// ── Using generated TypeScript types ──────────────────────────
// Run: supabase gen types typescript --project-id <ref> > types.ts
// Then import and pass to createClient:

// import type { Database } from "./types.ts"
// const supabase = createClient<Database>(url, key)
// Now supabase.from("users").select() is fully typed with column names

// ── Service role key for admin operations (bypasses RLS) ──────
// Use ONLY for server-side operations — never expose to the client
serve(async (_req: Request) => {
  const adminClient = createClient(
    Deno.env.get("SUPABASE_URL")!,
    Deno.env.get("SUPABASE_SERVICE_ROLE_KEY")!  // admin key — no RLS
  )

  const { data: allUsers } = await adminClient
    .from("users")
    .select("id, email, role")

  return Response.json({ users: allUsers })
})

The Supabase client communicates with the PostgREST API layer — all queries are translated to SQL internally. The .select() method accepts a comma-separated column list or "*" for all columns. Related tables can be joined inline: .select("id, title, author:users(name, avatar_url)") fetches the post and its author in one round trip. The returned data is always an array for .select() calls; .single() unwraps the array to a single object and returns an error if 0 or 2+ rows match.

CORS Headers for JSON APIs

Supabase Edge Functions are called directly from browser clients in many architectures — fetch requests with Content-Type: application/json trigger the CORS preflight mechanism. Handle the OPTIONS method explicitly and include the correct Access-Control-* headers on every response, including error responses. A missing CORS header on a 500 error response causes browsers to surface a generic network error instead of the actual error message.

import { serve } from "https://deno.land/std@0.168.0/http/server.ts"

// ── Define CORS headers once ───────────────────────────────────
const corsHeaders = {
  "Access-Control-Allow-Origin":  "*",    // replace with your domain in production
  "Access-Control-Allow-Headers": "authorization, x-client-info, apikey, content-type",
  "Access-Control-Allow-Methods": "POST, GET, OPTIONS, PUT, DELETE",
}

serve(async (req: Request) => {
  // ── Handle OPTIONS preflight ────────────────────────────────
  // Browsers send OPTIONS before any cross-origin request with a
  // non-simple Content-Type (like application/json)
  if (req.method === "OPTIONS") {
    return new Response(null, {
      status:  204,     // No Content
      headers: corsHeaders,
    })
  }

  try {
    const body = await req.json()

    // Add CORS headers to every successful response
    return Response.json(
      { data: body },
      { headers: corsHeaders }
    )
  } catch (_err) {
    // Add CORS headers to error responses too!
    // Without this, the browser can't read the error body.
    return Response.json(
      { error: "Invalid JSON" },
      { status: 400, headers: corsHeaders }
    )
  }
})

// ── Production: restrict to specific origin ───────────────────
const ALLOWED_ORIGIN = "https://myapp.com"

serve(async (req: Request) => {
  const origin = req.headers.get("Origin") ?? ""

  const headers = {
    // Only allow the specific origin instead of wildcard
    "Access-Control-Allow-Origin":  origin === ALLOWED_ORIGIN ? ALLOWED_ORIGIN : "",
    "Access-Control-Allow-Headers": "authorization, content-type",
    "Access-Control-Allow-Methods": "POST, OPTIONS",
    "Vary":                         "Origin",  // important for CDN caching
  }

  if (req.method === "OPTIONS") {
    return new Response(null, { status: 204, headers })
  }

  const result = { ok: true }
  return Response.json(result, { headers })
})

// ── Supabase official cors.ts utility pattern ──────────────────
// Many Supabase edge function templates ship with a cors.ts helper:
//
// export const corsHeaders = {
//   "Access-Control-Allow-Origin": "*",
//   "Access-Control-Allow-Headers":
//     "authorization, x-client-info, apikey, content-type",
// }
//
// Import it in your function:
// import { corsHeaders } from "../_shared/cors.ts"

The Vary: Origin header is important when using CDN caching with specific allowed origins — it tells the CDN to cache separate responses per Origin header value. Without it, a CDN might cache a response with Access-Control-Allow-Origin: https://myapp.com and serve it to requests from other origins, causing CORS failures for those clients. For public APIs with no authentication where all origins are allowed, the wildcard * is simpler and avoids the Vary concern entirely.

JWT Authentication in Edge Functions

Supabase issues JWTs to authenticated users — the client SDK attaches them as Authorization: Bearer <token> headers. Edge Functions verify the JWT by passing the header directly to the Supabase client, which delegates verification to the PostgREST layer. Row Level Security policies on the database then enforce which rows the authenticated user can access.

import { serve }        from "https://deno.land/std@0.168.0/http/server.ts"
import { createClient } from "https://esm.sh/@supabase/supabase-js@2"

const corsHeaders = {
  "Access-Control-Allow-Origin":  "*",
  "Access-Control-Allow-Headers": "authorization, x-client-info, apikey, content-type",
}

serve(async (req: Request) => {
  if (req.method === "OPTIONS") {
    return new Response(null, { status: 204, headers: corsHeaders })
  }

  // ── Create an auth-aware Supabase client ────────────────────
  // Passing the user's JWT makes the client act as that user.
  // All database queries enforce Row Level Security (RLS).
  const authHeader = req.headers.get("Authorization")

  const supabase = createClient(
    Deno.env.get("SUPABASE_URL")!,
    Deno.env.get("SUPABASE_ANON_KEY")!,
    {
      global: {
        headers: { Authorization: authHeader ?? "" },
      },
    }
  )

  // ── Get the authenticated user from the JWT ─────────────────
  const { data: { user }, error: authError } = await supabase.auth.getUser()

  if (authError || !user) {
    return Response.json(
      { error: "Unauthorized — valid JWT required" },
      { status: 401, headers: corsHeaders }
    )
  }

  // user.id is the authenticated user's UUID
  // user.email is their email address
  // user.role is their Supabase auth role ("authenticated" by default)

  // ── Fetch data scoped to the authenticated user ─────────────
  // RLS policy: "SELECT * FROM orders WHERE user_id = auth.uid()"
  // The query below automatically applies this policy
  const { data: orders, error } = await supabase
    .from("orders")
    .select("id, total, status, created_at")
    .order("created_at", { ascending: false })

  if (error) {
    return Response.json(
      { error: error.message },
      { status: 500, headers: corsHeaders }
    )
  }

  return Response.json(
    { user: { id: user.id, email: user.email }, orders },
    { headers: corsHeaders }
  )
})

// ── Manual JWT decode (without verification) ──────────────────
// Useful for reading claims without making a network call to auth.getUser()
// WARNING: This does NOT verify the signature — only use for non-sensitive reads

function decodeJwtPayload(token: string): Record<string, unknown> {
  const [, payloadB64] = token.split(".")
  const padded = payloadB64.replace(/-/g, "+").replace(/_/g, "/")
  const json    = atob(padded)
  return JSON.parse(json)
}

serve(async (req: Request) => {
  const token = req.headers.get("Authorization")?.replace("Bearer ", "")
  if (!token) {
    return Response.json({ error: "No token" }, { status: 401 })
  }

  const payload = decodeJwtPayload(token)
  // payload.sub — user UUID
  // payload.exp — expiry Unix timestamp (seconds)
  // payload.role — "authenticated" for logged-in users
  // payload.email — user email

  return Response.json({ userId: payload.sub, expires: payload.exp })
})

Supabase JWTs use RS256 (asymmetric RSA signing) — they are signed with the project's private key and verified with the public key. Tokens expire after 3,600 seconds (1 hour) by default. The Supabase client library on the frontend handles silent refresh automatically by calling supabase.auth.refreshSession() when a token is close to expiry. In Edge Functions, always use supabase.auth.getUser() rather than manual JWT decoding for requests that modify data — getUser() makes a network call to verify the token against the auth server, protecting against revoked tokens.

Validating JSON Input with Zod

Zod runs in Deno via the npm: specifier — import {'{ z }'} from "npm:zod" — with no installation step required. Define a schema for the expected request body shape, call schema.safeParse(body) after reading req.json(), and return a structured 422 error if validation fails. The schema also provides TypeScript types for the validated body, eliminating manual type assertions.

import { serve }        from "https://deno.land/std@0.168.0/http/server.ts"
import { createClient } from "https://esm.sh/@supabase/supabase-js@2"
import { z }            from "npm:zod"

const corsHeaders = {
  "Access-Control-Allow-Origin":  "*",
  "Access-Control-Allow-Headers": "authorization, x-client-info, apikey, content-type",
}

// ── Define a Zod schema for the request body ───────────────────
const CreatePostSchema = z.object({
  title:       z.string().min(1, "Title is required").max(200, "Title too long"),
  content:     z.string().min(10, "Content must be at least 10 characters"),
  tags:        z.array(z.string().min(1)).max(5, "Maximum 5 tags").optional(),
  publishedAt: z.string().datetime({ message: "Must be a valid ISO 8601 datetime" }).optional(),
})

// TypeScript type inferred from the schema — no separate interface
type CreatePostBody = z.infer<typeof CreatePostSchema>

serve(async (req: Request) => {
  if (req.method === "OPTIONS") {
    return new Response(null, { status: 204, headers: corsHeaders })
  }

  // ── Parse request body ─────────────────────────────────────
  let rawBody: unknown
  try {
    rawBody = await req.json()
  } catch {
    return Response.json(
      { error: "Request body must be valid JSON" },
      { status: 400, headers: corsHeaders }
    )
  }

  // ── Validate with Zod safeParse ────────────────────────────
  const result = CreatePostSchema.safeParse(rawBody)

  if (!result.success) {
    // error.flatten().fieldErrors → { title: ["Title is required"], content: [...] }
    return Response.json(
      {
        error:   "Validation failed",
        details: result.error.flatten().fieldErrors,
      },
      { status: 422, headers: corsHeaders }
    )
  }

  // result.data is fully typed as CreatePostBody
  const body: CreatePostBody = result.data

  const supabase = createClient(
    Deno.env.get("SUPABASE_URL")!,
    Deno.env.get("SUPABASE_ANON_KEY")!
  )

  const { data: post, error } = await supabase
    .from("posts")
    .insert({
      title:        body.title,
      content:      body.content,
      tags:         body.tags ?? [],
      published_at: body.publishedAt ?? null,
    })
    .select()
    .single()

  if (error) {
    return Response.json(
      { error: error.message },
      { status: 500, headers: corsHeaders }
    )
  }

  return Response.json({ post }, { status: 201, headers: corsHeaders })
})

// ── Query parameter validation with Zod ───────────────────────
const QueryParamsSchema = z.object({
  page:   z.coerce.number().int().min(1).default(1),
  limit:  z.coerce.number().int().min(1).max(100).default(20),
  status: z.enum(["draft", "published", "archived"]).optional(),
})

serve(async (req: Request) => {
  const url    = new URL(req.url)
  const params = Object.fromEntries(url.searchParams)

  const result = QueryParamsSchema.safeParse(params)
  if (!result.success) {
    return Response.json(
      { error: "Invalid query parameters", details: result.error.flatten().fieldErrors },
      { status: 400 }
    )
  }

  const { page, limit, status } = result.data
  // page and limit are numbers (z.coerce converts "1" string → 1)

  const supabase = createClient(
    Deno.env.get("SUPABASE_URL")!,
    Deno.env.get("SUPABASE_ANON_KEY")!
  )

  let query = supabase
    .from("posts")
    .select("id, title, status, created_at", { count: "exact" })
    .range((page - 1) * limit, page * limit - 1)

  if (status) query = query.eq("status", status)

  const { data, count, error } = await query
  if (error) return Response.json({ error: error.message }, { status: 500 })

  return Response.json({ posts: data, total: count, page, limit })
})

Use z.coerce.number() for URL query parameters — they arrive as strings ("20") but you want numbers. Zod's coercion calls Number() internally and validates the result as a number. .default(20) fills in a default value when the parameter is absent. The z.enum() validator restricts string values to a fixed set — useful for filtering by status, category, or any controlled vocabulary. Zod schemas compile in Deno with no special configuration — the npm: specifier resolves the package from the npm registry on first use.

Deploying and Testing Edge Functions Locally

The Supabase CLI manages the full local development workflow — supabase functions serve starts a local Deno server with hot reload, and supabase functions deploy pushes the function to the global edge infrastructure. The local server mirrors the production environment including environment variable injection from .env.local.

# ── Project setup ─────────────────────────────────────────────
# Install the Supabase CLI (macOS/Linux)
brew install supabase/tap/supabase

# Initialize a Supabase project (or link to an existing one)
supabase init
supabase link --project-ref <your-project-ref>

# ── Create a new edge function ─────────────────────────────────
supabase functions new my-function
# Creates: supabase/functions/my-function/index.ts

# ── Local development with hot reload ─────────────────────────
supabase functions serve
# Starts local server at http://localhost:54321/functions/v1/
# Watches for file changes and reloads automatically

# Set local environment variables (used by supabase functions serve)
# Create: supabase/functions/.env.local
# or:     .env.local in project root
# ─────────────────────────────────────────────────────────────
# SUPABASE_URL=http://localhost:54321
# SUPABASE_ANON_KEY=<your-local-anon-key>
# MY_SECRET=local-secret-value

# ── Test locally with curl ─────────────────────────────────────
# GET request
curl http://localhost:54321/functions/v1/my-function

# POST with JSON body
curl -X POST http://localhost:54321/functions/v1/my-function \
  -H "Content-Type: application/json" \
  -H "Authorization: Bearer <your-jwt>" \
  -d '{"name": "Alice", "email": "alice@example.com"}'

# ── Deploy to production ───────────────────────────────────────
# Deploy a single function
supabase functions deploy my-function

# Deploy all functions
supabase functions deploy

# Deploy with no-verify-jwt flag (for public endpoints)
supabase functions deploy my-function --no-verify-jwt

# ── Set production environment variables ──────────────────────
# Via CLI
supabase secrets set MY_SECRET=production-value
supabase secrets set --env-file ./production.env

# List set secrets (values are not shown, only names)
supabase secrets list

# ── View function logs ─────────────────────────────────────────
supabase functions logs my-function
# Streams live logs from the deployed function
# Logs also available in Supabase Dashboard > Edge Functions > Logs

# ── List deployed functions ────────────────────────────────────
supabase functions list
# Shows function name, version, and deployed-at timestamp

# ── Delete a function ─────────────────────────────────────────
supabase functions delete my-function

# ── Function URL format ────────────────────────────────────────
# https://<project-ref>.supabase.co/functions/v1/<function-name>
# Example:
# https://abcdefghijklmnop.supabase.co/functions/v1/my-function
// supabase/functions/my-function/index.ts
// ── Complete example with all patterns ────────────────────────
import { serve }        from "https://deno.land/std@0.168.0/http/server.ts"
import { createClient } from "https://esm.sh/@supabase/supabase-js@2"
import { z }            from "npm:zod"

const corsHeaders = {
  "Access-Control-Allow-Origin":  "*",
  "Access-Control-Allow-Headers": "authorization, x-client-info, apikey, content-type",
}

const BodySchema = z.object({
  message: z.string().min(1).max(500),
})

serve(async (req: Request) => {
  // 1. Handle preflight
  if (req.method === "OPTIONS") {
    return new Response(null, { status: 204, headers: corsHeaders })
  }

  // 2. Authenticate
  const supabase = createClient(
    Deno.env.get("SUPABASE_URL")!,
    Deno.env.get("SUPABASE_ANON_KEY")!,
    { global: { headers: { Authorization: req.headers.get("Authorization") ?? "" } } }
  )

  const { data: { user } } = await supabase.auth.getUser()
  if (!user) {
    return Response.json({ error: "Unauthorized" }, { status: 401, headers: corsHeaders })
  }

  // 3. Parse and validate body
  let raw: unknown
  try { raw = await req.json() }
  catch { return Response.json({ error: "Invalid JSON" }, { status: 400, headers: corsHeaders }) }

  const parsed = BodySchema.safeParse(raw)
  if (!parsed.success) {
    return Response.json(
      { error: "Validation failed", details: parsed.error.flatten().fieldErrors },
      { status: 422, headers: corsHeaders }
    )
  }

  // 4. Database operation
  const { data, error } = await supabase
    .from("messages")
    .insert({ user_id: user.id, content: parsed.data.message })
    .select()
    .single()

  if (error) {
    console.error("DB error:", error)
    return Response.json({ error: "Database error" }, { status: 500, headers: corsHeaders })
  }

  // 5. Return JSON response
  return Response.json({ message: data }, { status: 201, headers: corsHeaders })
})

The supabase functions serve command connects to your local Supabase stack (started with supabase start) — the local PostgreSQL, Auth, and Storage services are fully functional. This means you can test the full request lifecycle including RLS policies, Auth JWT verification, and database writes without deploying. The local stack runs in Docker and is identical to the production environment, preventing environment-specific bugs.

Key Terms

Deno isolate
A Deno isolate is a V8 JavaScript engine context that runs a single edge function instance. Isolates are lighter than Node.js processes — they share the V8 heap within a worker process but maintain separate JavaScript heaps and global state between requests. Cold start is typically under 100 ms because a Deno isolate requires less initialization than a full Node.js process. Supabase Edge Functions run on Deno Deploy, which uses isolates to serve functions from 40+ global edge regions simultaneously. Between requests, isolates may be kept warm (reused) or recycled — never store mutable state in module-level variables and expect it to persist between requests.
Row Level Security (RLS)
Row Level Security is a PostgreSQL feature that restricts which rows a database user or role can access. Supabase enables RLS on all tables by default — a table with RLS enabled and no policies returns zero rows to any query. Policies are SQL expressions like USING (user_id = auth.uid()) that filter results to rows matching the authenticated user's identity. When an Edge Function passes the user's JWT to the Supabase client via the Authorization header, PostgREST verifies the token and sets auth.uid() to the user's UUID for the duration of the request. All queries in that client context enforce the table's RLS policies automatically — no application-level filtering code is needed.
Response.json()
Response.json() is a static factory method on the Web API Response class, standardized in the Fetch specification and available natively in Deno, modern browsers, and other Web Platform runtimes (Cloudflare Workers, Vercel Edge). It accepts a JavaScript value as the first argument (serialized with JSON.stringify) and an optional ResponseInit object as the second argument for setting the HTTP status code (default 200), additional headers, and status text. It returns a Response object with Content-Type: application/json; charset=UTF-8 set automatically. Before Response.json(), the verbose pattern was new Response(JSON.stringify(data), {'{ headers: { "Content-Type": "application/json" } }'}).
PostgREST
PostgREST is the RESTful API server that Supabase runs in front of PostgreSQL. The Supabase JavaScript client communicates exclusively through PostgREST — supabase.from("users").select() translates to an HTTP GET request to the PostgREST endpoint, which executes the corresponding SQL query. PostgREST handles JWT verification, RLS policy enforcement, and JSON serialization of query results. The returned JSON follows a predictable format: .select() returns an array of row objects with column names as keys, matching the TypeScript types generated by supabase gen types typescript. PostgREST also supports embedded resources (joins), computed columns, and stored procedure calls via supabase.rpc().
npm: specifier
The npm: specifier is Deno's native support for importing npm packages without a node_modules directory or package manager install step. import {'{ z }'} from "npm:zod" resolves the latest version of the Zod package from the npm registry on first use and caches it locally. Pin a version with "npm:zod@3.22.4" to ensure reproducible builds. Deno's npm compatibility layer supports most npm packages that do not use Node.js-specific APIs — pure JavaScript and TypeScript packages like Zod, date-fns, and uuid work without changes. Packages that use fs, path, os, and other Node.js built-ins are supported via Deno's Node.js compatibility shims, enabled automatically when the npm: specifier is used.
anon key vs service role key
The anon key (SUPABASE_ANON_KEY) is a public JWT that identifies the Supabase project but grants access only to rows permitted by RLS policies. It is safe to use in client-side code and browser environments. The service role key (SUPABASE_SERVICE_ROLE_KEY) is an admin-level JWT that bypasses RLS entirely — every query returns all rows regardless of the authenticated user. Use the service role key only in server-side code (Edge Functions, server-side rendering) for administrative operations like sending emails, generating reports, or bulk data migrations. Never embed the service role key in client-side JavaScript, browser extensions, or mobile apps — treat it like a database root password.

FAQ

How do I return JSON from a Supabase Edge Function?

Use Response.json(data) — Deno's Web API that sets Content-Type: application/json automatically and serializes any JavaScript object or array. It is the shorter alternative to the manual new Response(JSON.stringify(data), {'{ headers: { "Content-Type": "application/json" } }'}) pattern. Response.json() accepts any JSON-serializable value: plain objects, arrays, strings, numbers, booleans, and null. Objects with circular references or non-serializable values like Date (serialized as ISO strings), Map, Set, and undefined fields will be handled the same way as JSON.stringifyundefined fields are omitted, Dates become strings. Always return a Response object from the edge function handler — Deno Deploy requires an HTTP Response. For error responses, pass a second options argument: Response.json({'{ error: "Not found" }'}, {'{ status: 404 }'}). The status code defaults to 200 when omitted.

How do I read a JSON request body in a Supabase Edge Function?

Use const body = await req.json() — the Web Fetch API method on the Request object. This parses the request body as JSON and throws if the body is malformed JSON. Always wrap in a try/catch block: if the client sends invalid JSON, req.json() throws a SyntaxError. The parsed body is typed as any in TypeScript — use Zod or a manual type assertion to get typed access. For GET requests with no body, calling req.json() will throw because the body is empty. Check req.method before attempting to read the body. For large payloads, be aware that the Deno isolate reads the entire body into memory at once — there is no streaming body parser. Supabase Edge Functions support payloads up to 6 MB per request.

How do I query the Supabase database from an Edge Function?

Create a Supabase client with createClient(Deno.env.get("SUPABASE_URL")!, Deno.env.get("SUPABASE_ANON_KEY")!) and use the standard query builder: const { data, error } = await supabase.from("users").select("*"). The SUPABASE_URL and SUPABASE_ANON_KEY environment variables are injected automatically in deployed functions — you do not need to set them manually in the Supabase dashboard. For local development, they are read from the .env.local file. The query returns typed JSON matching your database schema. Always check for error before using data — a network failure or RLS policy rejection sets error and leaves data null. For operations that require elevated privileges (bypassing RLS), pass Deno.env.get("SUPABASE_SERVICE_ROLE_KEY")! as the anon key argument. Never expose the service role key to client-side code.

How do I add CORS headers to a Supabase Edge Function JSON response?

Define a corsHeaders object with Access-Control-Allow-Origin, Access-Control-Allow-Headers, and Access-Control-Allow-Methods, then pass it in the Response headers. Handle the OPTIONS preflight method explicitly — browsers send an OPTIONS request before POST/PUT/DELETE requests with a custom Content-Type header. Return a 204 No Content response to the OPTIONS request with the CORS headers. Pass the corsHeaders object to every response in your function, including error responses. A missing CORS header on a 500 error response causes browsers to surface a generic network error instead of the actual error message. For production, replace the wildcard * in Access-Control-Allow-Origin with your specific frontend origin. Add Vary: Origin when using per-origin whitelisting so CDNs cache responses correctly.

How do I authenticate JWT tokens in a Supabase Edge Function?

Supabase Edge Functions receive the user's JWT in the Authorization header as a Bearer token. Pass it to createClient using the auth.global.headers option: createClient(url, anonKey, { global: { headers: { Authorization: req.headers.get("Authorization")! } } }). The Supabase client verifies the JWT automatically and enforces Row Level Security (RLS) policies for all subsequent database queries — only rows the user is allowed to see are returned. To get the authenticated user, call const {'{ data: { user } }'} = await supabase.auth.getUser(). If no valid token is provided, getUser() returns null for user. For endpoints that require authentication, return a 401 JSON error if user is null. Supabase uses RS256 (asymmetric RSA) to sign JWTs — tokens expire after 3,600 seconds (1 hour) by default and are automatically refreshed by the Supabase client library on the frontend.

How do I validate JSON input in a Supabase Edge Function?

Import Zod from npm:zod (Deno npm compatibility) and define a schema for your request body. Call schema.safeParse(body) after reading the request body with req.json(). If result.success is false, return a 422 Response.json({ error: "Validation failed", details: result.error.flatten().fieldErrors }, { status: 422 }). The error.flatten().fieldErrors object maps each invalid field name to an array of error message strings — a structured response clients can display field-by-field. Zod schemas also give TypeScript types: const body: z.infer<typeof BodySchema> = result.data gives a fully typed body after validation passes. Validating at the edge function boundary prevents malformed data from reaching the database layer. For simple cases, a manual check (typeof body.email === "string") is faster to write but less maintainable for complex input shapes.

How do I handle JSON errors in a Supabase Edge Function?

Wrap the entire handler body in a try/catch and return a structured JSON error response from the catch block. The pattern is: try { /* handler logic */ } catch (err) { return Response.json({ error: err instanceof Error ? err.message : "Internal server error" }, { status: 500 }) }. For known error types — bad request (400), unauthorized (401), not found (404), validation failed (422) — throw early with a typed error class or return immediately with the appropriate status code. Check for Supabase query errors with if (error) return Response.json({'{ error: error.message }'}, {'{ status: 500 }'}) after every database call. Log errors with console.error(err)— Supabase captures Edge Function logs in the Supabase dashboard under Functions > Logs. Logs retain for 7 days on free plans and 30 days on Pro plans.

How do I deploy a Supabase Edge Function that returns JSON?

Run supabase functions deploy my-function from the project root — the Supabase CLI bundles the function and uploads it to Supabase's globally distributed infrastructure across 40+ edge regions. The function is live within seconds and reachable at https://<project-ref>.supabase.co/functions/v1/my-function. For local development, run supabase functions serve, which starts a local Deno server with hot reload — edit the function file and changes are applied without restarting. Pass environment variables via a local .env.local file for development; set them in the Supabase dashboard under Project Settings > Edge Functions for production. Use supabase secrets set KEY=VALUE for production secrets. Cold starts are typically under 100 ms because Deno isolates start faster than Node.js processes, which must spin up a full V8 context and load CommonJS modules.

Further reading and primary sources