Tutorial 18 of 54 · for anyone running one app on two domains · Markdown

Run one app on two domains

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.

Before you start

Install from crates.io. You need no Node and no package manager.

cargo install snapfire_compiler
cargo install snapfire_fsr_cli
fsr --version

Every command and screenshot on this page was captured with fsr 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.

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.

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.

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.

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:

app/routes/page.loader.ts
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 covers keeping the rest of a page cacheable.

Next up: 310. Put a docs site inside your app, where a whole second application mounts inside this one.