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>
13 KiB
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) |
| B2–B7 | 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 B2–B7.
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: A1–A2 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/webhookund bleibt gültig (/api/*wird auf beiden Hosts weiter bedient, siehe B5). - TikTok
redirect_uribleibt aufqrmaster.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
B1. Cookie-Domain teilen — muss zuerst live sein
Ohne das ist auf app.qrmaster.net jeder ausgeloggt: das userId-Cookie ist heute host-only.
src/lib/cookieConfig.ts:11—getAuthCookieOptions():domain: process.env.COOKIE_DOMAINin Prod, in Devundefined(localhost verträgt keine Punkt-Domain)src/lib/cookieConfig.ts:24—getCsrfCookieOptions(): ditosrc/middleware.ts:34— Attribution-Cookie: ditosrc/app/(main)/api/auth/logout/route.ts:7— kritisch: 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üruserId,newsletter-adminund 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:82— höchstes Risiko im ganzen Umbau: Basis für die in den QR-Code kodierte/r/<slug>-URL. Bleibt das aufAPP_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/feedbacksrc/lib/email.ts:56,562,src/lib/marketingEmail.ts:30— Mail-Links auf Marketing-Inhaltesrc/app/(main)/api/auth/signup/route.ts:20— Verify-Mail-Linksrc/app/(main)/api/stripe/checkout/route.ts:64—cancel_url→/pricingsrc/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:63—success_url→/dashboardsrc/app/(main)/api/stripe/create-checkout-session/route.ts:112,128—appUrl+ returnPathsrc/app/(main)/api/stripe/portal/route.ts:59—return_url→/settingssrc/lib/email.ts:505— hartcodierteshttps://www.qrmaster.net/dashboardim Mail-Footersrc/app/(main)/api/auth/google/route.ts:40,97—redirect_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:56undlogin/LoginClient.tsx:65src/app/(main)/(auth)/signup/ClientPage.tsx:70src/app/(main)/api/auth/google/route.ts:224,228— Server-Redirectsrc/app/(main)/api/auth/verify-email/route.ts:38— setzt Cookie und redirectedsrc/lib/auth-flow.ts:46—getPostOnboardingDestination
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 aufAPP_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 aufWWW_URLumstellen,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 Hostapp.*X-Robots-Tag: noindex, nofollowauf alle Responsespublic/robots-app.txtneu anlegen (User-agent: * / Disallow: /), Middleware rewritet/robots.txtauf app.* dorthin.src/app/robots.tsbleibt für www unverändert./sitemap.xmlauf 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:34—NEXT_PUBLIC_APP_URLaufhttps://app.qrmaster.net, neuENV NEXT_PUBLIC_WWW_URL="https://www.qrmaster.net"docker-compose.yml:58—NEXT_PUBLIC_WWW_URLundCOOKIE_DOMAINinsenvironmentdesweb-Service durchreichenenv.example+.env.example— neue Variablen dokumentierensrc/lib/env.ts— optional, das Schema kenntNEXT_PUBLIC_*bisher gar nicht
Deploy-Choreografie
Zwei Deploys, nicht einer. Das entkoppelt das Cookie-Risiko vom Routing-Risiko:
- 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. - A1 + A2 + A4: DNS, Caddy, Google Console.
app.qrmaster.netantwortet, serviert aber noch dieselbe App wie www — unkritisch, weil noch nicht verlinkt und dank B6 noch nicht indexierbar. - Deploy 2 (B2–B7): 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 aufapp.qrmaster.net/dashboard, eingeloggtapp.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_urlapp.*/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→ noindexhttps://app.qrmaster.net/robots.txt→Disallow: /- Search Console:
app.qrmaster.netnicht 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: ~4–6 h Code über zwei Deploys
- Keine DB-Änderung, kein Forced-Logout, kein SEO-Verlust