Back to Blog
Architecture

Custom Supabase Domains Without Pro: The Cloudflare Proxy Pattern

2026-01-19·2 min read
supabasecloudflareproxyinfrastructure

Your shiny new API works. You ship it. Then you look at the URL you're asking customers to use:

text
https://xyzabc123.supabase.co/functions/v1/my-endpoint

Gross.

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:

bash
npm create cloudflare@latest -- api-proxy --type=hello-world
cd api-proxy

Replace src/index.ts:

typescript
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:

text
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:

toml
[[routes]]
pattern = "api.yourdomain.com/*"
zone_name = "yourdomain.com"

Secrets

Don't hardcode keys. Use Wrangler:

bash
npx wrangler secret put SUPABASE_URL
# Enter: https://xyzabc123.supabase.co

npx wrangler secret put SUPABASE_ANON_KEY
# Enter: your-anon-key

Deploy:

bash
npx wrangler deploy

Done. 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 →

Ready to generate PDFs?

Zero data retention. GDPR compliant.

Start Free TrialZero data retention · GDPR