Multi-brand domains — write.cafe + graphene.fm + hivejournal.com
One Next app, three branded front doors. Visitors land on a brand domain that matches the surface they came for; the URL bar stays on that domain even as they navigate within the surface. Out-of-surface paths on a branded domain pass through (no redirect to the canonical), so a writer who signs in via write.cafe can keep using every part of the platform without bouncing.
Brand → surface mapping
| Domain | Default page | Notes |
|---|---|---|
hivejournal.com | / (canonical app) | Umbrella — every surface available |
write.cafe | /write-cafe (rewritten from /) | Writers' room. /sprints → /write-cafe/sprints |
graphene.fm | /graphene (rewritten from /) | Audio-fiction network. /seasons/* first-class native |
Rewriting (not redirecting) means the visitor's URL bar reads write.cafe/... even though the response comes from the same Next app — preserving the brand on share links.
DNS
Each branded domain needs both apex + www configured:
write.cafe CNAME cname.vercel-dns.com
www.write.cafe CNAME cname.vercel-dns.com
graphene.fm CNAME cname.vercel-dns.com
www.graphene.fm CNAME cname.vercel-dns.com
If a registrar doesn't support apex CNAME (some do not), use Vercel's
A record 76.76.21.21 for the apex and CNAME for www. Vercel's
domain UI tells you which to use during onboarding.
Vercel project
In Project Settings → Domains, add:
hivejournal.com(already set; primary)www.hivejournal.comwrite.cafe+www.write.cafegraphene.fm+www.graphene.fm
Vercel auto-provisions TLS via Let's Encrypt for each. Verify the domain in Vercel before pointing DNS — they'll provide the exact records to use.
Middleware behavior
does the host-aware routing:apps/frontend/src/middleware.ts
- Strips
www.sowww.write.cafeandwrite.cafebehave the same. - On
write.cafe:/→/write-cafe;/sprints→/write-cafe/sprints. Everything else passes through. - On
graphene.fm:/→/graphene. Everything else passes through. - On
hivejournal.com(or any unknown host): no rewrite.
The matcher excludes /api, /_next/*, image assets, and common static file extensions so the middleware doesn't run on every static fetch.
Per-host metadata (favicon, apple-touch-icon, OG share card, title)
reads the request apps/frontend/src/app/layout.tsxhost header in generateMetadata and switches all brand-sensitive metadata in one place via the BRAND_META map:
- Favicon — SVG in
(apps/frontend/public/favicons/graphene.svg,write-cafe.svg). - Apple-touch-icon — Next route returning a 180×180 PNG via
@vercel/og.and/api/icons/graphene— iOS home-screen tiles need real PNG so we generate one on demand./api/icons/write-cafe - OG share card — Next route returning a 1200×630 PNG.
and/api/og/graphene— used as the default Open Graph + Twitter image when each brand is the request host./api/og/write-cafe - Title / description / siteName / twitterCreator — branded copy per host (e.g. "Graphene — Serialized fiction. Audio-first." instead of the HiveJournal default).
Vercel's HTML cache varies by host header by default, so cached pages on each domain ship the right <link rel="icon">, <link rel="apple-touch-icon">, and OG meta tags without us forcing the route dynamic.
Adding a fourth brand = one entry in BRAND_META plus matching route handlers under /api/icons/<brand> and /api/og/<brand>. The pickBrand(host) resolver is the only call site that needs to know about new domains.
Per-page metadata exports (e.g. on /seasons/[id]) still take precedence over the layout default — branded metadata is the FALLBACK, not an override of show-specific share cards.
Canonical URL handling
Because rewrites preserve the host, Open Graph + canonical metadata can specify any host you want. Default behavior:
- Pages don't currently emit explicit canonical URLs — they inherit the requesting host. Share links show whichever brand the user copied from.
- If you want to force canonical to
hivejournal.com(so SEO juice consolidates), add<link rel="canonical">in the affected page'smetadataexport.
Adding a fourth brand domain
The middleware is where the routing lives, but a new brand domain is not done until auth works on it too. Do all of these in one change:
- Add a
if (host === 'newbrand.tld') { ... }block inmiddleware.ts. - Decide what
/should rewrite to. - Optionally add named sub-paths (like the
/sprintsshortcut on write.cafe). - Add the DNS + Vercel domain.
- Add the apex +
wwworigins toPLATFORM_ORIGINSinapps/backend/src/services/request-origin.ts(backend CORS). - Add the domain to Supabase Auth → URL Configuration → Redirect URLs as a wildcard —
https://newbrand.tld/**+https://www.newbrand.tld/**(one/**per host covers OAuth/auth/callback, email-confirm/dashboard, password reset/auth/reset-password, and any future flow). Enumerating only/auth/callbackbreaks email confirmation. See GOOGLE_OAUTH_SETUP.md and RESEND_SMTP_SUPABASE_SETUP.md. - Add the domain to Google Cloud OAuth → Authorized domains (for the Google sign-in path).
⚠️ Steps 6–7 are dashboard config, not code — they're the ones that get forgotten, and the failure is silent. A domain wired through steps 1–5 looks live: it serves pages, the landing renders, signup forms submit. But auth initiated on a host missing from the Supabase allowlist falls back to the hivejournal.com Site URL, the session never lands on the originating host, and the client-side
/dashboardguard (apps/frontend/src/app/dashboard/page.tsx—getSession()→router.push('/auth/signin')) bounces every signup straight back to sign-in. This is exactly howlovio.ioshipped: it became the #1 signup source while 100% of its signups bounced and 0 activated, invisible because graphene.fm signups land on the public/graphene(no auth guard) and so never exposed the break. Verify a new domain by completing a real signup on it and confirming you reach the gated dashboard — not just that the landing page loads.
Testing locally
The middleware uses the Host header. Local Next dev defaults to localhost:3000, which falls through to the canonical-app branch. To test the rewrites locally:
- Add hosts-file entries:
127.0.0.1 write.cafe.local graphene.fm.local - Run
next dev -H 0.0.0.0 -p 3000 - Visit
http://write.cafe.local:3000— middleware seeswrite.cafe.local, doesn't match the production hosts, falls through. - For full rewrite testing, modify the host check to also match the
.localvariants during dev, or test on a Vercel preview deployment with a domain alias.
What this doesn't do
- It does NOT 301/302 redirect; it rewrites. If you want a brand domain to redirect to the canonical, replace
NextResponse.rewritewithNextResponse.redirect. - It does NOT enforce a canonical host for SEO. That's a metadata concern.
- It does NOT do path-mapping for arbitrary paths. Only
/(and the named shortcuts in the middleware) rewrite. Everything else passes through to the underlying route.
Cross-brand navigation — always use absolute URLs
A relative <Link href="/"> on write.cafe does NOT take you to hivejournal.com; it routes through the host-aware middleware and lands you back on /write-cafe. Same trap on graphene.fm.
Rule: any link that switches brands must be an absolute URL.
// ❌ Wrong — on write.cafe, this loops back to write.cafe/write-cafe
<Link href="/">HiveJournal</Link>
// ✅ Right — actually crosses domains
<Link href="https://www.hivejournal.com">HiveJournal</Link>
The brand-switcher dropdowns ( and WriteCafeHeaderBrand.tsx) are the canonical example. Next.js Link handles absolute URLs as plain GrapheneHeaderBrand.tsx<a> tags (no prefetch, same-tab nav) so the swap from relative → absolute requires no other changes.
Vercel-preview URL legacy redirect
apps/frontend/src/middleware.ts 308-redirects any *.vercel.app host to the canonical brand domain (path-aware: /seasons/* → graphene.fm, /write-cafe/* → write.cafe, else hivejournal.com). This catches email links that shipped before the URL helpers in apps/backend/src/utils/site-urls.ts got tightened — but only on deployments that include this middleware. Old preview deployments serve old code; they age out via Vercel GC or can be deleted manually.
Backend canonical URL helpers
apps/backend/src/utils/site-urls.ts is the source of truth for any URL that ships in an email, RSS feed, or other long-lived inbox content:
hivejournalOrigin()— umbrella surfaces (account, dashboard, earnings)grapheneOrigin()— season pages, listener letters, podcast feedwriteCafeOrigin()— cafe contest, sprints, writer profilesseasonUrl(seasonId, { episodeNumber? })— graphene-context permalink helper
All three reject *.vercel.app hosts and have hard-coded canonical fallbacks so a missing or misconfigured env var degrades to a working production URL — not to a preview alias or localhost. Do not use process.env.ALLOWED_ORIGINS to build user-facing URLs — that's a CORS allowlist that includes preview hosts.
CORS — platform brand origins are always allowed
apps/backend/src/index.ts hard-codes the six canonical brand origins (apex + www each) into the CORS allowlist via PLATFORM_ORIGINS, prepended to ALLOWED_ORIGINS. A missing or truncated env var on Railway no longer breaks fetch calls from write.cafe / graphene.fm to the backend. ALLOWED_ORIGINS still works for additional origins (preview URLs, dev hosts, partner embeds) — it's just no longer the only path.
Email — current state and "graduating to per-brand sending"
Today every transactional email is sent from hivejournal.com with brand-aware display names — Graphene <noreply@hivejournal.com>, HiveJournal <noreply@hivejournal.com>, etc. Call sites:
(apps/backend/src/services/season-subscribers.tsFROM_ADDR)(apps/backend/src/services/season-letters.tsRESEND_FROM_EMAILenv override)(apps/backend/src/services/graphene-subscribers.tsRESEND_FROM_EMAILenv override)(apps/backend/src/services/jq-bridge/invitation-email.tsRESEND_FROM_EMAILenv override)(apps/backend/src/routes/email.tsRESEND_FROM_EMAILenv override)
DKIM/SPF/DMARC for hivejournal.com is documented in docs/reference/EMAIL_DELIVERABILITY.md. Railway plays no role beyond running the Node process that calls Resend's API.
Why we're staying single-domain for now: "Brand noreply@parent.com" is the standard pattern (Stripe products, Notion, Atlassian, Square). Splitting to per-brand sending before each brand has real sustained volume means warming three reputations from zero with nothing to gain in deliverability. The user-visible display-name + email-body branding is already correct.
When to revisit: when a brand crosses ~100 active subscribers on its own list, or before any cold/marketing send from that domain (cold mail from a parent-domain return-path is more flag-y than transactional). Track that as a product task — /tasks add "Graduate graphene.fm to per-brand sending" --category "Monetization" --priority P3 once you're close.
Per-brand graduation checklist (per domain — graphene.fm and write.cafe each):
- Resend dashboard → Domains → Add domain. Resend will show 3 DKIM CNAMEs + 1 SPF TXT (or include) + 1 optional DMARC TXT.
- Registrar DNS:
resend._domainkey.<brand>CNAME →[resend-provided]<brand>TXT →v=spf1 include:_spf.resend.com ~all_dmarc.<brand>TXT →v=DMARC1; p=none; rua=mailto:dmarc@hivejournal.com(start relaxed; tighten later — seeEMAIL_DELIVERABILITY.md)
- Verify in Resend (usually minutes; can take hours).
- Decide reply handling before any user reply lands. Options: forwarding (
hello@graphene.fm→ personal inbox), Resend inbound webhook, or an auto-responder pointing athello@hivejournal.com. - Railway env vars:
RESEND_FROM_GRAPHENE=Graphene <hello@graphene.fm>RESEND_FROM_WRITECAFE=write.cafe <hello@write.cafe>- keep
RESEND_FROM_EMAILas the HiveJournal default fallback
- Backend wiring — add a
pickFromAddress({ surface })helper inthat returns the right env var (graphene-subscribers / season-letters → graphene; write.cafe-specific sends → writecafe; everything else → default). Replace the per-fileapps/backend/src/servicesFROM_ADDRconstants with calls to it. - Warm-up window — 1 to 2 weeks. New sending domains start at zero reputation; deliverability often dips before stabilizing. Don't ship a campaign blast as the first send. Send transactional volume first (subscriber confirmations, listener letters) for a week, then expand.
- Verify with Mail-Tester and MXToolbox — same checks as
EMAIL_DELIVERABILITY.md, just per new domain. - Tighten DMARC to
p=quarantinethenp=rejectafter a few weeks of clean reports.
What stays the same: Resend account, Railway, the Node service. Only the FROM addresses + DNS change.