setup-guides

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

DomainDefault pageNotes
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.com
  • write.cafe + www.write.cafe
  • graphene.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

apps/frontend/src/middleware.ts does the host-aware routing:

  • Strips www. so www.write.cafe and write.cafe behave 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)

apps/frontend/src/app/layout.tsx reads the request host 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. /api/icons/graphene and /api/icons/write-cafe — iOS home-screen tiles need real PNG so we generate one on demand.
  • OG share card — Next route returning a 1200×630 PNG. /api/og/graphene and /api/og/write-cafe — used as the default Open Graph + Twitter image when each brand is the request host.
  • 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's metadata export.

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:

  1. Add a if (host === 'newbrand.tld') { ... } block in middleware.ts.
  2. Decide what / should rewrite to.
  3. Optionally add named sub-paths (like the /sprints shortcut on write.cafe).
  4. Add the DNS + Vercel domain.
  5. Add the apex + www origins to PLATFORM_ORIGINS in apps/backend/src/services/request-origin.ts (backend CORS).
  6. 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/callback breaks email confirmation. See GOOGLE_OAUTH_SETUP.md and RESEND_SMTP_SUPABASE_SETUP.md.
  7. 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 /dashboard guard (apps/frontend/src/app/dashboard/page.tsxgetSession()router.push('/auth/signin')) bounces every signup straight back to sign-in. This is exactly how lovio.io shipped: 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 sees write.cafe.local, doesn't match the production hosts, falls through.
  • For full rewrite testing, modify the host check to also match the .local variants 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.rewrite with NextResponse.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 (WriteCafeHeaderBrand.tsx and GrapheneHeaderBrand.tsx) are the canonical example. Next.js Link handles absolute URLs as plain <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 feed
  • writeCafeOrigin() — cafe contest, sprints, writer profiles
  • seasonUrl(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.ts (FROM_ADDR)
  • apps/backend/src/services/season-letters.ts (RESEND_FROM_EMAIL env override)
  • apps/backend/src/services/graphene-subscribers.ts (RESEND_FROM_EMAIL env override)
  • apps/backend/src/services/jq-bridge/invitation-email.ts (RESEND_FROM_EMAIL env override)
  • apps/backend/src/routes/email.ts (RESEND_FROM_EMAIL env 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):

  1. Resend dashboard → Domains → Add domain. Resend will show 3 DKIM CNAMEs + 1 SPF TXT (or include) + 1 optional DMARC TXT.
  2. 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 — see EMAIL_DELIVERABILITY.md)
  3. Verify in Resend (usually minutes; can take hours).
  4. Decide reply handling before any user reply lands. Options: forwarding (hello@graphene.fm → personal inbox), Resend inbound webhook, or an auto-responder pointing at hello@hivejournal.com.
  5. Railway env vars:
    • RESEND_FROM_GRAPHENE=Graphene <hello@graphene.fm>
    • RESEND_FROM_WRITECAFE=write.cafe <hello@write.cafe>
    • keep RESEND_FROM_EMAIL as the HiveJournal default fallback
  6. Backend wiring — add a pickFromAddress({ surface }) helper in apps/backend/src/services that returns the right env var (graphene-subscribers / season-letters → graphene; write.cafe-specific sends → writecafe; everything else → default). Replace the per-file FROM_ADDR constants with calls to it.
  7. 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.
  8. Verify with Mail-Tester and MXToolbox — same checks as EMAIL_DELIVERABILITY.md, just per new domain.
  9. Tighten DMARC to p=quarantine then p=reject after a few weeks of clean reports.

What stays the same: Resend account, Railway, the Node service. Only the FROM addresses + DNS change.

MULTI BRAND DOMAINS — Docs | HiveJournal