Claude API Integration with Supabase Functions
Integrating Claude with Supabase Edge Functions means writing a Deno-based serverless function that receives a request from your app, calls Claude's Messages API, and returns the result — without exposing your API key to the client. This pattern is common for chat features, content generation, and AI-assisted forms built on top of a Supabase backend.
The short answer: create an Edge Function, store your API key as a Supabase secret, call Claude's /v1/messages endpoint with fetch, and stream or return the response to your frontend through supabase.functions.invoke. Below is a working setup, plus the details that trip people up — CORS, streaming, and key management.
Why use an Edge Function instead of calling Claude from the client
Calling any LLM API directly from a browser or mobile app means shipping your API key in client code, which anyone can extract with dev tools. Supabase Edge Functions run server-side (on Deno Deploy under the hood), so the key stays in an environment variable that never reaches the client. You also get:
- A single place to enforce rate limits, auth checks, or usage logging
- The ability to combine a database read (e.g. fetching a user's context from Postgres) with the Claude call in one request
- Consistent CORS handling for web and mobile clients
Setting up the function
Scaffold a new function with the Supabase CLI:
supabase functions new claude-chat
This creates supabase/functions/claude-chat/index.ts. Store your key as a secret rather than hardcoding it:
supabase secrets set ANTHROPIC_API_KEY=sk-ant-xxxxx
Basic function code
// supabase/functions/claude-chat/index.ts
import { serve } from "https://deno.land/std@0.203.0/http/server.ts";
const corsHeaders = {
"Access-Control-Allow-Origin": "*",
"Access-Control-Allow-Headers": "authorization, x-client-info, apikey, content-type",
};
serve(async (req) => {
if (req.method === "OPTIONS") {
return new Response("ok", { headers: corsHeaders });
}
const { message } = await req.json();
const response = await fetch("https://api.anthropic.com/v1/messages", {
method: "POST",
headers: {
"content-type": "application/json",
"x-api-key": Deno.env.get("ANTHROPIC_API_KEY")!,
"anthropic-version": "2023-06-01",
},
body: JSON.stringify({
model: "claude-sonnet-4-5",
max_tokens: 1024,
messages: [{ role: "user", content: message }],
}),
});
const data = await response.json();
return new Response(JSON.stringify(data), {
headers: { ...corsHeaders, "content-type": "application/json" },
});
});
Deploy it with:
supabase functions deploy claude-chat
Calling it from your app
const { data, error } = await supabase.functions.invoke("claude-chat", {
body: { message: "Summarize this support ticket in two sentences." },
});
This keeps the API key server-side and lets you attach Supabase Auth JWTs to the request automatically, so you can check req.headers.get("Authorization") inside the function and reject unauthenticated calls.
Handling streaming responses
Chat UIs usually need token-by-token output. Supabase Edge Functions support streaming responses via ReadableStream, and Claude's API supports server-sent events when you set "stream": true. The function needs to pipe Claude's SSE stream straight through to the client:
const claudeResponse = await fetch("https://api.anthropic.com/v1/messages", {
method: "POST",
headers: {
"content-type": "application/json",
"x-api-key": Deno.env.get("ANTHROPIC_API_KEY")!,
"anthropic-version": "2023-06-01",
},
body: JSON.stringify({
model: "claude-sonnet-4-5",
max_tokens: 1024,
stream: true,
messages: [{ role: "user", content: message }],
}),
});
return new Response(claudeResponse.body, {
headers: { ...corsHeaders, "content-type": "text/event-stream" },
});
On the frontend, you'll need to read the stream manually (Supabase's functions.invoke doesn't parse SSE for you) — typically with fetch directly against the function URL and a ReadableStream reader, rather than the JS client helper.
Handling errors and rate limits
Claude's API returns 429 on rate limits and 529 when it's overloaded. Your Edge Function should catch these and return a clear status to the client instead of letting a raw upstream error leak through:
if (!response.ok) {
const errorBody = await response.text();
return new Response(JSON.stringify({ error: "upstream_error", detail: errorBody }), {
status: response.status,
headers: corsHeaders,
});
}
If you're calling Claude from multiple functions or multiple environments (staging, production, preview branches), tracking usage per environment gets messy fast with a single shared key. This is where a layer like SubToAPI is useful: instead of one raw Anthropic key shared across every function, you issue separate sub_live_... application keys per environment or per team, see usage metadata per key in a dashboard, and keep the same Messages-compatible endpoint your Edge Function already calls. You'd swap the x-api-key header and base URL for a Authorization: Bearer $SUBTOAPI_KEY header pointed at https://api.subtoapi.app/v1/messages — the request and response shapes stay the same, so the Edge Function code above barely changes. See the quickstart and messages docs for the exact payload format, and streaming docs if you're piping SSE through as shown above.
curl https://api.subtoapi.app/v1/messages \
-H "Authorization: Bearer $SUBTOAPI_KEY" \
-H "content-type: application/json" \
-d '{
"model": "claude-sonnet-4-5",
"max_tokens": 1024,
"messages": [{"role": "user", "content": "Summarize this support ticket."}]
}'
Combining Claude with your database
A common pattern is fetching context from Postgres before calling Claude, all inside the same function:
import { createClient } from "https://esm.sh/@supabase/supabase-js@2";
const supabase = createClient(
Deno.env.get("SUPABASE_URL")!,
Deno.env.get("SUPABASE_SERVICE_ROLE_KEY")!
);
const { data: ticket } = await supabase
.from("tickets")
.select("body")
.eq("id", ticketId)
.single();
// then pass ticket.body into the Claude messages array
Use the service role key only inside Edge Functions, never in client code, since it bypasses row-level security.
Deployment checklist
- Store
ANTHROPIC_API_KEY(or your SubToAPI key) withsupabase secrets set, never in source control - Set
max_tokensexplicitly to control cost per request - Return proper HTTP status codes on upstream failures so your frontend can retry or show a clear error
- Add a timeout on the
fetchcall if you're not streaming, since Claude responses can take several seconds for long outputs - Log request IDs (from Claude's response headers) if you need to debug specific calls later
questions
Can I call the Claude API directly from Supabase Edge Functions without a proxy? Yes. Edge Functions run server-side on Deno, so you can call api.anthropic.com directly with your key stored as a secret — no separate proxy is required for basic use cases.
Does Supabase support streaming Claude responses to the browser? Edge Functions can return a ReadableStream, and Claude supports SSE streaming, so you can pipe the response through. The client needs to read the stream manually rather than using functions.invoke's default JSON parsing.
Why would I use SubToAPI instead of calling Anthropic directly from a function? If you need per-environment or per-team API keys, usage tracking across multiple functions, or a single dashboard for billing and seats, SubToAPI adds that layer on top of the same Messages API shape — see pricing for plan details.