Appearance
Domain resolution and multi-tenancy
One Next.js deployment and one Laravel API serve every customer event, each on its own domain, each with entirely different settings, branding and content.
There is no per-event build, no per-event deployment and no EVENT_ID baked into a bundle. A request's Host header decides which event it belongs to, and everything downstream follows from that one lookup.
The whole idea in one diagram
Three domains, three completely different-looking events, one running process. The difference between them is a row in event_domains and the configuration hanging off the event id it points to.
A request, end to end
The important step is 8: the middleware forwards the resolved id on the request headers so server components reuse it rather than resolving again. One lookup per request, not one per component.
The three ways an event is resolved
getEventId() in lib/event.ts tries these in order:
| Order | Source | Used for |
|---|---|---|
| 1 | EVENT_ID environment variable | Local development, single-event deploys |
| 2 | x-resolved-event-id request header | Every normal request — set by the middleware |
| 3 | Host header → /resolve-event | Fallback when the header is absent |
When none resolve, DomainNotConnectedError is thrown and the layout renders the "event not connected with this domain" page instead of a 500.
That page is a diagnosis, not a crash
Seeing it means the host reached the app but no active row matched. Check the domain's status in the admin, and check for a www. or port mismatch — see host normalisation below.
Host normalisation
The frontend and the API normalise identically, and they must stay in step. middleware.ts and ResolveEventController::normalizeHost() both:
- Lowercase and trim.
- Strip a leading
www.— sowww.run.example.comandrun.example.comare one event. - Strip default ports
:443and:80. Browsers omit them, but some proxies attach them, and aHost: togosg61.togoparts.com:443that failed to match its stored row once took a live event to the not-connected page. - Keep non-default ports. A dev site stored as
128.199.72.46:3000must match host and port exactly.
Caching, and why it is layered
Resolution runs on every request, so it is cached in three places:
| Layer | TTL | Purpose |
|---|---|---|
| WL API response header | 60s | Cache-Control: public, max-age=60 |
| Middleware in-memory map | 60s | Per host, on the Edge runtime |
Server-side cache (lib/serverCache) | 60s | Config, pages and meta, keyed by event |
The practical consequence: a newly activated domain goes live within about a minute, and so does a version switch. Neither is instant, and neither needs a deploy.
A hard browser reload (Cache-Control: no-cache) bypasses the server-side TTL cache, which is the fastest way to confirm a change without waiting.
Failing softly
The resolver is on the critical path for every page, so it is deliberately forgiving:
- 404 from
/resolve-eventis terminal — genuinely no active domain. The stale mapping is dropped. - 429 or 5xx is transient — retried once, then it falls back to the last known good mapping rather than dropping the header.
Losing the header would render the not-connected page on a live event, which is far worse than serving a slightly stale mapping.
/resolve-event is also exempted from the default rate limiter and uses the generous public-read limiter. The default 60/minute was being exhausted by a single active visitor, and the 429s surfaced as the not-connected page.
The domain lifecycle
A domain moves through four states, driven from Setup → Domain in the admin panel.
What each step does:
POST /events/{id}/domaincreates the row withstatus='pending', a TXT verification token, anda_record_ipfrom the admin backend'sSERVER_IPconfig.- The customer adds two DNS records at their provider — an A record pointing at that IP, and the TXT record proving ownership.
verify-txtlooks for the TXT record →txt_verified.verify-dnsconfirms the A record resolves to one of the acceptedSERVER_IPS→active.
Only active resolves. This is why a domain that "looks connected" in DNS still shows the not-connected page: DNS is only half of it.
The table
event_domains
id, event_id, domain,
status ENUM(pending, txt_verified, active, failed),
version_key,
txt_record_name, txt_record_value, a_record_ip,
txt_verified_at, dns_verified_at, last_checked_at,
created_at, updated_at
UNIQUE(domain) -- one host maps to exactly one event
INDEX(event_id) -- UNIQUE(event_id) was dropped: an event may have MANY domainsThe asymmetry is the point. A host resolves to exactly one event, or the whole model collapses. An event may own several hosts — a .com and a .sg, or a short campaign domain alongside a descriptive one — each verified independently.
Telling the server about a new domain
Resolution is dynamic, but Apache still needs a vhost and a certificate for a new hostname. That is the one place the admin side calls the WL side directly.
WlDeployService::syncDomains() is deliberately best-effort: 5s connect, 20s total, and a failure never blocks the admin action. The domain row is already correct in the database; the vhost can be synced again.
Two different failure modes, often confused
Resolution is dynamic and needs no deploy — an activated domain resolves within a minute.
Serving needs a vhost and a certificate, which the deploy agent creates.
So a domain can be active and resolving correctly while still failing in a browser because Apache has no vhost for it. If the domain works over the resolver but not in a browser, retry the sync.
Versions per domain
event_domains.version_key (default v3) decides which WL version serves that domain. The deploy agent reads it when regenerating the vhost, and points the proxy at the instance for that version.
This is why version is a per-domain property rather than a per-event or global one: it is the vhost's upstream, and the vhost is per domain.
Full background, including the open questions with the WL host team, is in the multi-version hosting brief.
How one codebase produces different sites
Once event_id is known, everything else is data. Nothing about a customer's event lives in the code.
Two events on the same deployment can differ in mode, theme, page structure, registration flow, payment account and email identity — while running identical code.
The corollary matters more. Because there is one deployment, a change to tga-v3-wl-web reaches every live event at once. There is no gradual rollout. Anything conditional on an event's configuration must default safely for events that have not configured it.
Two other things the middleware does
Event resolution is its first job, not its only one.
Admin gate. /admin/* on an event site is the challenge-management dashboard. It is password protected only when that event has actually set credentials. The check fails closed — if the API cannot be reached and there is no cached answer, it assumes protected. The dashboard exposes donor names and amounts, so a blip must never open the door.
Custom URL patterns. An admin can define a pattern such as /fundraiser/[id] that renders the user_detail page. The middleware rewrites /fundraiser/123 to /user/123, or issues a 301 where the pattern has moved. It only runs for two-segment numeric paths Next.js would otherwise 404, so the normal request path stays cheap.
Debugging a domain
Work down this list in order:
- Is there a row, and is it
active? Setup → Domain in the admin. - Does the resolver agree?
curl "https://wl-api.togoparts.com/api/v1/resolve-event?host=<host>"— a 404 means no active row for that exact host. - Is it a normalisation mismatch? Check
www., and check for a stored port. - Has DNS actually propagated? The A record must point at an accepted server IP.
- Does Apache have a vhost? If the resolver returns the right event but the browser does not load, re-run the domain sync.
- Is it just cache? Wait 60 seconds, or hard-reload.
Read next
- How the WL side works — what happens after resolution
- Multi-version hosting brief — versions in depth
- WL API and WL frontend — the full service docs