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:
@@ -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>
|
||||
·
|
||||
<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
125
src/lib/hosts.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 ?? {},
|
||||
|
||||
Reference in New Issue
Block a user