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 your servers, you save the domain against their account, and a middleware looks up the tenant from req.hostname. The hard part comes after that: 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 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 Server Actions that reject requests from custom domains.
In this guide:
- How custom domains reach your Node.js app
- Store your customers' domains
- Route custom domain requests to the right tenant
- Using Next.js?
- Add HTTPS for every custom domain: Caddy or a managed proxy
- Set it up with SaaS Custom Domains (optional)
- Test custom domains locally
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, on a server with the IP 192.0.2.1. One of its customers, Intercom, wants its status page on status.intercom.com.
Intercom's DNS has to send that hostname to you. A subdomain gets a CNAME to a hostname you own, ideally a dedicated one like in.statuspage.com, so you can move servers later without asking anyone to change anything. A root domain like intercomstatus.com can't have a CNAME, so it needs an A record with your IP, or an ALIAS record on DNS providers that support one:
; Subdomain
status.intercom.com. CNAME in.statuspage.com.
; Root domain
intercomstatus.com. A 192.0.2.1Whatever IP goes into those A records ends up in your customers' DNS, and you can't change it without asking every one of them. Use a static IP, or better, anycast IPs in front of a load balancer. Our guide to custom domains for SaaS covers the DNS side in more detail.
With DNS in place, requests for status.intercom.com arrive at your Node.js server with Host: status.intercom.com.
Store your customers' domains
A CustomDomain table maps each domain to a tenant, with a status so you only serve domains that are actually pointed at you. The examples use Prisma, but any ORM works. A Company is the tenant:
model CustomDomain {
id Int @id @default(autoincrement())
host String @unique
status String @default("pending") // "pending" or "active"
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.
Normalize every domain before you save it. Node.js has a built-in that lowercases a hostname and converts international domain names to punycode:
import { domainToASCII } from 'node:url'
const OUR_DOMAIN = 'statuspage.com'
export function normalizeHost(input) {
const host = domainToASCII(String(input).trim().replace(/\.$/, ''))
if (!/^[a-z0-9-]+(\.[a-z0-9-]+)+$/.test(host)) return null
// Don't let customers claim admin.statuspage.com
const ours = host === OUR_DOMAIN || host.endsWith(`.${OUR_DOMAIN}`)
if (ours) return null
return host
}domainToASCII returns an empty string for anything that isn't a valid domain, so the regex check catches that too.
Check that the DNS points at you
Keep new domains pending until their DNS points at you. node:dns can check both record types:
import { resolve4, resolveCname } from 'node:dns/promises'
const CNAME_TARGET = 'in.statuspage.com'
const OUR_IPS = new Set(['192.0.2.1'])
async function lookup(resolve, host) {
try {
return await resolve(host)
} catch {
return []
}
}
export async function pointsToUs(host) {
const cnames = await lookup(resolveCname, host)
if (cnames.some((name) => name.toLowerCase() === CNAME_TARGET)) {
return true
}
const ips = await lookup(resolve4, host)
return ips.some((ip) => OUR_IPS.has(ip))
}Check new domains every few minutes until they pass, then switch them to active. Keep checking active domains once a day, because customers change their DNS without telling you.
Route custom domain requests to the right tenant
Every request includes a Host header with the domain the visitor typed, and Express gives you the hostname without the port as req.hostname. Requests on a customer's domain go to their own router, so they never reach your app's dashboard or API:
import express from 'express'
import { prisma } from './db.js'
import { getStatusData } from './status.js'
const APP_HOSTS = new Set(['app.statuspage.com', 'localhost'])
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
next()
})
router.get('/', async (req, res) => {
const data = await getStatusData(req.company.id)
res.render('status-page', { company: req.company, data })
})
// 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)
}import express from 'express'
import { customDomains } from './custom-domains.js'
import { appRoutes } from './routes.js'
const app = express()
app.use(customDomains)
app.use(appRoutes)
app.listen(3000)A few notes:
- Unknown and pending domains get a 404, so pointing a random domain at your IP shows nothing.
- Express 5 forwards errors from async middleware to your error handler. On Express 4, wrap the lookup in
try/catchand callnext(err). hostFromis the one place that reads the hostname. If you put a proxy in front later, it's the only function you change.
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. Since you're choosing the tenant by hostname, only trust proxies you actually run, by address:
// Caddy on the same machine
app.set('trust proxy', 'loopback')You'll want this setting either way: without it, req.secure is false behind a TLS-terminating proxy, and express-session with cookie: { secure: true } silently never sets the session cookie.
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:
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. The rest of this guide applies as is: HTTPS, the proxy header and the token check.
One Next.js-specific catch: Server Actions compare the browser's Origin with the host from x-forwarded-host or host, and reject the action if they differ (docs). If a proxy in front rewrites the host, forms on custom domains break. allowedOrigins is a fixed list, so the fix is having the proxy forward the customer's domain in x-forwarded-host (more below).
Add HTTPS for every custom domain
At this point a customer's domain reaches your app, but only over plain HTTP. Open https://status.intercom.com and the browser stops at a certificate warning, because nothing has a certificate for that name yet.
Certificates are free from Let's Encrypt. What you need is something that requests one when a new domain appears, renews it every couple of months, and terminates TLS before traffic reaches Node.js: a TLS-terminating reverse proxy.
You could do it inside Node.js, with tls.createServer's SNICallback and an ACME client from npm. Most teams don't, because a certificate bug then takes down the app process too. The two realistic options are below.
Option 1: Caddy with on-demand TLS
Caddy's on-demand TLS fetches a certificate the first time a browser connects with a hostname it hasn't seen. Left unchecked, that lets anyone point a domain at your server and run up your Let's Encrypt rate limits, so Caddy first asks your app whether the domain is one of yours:
{
on_demand_tls {
ask http://localhost:3000/caddy/ask
}
}
https:// {
tls {
on_demand
}
reverse_proxy localhost:3000
}The ask endpoint returns 200 for active domains and 404 for everything else. Caddy calls it on localhost, so it belongs with your app's own routes:
appRoutes.get('/caddy/ask', async (req, res) => {
const host = String(req.query.domain ?? '').toLowerCase()
const known = await prisma.customDomain.findFirst({
where: { host, status: 'active' },
})
res.sendStatus(known ? 200 : 404)
})Caddy passes the original Host header through, so req.hostname is the customer's domain and the routing above works as is.
That's enough for one server. Running it across several adds shared certificate storage, a fallback certificate authority, renewal monitoring and abuse handling, and our custom domains guide walks through each.
Option 2: a managed proxy
If you'd rather not run that yourself, put a managed proxy in front of your app instead. That's our product, and the rest of this guide sets it up with SaaS Custom Domains ($29/month covers your first 100 domains).
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:
- Create an API token (1 minute)
- Create an upstream for your Node.js app (1 minute)
- Add custom domains from your app (5 minutes)
- Point the customer's DNS at us (1 minute)
- 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.

Then copy your account UUID from account settings:

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:
const BASE = 'https://app.saascustomdomains.com/api/v1/accounts'
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),
})
if (!res.ok) {
const detail = await res.text()
throw new Error(`SaaS Custom Domains ${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`),
deleteDomain: (uuid) => request('DELETE', `${domainsPath()}/${uuid}`),
}You only create the upstream once, so do it from a one-off script:
import { saasDomains } from '../src/saas-domains.js'
console.log(await saasDomains.createUpstream('app.statuspage.com'))
// {
// 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. 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:
import { prisma } from './db.js'
import { normalizeHost } from './hosts.js'
import { saasDomains } from './saas-domains.js'
export async function addCustomDomain(company, input) {
const host = normalizeHost(input)
if (!host) throw new Error('Invalid domain')
// Throws if another company already has this host
const domain = await prisma.customDomain.create({
data: { host, companyId: company.id },
})
const created = await saasDomains.createDomain(host, {
// Whoever manages their DNS gets step-by-step instructions
instructions_recipient: company.dnsContactEmail,
})
return prisma.customDomain.update({
where: { id: domain.id },
data: { providerUuid: created.uuid },
})
}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. The custom domains API docs list the other fields.
4. Point the customer's DNS at us
Your customer adds one of these:
; Subdomain
status.intercom.com. CNAME in.saascustomdomains.com.
; Root domain
intercomstatus.com. A 75.2.96.173
intercomstatus.com. A 99.83.186.151Tell customers to delete any existing A records for the domain first; otherwise some visitors keep landing on the old server. If you'd rather customers never see our hostname, you can white-label the CNAME target as in.statuspage.com. 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.

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
Our proxy forwards requests with Host set to your own hostname, app.statuspage.com, so your hosting setup routes them like any other request. The customer's domain travels in X-Served-For instead.
That's why everything reads the hostname through hostFrom. Update it to prefer the header:
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:
import { timingSafeEqual } from 'node:crypto'
export function safeEqual(given, expected) {
const a = Buffer.from(String(given ?? ''))
const b = Buffer.from(String(expected))
return a.length === b.length && timingSafeEqual(a, b)
}import { safeEqual } from './safe-equal.js'
// Register before customDomains
app.use((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()
})Store the token like any other secret, and rotate it from the dashboard if it leaks.
Forms and Server Actions
For most Express apps, that's it: res.redirect('/path') sends a relative URL, so visitors stay on the custom domain. Two things still see your own hostname behind the proxy:
- Absolute URLs you build from the request, such as
${req.protocol}://${req.get('host')}in emails or OAuth callback URLs. Build them from the custom domain you looked up instead. - Next.js Server Actions, which reject requests where the browser's
Origindoesn't match the host.
For Next.js, email support and we'll pass the custom domain through in X-Forwarded-Host for your account. Next.js reads it for its Server Actions check, so forms on custom domains work without an allowedOrigins list. Express apps usually don't need this: use the domain from X-Served-For.
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:
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:
const STATUS_BY_EVENT = {
domain_dns_proxy_record_valid: 'active',
domain_dns_proxy_record_invalid: 'pending',
}
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)
}
const status = STATUS_BY_EVENT[req.body.event]
if (status) {
await prisma.customDomain.updateMany({
where: { providerUuid: req.body.domain.uuid },
data: { status },
})
}
res.sendStatus(200)
})You'll also get events like domain_created, domain_deleted and tls_certificate_issued (handy for an "HTTPS ready" badge); 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.
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. With your own Caddy setup there's no API call at all: once the row is gone, the ask endpoint returns 404 and the certificate is never renewed.
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.
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 proxies you run, by address, for example 'loopback' for Caddy on the same machine.
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 its header and make sure x-forwarded-host carries it, or Server Actions fail.
Can Node.js issue SSL certificates for custom domains itself?
Yes, with tls.createServer and an SNICallback that loads certificates from an ACME client. It works, but you then own issuance, renewals, storage and rate limits inside your app process. Caddy or a managed proxy is usually simpler.
Can customers use a root domain?
Yes. A root domain can't have a CNAME, so the customer adds A records with your static IPs, or an ALIAS record or flattened CNAME where their DNS provider supports it.
Next steps
That's custom domains in Node.js: DNS pointing at you, a table of domains, routing by hostname, HTTPS, and a check on the proxy's token. When you're ready to scale it up, the full guide to custom domains for SaaS covers what changes. There are matching guides for 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.


