Framework guides
TanStack Start 1 (RC) custom domains guide
Versions
Example framework and runtime versions
- Node.js
- 24.20.0
- TanStack Start
- 1.168.50
- React
- 19.2.8
- TypeScript
- 7.0.2
A publishing app might start by giving every customer a page on your domain. When a customer wants to use their own address, your TanStack Start app needs to know which content to show there. That’s the feature we’ll work through in this guide.
You’ll begin with two local addresses that load different customers’ pages. We’ll then save those domains, connect DNS and HTTPS, and make the pages available online. We’ll also cover how to disconnect a domain when a customer removes it.
Start with your existing TanStack Start app, customer accounts, and login. You can adapt the examples to your own database and pages, with each step adding one part of the feature.
In this guide
1. Match a hostname to a customer locally
Every HTTP request includes a Host header such as atlas.test. Your server can use that value to choose which customer’s page to render.
Start with two development-only names and your existing page data. Put this server-side helper near your page loader:
The examples call a customer’s account a Tenant. Use your existing account, workspace, or organization model wherever you see that name.
// src/server/local-pages.ts
type Tenant = { id: number; name: string; headline: string }
const atlasTenant = {
id: 1, name: 'Atlas Press', headline: 'Field notes for curious teams',
}
const juniperTenant = {
id: 2, name: 'Juniper Journal', headline: 'Small stories, carefully published',
}
const localTenants = new Map<string, Tenant>([
['atlas.test', atlasTenant],
['juniper.test', juniperTenant],
])
export function hostnameFrom(request: Request) {
const host = request.headers.get('host') ?? ''
if (!/^[a-z0-9.-]+(?::\d{1,5})?$/i.test(host)) return ''
return host.replace(/:\d+$/, '').replace(/\.$/, '').toLowerCase()
}
export function tenantForLocalHost(request: Request) {
return localTenants.get(hostnameFrom(request)) ?? null
}
Wire it into a server function and your existing index route. The route’s not-found component can render your normal 404 page:
// src/server/public-page.ts
import { notFound } from '@tanstack/react-router'
import { createServerFn } from '@tanstack/react-start'
import { getRequest } from '@tanstack/react-start/server'
import { hostnameFrom, tenantForLocalHost } from './local-pages'
export const getPublicPage = createServerFn({ method: 'GET' })
.handler(() => {
const request = getRequest()
const hostname = hostnameFrom(request)
const tenant = tenantForLocalHost(request)
if (!tenant) throw notFound({ data: { hostname } })
return { tenant, hostname }
})
// src/routes/index.tsx
import { createFileRoute } from '@tanstack/react-router'
import { getPublicPage } from '../server/public-page'
export const Route = createFileRoute('/')({
loader: () => getPublicPage(),
component: () => {
const page = Route.useLoaderData()
return <main><h1>{page.tenant.name}</h1>
<p>{page.tenant.headline}</p></main>
},
notFoundComponent: () => <main><h1>Unknown domain</h1></main>,
})
Never fall back to the first customer: a typo or unknown domain must not expose someone else’s page.
Vite rejects unfamiliar Host values by default. Allow the three names used by this local checkpoint:
// vite.config.ts
import { defineConfig } from 'vite'
export default defineConfig({
server: {
host: '127.0.0.1',
port: 3000,
strictPort: true,
allowedHosts: ['atlas.test', 'juniper.test', 'unknown.test'],
},
// Keep your existing TanStack Start plugins here.
})
curl -H 'Host: atlas.test' http://127.0.0.1:3000/
curl -H 'Host: juniper.test' http://127.0.0.1:3000/
curl -i -H 'Host: unknown.test' http://127.0.0.1:3000/
Checkpoint: the first two requests show different customers, and the unknown hostname returns HTTP 404. You do not need DNS, HTTPS certificates, or a provider account yet.
2. Save each customer’s domains
Replace the temporary map with a table owned by your app. Each normalized hostname belongs to one signed-in customer and has a small local status: pending, active, or removing.
This SQLite schema keeps the example compact. Use your app’s normal database and migrations in production.
CREATE TABLE customer_domains (
id INTEGER PRIMARY KEY,
reservation_key TEXT NOT NULL UNIQUE,
tenant_id INTEGER NOT NULL REFERENCES tenants(id),
hostname TEXT NOT NULL UNIQUE,
status TEXT NOT NULL DEFAULT 'pending'
CHECK (status IN ('pending', 'active', 'removing'))
);
Normalize before the unique insert and before every lookup. Accept a hostname only: no scheme, port, path, wildcard, IP address, or local name. Convert internationalized names to ASCII and lowercase them.
import { isIP } from 'node:net'
import { domainToASCII } from 'node:url'
export function normalizeHostname(input: string) {
const raw = input.trim().replace(/\.$/, '')
if (!raw || raw.includes('://') || /[/?#:@*\s]/.test(raw)) {
throw new Error('Enter a hostname without a URL, port, or path.')
}
const hostname = domainToASCII(raw).toLowerCase()
const labels = hostname.split('.')
if (!hostname || hostname.length > 253 || isIP(hostname) ||
labels.length < 2 || hostname.endsWith('.localhost') ||
hostname.endsWith('.test') || labels.some((part) =>
part.length > 63 ||
!/^[a-z0-9](?:[a-z0-9-]*[a-z0-9])?$/.test(part))) {
throw new Error('Enter a valid public hostname.')
}
return hostname
}
Create reservation_key with randomUUID(). It remains stable for the life of this claim, even if your database later reuses a numeric row ID.
For management actions, derive tenant_id from the server-side session. A browser may submit a hostname or local row ID, but it must never choose the trusted tenant ID.
Replace the map lookup with a query that joins customer_domains.tenant_id to your customer table and requires status = 'active'. Seed atlas.test and juniper.test as explicit active fixtures only in development; they bypass the public-domain form and never reach DNS.
Checkpoint: two signed-in customers can save different domains, the same normalized hostname cannot be claimed twice, and step 1 now reads from this table.
3. Get the domain to your app over HTTPS
DNS is the public address book for hostnames. Give the customer the record supplied by the service that receives traffic for your app. That may be your hosting platform or a reverse proxy: a server that handles the public connection and passes the request to TanStack Start.
An apex is the root domain, such as example.com; docs.example.com is a subdomain. Use a stable IPv4 address and an A record for an apex. Many DNS providers cannot put a normal CNAME at the apex, so do not make CNAME flattening a requirement. Use a CNAME for subdomains, and add AAAA only when your receiving service offers stable IPv6.
- Register the customer hostname with the server or hosting service that receives public traffic.
- Show the exact A, AAAA, or CNAME value that service gives you. Do not copy an example IP into production.
- Have the customer add the record at their DNS provider.
- Wait until the receiver confirms both routing and a valid HTTPS certificate for that hostname.
- Mark the app-owned row
activewhen DNS reaches the receiver and HTTPS is ready.
DNS gets the request to the receiving server; the HTTP Host value selects the customer. HTTPS happens first, before TanStack Start sees the request, so the receiver must obtain and renew the certificate and forward the original Host value.
curl -I https://customer.example
You’ll probably want to check that DNS points directly or indirectly to the service receiving traffic for your app. Tools and managed services can make DNS setup and HTTPS much easier; see the bonus section below for one option.
Checkpoint: the customer domain reaches your receiver over HTTPS, preserves its Host value, and has moved from pending to active in your app.
4. Serve the customer’s page
Keep the index route from step 1 and replace only its server function body. Query an actual stored tenant through an active domain row, turn a miss into TanStack Router’s notFound(), and set conservative cache headers so one host’s HTML is never reused for another.
import { notFound } from '@tanstack/react-router'
import { createServerFn } from '@tanstack/react-start'
import { getRequest, setResponseHeader } from '@tanstack/react-start/server'
export const getPublicPage = createServerFn({ method: 'GET' })
.handler(async () => {
const request = getRequest()
const hostname = hostnameFrom(request)
setResponseHeader('Cache-Control', 'private, no-store')
setResponseHeader('Vary', 'Host')
const tenant = await domains.findActiveTenantByHost(hostname)
if (!tenant) throw notFound({ data: { hostname } })
return { tenant, hostname }
})
Keep login, billing, and domain management on your normal app hostname. Recheck the session and tenant access inside every mutation server function; matching a public Host only selects content and never authorizes an action.
Read the direct Host by default. Trust X-Forwarded-Host only when direct access to the app server is blocked and your known proxy overwrites and authenticates that header. Keep session cookies host-only, and build absolute links from a trusted app URL or the validated customer hostname.
For browser mutations, keep your existing CSRF protection. A practical JSON endpoint also checks that Origin exactly matches the configured control-panel origin.
Checkpoint: active customer domains render the right page; unknown, pending, and removing domains return HTTP 404; management remains on the normal app hostname.
5. Remove a domain safely
Removal has two phases. First change the local row to removing, which makes step 4 return 404 immediately. Keep the unique hostname reserved while your hosting layer removes its route and certificate.
The repository method names below stand for atomic updates in your app database: they check the signed-in tenant and current state as they write.
type DomainReceiver = {
detach(hostname: string): Promise<void>
isDetached(hostname: string): Promise<boolean>
}
async function removeDomain(tenantId: number, id: number) {
const saved = await domains.markRemovingIfOwned(tenantId, id)
if (!saved) throw new Error('Domain not found')
await receiver.detach(saved.hostname) // retry this step on failure
if (await receiver.isDetached(saved.hostname)) {
await domains.releaseIfStillRemoving(saved)
}
}
DomainReceiver is the boundary to your hosting platform; implement it with that platform’s API or operations workflow. A timeout is not confirmation, so leave the row in removing and retry.
Make the final release conditional on the same tenant, hostname, random reservation_key, and removing state you started with. A numeric row ID can be reused after deletion; the persistent random key prevents a delayed response from deleting or reactivating a newer reservation.
Checkpoint: removal stops public routing at once, retries safely after a temporary failure, and releases the hostname only after the receiver confirms it is detached.
Optional bonus: let Approximated handle DNS onboarding, HTTPS, and forwarding
The five steps above work with any hosting setup. If you do not want to build the hostname receiver and certificate automation yourself, Approximated can accept customer hostnames, check their connection, return DNS instructions, issue and renew certificates, and forward requests to your app.
Your app still owns customer authentication, the hostname reservation, page content, and the decision to activate. Here, a virtual host is simply the API record that maps one customer domain to your reachable app-server hostname.
Create the mapping on the server
Keep the API key in a server-only environment variable. Add an optional provider_id to your domain row, then call POST /api/vhosts after reserving the hostname:
import { z } from 'zod'
const virtualHost = z.object({
id: z.number().int().positive(),
incoming_address: z.string(),
status: z.string().optional(),
status_message: z.string().optional(),
user_message: z.string().optional(),
is_resolving: z.boolean().nullable().optional(),
apx_hit: z.boolean().nullable().optional(),
has_ssl: z.boolean().nullable().optional(),
})
const responseEnvelope = z.object({ data: virtualHost })
const response = await fetch(
'https://cloud.approximated.app/api/vhosts',
{
method: 'POST',
signal: AbortSignal.timeout(5_000),
headers: {
'api-key': process.env.APPROXIMATED_API_KEY!,
accept: 'application/json',
'content-type': 'application/json',
},
body: JSON.stringify({
incoming_address: hostname,
target_address: process.env.APP_ORIGIN_HOST,
target_ports: '443',
keep_host: true,
}),
},
)
if (!response.ok) {
throw new Error(`Create failed: ${response.status} ${await response.text()}`)
}
const { data } = responseEnvelope.parse(await response.json())
if (normalizeHostname(data.incoming_address) !== hostname) {
throw new Error('Unexpected virtual-host identity')
}
await domains.attachProviderIdIfStillPending(domain, data.id)
target_address must be a hostname that reaches your app server. keep_host: true preserves the customer Host for step 4. Validate the positive returned ID and matching normalized hostname before attaching them to the reservation. The final helper is a conditional database update that still requires this tenant, hostname, and pending row.
If creation times out, conflicts, or returns malformed data, keep the hostname reserved and unavailable. Do not search by hostname and adopt or delete whatever you find; its remote record may belong to someone else.
Use the returned instructions and status
Create and get responses wrap the record in { data: virtualHost }. Show user_message to the customer as plain DNS guidance. Approximated checks DNS and HTTPS automatically. A GET for the confirmed stored ID reads the latest saved result; it does not start a fresh check. The three monitoring values are optional and may be null, which means unknown:
is_resolvingmeans an HTTP response was observed for the hostname, even if it came from somewhere else.apx_hitmeans the request reached Approximated.has_sslmeans HTTPS is ready.
const response = await fetch(
`https://cloud.approximated.app/api/vhosts/${domain.providerId}`,
{
signal: AbortSignal.timeout(5_000),
headers: {
'api-key': process.env.APPROXIMATED_API_KEY!,
accept: 'application/json',
},
},
)
if (!response.ok) throw new Error(`Refresh failed: ${response.status}`)
const { data } = responseEnvelope.parse(await response.json())
if (data.id !== domain.providerId ||
normalizeHostname(data.incoming_address) !== domain.hostname) {
throw new Error('Unexpected virtual-host identity')
}
const ready = data.apx_hit === true && data.has_ssl === true
await domains.saveStatusIfCurrent(domain, data, ready ? 'active' : 'pending')
The schema validates the same positive ID and hostname on create and refresh, plus optional status strings and nullable boolean monitoring fields. Treat is_resolving as a useful reachability clue: it can be true when HTTP is answering somewhere else. apx_hit confirms traffic reached Approximated, and has_ssl confirms HTTPS is ready, so both must be true before routing.
Remove by the confirmed ID
Disable local routing first, then send DELETE /api/vhosts/{id}. A successful delete returns HTTP 200 with plain text, but removal still takes time. Keep the reservation and retry the same stored ID after a timeout or error.
Release the hostname only after GET /api/vhosts/{id} returns 404. Bind every delayed status update or release to the original tenant, hostname, random reservation key, provider ID, and local state so it cannot change a replacement record.
See the complete TanStack Start example
The snippets in this guide are intentionally small adaptation points. The companion repository contains the full TanStack Start integration, including two SQLite tenants, server-side sessions, Host routing, the HTTP client, lifecycle guards, and local tests.
git clone https://github.com/Approximated-Inc/tanstack-start-custom-domains-example.git
cd tanstack-start-custom-domains-example
npm ci
cp .env.example .env
npm run seed
npm run dev