Tutorial 12 of 21 · for app developers

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:

toml
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:

rust
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.

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

Proudly Created by Excerion Sun LLC