Files
QR-master/PLAN_APP_SUBDOMAIN_2026-08-12.md
Timo Knuth 53ef4b3b91 Serve the app on app.qrmaster.net, marketing on www
Splits the two hostnames across one deployment. No files move: the Next app still
serves every route on both hosts, and the middleware decides per host which paths it
owns and 301s the rest. /login and /signup stay on www - all 82 marketing CTAs point
at /signup, which carries a hard canonical to www plus ad traffic.

src/lib/hosts.ts is the single source of truth for the boundary (APP_PATH_PREFIXES,
isAppPath, wwwUrl, appUrl, urlForPath). The middleware and every absolute-URL builder
read from it so they cannot drift apart.

- Split the overloaded NEXT_PUBLIC_APP_URL into a www and an app origin. It previously
  fed both public URLs and in-app URLs, so any single value was wrong somewhere. Most
  important: QRCodeCard encodes this origin into the QR code the user downloads and
  prints, so it must stay on www.
- Route Stripe return URLs, email links and OAuth redirects per path rather than
  against one origin, so /dashboard lands on app and /pricing on www.
- Cross the host boundary once, after a successful login: the router cannot push across
  origins, so that jump needs a full load. The user arrives signed in because the
  session cookie is scoped to COOKIE_DOMAIN.
- Keep the app host out of search indexes: X-Robots-Tag on every response plus a
  Disallow-all robots.txt via rewrite, and /sitemap.xml redirects to www.
- Point the TikTok callback fallback at www explicitly. It used to read
  NEXT_PUBLIC_APP_URL, whose meaning changed here, and only the apex domain is
  verified with TikTok.

Host splitting is inert while both origins are equal, so development is unaffected.

Verified: tsc clean, production build succeeds including the Edge middleware bundle,
and the path-to-host mapping is unit-checked (prefix traps like /created and
/settings-guide stay on www, query strings do not break matching).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-12 19:44:08 +02:00

13 KiB
Raw Permalink Blame History

Plan: Dashboard auf app.qrmaster.net

Stand: 2026-08-12 · Ziel: die eingeloggte App liegt auf app.qrmaster.net, Marketing/SEO bleibt auf www.qrmaster.net.

Status

Schritt Stand
B1 Cookie-Domain committed + gepusht (35ea8cc)
B2B7 Code fertig, typecheck + Production-Build grün, noch nicht deployt
A4 Google Console erledigt (beide Redirect-URIs eingetragen)
A1 DNS, A2 Caddy, A3 .env, A6 Deploy offen bei Timo

Deploy-Reihenfolge unverändert: B1 zuerst allein live und einen Tag beobachten, dann B2B7.

Neu gegenüber dem ursprünglichen Plan: src/lib/hosts.ts ist die einzige Quelle der Wahrheit für die Host-Grenze (APP_PATH_PREFIXES, isAppPath, wwwUrl, appUrl, urlForPath). Middleware, Stripe-Rückkehr-URLs und E-Mail-Links lesen alle daraus, damit sie nicht auseinanderdriften.

Zielarchitektur

Ein Docker-Image, ein Container, zwei Hostnames. Caddy routet www.qrmaster.net und app.qrmaster.net auf denselben Upstream. Die Middleware macht Host-basiertes Routing.

Wichtig: keine Datei zieht um. Die Next-App serviert auf beiden Hosts weiterhin alle Routen. src/middleware.ts entscheidet pro Host, welcher Pfad ausgeliefert wird, und 301t den Rest auf den jeweils anderen Host. Damit bleiben alle relativen Links (router.push('/dashboard'), <Link href="/settings">) unverändert korrekt, weil sie innerhalb desselben Hosts aufgelöst werden.

qrmaster.net        --301-->  www.qrmaster.net              (bleibt wie heute)
www.qrmaster.net    ->  Marketing, /login, /signup, /r/*, /api/*
app.qrmaster.net    ->  /dashboard /create /analytics /settings /bulk-creation
                        /integrations /qr/* /upgrade /onboarding, /api/*

Fixierte Entscheidungen

Frage Entscheidung Begründung
Hosting Docker + Caddy auf eigenem Server Bestand
/login, /signup bleiben auf www Alle 82 Marketing-CTAs zeigen auf /signup, /signup hat ein hartes Canonical auf www und trägt Ad-Traffic. Umzug wäre teuer ohne Nutzen.
/onboarding zieht auf app Reiner Logged-in-Flow, kein SEO-Wert
Host-Wechsel genau einmal, nach erfolgreichem Login/Signup einzige Cross-Host-Stelle im ganzen Flow
DB keine Änderung

Teil A — Deine Aufgaben (Timo)

Reihenfolge beachten: A1A2 vor dem Deploy von Schritt B5, sonst zeigt die Subdomain ins Leere.

A1. DNS

CNAME app → auf denselben Zielhost wie www (bzw. A-Record auf dieselbe Server-IP). Kein Proxy-Only-Sonderfall nötig, Caddy holt das Cert selbst.

A2. Caddyfile auf dem Server

app.qrmaster.net in den bestehenden Site-Block aufnehmen, damit Caddy automatisch ein Let's-Encrypt-Cert zieht:

www.qrmaster.net, app.qrmaster.net {
    reverse_proxy qrmaster-web:3000
}

Danach caddy reload. Prüfen: curl -sI https://app.qrmaster.net muss 200 oder 301 liefern, kein TLS-Fehler.

A3. .env auf dem Server ergänzen

Zwei Variablen statt einer. Die Trennung ist der Kern des ganzen Umbaus:

Für Deploy 1 reicht:

COOKIE_DOMAIN=.qrmaster.net

Für Deploy 2 kommen dazu:

NEXT_PUBLIC_WWW_URL=https://www.qrmaster.net
NEXT_PUBLIC_APP_URL=https://app.qrmaster.net

NEXT_PUBLIC_APP_URL erst zu Deploy 2 umstellen - vorher zeigt es auf www und muss dort bleiben. Fehlen die Werte, greifen die Produktions-Fallbacks in src/lib/hosts.ts; ein localhost-Wert kann damit nicht in gedruckte QR-Codes gelangen.

NEXTAUTH_URL bleibt https://www.qrmaster.net (wird nur noch von api/social-assets/route.ts gelesen, kein Auth-Bezug mehr).

A4. Google Cloud Console

Bei den OAuth-Credentials als Authorized redirect URI zusätzlich eintragen:

https://app.qrmaster.net/api/auth/google

Die alte www-URI nicht löschen sie wird während der Übergangszeit noch von Sessions genutzt, die den Flow auf www gestartet haben.

A5. Nichts zu tun bei Stripe und TikTok

  • Stripe-Webhook zeigt auf www.qrmaster.net/api/stripe/webhook und bleibt gültig (/api/* wird auf beiden Hosts weiter bedient, siehe B5).
  • TikTok redirect_uri bleibt auf qrmaster.net verifizierte Domain, nicht anfassen.

A6. Deploy

npm run docker:prod (Rebuild ist zwingend NEXT_PUBLIC_* wird zur Build-Zeit ins Client-Bundle inlined, ein reiner Container-Restart genügt nicht).

Teil B — Meine Aufgaben (Code), in Diff-Reihenfolge

Ohne das ist auf app.qrmaster.net jeder ausgeloggt: das userId-Cookie ist heute host-only.

  • src/lib/cookieConfig.ts:11getAuthCookieOptions(): domain: process.env.COOKIE_DOMAIN in Prod, in Dev undefined (localhost verträgt keine Punkt-Domain)
  • src/lib/cookieConfig.ts:24getCsrfCookieOptions(): dito
  • src/middleware.ts:34 — Attribution-Cookie: dito
  • src/app/(main)/api/auth/logout/route.ts:7kritisch: löscht heute host-only. Nach der Umstellung existieren bei Bestandsnutzern beide Varianten (alt host-only + neu domain-scoped). Logout muss beide überschreiben, sonst bleibt ein Zombie-Cookie und der Nutzer ist nicht wirklich ausgeloggt. Gilt für userId, newsletter-admin und das Attribution-Cookie.
  • src/app/(main)/api/auth/google/route.ts:53,62 — OAuth-State + Post-Auth-Redirect-Cookie

Kein Forced-Logout nötig: beide Cookie-Varianten tragen denselben signierten Wert, der Server akzeptiert jede. verifySignedUserIdEdge prüft die Signatur, das Teilen über eigene Subdomains ist unkritisch.

Dieser Schritt kann allein auf www deployt werden, bevor die Subdomain existiert — nach außen unsichtbar, und wenn app.* dann live geht, funktionieren Sessions sofort.

B2. NEXT_PUBLIC_APP_URL entflechten

Die Variable bedient heute App- und öffentliche URLs. Jede Fundstelle einzeln zuordnen:

Muss auf WWW_URL (öffentlich, teils in QR-Codes kodiert):

  • src/components/dashboard/QRCodeCard.tsx:82höchstes Risiko im ganzen Umbau: Basis für die in den QR-Code kodierte /r/<slug>-URL. Bleibt das auf APP_URL, zeigen alle neu heruntergeladenen und gedruckten Codes auf die Subdomain.
  • src/app/(main)/r/[slug]/route.ts:50,61,84,89 — Landing-Basis vcard/text/coupon/feedback
  • src/lib/email.ts:56,562, src/lib/marketingEmail.ts:30 — Mail-Links auf Marketing-Inhalte
  • src/app/(main)/api/auth/signup/route.ts:20 — Verify-Mail-Link
  • src/app/(main)/api/stripe/checkout/route.ts:64cancel_url/pricing
  • src/lib/metaConversions.ts:44, src/app/(main)/api/auth/signup/route.ts:150 — Event-Source-URLs

Bleibt/wird APP_URL (eingeloggt):

  • src/app/(main)/api/stripe/checkout/route.ts:63success_url/dashboard
  • src/app/(main)/api/stripe/create-checkout-session/route.ts:112,128appUrl + returnPath
  • src/app/(main)/api/stripe/portal/route.ts:59return_url/settings
  • src/lib/email.ts:505 — hartcodiertes https://www.qrmaster.net/dashboard im Mail-Footer
  • src/app/(main)/api/auth/google/route.ts:40,97redirect_uri (deckt A4 ab)

B3. Post-Auth-Sprung auf app.*

Die einzige Cross-Host-Stelle. sanitizeRedirectPath (src/lib/auth-flow.ts:4) erlaubt bewusst nur relative Pfade — bleibt so, ich baue den Host separat davor:

  • src/app/(main)/(auth)/login/ClientPage.tsx:56 und login/LoginClient.tsx:65
  • src/app/(main)/(auth)/signup/ClientPage.tsx:70
  • src/app/(main)/api/auth/google/route.ts:224,228 — Server-Redirect
  • src/app/(main)/api/auth/verify-email/route.ts:38 — setzt Cookie und redirected
  • src/lib/auth-flow.ts:46getPostOnboardingDestination

Muster: relativen Zielpfad wie heute bestimmen, dann new URL(path, APP_URL). Weil das Auth-Cookie nach B1 auf .qrmaster.net gilt, ist der Nutzer nach dem Sprung sofort eingeloggt — kein Token-Handover über die URL nötig.

B4. Onboarding-Checkliste

src/components/dashboard/OnboardingChecklist.tsx:141 verlinkt /onboarding mit redirect=/dashboard. Beide Pfade liegen nach dem Umzug auf app.* → bleibt relativ, keine Änderung. Nur verifizieren.

B5. Middleware: Host-Routing

src/middleware.ts — Kern des Umbaus. Der bestehende Apex-Redirect (Zeile 49) bleibt unberührt. Neu, direkt danach:

  • Host app.qrmaster.net:
    • /api/*, /_next/*, statische Dateien: durchlassen (Stripe-Webhook, CSRF, alles)
    • protectedPaths (Zeile 145) + /upgrade + /onboarding: bedienen wie heute
    • alles andere: 301 auf WWW_URL + gleicher Pfad
    • /r/*: 301 auf www — QR-Redirects gehören nicht auf die App-Subdomain
  • Host www.qrmaster.net:
    • protectedPaths + /upgrade + /onboarding: 301 auf APP_URL + Pfad + Query (damit alte Bookmarks und der Mail-Footer-Link weiter funktionieren)
  • Auth-Fail-Redirect (Zeile 166): zeigt auf /signup — das liegt auf www, also absolut auf WWW_URL umstellen, redirect-Param bleibt relativ

/login und /signup bleiben in publicPaths und werden nur auf www bedient.

B6. Indexierung der Subdomain dichtmachen

app.* darf nicht in den Index, sonst Duplicate Content.

  • src/middleware.ts: auf Host app.* X-Robots-Tag: noindex, nofollow auf alle Responses
  • public/robots-app.txt neu anlegen (User-agent: * / Disallow: /), Middleware rewritet /robots.txt auf app.* dorthin. src/app/robots.ts bleibt für www unverändert.
  • /sitemap.xml auf app.* → 301 auf www

Gute Nachricht: /dashboard, /create, /settings sind in src/app/robots.ts:7 bereits disallowed und nicht in der Sitemap → kein Ranking-Verlust durch den Umzug. Die Canonicals sind ohnehin hart auf www verdrahtet (src/app/(main)/layout.tsx:13).

B7. Docker-Env-Kette

NEXT_PUBLIC_* wird zur Build-Zeit inlined und zur Laufzeit serverseitig gelesen. Beide Stellen müssen übereinstimmen, sonst gibt es Bugs, die nur im Client oder nur im Server auftreten:

  • Dockerfile:34NEXT_PUBLIC_APP_URL auf https://app.qrmaster.net, neu ENV NEXT_PUBLIC_WWW_URL="https://www.qrmaster.net"
  • docker-compose.yml:58NEXT_PUBLIC_WWW_URL und COOKIE_DOMAIN ins environment des web-Service durchreichen
  • env.example + .env.example — neue Variablen dokumentieren
  • src/lib/env.ts — optional, das Schema kennt NEXT_PUBLIC_* bisher gar nicht

Deploy-Choreografie

Zwei Deploys, nicht einer. Das entkoppelt das Cookie-Risiko vom Routing-Risiko:

  1. Deploy 1 (nur B1): Cookie-Domain auf .qrmaster.net. Nur www ist live, nach außen unsichtbar. 24 h beobachten: Login, Logout, Checkout müssen normal laufen.
  2. A1 + A2 + A4: DNS, Caddy, Google Console. app.qrmaster.net antwortet, serviert aber noch dieselbe App wie www — unkritisch, weil noch nicht verlinkt und dank B6 noch nicht indexierbar.
  3. Deploy 2 (B2B7): Host-Routing scharf. Ab hier springt Login auf app.*.

Rollback: Deploy 2 zurücknehmen. Weil das Cookie auf .qrmaster.net gilt, bleiben Sessions auch nach dem Rollback auf www gültig — niemand wird ausgeloggt. DNS/Caddy können stehen bleiben.

Testcheckliste (nach Deploy 2)

Jeweils über beide Hosts:

  • www.qrmaster.net/dashboard → 301 auf app.qrmaster.net/dashboard, eingeloggt
  • app.qrmaster.net/pricing → 301 auf www
  • Signup auf www → Verify-Mail → Link führt eingeloggt auf app.*
  • Google-Login von www aus → landet eingeloggt auf app.*/dashboard bzw. /onboarding
  • Logout auf app.* → auf www auch ausgeloggt (prüft B1, häufigster Fehler)
  • Checkout: Upgrade auf app.* → Stripe → success_url app.*/dashboard, Abbruch → www/pricing
  • Stripe-Portal → zurück auf app.*/settings
  • Stripe-Webhook feuert weiter (Dashboard → Events, keine 4xx)
  • QR-Code neu anlegen + herunterladen → kodierte URL ist www.qrmaster.net/r/<slug>, nicht app.* (prüft B2, das teuerste Fehlerbild)
  • Bestehender /r/<slug> redirected + trackt weiter, vcard/coupon/feedback-Landings laden
  • Mutation auf app.* (QR umbenennen) → CSRF greift, kein 403
  • curl -sI https://app.qrmaster.net/dashboard | grep -i x-robots-tag → noindex
  • https://app.qrmaster.net/robots.txtDisallow: /
  • Search Console: app.qrmaster.net nicht als Property anlegen, keine Sitemap einreichen

Risiken

Risiko Wo Absicherung
Gedruckte QR-Codes zeigen auf app.* QRCodeCard.tsx:82 B2, explizit im Test
Logout wirkt nicht (Zombie-Cookie) logout/route.ts B1 löscht beide Varianten
Client/Server-Env divergieren Dockerfile vs. docker-compose.yml B7, beide Stellen setzen
Google-OAuth bricht Cloud Console A4, alte URI stehen lassen
Duplicate Content auf app.* B6 vor Deploy 2

Aufwand

  • Deine Seite: ~45 min (DNS, Caddy, .env, Google Console, Deploy)
  • Meine Seite: ~46 h Code über zwei Deploys
  • Keine DB-Änderung, kein Forced-Logout, kein SEO-Verlust