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>
This commit is contained in:
@@ -7,6 +7,13 @@ import {
|
||||
} from '@/lib/revops';
|
||||
import { verifySignedUserIdEdge } from '@/lib/session-edge';
|
||||
import { getCookieDomain } from '@/lib/cookieConfig';
|
||||
import {
|
||||
getAppOrigin,
|
||||
getWwwOrigin,
|
||||
isAppPath,
|
||||
isHostSplitEnabled,
|
||||
wwwUrl,
|
||||
} from '@/lib/hosts';
|
||||
|
||||
const isProduction = process.env.NODE_ENV === 'production';
|
||||
|
||||
@@ -44,7 +51,69 @@ function attachAttributionCookie(req: NextRequest, response: NextResponse) {
|
||||
return response;
|
||||
}
|
||||
|
||||
export async function middleware(req: NextRequest) {
|
||||
/** Hostname of the app host, or null when marketing and app share one origin (dev). */
|
||||
function getAppHostname(): string | null {
|
||||
if (!isHostSplitEnabled()) {
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
return new URL(getAppOrigin()).hostname;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/** Absolute target on the other host, preserving path and query. */
|
||||
function crossHostUrl(origin: string, req: NextRequest): string {
|
||||
const url = new URL(req.nextUrl.pathname + req.nextUrl.search, origin);
|
||||
|
||||
return url.toString();
|
||||
}
|
||||
|
||||
/**
|
||||
* Route a request that arrived on the app host (app.qrmaster.net).
|
||||
*
|
||||
* The app host serves only the logged-in app; everything else belongs to the marketing
|
||||
* host and gets redirected so a stray link or an old bookmark still lands somewhere
|
||||
* sensible. Returns null when the request is an app path and should continue through the
|
||||
* normal auth handling below.
|
||||
*/
|
||||
function routeAppHost(req: NextRequest): NextResponse | null {
|
||||
const path = req.nextUrl.pathname;
|
||||
|
||||
// Keep the app host out of search indexes entirely - the marketing host owns all SEO.
|
||||
if (path === '/robots.txt') {
|
||||
return NextResponse.rewrite(new URL('/robots-app.txt', req.url));
|
||||
}
|
||||
if (path === '/sitemap.xml') {
|
||||
return NextResponse.redirect(wwwUrl('/sitemap.xml'), 301);
|
||||
}
|
||||
|
||||
// API and framework internals must be served on both hosts: the app calls its own
|
||||
// /api routes, and the Stripe webhook still points at the marketing host.
|
||||
if (path.startsWith('/api/') || path.startsWith('/_next')) {
|
||||
return NextResponse.next();
|
||||
}
|
||||
|
||||
// QR redirects belong to the marketing host. Redirecting instead of 404ing keeps any
|
||||
// code that was generated with the wrong origin working.
|
||||
if (path.startsWith('/r/')) {
|
||||
return NextResponse.redirect(crossHostUrl(getWwwOrigin(), req), 301);
|
||||
}
|
||||
|
||||
if (path.includes('.')) {
|
||||
return NextResponse.next();
|
||||
}
|
||||
|
||||
if (isAppPath(path)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return NextResponse.redirect(crossHostUrl(getWwwOrigin(), req), 301);
|
||||
}
|
||||
|
||||
async function routeRequest(req: NextRequest): Promise<NextResponse> {
|
||||
const path = req.nextUrl.pathname;
|
||||
const hostname = req.headers.get('host')?.split(':')[0] || req.nextUrl.hostname;
|
||||
|
||||
@@ -56,6 +125,23 @@ export async function middleware(req: NextRequest) {
|
||||
return NextResponse.redirect(url, 301);
|
||||
}
|
||||
|
||||
const appHostname = getAppHostname();
|
||||
|
||||
if (appHostname) {
|
||||
if (hostname === appHostname) {
|
||||
const appHostResponse = routeAppHost(req);
|
||||
|
||||
if (appHostResponse) {
|
||||
return appHostResponse;
|
||||
}
|
||||
// Falls through: app path on the app host, continue to the auth check below.
|
||||
} else if (isAppPath(path)) {
|
||||
// App path requested on the marketing host - move it to the app host. Keeps old
|
||||
// bookmarks and the dashboard link in email footers working.
|
||||
return NextResponse.redirect(crossHostUrl(getAppOrigin(), req), 301);
|
||||
}
|
||||
}
|
||||
|
||||
// 301 Redirects for /guide -> /learn to avoid duplicate content and consolidate authority
|
||||
if (path === '/guide/tracking-analytics') {
|
||||
return attachAttributionCookie(req, NextResponse.redirect(new URL('/learn/tracking', req.url), 301));
|
||||
@@ -164,8 +250,8 @@ export async function middleware(req: NextRequest) {
|
||||
const userId = await verifySignedUserIdEdge(req.cookies.get('userId')?.value);
|
||||
|
||||
if (!userId) {
|
||||
// Not authenticated - redirect to signup
|
||||
const signupUrl = new URL('/signup', req.url);
|
||||
// Not authenticated - redirect to signup, which lives on the marketing host.
|
||||
const signupUrl = new URL(wwwUrl('/signup'));
|
||||
const redirectTarget = `${path}${req.nextUrl.search}`;
|
||||
signupUrl.searchParams.set('redirect', redirectTarget);
|
||||
return attachAttributionCookie(req, NextResponse.redirect(signupUrl));
|
||||
@@ -175,6 +261,20 @@ export async function middleware(req: NextRequest) {
|
||||
return attachAttributionCookie(req, NextResponse.next());
|
||||
}
|
||||
|
||||
export async function middleware(req: NextRequest) {
|
||||
const response = await routeRequest(req);
|
||||
const appHostname = getAppHostname();
|
||||
const hostname = req.headers.get('host')?.split(':')[0] || req.nextUrl.hostname;
|
||||
|
||||
// Belt and braces alongside robots-app.txt: the app host must never be indexed, and
|
||||
// setting the header here covers every response the routing above can produce.
|
||||
if (appHostname && hostname === appHostname) {
|
||||
response.headers.set('X-Robots-Tag', 'noindex, nofollow');
|
||||
}
|
||||
|
||||
return response;
|
||||
}
|
||||
|
||||
export const config = {
|
||||
matcher: [
|
||||
/*
|
||||
|
||||
Reference in New Issue
Block a user