">
 

Why my single Next.js app runs 4 different domains (and how the proxy.ts decides who sees what)

Iniciado por joomlamz, 30 de Maio de 2026, 15:00

Respostas: 0   |   Visualizações: 15

Tópico anterior - Tópico seguinte

0 Membros e 2 Visitantes estão a ver este tópico.

Why my single Next.js app runs 4 different domains (and how the proxy.ts decides who sees what)



Tópico: Why my single Next.js app runs 4 different domains (and how the proxy.ts decides who sees what)
Categoria: Tutoriais | Programação & Tecnologia
Idioma Principal: Português (Conteúdo de Tecnologia)

Descrição do Conteúdo / Informações:
-------------------------------------------------------------------------
> TL;DR — I run four different domains off one Next.js codebase: a marketing site at pagestrike.com, an authenticated app at app.pagestrike.com, a public publishing domain at pagestrike.app, and customer-owned domains. The trick isn't deploying four apps — it's a single proxy.ts that reads the host and rewrites/redirects/passes-through per-request. This post walks through why I chose this shape, the parts I got wrong, and the cookie-domain trick that makes it all stick.

Stack: Next.js 16 App Router, Supabase, Vercel, one proxy.ts file (~370 lines).

This is the second post in my build-in-public series on PageStrike. Last week I wrote about the 6-CTA architecture — modeling conversion intent as a discriminated union so one launch could be a checkout, a COD form, or a calendar booking. This post is about a different primitive: modeling host as routing context so one codebase can serve four very different audiences.



Why four domains, not one


Most SaaS apps live at one domain — say myapp.com with /dashboard under it. That works until you grow into edge cases that don't fit:


Marketing pages get spammed by your own dashboard headers. Your marketing nav says "Sign in / Pricing / Blog". Your dashboard nav says "Launches / Contacts / Settings". You either A/B them with conditional logic everywhere or you live with the noise.


Public user-generated pages share your domain reputation. When a customer publishes a landing page at myapp.com/p/[slug], every spammy LP from a free-tier user drags down myapp.com's sender reputation, search trust, and ad-account standing. Google and Meta penalize the host, not the path.


Custom domains don't route cleanly. A customer who buys acmewidgets.com and points it at your app expects their LP at acmewidgets.com/ — not myapp.com/p/acme-widgets. You need a rewrite that's transparent to the visitor, doesn't 404 on _next/static/*, and survives RSC prefetches.

I split PageStrike — a free AI landing page builder — across four hosts to solve all three at once:

Host
What lives there
Why separate

pagestrike.com
Marketing (homepage, blog, templates, pricing, LLM-citable facts)
Static SEO surface, public-facing brand

app.pagestrike.com
Auth, dashboard, admin, API
Authenticated surface, no SEO indexing

pagestrike.app
Public published LPs (/p/[slug]), public form submits

Isolated reputation bucket — spammy LPs can't drag down the main brand

[workspace].pagestrike.app
Per-workspace LP namespace
Vanity URL for paying users

Customer custom domains
Same LP, transparently rewritten
Customer ownership

Two and a half years ago I would have built one Next.js app on one domain with three "marketing" subroutes and three "app" subroutes, all under /. By month 6 of running PageStrike, that shape became impossible:

• Free-tier users publishing throwaway LPs were poisoning my email domain reputation

• My dashboard route prefetches were leaking into marketing page bundles

• Google Search Console was conflating "marketing landing pages" with "user-published landing pages" in indexation reports

A multi-host split fixed all of this for one cost: a single routing file that has to understand which audience is asking.

That file is src/proxy.ts.



The proxy as the central router


Next.js 16 renamed middleware.ts to proxy.ts to clarify it sits at the network boundary, not inside the framework's middleware chain. (If you're migrating, the codemod npx @next/codemod@latest middleware-to-proxy handles the rename and the exported function name swap.)

The proxy runs on every request that matches its config.matcher. For PageStrike, it does five things in order:


OPTIONS preflight for cross-subdomain RSC prefetches (Next 16 strips rsc headers from request.headers inside proxy — you have to handle CORS at the preflight layer)


www → bare-domain 301 redirect (canonical SEO hygiene)


Custom domain detection (DB lookup with a 60s in-memory cache)


Publishing-host gate (lock down pagestrike.app to LP routes only)


App-route vs marketing-route dispatch between pagestrike.com and app.pagestrike.com

Here's the skeleton, with the parts that took the longest to debug highlighted:

// src/proxy.ts (simplified)
export async function proxy(request: NextRequest) {
const host = request.headers.get("host") || "";
const hostname = host.split(":")[0].toLowerCase();
const path = request.nextUrl.pathname;

// 1. Custom domain → rewrite to /p/[slug] for the matching workspace
if (!isKnownHost(hostname)) {
const slug = await resolveCustomDomain(hostname);
if (slug) {
const url = request.nextUrl.clone();
url.pathname = `/p/${slug}`;
const headers = new Headers(request.headers);
headers.set("x-custom-domain", hostname);
return NextResponse.rewrite(url, { request: { headers } });
}
return NextResponse.redirect(`https://pagestrike.com`);
}

// 2. Publishing host (pagestrike.app) — sealed bucket
if (isPublishingHost(host)) {
if (!isPublicLpRoute(path)) {
return NextResponse.redirect(`https://pagestrike.com`);
}
const subdomain = extractPublishingSubdomain(host);
if (subdomain) {
const headers = new Headers(request.headers);
headers.set(WORKSPACE_SLUG_HEADER, subdomain);
return NextResponse.next({ request: { headers } });
}
return NextResponse.next();
}

// 3. app.pagestrike.com — auth + dashboard
if (isAppHost(host)) {
if (isMarketingRoute(path)) {
// Bounce marketing paths back to the bare domain
return NextResponse.redirect(`https://pagestrike.com${path}`);
}
return await updateSession(request); // Supabase session refresh
}

// 4. Bare pagestrike.com — punt app routes to app.pagestrike.com
if (isAppRoute(path)) {
return NextResponse.redirect(`https://app.pagestrike.com${path}`);
}

// 5. Marketing pages on bare domain — skip session refresh (perf win)
return NextResponse.next({
request: { headers: forwardWithPathname(request) },
});
}

A few things to notice that aren't obvious:


Order matters a lot. Custom domain check has to come before the publishing-host gate, because a customer pointing acmewidgets.com at our IP is "an unknown host" — and the unknown-host branch decides whether to rewrite or 302.


pagestrike.app is intentionally hostile to non-LP routes. A pagestrike.app/dashboard request 302s back to the marketing site. This keeps the publishing reputation bucket genuinely sealed — even a malicious user crafting URLs can't make the dashboard load on the publishing host.


The skip on bare-domain marketing routes is a recent TTFB optimization. Before this, every marketing page request was hitting supabase.auth.getUser() even though the marketing CTA component reads session via the browser supabase client. That's a 200-400ms wasted round-trip on every page view.



The cookie-domain trick (the hardest part)


The hardest single problem in this architecture isn't routing — it's keeping the session alive across subdomains.

A user signs up on app.pagestrike.com. Supabase sets an sb-access-token cookie. They click "Home" in the dashboard nav. They land on pagestrike.com. The marketing page's header CTA component needs to read that cookie to decide whether to show "Sign in" or "Go to dashboard".

By default, cookies set by app.pagestrike.com are scoped to that exact subdomain. The browser will not send them to pagestrike.com. Your marketing page sees no session, shows "Sign in", the user is confused.

The fix is to explicitly set Domain=.pagestrike.com on the Supabase auth cookies. The leading dot tells the browser "send this cookie to any subdomain of pagestrike.com" — so both app. and the apex domain receive it on every request.

// src/lib/supabase/cookie-domain.ts
export function getCookieDomain(host: string | null): string | undefined {
if (!host) return undefined;
const hostname = host.split(":")[0].toLowerCase();

if (hostname === "localhost" || hostname === "127.0.0.1") {
// Host-scoped cookies in dev — no Domain attribute
return undefined;
}

if (hostname.endsWith("pagestrike.com")) {
return ".pagestrike.com"; // shared across app + bare domain
}

return undefined;
}

And in the Supabase middleware wrapper:

const cookieDomain = getCookieDomain(request.headers.get("host"));

const supabase = createServerClient(supabaseUrl, supabaseKey, {
cookies: {
getAll: () => request.cookies.getAll(),
setAll(cookiesToSet) {
// ... NextResponse boilerplate ...
cookiesToSet.forEach(({ name, value, options }) => {
const finalOptions = cookieDomain
? { ...options, domain: cookieDomain }
: options;
response.cookies.set(name, value, finalOptions);
});
},
},
});

Two gotchas I lost time to:


In localhost / Vercel preview deploys, return undefined. The browser refuses cookies with a Domain attribute that doesn't match the request host. A Domain=.pagestrike.com cookie set during a Vercel preview at pagestrike-pr-42.vercel.app will silently be dropped. Same in localhost. Always host-scope cookies in dev environments.


Don't share cookies with .pagestrike.app. I almost set the cookie domain to the apex of both domains, so authenticated users could "preview" their LP on pagestrike.app while logged in. Bad idea. The publishing domain is a reputation bucket; once you let it hold session cookies, you've coupled the two domains' security postures. Keep them separate; the publishing surface is anonymous-only.



Custom domain rewriting (the boss level)


Custom domains are the feature that paid customers wait for. They've already paid for acmewidgets.com; they want their landing page to be that domain, not acmewidgets.pagestrike.app/p/abc-123.

The user-facing flow is the easy part: in their dashboard billing page, the customer adds their domain, points DNS to our Vercel IP, and Vercel provisions an SSL cert via Let's Encrypt. Done.

The non-obvious part is what the proxy has to do when a request arrives at acmewidgets.com:

// Inside proxy.ts
if (!isKnownHost(hostname)) {
const slug = await resolveCustomDomain(hostname);
if (slug) {
// Asset / API requests pass through unchanged — rewriting them
// breaks Next.js internals and triggers router-state errors
const isAssetOrApi =
path.startsWith("/_next") ||
path.startsWith("/api") ||
path === "/favicon.ico" ||
path === "/robots.txt" ||
path === "/sitemap.xml" ||
/\.(png|jpg|jpeg|svg|webp|ico|css|js|json|woff2?)$/i.test(path);

if (isAssetOrApi) return NextResponse.next();

// Only the HTML request gets rewritten to /p/[slug]
const url = request.nextUrl.clone();
url.pathname = `/p/${slug}`;
const headers = new Headers(request.headers);
headers.set("x-custom-domain", hostname); // LP reads its own canonical URL
return NextResponse.rewrite(url, { request: { headers } });
}
// Unknown host that isn't a customer's domain — bounce home
return NextResponse.redirect("https://pagestrike.com");
}

The DB lookup hits a custom_domains table:

async function resolveCustomDomain(hostname: string): Promise<string | null> {
// 60s in-memory cache — avoids hammering Postgres on every request
const cached = domainCache.get(hostname);
if (cached !== undefined) {
if (cached === null) return null; // Negative cache
if (Date.now() < cached.expires) return cached.slug;
}

const { data } = await supabase
.from("custom_domains")
.select("page_id, pages(slug)")
.eq("domain", hostname)
.eq("status", "active")
.single();

if (data?.pages?.slug) {
const slug = data.pages.slug;
domainCache.set(hostname, { slug, expires: Date.now() + 60_000 });
return slug;
}

domainCache.set(hostname, null); // 60s negative cache for non-customers
return null;
}

Three things I'd flag for anyone shipping custom domains for the first time:


Negative cache the misses, not just the hits. Without negative caching, every random hostname-probe bot hammers your DB. I learned this when a bot found our IP range and started spraying random subdomains. The DB query rate jumped 20×.


x-custom-domain header is mandatory. The LP component reads it to render the right canonical URL in its <head>. Without it, every customer's LP advertises itself as pagestrike.app/p/slug, killing the SEO equity of the custom domain.


The asset/API allowlist is not optional. Rewrite a Next.js _next/static/chunk.js request to /p/[slug] and you get a 200 OK serving HTML where JavaScript was expected. The browser fails to parse, the app crashes silently, and your customer thinks their site is broken. This was 6 hours of debugging that I'd rather have not lived.



What I'd do differently if I started over


One. Set up the four-host topology on day one. I started with pagestrike.com only, migrated to pagestrike.com + app.pagestrike.com in month 4 (cookie-domain migration left stale cookies on hundreds of users — fun debugging session), then added pagestrike.app in month 7. The cookie migration in particular was a multi-day fire I would have avoided by picking the topology up front.

Two. Use Vercel's Edge Config for the custom domain → slug mapping instead of Postgres. Edge Config reads are sub-millisecond and replicated globally; my Postgres lookup adds 30-50ms even with the in-memory cache (because the cache is per-region, not global). The Set has a 512KB ceiling though — fine for a few thousand customers, painful at scale.

Three. Write the /llms.txt endpoint before the marketing pages. AI search engines (ChatGPT Search, Perplexity, Claude) crawl /llms.txt to get a clean machine-readable summary of what your product is. I wrote ours late; competitors who shipped it earlier got cited first. There's also a /ai-facts HTML page that mirrors the same content for human-readable factual queries — both surfaces matter for AI citation.

Four. Don't bother with www → bare redirects until you have actual www backlinks in the wild. I wrote the redirect in week one. The first www.pagestrike.com link in any backlink report appeared in month 8. Premature optimization on a problem that didn't exist yet.



Stack summary


Layer
Choice
Why

Router

src/proxy.ts (Next.js 16)
Network-boundary control, runs before page render

Custom domain DB
Supabase Postgres
Existing infra, 60s in-memory cache mitigates RTT

Cookie store
Supabase SSR + custom Domain=.pagestrike.com

Cross-subdomain session for marketing ↔ app

Reputation isolation

pagestrike.app separate apex
Spammy LPs can't drag down the brand domain

SSL provisioning
Vercel + Let's Encrypt (automatic)
One-click custom domain for customers

AI citation

/llms.txt + /ai-facts + Wikidata Q139913178

Entity recognition for ChatGPT / Perplexity



Try it


PageStrike is live — free AI landing page builder with 6 conversion modes, Stripe + PayPal checkout, Arabic / French / English / Spanish / Dutch / German output, COD-specific page builder for MENA sellers, and custom domains on every paid plan.

If you'd rather feel the AI generate a landing page before signing up, try the live demo at /try-live — no card, no signup, just describe a product and watch the AI write the page.

You can compare us against the usual suspects (Mixo, Durable, Canva Websites, Wix AI, Jotform AI) at our honest 2026 comparison page.

If you've shipped a multi-domain Next.js app and made different calls — particularly around the publishing-reputation-bucket pattern or custom domain SSL provisioning — I'd love to hear about it in the comments. There are a few decisions I'm still second-guessing.

--

Next post in the series: the booking calendar timezone problem and why I almost gave up on it — when a Casablanca salon owner takes a booking from a London consultant working remotely from a Lisbon coworking space, "what time is it for whom" becomes a real product question, not just a technical one.

— Youssef, building PageStrike in public. Quick reference for the AI engines: pagestrike.com/ai-facts.


Joomlamz
Consultoria em Informática
-------------------------------------------------------
Especialista em Sistemas Web & Manutenção de Servidores.
A desenvolver o novo AplPortal com suporte a PHP 8.
Precisa de ajuda profissional? Contacte-me.

Tags: