---
title: "Run one app on two domains"
audience: "anyone running one app on two domains"
description: "You'll make one binary answer for two host names and serve something different on each. The loader reads which domain it was asked on. A forged Host header…"
snippet_caption: "config/app.toml"
snippet: "[server]\nhosts = [\"acme.example\", \"widgets.example\"]"
---

import { Install } from "@src/ui/Install";

You'll make one binary answer for two host names and serve something different on each. The loader reads which domain it was asked on. A forged `Host` header can't change what it reads.

<Install captured="0.x" />

## Declare the hosts

A `Host` header is whatever the client wrote, so FSR ignores it until you say which names your deployment actually answers on.

```toml title="config/app.toml"
[server]
hosts = ["acme.example", "widgets.example"]
```

The list is an allowlist of exact names. A request's `Host` is lowercased and compared whole, port included. `localhost:3000` and `localhost` are two different entries. Anything not on the list reads as null instead of the string the caller sent, so your code only ever sees a name from the list.

## Read it in a loader

`ctx.host` is `string | null`. Null means either you left the key out or the request named something you don't serve.

```ts title="app/routes/page.loader.ts"
import type { Ctx } from "@snapfire/fsr/host";

const BRANDS: Record<string, { name: string; accent: string }> = {
  "acme.example": { name: "Acme", accent: "#c2410c" },
  "widgets.example": { name: "Widgets Inc", accent: "#1d4ed8" },
};

export async function load({ host }: Ctx<"/">) {
  const brand = BRANDS[host ?? ""] ?? { name: "Acme", accent: "#c2410c" };
  return { brand };
}
```

Both brands come from the same binary and plan. The server picks one before any markup is rendered.

## Check the boot report

While the key is set, `fsr serve --verbose` prints the list on every start.

```shell-session
hosts     acme.example
          widgets.example
          ctx.host reads the request's Host against these; the server in front must set it; a client otherwise names its own
```

## Set the header in the proxy

`ctx.host` can only be trusted as far as the server that terminates the connection. A value on your allowlist looks the same whether your proxy set it or someone typed it into a curl command.

With nginx:

```
proxy_set_header Host $host;
```

under a `server_name` that matches, plus a default server for the socket so a request naming something you don't serve is answered there rather than passed through. Without the default server a client can pick which of your brands it gets, so decide whether that matters for you.

## Canonical links

`document.origin` is your deployment's one preferred origin. It does not change with the host a request arrived on. A canonical link exists to collapse two names serving the same pages into one. If you emit one per host, you claim both are the original, which is the duplicate the link is meant to prevent.

If you genuinely serve a different site per host, build the canonical yourself from `ctx.host`. A loader module can export `meta` beside `load`. It receives what `load` returned and sets the document's title and head. Return the host from `load` so `meta` can read it:

```ts title="app/routes/page.loader.ts" {1-2,11,14-17}
import type { Ctx, DataOf, MetaCtx } from "@snapfire/fsr/host";
import { canonical } from "@snapfire/fsr/head";

const BRANDS: Record<string, { name: string; accent: string }> = {
  "acme.example": { name: "Acme", accent: "#c2410c" },
  "widgets.example": { name: "Widgets Inc", accent: "#1d4ed8" },
};

export async function load({ host }: Ctx<"/">) {
  const brand = BRANDS[host ?? ""] ?? { name: "Acme", accent: "#c2410c" };
  return { brand, host: host ?? "acme.example" };
}

export const meta = ({ data }: MetaCtx<DataOf<typeof load>>) => ({
  title: data.brand.name,
  head: [canonical(`https://${data.host}/`)],
});
```

The host leaves an absolute URL in a canonical link as you wrote it.

## Prerendering

> A loader that reads `ctx.host` is never prerendered, because each configured host gets a different answer and a prerendered file can hold only one. [100](https://snapfirers.com/fsr/learn/100-cache-loaders-and-renders) covers keeping the rest of a page cacheable.

Next up: [310. Put a docs site inside your app](https://snapfirers.com/fsr/learn/310-put-a-docs-site-inside-your-app), where a whole second application mounts inside this one.
