Tutorial 19 of 54 · for app developers · Markdown

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. The two applications can live in separate repositories with separate owners and release schedules. One binary still serves both on one domain. The page you're reading is a mounted site.

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.

Create the site#

A mount is written in two places. The site's config names the site and the shell's config says where it mounts. fsr sites link writes both for you.

$ fsr new docs --site --at /docs --into ../shell
wrote     docs/.gitignore
wrote     docs/config/app.toml
wrote     docs/config/development.toml
wrote     docs/config/production.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/docs.css
wrote     docs/app/generated/plan.sexp
wrote     docs/app/generated/contracts/schemas.json
wrote     docs/app/.fsr-bundle/routes/layout.tsx
wrote     docs/app/.fsr-bundle/routes/error.tsx
wrote     docs/app/.fsr-bundle/routes/not-found.tsx
wrote     docs/app/.fsr-bundle/routes/page.tsx
wrote     docs/app/generated/shell.d.ts
wrote     docs/app/generated/uploads.d.ts
wrote     docs/app/generated/assets.d.ts
wrote     docs/app/generated/assets.json
wrote     docs/app/generated/assets.map.json
wrote     docs/app/generated/native.d.ts
wrote     docs/app/generated/services.d.ts
wrote     docs/app/generated/elements.d.ts
wrote     docs/app/generated/head.ts
wrote     docs/app/generated/fsr.ts
wrote     docs/app/generated/islands.ts
wrote     docs/app/generated/client.ts
wrote     docs/app/generated/testing.ts
wrote     docs/app/tsconfig.json
wrote     docs/app/tsconfig.build.json
types     @snapfire/fsr/authoring      fsr 0.16.4
types     @snapfire/fsr/client         fsr 0.16.4
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.

There is no app/vendor/ and the import map is empty. 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 supplies the pages. The navbar, theme and footer come from the shell.

Nesting goes one level deep. A site can't mount other sites and the host refuses an artifact that tries.

What the shell ignores#

The shell drops some of the site's config and the report lists 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.

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. When the URLs are identical the merge changes nothing.

Frameworks in a mounted site#

A mounted site renders under the shell's React, because the shell's import map overrides the site's on every shared specifier. The browser never loads a copy the site vendors itself.

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, so the two stay in step.

If you vendor React in the site anyway at a different version, the build 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 lets a docs team ship docs without anyone restarting the product.

The reload route needs the reload_route feature and the mounter. Without either one the route does not exist. Both live in the shell's own binary, so the shell needs a Cargo project beside its app/ and config/. Turn on the feature in its dependencies:

Cargo.toml
[package]
name = "shell"
version = "0.1.0"
edition = "2024"

[dependencies]
snapfire_fsr_host = "0"
snapfire_fsr_sites = { version = "0", features = ["reload_route"] }
tokio = { version = "1", features = ["macros", "rt-multi-thread"] }

and install the mounter in main, which tells the host how to mount the sites a second time:

src/main.rs
use std::sync::Arc;

use snapfire_fsr_host::Host;

#[tokio::main]
async fn main() -> std::io::Result<()> {
  let builder = Host::from_cwd().map_err(std::io::Error::other)?;
  let builder = snapfire_fsr_sites::mountable(builder);
  let builder = snapfire_fsr_sites::mount_all(builder).map_err(std::io::Error::other)?;
  let host = builder.build().map_err(std::io::Error::other)?;
  print!("{}", host.report());
  let listen = host.listen().to_owned();
  Arc::new(host).serve(&listen).await
}

mount_all on its own mounts the sites at boot and leaves the process with no way to do it again. The reload route then answers 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 don't match the pin is refused, so a half-copied deploy fails instead of serving a mix of old and new files.

Next up: 320. Port a Next.js route, side by side with the code you already have.