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:
2026-08-12 19:44:08 +02:00
parent 35ea8cc3e9
commit 53ef4b3b91
22 changed files with 383 additions and 63 deletions

View File

@@ -25,6 +25,7 @@
*/
import { Resend } from 'resend';
import { appUrl, getWwwOrigin, wwwUrl } from '@/lib/hosts';
import nodemailer from 'nodemailer';
// Use a placeholder during build time, real key at runtime
@@ -53,8 +54,7 @@ async function waitForRateLimit() {
export async function sendPasswordResetEmail(email: string, resetToken: string) {
await waitForRateLimit();
const appUrl = process.env.NEXT_PUBLIC_APP_URL || 'http://localhost:3050';
const resetUrl = `${appUrl}/reset-password?token=${resetToken}`;
const resetUrl = wwwUrl(`/reset-password?token=${resetToken}`);
try {
await resend.emails.send({
@@ -502,7 +502,7 @@ export async function sendAIFeatureLaunchEmail(email: string) {
<td align="center">
<p style="margin: 0 0 8px 0; color: #888888; font-size: 13px;">
<a href="https://www.qrmaster.net" style="color: #667eea; text-decoration: none;">www.qrmaster.net</a> •
<a href="https://www.qrmaster.net/dashboard" style="color: #667eea; text-decoration: none;">Dashboard</a> •
<a href="${appUrl('/dashboard')}" style="color: #667eea; text-decoration: none;">Dashboard</a> •
<a href="https://www.qrmaster.net/faq" style="color: #667eea; text-decoration: none;">Help</a>
</p>
<p style="margin: 0; color: #999999; font-size: 12px;">
@@ -559,7 +559,9 @@ function createSmtpTransport() {
});
}
const appUrl = process.env.NEXT_PUBLIC_APP_URL || 'https://www.qrmaster.net';
// Public marketing origin, used for the email chrome: logo, hero image, footer links.
// Per-page links below resolve their own host through appUrl() / wwwUrl().
const wwwOrigin = getWwwOrigin();
export async function sendEmailVerificationEmail(email: string, name: string, verificationUrl: string) {
const transport = createSmtpTransport();
@@ -579,7 +581,7 @@ export async function sendEmailVerificationEmail(email: string, name: string, ve
export async function sendDesignerAnnouncementEmail(email: string, unsubscribeUrl: string) {
await waitForRateLimit();
const createUrl = `${appUrl}/create`;
const createUrl = appUrl('/create');
const transport = createSmtpTransport();
@@ -714,7 +716,7 @@ function emailShell(headExtra: string, bodyContent: string): string {
<tr>
<td style="text-align:center;padding:0 20px;">
<p style="margin:0 0 6px;font-family:'DM Sans',-apple-system,sans-serif;font-size:12px;color:${clr.textMuted};">
<a href="${appUrl}" style="color:${clr.gold};text-decoration:none;font-weight:500;">www.qrmaster.net</a>
<a href="${wwwOrigin}" style="color:${clr.gold};text-decoration:none;font-weight:500;">www.qrmaster.net</a>
&nbsp;·&nbsp;
<a href="mailto:support@qrmaster.net" style="color:${clr.textMuted};text-decoration:none;">support@qrmaster.net</a>
</p>
@@ -740,7 +742,7 @@ const dotGridPattern = `url(data:image/svg+xml,%3Csvg%20xmlns%3D%22http%3A%2F%2F
*/
export async function sendWelcomeEmail(email: string, name: string) {
const transport = createSmtpTransport();
const createUrl = `${appUrl}/create`;
const createUrl = appUrl('/create');
const firstName = name.split(' ')[0];
const html = emailShell('', `
@@ -792,7 +794,7 @@ export async function sendWelcomeEmail(email: string, name: string) {
<!-- ── HERO IMAGE ── -->
<tr>
<td style="padding: 0; text-align: center; background-color: ${clr.card};">
<img src="${appUrl}/email-hero-light.png" width="560" style="display:block;width:100%;max-width:560px;height:auto;border-bottom:3px solid ${clr.gold};" alt="Beautiful QR Code Experience">
<img src="${wwwOrigin}/email-hero-light.png" width="560" style="display:block;width:100%;max-width:560px;height:auto;border-bottom:3px solid ${clr.gold};" alt="Beautiful QR Code Experience">
</td>
</tr>
@@ -904,7 +906,7 @@ export async function sendWelcomeEmail(email: string, name: string) {
<table role="presentation" cellpadding="0" cellspacing="0" border="0">
<tr>
<td style="width:56px; height:56px; background-color:#0B0D14; border-radius:50%; text-align:center; vertical-align:middle; border:2px solid ${clr.border}; box-shadow:0 4px 10px rgba(0,0,0,0.05);">
<img src="${appUrl}/favicon1.png" width="32" height="32" alt="Timo" style="display:inline-block; vertical-align:middle; border-radius:50%; object-fit:cover;">
<img src="${wwwOrigin}/favicon1.png" width="32" height="32" alt="Timo" style="display:inline-block; vertical-align:middle; border-radius:50%; object-fit:cover;">
</td>
</tr>
</table>
@@ -940,7 +942,7 @@ export async function sendWelcomeEmail(email: string, name: string) {
*/
export async function sendActivationNudgeEmail(email: string, name: string) {
const transport = createSmtpTransport();
const createUrl = `${appUrl}/create`;
const createUrl = appUrl('/create');
const firstName = name.split(' ')[0];
const steps = [
@@ -1077,7 +1079,7 @@ export async function sendActivationNudgeEmail(email: string, name: string) {
*/
export async function sendUpgradeNudgeEmail(email: string, name: string, qrCount: number) {
const transport = createSmtpTransport();
const pricingUrl = `${appUrl}/pricing`;
const pricingUrl = wwwUrl('/pricing');
const firstName = name.split(' ')[0];
const features = [
@@ -1253,7 +1255,7 @@ export async function sendThirtyDayNudgeEmail(
scanCount: number = 0
) {
const transport = createSmtpTransport();
const pricingUrl = `${appUrl}/pricing`;
const pricingUrl = wwwUrl('/pricing');
const firstName = name.split(' ')[0];
const html = emailShell('', `
@@ -1435,7 +1437,7 @@ export async function sendFirstScanEmail(
) {
const transport = createSmtpTransport();
const firstName = name.split(' ')[0];
const analyticsUrl = `${appUrl}/analytics`;
const analyticsUrl = appUrl('/analytics');
const time = scan.ts.toLocaleTimeString('en-GB', {
hour: '2-digit',

125
src/lib/hosts.ts Normal file
View File

@@ -0,0 +1,125 @@
/**
* Host boundary between the marketing site and the app.
*
* Marketing/SEO content and the auth entry points (/login, /signup) live on
* www.qrmaster.net; everything behind the login lives on app.qrmaster.net.
*
* Both hostnames are served by the SAME Next deployment - nothing moves in the file
* tree. This module is the single source of truth for which host owns which path, shared
* by the middleware (which redirects the mismatches) and by every place that builds an
* absolute URL: Stripe return URLs, emails, OAuth redirects.
*
* Safe to import from middleware, route handlers and client components alike: no node
* APIs, and the NEXT_PUBLIC_* reads stay literal so the compiler can inline them.
*/
const isProduction = process.env.NODE_ENV === 'production';
/**
* Production fallbacks are hardcoded on purpose. If NEXT_PUBLIC_WWW_URL were missing in
* production a localhost fallback would end up encoded into downloaded - and printed -
* QR codes. A wrong-but-real domain is recoverable, `http://localhost:3050` on a flyer
* is not.
*/
const WWW_FALLBACK = isProduction ? 'https://www.qrmaster.net' : 'http://localhost:3050';
const APP_FALLBACK = isProduction ? 'https://app.qrmaster.net' : 'http://localhost:3050';
/**
* Path prefixes owned by the app host.
*
* Keep in sync with the `(app)` route group. `/upgrade` is included even though it is not
* in the middleware's protectedPaths list - it is an in-app page, only ever linked from
* inside the app.
*/
export const APP_PATH_PREFIXES = [
'/analytics',
'/bulk-creation',
'/create',
'/dashboard',
'/integrations',
'/onboarding',
'/qr',
'/settings',
'/upgrade',
] as const;
function stripTrailingSlash(url: string): string {
return url.endsWith('/') ? url.slice(0, -1) : url;
}
/**
* True when `path` is served by the app host.
*
* Accepts a bare pathname or a path with query/hash - callers routinely pass things like
* `/dashboard?success=true`, and matching those against the prefixes directly would miss.
*/
export function isAppPath(path: string): boolean {
const pathname = path.split(/[?#]/)[0];
return APP_PATH_PREFIXES.some(
(prefix) => pathname === prefix || pathname.startsWith(`${prefix}/`)
);
}
/** Origin of the marketing host, e.g. `https://www.qrmaster.net`. */
export function getWwwOrigin(): string {
return stripTrailingSlash(process.env.NEXT_PUBLIC_WWW_URL || WWW_FALLBACK);
}
/** Origin of the app host, e.g. `https://app.qrmaster.net`. */
export function getAppOrigin(): string {
return stripTrailingSlash(process.env.NEXT_PUBLIC_APP_URL || APP_FALLBACK);
}
/**
* Whether marketing and app actually live on different hostnames.
*
* False in development, where both point at localhost:3050 - the middleware must not try
* to split hosts there or every request would redirect to itself.
*/
export function isHostSplitEnabled(): boolean {
try {
return new URL(getWwwOrigin()).host !== new URL(getAppOrigin()).host;
} catch {
return false;
}
}
/** Absolute URL for `path` on the marketing host. */
export function wwwUrl(path: string): string {
return new URL(path, getWwwOrigin()).toString();
}
/** Absolute URL for `path` on the app host. */
export function appUrl(path: string): string {
return new URL(path, getAppOrigin()).toString();
}
/**
* Absolute URL for `path` on whichever host owns it.
*
* Use this whenever the path is not known statically - Stripe return paths, post-auth
* redirect targets - so a caller can never send a user to the wrong host.
*/
export function urlForPath(path: string): string {
return isAppPath(path) ? appUrl(path) : wwwUrl(path);
}
/**
* Whether navigating to `path` from the current page crosses the host boundary.
*
* next/navigation's router can only push same-origin URLs, so a crossing needs a full
* `window.location` load. Always false on the server and in development, where both
* hosts are the same origin - callers then keep their normal client-side navigation.
*/
export function needsHostChange(path: string): boolean {
if (typeof window === 'undefined') {
return false;
}
try {
return new URL(urlForPath(path)).origin !== window.location.origin;
} catch {
return false;
}
}

View File

@@ -1,6 +1,7 @@
import 'server-only';
import crypto from 'crypto';
import { wwwUrl } from '@/lib/hosts';
const TOKEN_TTL_MS = 1000 * 60 * 60 * 24 * 365;
@@ -27,9 +28,7 @@ export function createMarketingUnsubscribeUrl(email: string): string {
JSON.stringify({ email: normalizeEmail(email), expiresAt: Date.now() + TOKEN_TTL_MS })
).toString('base64url');
const token = `${payload}.${sign(payload)}`;
const appUrl = process.env.NEXT_PUBLIC_APP_URL || 'https://www.qrmaster.net';
return `${appUrl}/unsubscribe?token=${encodeURIComponent(token)}`;
return wwwUrl(`/unsubscribe?token=${encodeURIComponent(token)}`);
}
export function getUnsubscribeEmail(token: string | null | undefined): string | null {

View File

@@ -1,4 +1,5 @@
import * as crypto from 'crypto';
import { getWwwOrigin } from '@/lib/hosts';
const BASE_URL = 'https://graph.facebook.com/v21.0';
const PIXEL_ID = process.env.META_PIXEL_ID;
@@ -41,7 +42,8 @@ export async function sendConversionEvent(event: ConversionEvent): Promise<void>
{
event_name: event.eventName,
event_time: event.eventTime ?? Math.floor(Date.now() / 1000),
event_source_url: event.eventSourceUrl ?? process.env.NEXT_PUBLIC_APP_URL,
// Ad attribution happens on the public site, so the fallback is the marketing host.
event_source_url: event.eventSourceUrl ?? getWwwOrigin(),
action_source: 'website',
user_data: hashedUserData,
custom_data: event.customData ?? {},