Tutorial 11 of 21 · for anyone running one app on two domains

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. The wrong header can't forge it.

Before you start

Straight from crates.io. No Node, 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.

Nothing reads the Host header by default

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

toml
[server]
hosts = ["acme.example", "widgets.example"]

The list is an allowlist, not a pattern. 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 rather than the string the caller sent, so a body only ever sees a name you named.

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
import type { Ctx } from "@snapfire/fsr";

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 };
}

One binary. One plan. Two brands, chosen on the server before any markup exists.

Check the boot report

The host tells you the arrangement is live, every start, while the key is set.

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

The part people get wrong

Read that last line again. ctx.host is exactly as trustworthy as whatever terminates the connection. A value that happens to be on your allowlist is indistinguishable from the same value typed by hand into a curl command.

So the proxy has to set it. With nginx:

text
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, someone can pick which of your brands they get. Usually harmless, sometimes not. Decide it on purpose.

document.origin is your deployment's one preferred origin, not the host a request arrived on. Those are different on purpose. A canonical link is there to collapse two names serving the same pages. Emit one per host and you claim both are the original, which is the duplicate you were trying to avoid.

If you genuinely serve a different site per host, build the canonical yourself from ctx.host:

ts
export const meta = ({ data }: MetaCtx<Data>) => ({
  title: data.title,
  head: [canonical(`https://${data.host}/docs/${data.slug}`)],
});

An absolute URL passes through untouched.

One catch

A loader that reads ctx.host is never prerendered, because two configured hosts are two different answers and a prerendered file is one. You traded the static file for the per-domain content. That's the right trade when you meant it. 100 is where to look when you did not.

One deployment, not two

One binary, one plan, one build. No middleware rewriting paths by domain, no environment variable per brand.

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

Built with SnapFire FSR. Pure Rust runtime, zero Node.js on the server.

Proudly Created by Excerion Sun LLC