Put a docs site inside your app
You'll mount a whole second FSR application inside the first one, at a prefix, sharing its navbar and its theme. Two teams, two repositories, two release cadences, one binary and one domain. This page you're reading is one.
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.
A mount has two halves
A mount is written in two places. The site says what it's. The shell says where it goes. fsr sites link writes both so you don't have to.
$ fsr new docs --site --at /docs --into ../shell
wrote docs/.gitignore
wrote docs/config/app.toml
wrote docs/app/importmap.json
wrote docs/app/src/main.ts
wrote docs/app/routes/layout.tsx
wrote docs/app/routes/page.loader.ts
wrote docs/app/routes/page.tsx
wrote docs/app/routes/not-found.tsx
wrote docs/app/routes/error.tsx
wrote docs/app/styles/app.css
types @snapfire/fsr-authoring fsr 0.13.0
types @snapfire/fsr-client fsr 0.13.0
wrote [site] docs at /docs in docs/config/app.toml
wrote [sites.docs] in ../shell/config/app.toml
next fsr use docs/app react # only if the application wants React; also vue, elements, htmx or tera
next fsr dev docs/app
The site's own config gains a [site] section naming itself, its prefix and the shell it renders inside. The shell's config gains a [sites.docs] row naming the artifact directory.
Note what it did not write. There is no app/vendor/ and the import map has three entries. A mounted site takes its frameworks from the shell, so ignore that fsr use docs/app react line unless you intend to run this site standalone as well.
What a mount does
At boot the shell reads the artifact, prefixes every id in it with the site's name and grafts the whole route subtree into the shell's root layout. So the site's pages arrive inside your navbar, your theme class and your footer, with the shell's store seeds already in place. The site's author writes a page. They don't write your chrome.
Nesting is exactly one level. A site can't mount sites. The host refuses an artifact that tries.
What the shell ignores
Not everything in the site's config survives. The report says what it dropped.
The site's static roots are taken only when they sit under its prefix. A root it configured elsewhere is dropped, so it can't claim /static/js/vendor and shadow the shell's. The roots the host infers are already under the prefix, so a site that vendors a package of its own gets it at /fsr/docs/static/js/vendor, its stylesheets and icons the same way. Its [session], [auth], [locales] and [cache] are ignored, because a visitor has one session and one identity across the whole binary, not one per mounted site.
Its import map entries are merged in where the shell lacks them, which is why the site should use the same URLs the shell does. Identical URLs mean the merge is a no-op.
The site doesn't vendor React
This is the part that used to bite. A mounted site renders under the shell's React, because the shell's import map overrides the site's on every shared specifier. So the site's own copy would be dead weight the browser never loads.
You don't have to keep the two in step by hand. The shell's generated/shell.json records the exact version of each framework it vendors and the site's build reads it out of there. Nothing to vendor, nothing to bump twice.
What the build does is stop you getting it wrong. Vendor React in the site anyway at a different version and it names both copies and tells you which one wins:
$ fsr build sites/docs/app
react@19.0.0 is recorded in `sites/docs/app/vendor/.fsr-vendor.json`, but `app/generated/shell.json` serves react@18.3.1; the browser loads the shell's copy
A site can still vendor something of its own, a chart library say, which gets served under the site's prefix like everything else. That means the import map has to name the prefixed URL. Copy an entry over from the shell and forget to change it and the build tells you what to write:
$ fsr build sites/docs/app
`sites/docs/app/importmap.json` maps `react` to `/static/js/vendor/react/react.bundle.mjs`, but this application serves its vendor tree from `/fsr/docs/static/js/vendor`; write `/fsr/docs/static/js/vendor/react/react.bundle.mjs` or run `fsr add` on the package, which rewrites every vendored entry
Pinning a framework to a version the shell doesn't serve gets refused for the same reason: the shell's map overrides the site's at mount, so the pin would never take effect.
See what's mounted
$ fsr sites list .
site docs /fsr/docs path 717420d0f437e6f7
sites/docs
site learn /fsr/learn path 6cd0664629049368
sites/learn
path means the mount names a working tree rather than a pinned release. The hash is the content hash of everything the artifact ships:
$ fsr sites hash sites/docs
site docs at /fsr/docs
hash 717420d0f437e6f7
ships 48 files, 630.8 KiB
The running host will tell you the same thing over HTTP:
$ curl -s localhost:11110/__fsr/sites
{"sites":[{"name":"docs","at":"/fsr/docs","version":"path","hash":"9b4d14c970386547"}]}
Swap a site without restarting
This is why you'd bother. A docs team ships docs without anyone restarting the product.
Two things have to be true. A host missing either has no reload route at all. The dependency carries the feature:
snapfire_fsr_sites = { version = "0", features = ["reload_route"] }and the application installs the mounter, which is what tells the host how to mount the sites a second time:
let builder = snapfire_fsr_sites::mountable(builder);
snapfire_fsr_sites::mount_all(builder)?mount_all on its own mounts the sites at boot and leaves the process with no way to do it again. That's the one to watch for. Everything looks fine until the day you need the reload and get a 404.
With both in place, POST /__fsr/sites/reload re-reads every artifact and swaps the tables. Requests in flight finish on the tables they started with. Sessions survive, so nobody is signed out by a docs deploy.
Pin it for production
While you're developing, artifact = "sites/docs" pointing at a directory is what you want. For a release, pack the artifact and pin the hash:
$ fsr sites pack sites/docs --version 1.4.0 -o docs-1.4.0.tar.gz
packed docs 1.4.0
hash 75b457f6f297f8c9
Install it into the shell and the mount is pinned to that hash. An artifact whose contents drift from the pin is refused rather than served, so a half-copied deploy fails loudly instead of serving a mixture.
No proxy rules, no iframe
No reverse proxy rule, no second deployment, no iframe. The shared header is the shell's, so neither side publishes a component library to get it.
Next up: 080. Port a Next.js route, side by side with the code you already have.