---
title: "Put a docs site inside your app"
audience: "app developers"
description: "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…"
snippet_caption: "terminal"
snippet: "$ fsr new docs --site --at /docs --into ../shell\nwrote     docs/.gitignore\nwrote     docs/config/app.toml\nwrote     docs/config/development.toml\nwrote     docs/config/production.toml\nwrote     docs/app/importmap.json\nwrote     docs/app/src/main.ts\nwrote     docs/app/routes/layout.tsx\nwrote     docs/app/routes/page.loader.ts\nwrote     docs/app/routes/page.tsx\nwrote     docs/app/routes/not-found.tsx\nwrote     docs/app/routes/error.tsx\nwrote     docs/app/styles/docs.css\nwrote     docs/app/generated/plan.sexp\nwrote     docs/app/generated/contracts/schemas.json\nwrote     docs/app/.fsr-bundle/routes/layout.tsx\nwrote     docs/app/.fsr-bundle/routes/error.tsx\nwrote     docs/app/.fsr-bundle/routes/not-found.tsx\nwrote     docs/app/.fsr-bundle/routes/page.tsx\nwrote     docs/app/generated/shell.d.ts\nwrote     docs/app/generated/uploads.d.ts\nwrote     docs/app/generated/assets.d.ts\nwrote     docs/app/generated/assets.json\nwrote     docs/app/generated/assets.map.json\nwrote     docs/app/generated/native.d.ts\nwrote     docs/app/generated/services.d.ts\nwrote     docs/app/generated/elements.d.ts\nwrote     docs/app/generated/head.ts\nwrote     docs/app/generated/fsr.ts\nwrote     docs/app/generated/islands.ts\nwrote     docs/app/generated/client.ts\nwrote     docs/app/generated/testing.ts\nwrote     docs/app/tsconfig.json\nwrote     docs/app/tsconfig.build.json\ntypes     @snapfire/fsr/authoring      fsr 0.16.4\ntypes     @snapfire/fsr/client         fsr 0.16.4\nwrote     [site] docs at /docs in docs/config/app.toml\nwrote     [sites.docs] in ../shell/config/app.toml\nnext      fsr use docs/app react      # only if the application wants React; also vue, elements, htmx or tera\nnext      fsr dev docs/app"
---

import { Install } from "@src/ui/Install";

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.

<Install captured="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.

```shell-session
$ 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:

```shell-session
$ 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:

```shell-session
$ 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

```shell-session
$ 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:

```shell-session
$ 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:

```shell-session
$ 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:

```toml title="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:

```rust title="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:

```shell-session
$ 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](https://snapfirers.com/fsr/learn/320-port-a-next-js-route), side by side with the code you already have.
