← Back to blog

How to Add Custom Domains to a Supabase Multi-Tenant SaaS (with Code)

Daniel Sternlicht·
tutorialsupabasemulti-tenantcustom domainssaas
How to Add Custom Domains to a Supabase Multi-Tenant SaaS (with Code)

To offer customer custom domains in a Supabase app you need three pieces: a custom_domains table in Postgres, a resolver that maps the request's Host header to a tenant, and an edge that terminates TLS for each customer hostname. Supabase's own custom-domain add-on is a different feature: it rebrands your project's API URL and does nothing for customer domains.

That naming collision trips up almost everyone. Search "supabase custom domain" and every result explains the $10/month add-on, which is about whether your users see abcdefgh.supabase.co or api.yourapp.com in OAuth screens and email links. Useful, but it will not put your customer's app.acme.com in front of their dashboard. This guide builds the thing that will, with working code.

This is the Supabase sibling of our Next.js multi-tenant guide and the Rails one. Same architecture, Supabase idioms.

Three different "custom domains" in a Supabase app

What it coversWhere it's configuredCost
Your API and auth URLs (api.yourapp.com instead of <ref>.supabase.co)Supabase custom-domain add-on, one domain per project, CNAME + TXT verification$10/mo ($0.0137/hr) on any paid plan; vanity subdomains (yourapp.supabase.co) are free
Your app's own domain (yourapp.com)Your frontend host (Vercel, Netlify, wherever)Usually free
Your customers' domains (app.acme.com, hundreds of them)This guide: your data model + an edge that issues certificates per hostnameDepends on the route, table below

The add-on is genuinely worth it once real users see your auth URLs, and it caps at exactly one domain per project. Customer domains are unbounded and live entirely outside Supabase's domain settings.

Architecture

[ visitor: app.acme.com ]
        |
        v  TLS handshake (cert for app.acme.com)
[ edge layer: terminates TLS, proxies to origin ]
        |
        v  X-Forwarded-Host: app.acme.com
[ your frontend origin (Vercel / Netlify / VPS) ]
        |
        v  middleware reads the hostname
[ resolve_tenant(host) in Postgres -> tenant loaded ]
        |
        v  supabase-js with RLS scoping every query
[ your Supabase project ]

The edge layer is whatever answers the TLS handshake for customer hostnames: a Caddy server you run, your frontend platform's certificate manager, or a custom domains API. The Supabase side is identical in all three cases, so we build that first.

Step 1: model custom domains in Postgres

A tenant has many custom domains. Keep them in their own table, not a column on tenants, because one tenant will eventually want two domains and each domain needs its own lifecycle state.

create table custom_domains (
  id uuid primary key default gen_random_uuid(),
  tenant_id uuid not null references tenants (id) on delete cascade,
  hostname text not null unique,
  status text not null default 'pending', -- pending -> verifying -> active
  created_at timestamptz not null default now(),
  constraint hostname_lowercase check (hostname = lower(hostname))
);

alter table custom_domains enable row level security;

create policy "members manage their tenant's domains"
  on custom_domains for all
  using (
    tenant_id in (
      select tenant_id from tenant_members where user_id = auth.uid()
    )
  );

Don't skip the unique constraint. Two tenants claiming the same hostname means one tenant's visitors render the other tenant's data, and that's a security incident, not a UX papercut. The lowercase check earns its place too: DNS is case-insensitive, Postgres string equality is not, so normalize on the way in.

Resolution happens on every request from an unauthenticated context (your middleware has no user session for a first-time visitor), so RLS on the table would block it. Instead of handing your middleware the service-role key, expose one narrow function:

create or replace function resolve_tenant(host text)
returns table (tenant_id uuid, slug text)
language sql
security definer
set search_path = public
stable
as $$
  select t.id, t.slug
  from custom_domains d
  join tenants t on t.id = d.tenant_id
  where d.hostname = lower(host) and d.status = 'active';
$$;

security definer deliberately bypasses RLS, and that's fine here: the function takes an exact hostname and returns two non-secret fields. Callable with the anon key, nothing else exposed.

Step 2: resolve the tenant from the Host header

In the typical Supabase stack the frontend is Next.js, so the resolver is middleware. (Different framework? The pattern is identical: read the forwarded host, call the RPC, scope the request.)

// middleware.ts
import { NextResponse, type NextRequest } from "next/server";

const APP_HOSTS = new Set(["yourapp.com", "www.yourapp.com", "localhost:3000"]);
const cache = new Map<string, { slug: string | null; exp: number }>();

export async function middleware(req: NextRequest) {
  // Your edge preserves the customer hostname in X-Forwarded-Host.
  const host = (
    req.headers.get("x-forwarded-host") ?? req.headers.get("host") ?? ""
  ).toLowerCase();

  if (APP_HOSTS.has(host)) return NextResponse.next();

  let hit = cache.get(host);
  if (!hit || hit.exp < Date.now()) {
    const res = await fetch(
      `${process.env.NEXT_PUBLIC_SUPABASE_URL}/rest/v1/rpc/resolve_tenant`,
      {
        method: "POST",
        headers: {
          apikey: process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!,
          "Content-Type": "application/json",
        },
        body: JSON.stringify({ host }),
      }
    );
    const [tenant] = await res.json();
    hit = { slug: tenant?.slug ?? null, exp: Date.now() + 5 * 60_000 };
    cache.set(host, hit);
  }

  if (!hit.slug) return new NextResponse("Unknown domain", { status: 404 });

  return NextResponse.rewrite(
    new URL(`/t/${hit.slug}${req.nextUrl.pathname}`, req.url)
  );
}

The five-minute cache is not optional decoration. This lookup runs on every request for every customer domain; without it, your Postgres instance sits on the hot path of every single tenant request. The routing itself (the /t/[slug] rewrite and everything downstream) is covered in depth in the Next.js guide, so this post stays on the Supabase-specific parts.

Step 3: the auth redirect allow-list trap

This is the Supabase-specific gotcha that breaks almost every first attempt, the way config.hosts does in Rails. Supabase Auth only redirects to URLs on an allow-list you configure per project. Your app boots, subdomain logins work, and then the first user on a real customer domain requests a magic link:

await supabase.auth.signInWithOtp({
  email,
  options: { emailRedirectTo: `https://${host}/auth/callback` },
});

https://app.acme.com/auth/callback is not on the allow-list, so Supabase silently falls back to your Site URL. The user clicks the link on app.acme.com and lands logged-in on yourapp.com, on the wrong origin, where their session cookie does nothing useful. It looks like a session bug. It's a config bug.

Wildcards won't save you upfront. The allow-list supports patterns like https://*.yourapp.com/**, which is perfect for your own preview subdomains, but every customer domain is a different registered domain. You cannot pre-wildcard domains you haven't seen yet, so the allow-list has to grow at runtime, and Supabase's Management API is the lever:

// supabase/functions/_shared/allow-redirect.ts
const MGMT = "https://api.supabase.com/v1";
const token = Deno.env.get("SUPABASE_MGMT_TOKEN")!; // personal access token
const ref = Deno.env.get("SUPABASE_PROJECT_REF")!;

export async function allowRedirect(hostname: string) {
  const headers = {
    Authorization: `Bearer ${token}`,
    "Content-Type": "application/json",
  };
  const config = await fetch(`${MGMT}/projects/${ref}/config/auth`, {
    headers,
  }).then((r) => r.json());

  const urls = new Set(
    (config.uri_allow_list ?? "").split(",").filter(Boolean)
  );
  urls.add(`https://${hostname}/**`);

  await fetch(`${MGMT}/projects/${ref}/config/auth`, {
    method: "PATCH",
    headers,
    body: JSON.stringify({ uri_allow_list: [...urls].join(",") }),
  });
}

Call it the moment a domain becomes active (step 4 wires that up). One warning: the Management API token is scoped to your whole Supabase account, not one project. Treat it like a root credential, keep it in an Edge Function secret, and never let it near the browser.

Step 4: terminate TLS for customer hostnames

Everything above is plain Postgres and middleware. The hard part of custom domains is that https://app.acme.com needs a valid certificate for app.acme.com, issued automatically when the customer connects it, renewed forever. (The mechanics, ACME challenges, SNI and wildcards, are covered in how SSL provisioning works for custom domains.)

You have three realistic routes.

Option A: run Caddy with on-demand TLS

Caddy obtains a certificate on the first TLS handshake for a new hostname if you enable on_demand_tls. The piece that makes this safe is the ask endpoint: before issuing, Caddy checks with your app that the hostname belongs to a real tenant, so strangers can't burn your rate limits by pointing junk domains at your IP. A Supabase Edge Function is a natural home for it:

// supabase/functions/domain-check/index.ts
import { createClient } from "jsr:@supabase/supabase-js@2";

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

Deno.serve(async (req) => {
  const domain = new URL(req.url).searchParams.get("domain") ?? "";
  const { count } = await supabase
    .from("custom_domains")
    .select("*", { count: "exact", head: true })
    .eq("hostname", domain.toLowerCase())
    .in("status", ["verifying", "active"]);
  return new Response(null, { status: count ? 200 : 404 });
});
{
  on_demand_tls {
    ask https://<ref>.supabase.co/functions/v1/domain-check
  }
}

https:// {
  tls {
    on_demand
  }
  reverse_proxy your-frontend-origin:3000 {
    header_up X-Forwarded-Host {host}
  }
}

Supabase-specific detail: Edge Functions reject requests without a valid JWT by default, and Caddy's ask call arrives with no auth header at all. Deploy this one function with verification off, supabase functions deploy domain-check --no-verify-jwt, or every certificate request fails with a 401 before your code runs.

This route costs the least in server bills and the most in everything else. Let's Encrypt allows 50 new certificates per registered domain per week, five failed validations lock a hostname out for an hour, and you now own renewals, monitoring, and the 3am page when a certificate expires. Our debugging runbook for customer domains exists because of this route.

Option B: let your frontend platform do it

If your frontend is on Vercel (most Supabase apps are), Vercel will issue a certificate for every domain you add to the project, at $0 per domain, via dashboard or API. That's real and it works. The limits worth knowing before you commit: domain attachments are rate-limited to 100 per hour, wildcard domains require moving the customer's nameservers to Vercel (a much bigger ask than a CNAME), and uploading your own certificates is Enterprise-only. We went deeper on this in the Vercel Platforms Starter Kit review.

Platform certs are fine at small scale. The catch is lock-in: your domain onboarding flow becomes a pile of Vercel API calls, and it stays behind if the frontend ever moves.

Option C: use a custom domains API

Domainee is a custom domains API for SaaS with a native MCP server, 50 domains and 100 GB free. I build it, so weigh that as you read this section; the pricing math is public and you can check it yourself.

The model: your customer points a CNAME at the Domainee edge, the edge handles validation, issuance, renewal and serving, and proxies to your frontend origin with X-Forwarded-Host set, which is exactly what the middleware in step 2 reads. Your app makes one API call when the customer submits the form:

// app/actions/add-domain.ts (server action)
const res = await fetch("https://api.domainee.dev/v1/domains", {
  method: "POST",
  headers: {
    Authorization: `Bearer ${process.env.DOMAINEE_API_KEY}`,
    "Content-Type": "application/json",
  },
  body: JSON.stringify({ hostname }),
});
const { domain } = await res.json();
// domain.cnameTarget -> show this to the customer

Then receive one webhook when DNS and TLS are live. This is also the natural moment to fix the step 3 allow-list, so login works on the domain the instant it activates:

// supabase/functions/domainee-webhook/index.ts
import { allowRedirect } from "../_shared/allow-redirect.ts";

Deno.serve(async (req) => {
  const event = await req.json();
  if (event.type === "domain.active") {
    const hostname = event.data.hostname.toLowerCase();
    await supabase
      .from("custom_domains")
      .update({ status: "active" })
      .eq("hostname", hostname);
    await allowRedirect(hostname);
  }
  return new Response("ok");
});

(This one also deploys with --no-verify-jwt, since a webhook carries no Supabase JWT. Verify the webhook signature instead.) No certificate code in your repo, no rate-limit arithmetic, and the frontend can stay on Vercel, Netlify, or a VPS unchanged. The tradeoff is a vendor in your request path, which is the same tradeoff as Option B with a different vendor.

Which one should you pick?

DIY CaddyVercel (frontend host)Domainee
Setup effortHigh (server + ask endpoint + monitoring)Low, if the frontend is on VercelLow (CNAME + 1 API call + webhook)
IssuanceSeconds, on first handshakeMinutesMinutes, webhook on completion
Rate limits you manageLet's Encrypt, 50/week100 domain-adds/hourDomainee's problem
Wildcard supportYes, via DNS-01Only with Vercel nameserversYes
Apex domainsYes, you run the IPsYes, A recordYes, CNAME flattening or ALIAS
Cost at 100 domainsServer + your ops time$0 on top of hosting$10/mo (50 free, then $0.20)
Portable across hostsYesNoYes

Honest summary: under 10 domains with the frontend already on Vercel, use Vercel, it's free. Past 50 domains, or the day a customer asks for a wildcard and you'd rather not ask them to hand over their nameservers, the API route starts earning its $10.

Step 5: the onboarding UX

Whatever edge you chose, the customer-facing flow is the same:

  1. Customer types app.acme.com into your settings page.
  2. You insert the custom_domains row (and make the API call, if using one) and show exactly one instruction: "Add a CNAME record pointing app.acme.com to edge.yourapp.com".
  3. Poll or receive a webhook until DNS resolves and the certificate is live, then flip the status badge to Active and add the domain to the auth allow-list.

Do not make the customer click "verify" and interpret DNS errors themselves. Check it for them, on a loop, and show plain-language status. A CNAME lookup on your side answers "has their DNS change landed" before you ever attempt issuance, and propagation can lag by minutes to hours depending on the old record's TTL. And if your app was built by an AI builder on top of Supabase (Lovable, Bolt, v0), the Lovable custom-domain guide covers that variant of this exact flow.

FAQ

Does Supabase's custom domain add-on let my customers use their own domains?

No. The add-on attaches exactly one domain per project and it rebrands your API and auth URLs (api.yourapp.com instead of <ref>.supabase.co). Customer domains are unbounded, live in your own custom_domains table, and terminate TLS at an edge you choose, none of which touches Supabase's domain settings.

Why do magic links and OAuth break on customer domains?

Supabase Auth only redirects to allow-listed URLs, and a runtime-added customer domain is never on the list, so users bounce to your Site URL on the wrong origin. Fix it by adding https://<customer-domain>/** to uri_allow_list via the Management API when the domain activates.

Can one Supabase project serve thousands of customer domains?

Yes. Domains are rows in a table, and Postgres does not care how many hostnames resolve to your app. The real work is per-request tenant resolution (step 2), the auth allow-list (step 3), and per-hostname TLS (step 4).

Do I need the $10/month add-on for a multi-tenant SaaS?

Not for customer domains, the two features are independent. It is worth it for a different reason: without it, OAuth consent screens and auth emails show your raw <ref>.supabase.co URL to every user. Vanity subdomains (yourapp.supabase.co) are a free middle ground on paid plans.

How much does it cost to offer custom domains in a Supabase app?

If the frontend is on Vercel, platform certificates are free until the quotas bite. DIY costs a small server plus your operations time. Domainee is free for 50 domains and 100 GB bandwidth, then $0.20 per domain per month; at 100 customer domains that is $10/mo, coincidentally the price of the Supabase add-on. Run your own numbers in the cost calculator.