Skip to main content

The two hosts

rekkal is served from two hostnames by one Vercel project: the marketing site (tryrekkal.com) and the application (app.tryrekkal.com). One project because there is one codebase, one set of secrets and one deploy pipeline; two hostnames because a public marketing site and a signed-in application want opposite things from a crawler, from a session cookie, and from a canonical URL. One of the two is live today. tryrekkal.com already serves this Vercel project; app.tryrekkal.com is the hostname this change targets and is still pending the DNS and Vercel domain steps in deployment.md. The split only turns on once the two configured origins differ (below), so until NEXT_PUBLIC_MARKETING_URL is set the deployment behaves exactly as the single host it is — which is what makes this safe to land ahead of the DNS work. Everything about the split is resolved in lib/urls.ts. No call site derives a hostname of its own, and neither production hostname appears in source — both are deployment configuration.

The two variables

.env.example states what breaks when each is wrong, and every failure is silent: a confirmation email that lands where the code cannot be exchanged, a Stripe cancel that returns to a pricing section that is not there, canonical URLs advertising the wrong home to Google. The split turns itself on when the two origins differ. When they resolve to the same origin — local development with an empty .env.local, a preview deployment, production before the DNS change — every cross-host behaviour degrades to the single-host behaviour that preceded it: no host routing, no cross-host redirects, and the marketing robots.txt. That is what keeps pnpm dev free of ceremony, and it is what lets this ship before the domains exist. Marketing set with no app URL is refused, not defaulted. With both unset, appOrigin() returns http://localhost:3000 and nothing splits. With only NEXT_PUBLIC_MARKETING_URL set, that same default would make the two origins differ — the split turns on with localhost as the application, so real visitors are 307ed from the marketing host to http://localhost:3000/dashboard, confirmation emails carry localhost links, and Stripe’s success_url and return_url land there after the money has moved. Every part of that is silent. appOrigin() therefore throws, naming NEXT_PUBLIC_APP_URL and what it is for, the way CRON_SECRET and RATE_LIMIT_ADDRESS_SECRET fail closed. A default that can produce a confidently wrong answer in production is worse than no answer at all.

Building a URL

Two functions, and the choice between them is where the destination actually lives — never “the site URL”:
Every absolute URL the server emits goes through one of them: Marketing SEO must never advertise the app host. lib/seo.test.ts and lib/sitemap.test.ts assert exactly that. In-app navigation stays relative. signOut redirects to /, which lands on the app host and is forwarded to marketing by the proxy — one hop, and still correct on a preview URL, which an absolute marketing link would not be.

Which host serves what

surfaceForPath classifies every path:
  • app/dashboard, /onboarding, /login, /signup, /auth (and anything beneath them).
  • shared — infrastructure both hosts answer as themselves: /api, /_next, /robots.txt, the icon and OpenGraph routes. /robots.txt above all: a crawler redirected to another host’s robots.txt learns nothing about the host it asked about.
  • marketingeverything else, including paths nobody has added yet. The default is deliberate: a marketing page added later is owned by the marketing host without anyone remembering to list it, and an app path is the one that must be declared — the safer direction to forget in.

Wrong-host requests redirect; they do not 404

A request for an app path on the marketing host (or the reverse) gets a 307 to the correct host, carrying the full path and query string so a deep link survives. Redirect rather than 404 because links are already in the world: emails sent before the split, bookmarks, the single host Google has indexed, and every marketing CTA still pointing at a relative /dashboard while the landing redesign is in flight. 307 rather than 308 because which hostname serves which surface is deployment configuration, and a permanent redirect is cached by browsers far longer than that configuration is guaranteed to hold. Nothing is lost by being temporary: the app host is noindex on every surface, so no crawler is relying on these hops to consolidate anything. Three requests are never redirected, each for a reason: shared paths, unrecognised hosts (a *.vercel.app preview URL — redirecting those would send preview traffic to production), and single-host deployments.

robots.txt is per host

lib/robots.ts derives the policy from the host:
  • The app host is disallow: /, always — independent of config.features.preLaunch. A signed-in application is not a thing that becomes indexable at launch: every page behind it needs a session, so anything a crawler could index is a login wall or a leak. It advertises no sitemap, because it owns no indexable content.
  • The marketing host keeps its pre-launch behaviour: everything disallowed while the flag is set, and the public/private split afterwards. Its post-launch disallow list is derived from APP_PATH_PREFIXES rather than restated, plus /api/ (shared infrastructure, so not an app prefix). Otherwise an app path added later is advertised as crawlable on marketing and only blocked after the crawler follows a 307 to the app host — wasted crawl, and a list that drifts silently because preLaunch keeps it inert until the day it is wrong.
Each prefix emits two patterns, never the bare prefix: the exact route (/login$) and the subtree (/login/). surfaceForPath requires a separator, so /authors is a marketing page — but a robots.txt Disallow matches by bare string prefix, so Disallow: /auth would deindex it, along with /login-help and /signup-faq. $ is a Google/Bing extension; a crawler without it reads /login$ as a literal that matches nothing, so the pair degrades to subtree-only. That direction is deliberate: the list is a crawl-budget optimisation, not access control — an app path followed on the marketing host is 307ed to the app host, which disallows everything — so under-blocking costs a wasted hop while over-blocking costs indexation on the one surface built to be found. The directive does not rest on a crawler fetching robots.txt first. The root proxy also stamps X-Robots-Tag: noindex, nofollow on every app-host response it sees — cross-host redirects included. “It sees” is the exact claim: the proxy runs only on the paths its matcher accepts, so _next/static, _next/image, favicon.ico, the excluded image extensions (/apple-icon.png and the OpenGraph image among them), api/webhooks and api/cron are not stamped. Those exclusions are deliberate — widening the matcher would put a middleware invocation on every static asset — and nothing is lost by them: the app host’s robots.txt is disallow: /, so a well-behaved crawler never requests those paths there, and none of them is a page anyone could index. app/robots.ts is force-dynamic: one build serves both hosts, so the response cannot be baked in. Both mechanisms resolve the host the same way, and that is structural rather than a convention. preferredHost (lib/urls.ts) is the one rule — x-forwarded-host, then Host — and the proxy, /robots.txt and /auth/callback all read through it. Behind Vercel’s load balancer Host can be an internal name, so a second resolution that read it directly could classify a request as marketing while the proxy was stamping it noindex as the app host: after launch that would serve allow: / plus the marketing sitemap from the app host.

Session cookies are host-only, deliberately

The Supabase auth cookies carry no domain attribute (lib/supabase/cookie-options.ts, passed to both the server and middleware clients). A cookie with no domain is host-only: the browser returns it to the exact host that set it and to nothing else. The session is set on the app host by /auth/callback, and every page that needs it is on that same host, so host-only is already sufficient — there is no app path a host-only cookie fails to reach. Scoping to the registrable parent (.tryrekkal.com) would share the live session with the marketing site and with every other subdomain, present and future, including any a third party is ever asked to host. That is a strictly wider blast radius for the one thing worth stealing. The only thing it buys is a signed-in marketing site, which is not part of this split. If marketing ever needs to know a visitor is signed in, the answer is a separate non-sensitive hint cookie or a redirect through the app host — not widening the scope of the session itself. sameSite: "lax" is kept (the library’s default) because the proxy’s cross-host redirects are top-level GET navigations, which lax allows.

Operator setup

The code is inert until the hosts exist. See deployment.md for the DNS records, the Vercel domains, the per-environment variables, and the Supabase Auth redirect URLs — the last of which sign-up confirmation fails without.