Engineering

How to Add Custom Domains to a Node.js App

Drago Crnjac
Drago Crnjac
CEO & Founder
Cover Image for How to Add Custom Domains to a Node.js App

To add custom domains to a Node.js app, your customer points a CNAME (or A records for a root domain) at a TLS proxy in front of your app, you save the domain against their account, and an Express middleware picks the tenant from the hostname the proxy passes along. The part that takes real work is the proxy: a valid HTTPS certificate for every one of those domains.

The Node.js part is small: a table, one Express middleware, and a router for customer-facing pages. This guide uses Express 5 on Node.js 22.18 or later, with a section for Next.js. It also covers what trips people up in production: trust proxy, cookies that never get set, and forms on custom domains that fail an origin check.

In this guide:

Run the example app

Every Express snippet below comes from custom-domains-express, a small status page app on GitHub with two companies, a dashboard for their domains, and a local mock of our API and proxy. You need Docker:

git clone https://github.com/saas-custom-domains/custom-domains-express
cd custom-domains-express
docker compose up --build

Open http://localhost:3000, add intercom.localhost to Intercom, and it turns Live a few seconds later. Then open http://intercom.localhost: the request goes through the mock proxy on port 80 and you get Intercom's status page. *.localhost names always resolve to 127.0.0.1, so there's no DNS to set up.

Tested with Express 5.3.0, Prisma 7.10.1 and Node.js 24, checked October 28, 2026.

How custom domains reach your Node.js app

We'll use the same example as our other framework guides: a status page product on app.statuspage.com. One of its customers, Intercom, wants its status page on status.intercom.com.

Intercom's DNS doesn't name your servers. It names the proxy in front of them, which holds the certificate for status.intercom.com and passes each request on to your app. With SaaS Custom Domains, subdomains use a CNAME to in.saascustomdomains.com, while a root domain such as intercomstatus.com, which can't have a CNAME, uses A records for our two static anycast IPs:

; Subdomain
status.intercom.com.   CNAME  in.saascustomdomains.com.

; Root domain
intercomstatus.com.    A      75.2.96.173
intercomstatus.com.    A      99.83.186.151

Because those records point at the proxy and not at a machine of yours, you can move or scale your app without asking a single customer to edit their DNS. If you'd rather customers see your brand in the record, the CNAME target can be white-labeled, for example as in.statuspage.com. Our guide to custom domains for SaaS goes deeper into the DNS side.

The proxy then forwards the request to app.statuspage.com and tells your app which domain the visitor asked for in an X-Served-For header.

Store your customers' domains

A CustomDomain table maps each domain to a tenant, with a status so you only serve domains whose DNS already points at the proxy. The examples use Prisma, but any ORM works. A Company is the tenant:

prisma/schema.prisma
generator client {
  provider = "prisma-client"
  output   = "../src/generated/prisma"
}

datasource db {
  provider = "sqlite"
}

model CustomDomain {
  id           Int      @id @default(autoincrement())
  host         String   @unique
  status       String   @default("pending") // "pending" or "active"
  httpsReady   Boolean  @default(false)
  providerUuid String?  @unique
  company      Company  @relation(fields: [companyId], references: [id])
  companyId    Int
  createdAt    DateTime @default(now())
}

The unique constraint on host matters. Without it, two companies can claim the same domain, and whichever row comes back first gets the other's traffic. httpsReady turns a domain's badge from "DNS OK, waiting for HTTPS" into "Live" once its certificate exists.

Prisma 7 moved the database URL out of the schema and into prisma.config.js, and the client now needs a driver adapter. The generated client is TypeScript, which plain JavaScript can import directly on Node.js 22.18 or later because Node strips the types:

src/db.js
import { PrismaBetterSqlite3 } from '@prisma/adapter-better-sqlite3'
import { PrismaClient } from './generated/prisma/client.ts'

// SQLite keeps the example to one container. For Postgres, use
// @prisma/adapter-pg instead; the queries stay the same.
const url = process.env.DATABASE_URL ?? 'file:./data/app.db'

export const prisma = new PrismaClient({
  adapter: new PrismaBetterSqlite3({ url }),
})

Normalize every domain before you save it. Node.js has a built-in, domainToASCII, that lowercases a hostname and converts international domain names to punycode:

src/hosts.js
import { domainToASCII } from 'node:url'

// Every hostname your own traffic arrives on, including internal
// names your platform or webhooks use to reach the app.
export const APP_HOSTS = new Set(
  (process.env.APP_HOSTS ?? 'localhost')
    .split(',')
    .map((h) => h.trim().toLowerCase())
    .filter(Boolean),
)

// Customers can't claim this domain or any subdomain of it
const APP_DOMAIN = process.env.APP_DOMAIN ?? 'statuspage.com'

const LABEL = /^(?!-)[a-z0-9-]{1,63}(?<!-)$/

export function normalizeHost(input) {
  const host = domainToASCII(String(input).trim().replace(/\.$/, ''))
  const labels = host.split('.')
  const valid =
    host.length <= 253 &&
    labels.length >= 2 &&
    labels.every((label) => LABEL.test(label)) &&
    // domainToASCII turns "1.2.3" into "1.2.0.3"; IPs aren't domains
    !/^\d+$/.test(labels.at(-1))
  if (!valid) return null

  // Don't let customers claim admin.statuspage.com
  const ours =
    APP_HOSTS.has(host) ||
    host === APP_DOMAIN ||
    host.endsWith(`.${APP_DOMAIN}`)
  if (ours) return null

  return host
}

domainToASCII returns an empty string for input it can't parse at all, such as a pasted URL or a host with a port. It is more forgiving than you'd expect with the rest: -acme.com and 192.0.2.1 come back unchanged, and 1.2.3 comes back as the IP address 1.2.0.3. Checking every label, and refusing a numeric last label, keeps those out of your table.

New domains start as pending. They become active when our API confirms their DNS records, either because the customer clicked "Check DNS" or because a webhook arrived; both are further down.

Route custom domain requests to the right tenant

Express gives you the request's hostname without the port as req.hostname. On your laptop, a request for intercom.localhost carries that name in Host, so start there; step 5 switches hostFrom to the proxy's header. Requests on a customer's domain go to their own router, so they never reach your app's dashboard or API:

src/custom-domains.js
import express from 'express'
import { prisma } from './db.js'
import { APP_HOSTS } from './hosts.js'
import { getStatusData } from './status.js'

// req.hostname is undefined without a Host header (HTTP/1.0)
export function hostFrom(req) {
  return (req.hostname || '').toLowerCase()
}

const router = express.Router()

router.use(async (req, res, next) => {
  const domain = await prisma.customDomain.findFirst({
    where: { host: hostFrom(req), status: 'active' },
    include: { company: true },
  })
  if (!domain) return res.status(404).send('Unknown domain')

  req.company = domain.company
  req.customDomain = domain.host
  next()
})

router.get('/', async (req, res) => {
  const data = await getStatusData(req.company.id)
  res.render('status-page', {
    company: req.company,
    data,
    // For the canonical link: never build it from req.get('host')
    host: req.customDomain,
    subscribed: req.query.subscribed === '1',
  })
})

// Anything else on a custom domain is a 404, not your app
router.use((req, res) => res.sendStatus(404))

export function customDomains(req, res, next) {
  if (APP_HOSTS.has(hostFrom(req))) return next()
  router(req, res, next)
}
src/app.js
const app = express()
app.set('view engine', 'ejs')

// Load balancers call this by IP address, so it goes before
// anything that looks at the hostname
app.get('/up', (req, res) => res.send('ok'))

app.use(customDomains)
app.use(appRoutes)

A few notes:

  • Unknown and pending domains get a 404, so a domain nobody connected shows nothing.
  • Anything that reaches your app by IP address or an internal name looks like an unknown customer domain. That's why the health check sits above customDomains, and why APP_HOSTS has to list internal hostnames too (in the example's Docker setup, webhooks from the mock reach the app as app:3000).
  • Without a Host header, which HTTP/1.0 allows, req.hostname is undefined, so a bare req.hostname.toLowerCase() would answer such requests with a 500.
  • Express 5 forwards errors from async middleware to your error handler. On Express 4, wrap the lookup in try/catch and call next(err).
  • hostFrom is the one place that reads the hostname, so moving to the proxy's header later changes a single function.

Don't trust X-Forwarded-Host by accident

With app.set('trust proxy', true), Express takes req.hostname from the X-Forwarded-Host header, which any client can send. A hop count like 1 behaves the same way when clients can reach the app directly, because Express only looks at the address of the connection. Since you're choosing the tenant by hostname, trust only the load balancer your platform puts in front of the app, by address or subnet:

// Your load balancer's subnet
app.set('trust proxy', '10.0.0.0/8')

The example app reads this from TRUST_PROXY and defaults to loopback.

You'll want this setting either way: without it, req.secure is false whenever something in front of Node.js terminates TLS, and express-session with cookie: { secure: true } silently never sets the session cookie. There's no error; only DEBUG=express-session logs not secured.

Leave the session cookie's domain unset, too. A host-only cookie is what you want on a domain you don't own. If you set domain: '.statuspage.com' to share sessions across your subdomains, browsers reject that cookie on custom domains.

Using Next.js?

In Next.js, do the hostname lookup in proxy.js (called middleware.js before Next.js 16) and rewrite customer domains to a route that renders their site:

proxy.js
import { NextResponse } from 'next/server'

const APP_HOSTS = new Set(['app.statuspage.com', 'localhost'])

export function proxy(request) {
  const host = (request.headers.get('host') ?? '')
    .split(':')[0]
    .toLowerCase()
  const url = request.nextUrl.clone()

  if (APP_HOSTS.has(host)) {
    // Your own domain can't reach customer sites by path
    if (url.pathname.startsWith('/sites/')) {
      return new NextResponse(null, { status: 404 })
    }
    return NextResponse.next()
  }

  url.pathname = `/sites/${host}${url.pathname}`
  return NextResponse.rewrite(url)
}

export const config = {
  matcher: ['/((?!_next/static|_next/image|favicon.ico).*)'],
}

Then app/sites/[domain]/page.js looks up the active domain from params.domain and calls notFound() if there isn't one. HTTPS, the proxy header and the token check work as described below. For the full Next.js 16 version, with per-tenant caching and Server Actions, see how to add custom domains to a multi-tenant Next.js app.

Server Actions are the Next.js-specific catch: they reject a request when the browser's Origin doesn't match x-forwarded-host (or host), and behind a proxy that's your own hostname. The Next.js guide's proxy.ts handles it in the app: once the token check passes, it sets x-forwarded-host to the customer's domain before the action runs.

Add HTTPS for every custom domain

A browser opening https://status.intercom.com checks that the certificate it receives names status.intercom.com itself. Your own certificate for statuspage.com doesn't cover it, so every customer domain needs one of its own, and Let's Encrypt certificates expire after about three months, so each one has to be renewed on schedule too.

Running this on your own leaves you with five jobs:

  • a TLS layer that fetches a certificate when an unfamiliar domain connects for the first time, and turns away names missing from your table, so strangers can't burn through your certificate authority's rate limits
  • certificate storage shared by every server that terminates TLS
  • renewals that never slip, plus a backup certificate authority for the days the main one is down
  • static IP addresses for root domains, which your customers then hard-code into their DNS
  • monitoring for certificates that fail to issue or renew, and someone on call when they do

Our custom domains guide explains each of these in detail.

SaaS Custom Domains runs all of it for you. The steps below connect your Express app.

Set it up with SaaS Custom Domains

SaaS Custom Domains runs the proxy for you. Customers point their domains at us. We issue and renew certificates with Let's Encrypt and ZeroSSL, serve traffic from 119 locations on AWS, and forward each request to your app with the customer's domain in a header. Root domains get two static anycast IPs, and we can email your customers the exact DNS records to add.

It costs $0.29 per domain per month, with a 100-domain minimum ($29/month) and volume discounts from 1,000 domains. You can try it free for 7 days; the trial needs a card, and you can cancel any time.

Here are the steps:

  1. Create an API token (1 minute)
  2. Create an upstream for your Node.js app (1 minute)
  3. Add custom domains from your app (5 minutes)
  4. Point the customer's DNS at us (1 minute)
  5. Read the custom domain in your app and verify the proxy's token (5–15 minutes)

1. Create an API token

Create a token on the API tokens page of your account settings.

Screenshot showing the API tokens settings in the UI.

Then copy your account UUID from account settings:

Screenshot of the account settings page in the UI

Put both in your environment as SCD_API_TOKEN and SCD_ACCOUNT_UUID, and keep the token as secret as a password.

2. Create an upstream

An upstream is the server we forward requests to: your Node.js app. Here's a small API client using the built-in fetch, so there's nothing to install:

src/saas-domains.js
// A small client for the SaaS Custom Domains API, using the built-in
// fetch. SCD_API_URL is only set to point at the local mock.
const API_URL =
  process.env.SCD_API_URL ?? 'https://app.saascustomdomains.com'
const BASE = `${API_URL}/api/v1/accounts`

export class SaasDomainsError extends Error {
  constructor(status, detail) {
    super(`SaaS Custom Domains ${status}: ${detail}`)
    this.status = status
  }
}

function domainsPath() {
  return `upstreams/${process.env.SCD_UPSTREAM_UUID}/custom_domains`
}

async function request(method, path, body) {
  const url = `${BASE}/${process.env.SCD_ACCOUNT_UUID}/${path}`
  const res = await fetch(url, {
    method,
    headers: {
      Authorization: `Bearer ${process.env.SCD_API_TOKEN}`,
      'Content-Type': 'application/json',
    },
    body: body && JSON.stringify(body),
    signal: AbortSignal.timeout(10_000),
  })
  if (!res.ok) {
    const detail = await res.text()
    throw new SaasDomainsError(res.status, detail)
  }
  return res.json()
}

export const saasDomains = {
  createUpstream: (host) => request('POST', 'upstreams', { host }),
  createDomain: (host, options = {}) =>
    request('POST', domainsPath(), { host, ...options }),
  verifyDns: (uuid) =>
    request('POST', `${domainsPath()}/${uuid}/verify_dns_records`),
  async deleteDomain(uuid) {
    try {
      await request('DELETE', `${domainsPath()}/${uuid}`)
    } catch (err) {
      // Already gone, say deleted in the dashboard, is fine
      const gone = err instanceof SaasDomainsError && err.status === 404
      if (!gone) throw err
    }
  },
}

deleteDomain treats a 404 as success. The goal is that the domain no longer exists on the proxy, and if someone already removed it in our dashboard, an error would leave your own row stuck forever.

You only create the upstream once, so do it from a one-off script:

scripts/create-upstream.js
// Run once: node --env-file=.env scripts/create-upstream.js <host>
// Copy uuid into SCD_UPSTREAM_UUID, auth_token into PROXY_AUTH_TOKEN.
import { saasDomains } from '../src/saas-domains.js'

const host = process.argv[2] ?? 'app.statuspage.com'
console.log(await saasDomains.createUpstream(host))
// {
//   uuid: 'your_upstream_uuid',
//   host: 'app.statuspage.com',
//   port: 443,
//   tls: true,
//   auth_token: '3f9c...',
//   ...
// }

Run it with node --env-file=.env scripts/create-upstream.js app.statuspage.com. Copy the uuid into SCD_UPSTREAM_UUID and the auth_token into PROXY_AUTH_TOKEN (step 5 uses it). The dashboard can create upstreams too, and the upstreams API docs list the other settings.

3. Add custom domains from your app

When a customer adds a domain, save it, register it with us, and keep the UUID we return so you can check and remove it later:

src/domains.js
// A problem the user can fix, shown next to the form
export class DomainError extends Error {}

export async function addCustomDomain(
  company,
  input,
  dnsContact = company.dnsContactEmail,
) {
  const host = normalizeHost(input)
  if (!host) {
    throw new DomainError(`"${input}" isn't a domain you can connect.`)
  }

  let domain
  try {
    domain = await prisma.customDomain.create({
      data: { host, companyId: company.id },
    })
  } catch (err) {
    // The unique index on host: another company already has it
    if (err.code !== 'P2002') throw err
    throw new DomainError(`${host} is already connected.`)
  }

  try {
    const created = await saasDomains.createDomain(host, {
      // Whoever manages their DNS gets step-by-step instructions
      ...(dnsContact && { instructions_recipient: dnsContact }),
    })
    return await prisma.customDomain.update({
      where: { id: domain.id },
      data: { providerUuid: created.uuid },
    })
  } catch (err) {
    // Don't keep a row the proxy doesn't know about: the host is
    // unique, so the customer could never add this domain again
    await prisma.customDomain.delete({ where: { id: domain.id } })
    throw err
  }
}

If you pass instructions_recipient, we email that person the exact DNS records for this domain. It's optional, but useful, because whoever adds the domain in your app often isn't whoever runs the company's DNS. Leave the field out when you have no address, rather than sending null. The custom domains API docs list the other fields.

4. Point the customer's DNS at us

The records your customer needs are the ones at the top of this guide: a CNAME for a subdomain, two A records for a root domain.

Tell customers to delete any existing A records for the domain first; otherwise some visitors keep landing on the old server. Most DNS changes show up within minutes.

If the customer uses Cloudflare, the record has to be grey-clouded (DNS only). With the orange cloud on, Cloudflare hides the record behind its own IPs, so the DNS check fails and the certificate can't be issued.

DNS records should have the DNS Only option enabled.

Customers who need Cloudflare's proxy can keep it: create the domain with challenge_type: 'dns01' and give them the extra _acme-challenge CNAME from the API response. The certificate is then validated over DNS.

5. Read the custom domain in your app

Requests from our proxy arrive with Host and X-Forwarded-Host set to your app's own hostname, app.statuspage.com. That's deliberate: your hosting platform uses it to find your app, and plenty of apps refuse hostnames they don't recognize. The customer's domain gets a header of its own, X-Served-For.

That's why everything reads the hostname through hostFrom. Update it to prefer the header:

src/custom-domains.js
export function hostFrom(req) {
  return (req.get('X-Served-For') || req.hostname || '').toLowerCase()
}

In Next.js, read request.headers.get('x-served-for') first in proxy.js the same way, and run the token check below at the top of proxy().

Check that the request came through the proxy

There's a catch: X-Served-For is just a header, and anyone who finds your app's own address can send it. Without a check, a request straight to app.statuspage.com with X-Served-For: status.intercom.com gets Intercom's pages.

Our proxy adds a secret to every request it forwards: your upstream's auth token, in X-SaaS-Domains-Auth-Token (docs). Reject requests that carry X-Served-For without the right token. Use a constant-time comparison, so response timing doesn't reveal how close a guess was:

src/safe-equal.js
import { timingSafeEqual } from 'node:crypto'

// Constant-time comparison, so response timing doesn't reveal how
// close a guess was. An unset secret never matches: otherwise
// String(undefined) would make "undefined" a valid token.
export function safeEqual(given, expected) {
  if (!expected) return false
  const a = Buffer.from(String(given ?? ''))
  const b = Buffer.from(String(expected))
  return a.length === b.length && timingSafeEqual(a, b)
}
src/app.js
// Requests with X-Served-For must prove they came through the proxy,
// or anyone could claim a customer's domain by sending the header.
export function verifyProxyToken(req, res, next) {
  if (!req.get('X-Served-For')) return next()

  const token = req.get('X-SaaS-Domains-Auth-Token')
  if (!safeEqual(token, process.env.PROXY_AUTH_TOKEN)) {
    return res.sendStatus(403)
  }
  next()
}

app.use(verifyProxyToken) // before customDomains

The if (!expected) line matters more than it looks. If PROXY_AUTH_TOKEN is missing from one environment, String(undefined) turns it into the literal text undefined, and a request sending that word as its token would pass. The webhook check below uses the same function.

Store the token like any other secret, and rotate it from the dashboard if it leaks.

Whenever your code needs the customer's domain, take it from the lookup that ran after the token check, not from Host. In the example app that's req.customDomain, and three places use it.

A form on the custom domain is the first. Express has no built-in CSRF check, but if you compare the browser's Origin with req.get('host'), every real submission fails behind the proxy, because Host is app.statuspage.com. Compare it with the customer's domain instead:

src/custom-domains.js
// Browsers send Origin with form posts. Compare it with the customer's
// domain: behind the proxy, Host is your own hostname.
function sameOrigin(req, res, next) {
  const origin = req.get('Origin')
  if (!origin) return next() // not a browser
  if (URL.parse(origin)?.hostname === req.customDomain) return next()
  res.status(403).send('Cross-site form submission')
}

router.post(
  '/subscribe',
  express.urlencoded(),
  sameOrigin,
  async (req, res) => {
    // The company comes from the domain, never from the form
    const companyId = req.company.id
    const email = String(req.body?.email ?? '').trim().toLowerCase()
    // ...validate and save the subscriber...
    // Relative, so the visitor stays on the custom domain
    res.redirect(303, '/?subscribed=1')
  },
)

Redirects are the second, and they mostly take care of themselves: res.redirect('/path') sends a relative URL, so the browser stays on the custom domain. The third is absolute URLs, in emails, OAuth callbacks or a canonical link. ${req.protocol}://${req.get('host')} points at your own app there; build them from req.customDomain.

Our support can also put the customer's domain in X-Forwarded-Host for your account, if a library you use reads that header.

Show DNS status and react to webhooks

Customers will keep asking whether their domain is working yet. Give them a "Check DNS" button that asks us to re-check right away:

src/domains.js
// Asks the API to check the DNS records right away
export async function checkDns(domain) {
  const res = await saasDomains.verifyDns(domain.providerUuid)
  if (res.dns_status === 'valid') {
    await prisma.customDomain.update({
      where: { id: domain.id },
      data: { status: 'active' },
    })
  }
  return res.dns_status // 'valid', 'pending' or 'invalid'
}

Webhooks save you from polling. Register your endpoint in the dashboard. Each delivery carries the webhook's security token in a token query parameter, so verify it before you trust the body:

src/routes.js
const UPDATES_BY_EVENT = {
  domain_dns_proxy_record_valid: { status: 'active' },
  domain_dns_proxy_record_invalid: { status: 'pending' },
  tls_certificate_issued: { httpsReady: true },
}

const webhookPath = '/webhooks/saas-domains'

appRoutes.post(webhookPath, express.json(), async (req, res) => {
  if (!safeEqual(req.query.token, process.env.SCD_WEBHOOK_TOKEN)) {
    return res.sendStatus(401)
  }

  // req.body is undefined in Express 5 if the body wasn't JSON
  const data = UPDATES_BY_EVENT[req.body?.event]
  const uuid = req.body?.domain?.uuid
  // Prisma reads `providerUuid: undefined` as no filter at all, so
  // without this check updateMany would change every domain
  if (data && typeof uuid === 'string') {
    await prisma.customDomain.updateMany({
      where: { providerUuid: uuid },
      data,
    })
  }
  res.sendStatus(200)
})

Don't skip the uuid check. Given where: { providerUuid: undefined }, Prisma drops the condition, so one malformed delivery would mark every domain in your table active. tls_certificate_issued sets httpsReady, which is what turns the dashboard badge to "Live". You'll also get events like domain_created and domain_deleted; the webhooks docs list them all. Respond with a 2xx quickly, because failed deliveries are retried, up to 10 times.

Remove a domain

Removing a domain takes two calls, and the order matters: the proxy first, your database second.

src/domains.js
export async function removeCustomDomain(domain) {
  await saasDomains.deleteDomain(domain.providerUuid)
  await prisma.customDomain.delete({ where: { id: domain.id } })
}

If the API call throws, the row is still there and you can try again. Delete the row first, and a failed call leaves an orphaned domain on the proxy that nothing in your app knows about.

Test custom domains locally

Testing the routing doesn't need a real domain. Chrome, Firefox and curl resolve any *.localhost name to your own machine, so add a domain like intercom.localhost to your database with status active and open http://intercom.localhost:3000. You'll see that company's status page, while http://localhost:3000 still shows your app.

You can play the proxy's part with curl:

curl http://localhost:3000/ \
  -H "X-Served-For: intercom.localhost" \
  -H "X-SaaS-Domains-Auth-Token: $PROXY_AUTH_TOKEN"

Drop the token header and you should get a 403.

The example app goes one step further in its end-to-end tests: Playwright uses the mock proxy as its HTTP proxy for *.test domains, which keeps Host, Origin and the forwarded headers production-like.

Frequently asked questions

How do I get the custom domain in Express?

Use req.hostname. It returns the hostname from the Host header, without the port. Behind SaaS Custom Domains, read req.get('X-Served-For') instead, after checking the X-SaaS-Domains-Auth-Token header.

Should I set trust proxy to true?

No, not if you choose tenants by hostname. With true, any client can set X-Forwarded-Host and change req.hostname. Trust only the load balancer in front of your app, by its address or subnet.

How do I support custom domains in Next.js?

Look up the hostname in proxy.js (middleware.js before Next.js 16) and rewrite customer domains to a route like /sites/[domain]. Behind a proxy, read the custom domain from x-served-for after the token check, and set x-forwarded-host to it in proxy.js so Server Actions accept the request. Our Next.js guide walks through all of it.

How do I know when a customer's domain is live?

Listen for webhooks. domain_dns_proxy_record_valid arrives once the customer's DNS points at us, and tls_certificate_issued once the certificate exists; after both, the domain serves your app over HTTPS. If a customer doesn't want to wait, a "Check DNS" button can call the verify_dns_records endpoint, which checks right away.

Can customers use a root domain?

Yes. A CNAME isn't permitted on a bare domain like intercomstatus.com, so the customer adds two A records for our static anycast IPs, 75.2.96.173 and 99.83.186.151. Subdomains use a CNAME to in.saascustomdomains.com.

Next steps

That's custom domains in Node.js: DNS pointing at the proxy, a table of domains, routing by hostname, certificates you never touch, and a check on the proxy's token. The custom-domains-express repo has all of it running, with tests, so you can copy what you need. When you're ready to scale it up, the full guide to custom domains for SaaS covers what changes. There are matching guides for Next.js, Ruby on Rails and Django.

Questions? Email us or use the chat on saascustomdomains.com. To try it on your own app, start a 7-day free trial or book a 15-minute demo.

More Articles

Cover Image for 45-Day Certificates: What Changes for SaaS Custom Domains
Drago Crnjac

45-Day Certificates: What Changes for SaaS Custom Domains

Let's Encrypt cuts certificates to 64 days on February 10, 2027. For platforms that issue certificates for customer domains, the real change is how little time you get to fix a customer's broken DNS.

Read more
Cover Image for Custom Domain SSL: 12 Reasons Not to Build It Yourself
Drago Crnjac

Custom Domain SSL: 12 Reasons Not to Build It Yourself

The first certificate takes an afternoon. Then come 45-day renewals, rate limits, static IPs for root domains, scanners, abuse, and a CDN you never meant to build.

Read more
Cover Image for How to Add Custom Domains to a Multi-Tenant Spring Boot App
Drago Crnjac

How to Add Custom Domains to a Multi-Tenant Spring Boot App

How to add custom domains to a Spring Boot app: finding the customer from the hostname, redirects and forms that stay on their domain, and HTTPS.

Read more