> ## Documentation Index
> Fetch the complete documentation index at: https://docs.tryrekkal.com/llms.txt
> Use this file to discover all available pages before exploring further.

# Hosts

# 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](./deployment.md#serve-the-two-hosts). The split only turns on once the two configured origins differ ([below](#the-two-variables)), 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`](../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

| Variable                    | What it is                                                  | Required?                                      |
| --------------------------- | ----------------------------------------------------------- | ---------------------------------------------- |
| `NEXT_PUBLIC_APP_URL`       | Origin of the **application** (`https://app.tryrekkal.com`) | Yes in production                              |
| `NEXT_PUBLIC_MARKETING_URL` | Origin of the **marketing site** (`https://tryrekkal.com`)  | Optional — falls back to `NEXT_PUBLIC_APP_URL` |

[`.env.example`](../.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":

```ts theme={null}
import { appUrl, marketingUrl } from "@/lib/urls";

appUrl("/auth/callback");   // https://app.tryrekkal.com/auth/callback
marketingUrl("/#pricing");  // https://tryrekkal.com/#pricing
```

Every absolute URL the server emits goes through one of them:

| Consumer                                | Host          | Why                                                          |
| --------------------------------------- | ------------- | ------------------------------------------------------------ |
| `emailRedirectTo` (sign-up, magic link) | **app**       | The callback exchanges a code for a session cookie           |
| `/auth/callback` redirects              | **app**       | Both its destinations (`/dashboard`, `/login`) are app paths |
| Stripe `success_url`, `return_url`      | **app**       | Settings page, reading what the webhook wrote                |
| Stripe `cancel_url`                     | **marketing** | It returns to the pricing section, a marketing page          |
| Welcome & subscription emails           | **app**       | Their one link is the dashboard                              |
| `metadataBase`, canonical, OpenGraph    | **marketing** | A canonical URL names the host to index                      |
| `sitemap.xml`, JSON-LD                  | **marketing** | Public identity and public content                           |

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.
* **marketing** — **everything 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`](../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](./deployment.md#serve-the-two-hosts) 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.
