Your shiny new API works. You ship it. Then you look at the URL you're asking customers to use:
https://xyzabc123.supabase.co/functions/v1/my-endpointGross.
You want api.yourdomain.com. Supabase offers custom domains. But only on Pro. $25/month. $300/year to hide an implementation detail.
Nah.
Why This Actually Matters
This isn't about vanity. A few reasons:
Portability. Tomorrow you might move to Vercel Edge, Deno Deploy, or bare metal. With a custom domain, your clients don't change a single line of code. Without it, you're locked in.
Trust. api.stripe.com hits different than random-hash.cloud-provider.io. Your customers notice.
CORS sanity. Same-origin requests from yourdomain.com to api.yourdomain.com are cleaner than cross-origin to supabase.co.
The $25/month isn't for a feature. It's rent for not being trapped.
The Pattern
Cloudflare Workers sit at the edge. They intercept requests and forward them wherever you want. Free tier gives you 100k requests/day.
Here's what we're building:
Client hits your domain. Cloudflare rewrites. Supabase responds. Client never sees supabase.co.
The Code
Create a Worker:
npm create cloudflare@latest -- api-proxy --type=hello-world
cd api-proxyReplace src/index.ts:
interface Env {
SUPABASE_URL: string
SUPABASE_ANON_KEY: string
}
const CORS_HEADERS = {
'Access-Control-Allow-Origin': '*',
'Access-Control-Allow-Methods': 'GET, POST, OPTIONS',
'Access-Control-Allow-Headers': 'Content-Type, Authorization',
'Access-Control-Max-Age': '86400',
}
export default {
async fetch(request: Request, env: Env): Promise<Response> {
// CORS preflight
if (request.method === 'OPTIONS') {
return new Response(null, { status: 204, headers: CORS_HEADERS })
}
const url = new URL(request.url)
const targetUrl = `${env.SUPABASE_URL}${url.pathname}${url.search}`
const headers = new Headers(request.headers)
headers.set('apikey', env.SUPABASE_ANON_KEY)
headers.delete('Host')
const response = await fetch(targetUrl, {
method: request.method,
headers,
body: request.body,
})
const responseHeaders = new Headers(response.headers)
for (const [key, value] of Object.entries(CORS_HEADERS)) {
responseHeaders.set(key, value)
}
return new Response(response.body, {
status: response.status,
headers: responseHeaders,
})
},
}That's it. ~40 lines.
DNS Setup
In Cloudflare DNS:
Type: AAAA
Name: api
Content: 100::
Proxy: ON (orange cloud)The 100:: is a placeholder. Traffic never reaches it—the Worker intercepts first.
Add the route in wrangler.toml:
[[routes]]
pattern = "api.yourdomain.com/*"
zone_name = "yourdomain.com"Secrets
Don't hardcode keys. Use Wrangler:
npx wrangler secret put SUPABASE_URL
# Enter: https://xyzabc123.supabase.co
npx wrangler secret put SUPABASE_ANON_KEY
# Enter: your-anon-keyDeploy:
npx wrangler deployDone. https://api.yourdomain.com now proxies to Supabase.
What About Latency?
Cold start adds ~5ms. Warm requests add basically nothing. If your API takes 2 seconds to respond, 5ms is a rounding error.
What We Use at NeatPDF
This is exactly how api.neat-pdf.com works. Cloudflare Worker in front, Supabase Edge Functions behind.
If we migrate off Supabase tomorrow, we update the Worker. Clients don't notice. That's the point.
Cost
| Approach | Monthly | Requests |
|---|---|---|
| Supabase Pro | $25 | Unlimited |
| Cloudflare Workers Free | $0 | 100k/day |
| Cloudflare Workers Paid | $5 | 10M/month |
If you're hitting 100k requests/day, you're probably making money. $5/month is nothing.
50 lines. Zero cost. Full control over your URL.
The proxy pattern isn't clever. It's just obvious once you see it.
Building something that generates PDFs? Check out the NeatPDF SDK →