---
title: "Move a loader into Rust"
audience: "Rust developers who want a loader written in Rust"
description: "You'll take one loader out of TypeScript and answer it from Rust. The page, the route and the tests don't change. At the end you'll load the page and see your…"
snippet_caption: "src/main.rs"
snippet: "use std::sync::Arc;\n\nuse snapfire_fsr_core::{Data, Value};\nuse snapfire_fsr_host::Host;\n\n#[tokio::main]\nasync fn main() -> std::io::Result<()> {\n  let host = Host::from_cwd()\n    .and_then(|builder| {\n      builder\n        .source_override(\"product.$id\", |ctx| async move {\n          let id = ctx.params.get(\"id\").cloned().unwrap_or_default();\n          let product: Data = [\n            (\"id\".to_owned(), id.into()),\n            (\"name\".to_owned(), \"Chore coat\".into()),\n            (\"blurb\".to_owned(), \"Answered by Rust.\".into()),\n            (\"price_cents\".to_owned(), 14800i64.into()),\n          ]\n          .into_iter()\n          .collect();\n          Ok([(\"product\".to_owned(), Value::Map(product))].into_iter().collect())\n        })\n        .build()\n    })\n    .map_err(std::io::Error::other)?;\n\n  print!(\"{}\", host.report());\n  let listen = host.listen().to_owned();\n  Arc::new(host).serve(&listen).await\n}"
---

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

You'll take one loader out of TypeScript and answer it from Rust. The page, the route and the tests don't change. At the end you'll load the page and see your Rust string in the markup.

<Install captured="0.x" />

Start from the app you built in [320](https://snapfirers.com/fsr/learn/320-port-a-next-js-route), which has a `/product/{id}` route whose loader is TypeScript.

## Find the name you're taking over

The plan file names everything. Build the app and read the `sources` column:

```shell-session
$ fsr build shop/app --verbose
sources   $root                  lowered     routes/page.loader.ts
          product.$id            lowered     routes/product/[id]/page.loader.ts
```

`product.$id` is the name. A route directory `product/[id]` gives the source `product.$id`. You bind that string in Rust.

## Add a Cargo project beside the app

Up to now the stock `fsr` binary has been serving this. To write Rust you need your own binary. Put this `Cargo.toml` next to `app/` and `config/`:

```toml title="Cargo.toml"
[package]
name = "shop"
version = "0.1.0"
edition = "2024"

[dependencies]
snapfire_fsr = "0"
snapfire_fsr_core = "0"
snapfire_fsr_host = "0"
tokio = { version = "1", features = ["macros", "rt-multi-thread"] }
```

A client on the mock transport needs no feature. Against a real service over HTTP turn on the host's `http` feature: `snapfire_fsr_host = { version = "0", features = ["http"] }`. `[auth]` needs `auth`, a `.graphql` client `graphql` and a `.proto` client `grpc`.

## Write the override

Here is `src/main.rs` in full. It compiles as written.

```rust title="src/main.rs"
use std::sync::Arc;

use snapfire_fsr_core::{Data, Value};
use snapfire_fsr_host::Host;

#[tokio::main]
async fn main() -> std::io::Result<()> {
  let host = Host::from_cwd()
    .and_then(|builder| {
      builder
        .source_override("product.$id", |ctx| async move {
          let id = ctx.params.get("id").cloned().unwrap_or_default();
          let product: Data = [
            ("id".to_owned(), id.into()),
            ("name".to_owned(), "Chore coat".into()),
            ("blurb".to_owned(), "Answered by Rust.".into()),
            ("price_cents".to_owned(), 14800i64.into()),
          ]
          .into_iter()
          .collect();
          Ok([("product".to_owned(), Value::Map(product))].into_iter().collect())
        })
        .build()
    })
    .map_err(std::io::Error::other)?;

  print!("{}", host.report());
  let listen = host.listen().to_owned();
  Arc::new(host).serve(&listen).await
}
```

`Data` is a `ValueMap`, which you build by collecting `(String, Value)` pairs. A nested object is a `Value::Map`. The keys have to match what the page destructures, because the page is unchanged and still expects `product`.

`ctx.params` is the same `params` the TypeScript loader read. `ctx` also carries `query`, the session cell and the service handle, so your Rust calls the services through the same registry and against the same contract.

`Host::from_cwd` takes the directory you run the binary in as the project root, not the app directory. It reads `config/`, the plan and the contracts from there. So the binary serves whichever project it runs inside: this one under `cargo run`, the deployed tree in [410](https://snapfirers.com/fsr/learn/410-ship-it#ship-an-app-with-its-own-crate).

## Run it

```shell-session
$ cargo run
routes    /                      plan file
sources   $root                  lowered
          product.$id            rust override
```

One row changed. Open `/product/9` and the page renders "Answered by Rust." inside the same markup the TSX component produced. The component gets the same props and doesn't know they came from Rust.

## Rules for claiming a name

The host refuses to guess when the plan file and Rust both name something. Each conflict is a boot error that names the source.

`.source("pricing", f)` on a name the plan doesn't lower just binds it. That's how you add a source that has no TypeScript at all.

`.source("product.$id", f)` on a name the plan lowered is refused:

```shell-session
claimed by the plan file and by Rust; mark the Rust one as an override
```

Use `.source_override` instead. Every loader Rust replaces is then marked as an override in `main.rs`.

> `.source_override("products.$id", f)` when no such source exists fails at boot. That's almost always a rename that left the override dangling. A dangling override would leave the TypeScript running while you believed Rust had taken it.

## Override an action

An action override takes the context and the decoded input and returns a `Value`. Chain it onto the builder in `main`, after `.source_override(...)` and before `.build()`:

```rust title="src/main.rs"
.action_override("product.$id.addToCart", |_ctx, input| async move {
  let quantity = match &input {
    Value::Map(fields) => fields.get("quantity").cloned().unwrap_or(Value::Int(0)),
    _ => Value::Int(0),
  };
  Ok(Value::Map([("count".to_owned(), quantity)].into_iter().collect()))
})
```

`input` arrives as a `Value`, so you match on `Value::Map` to read a field off it. There is no `.get()` on `Value` itself.

## Add a route in Rust

`.route` binds a pattern the plan file doesn't have, so it adds a route and doesn't override anything. A `Plan` says which module renders and what fills the shell's slot. Add `use snapfire_fsr::Plan;` at the top of `main.rs` and chain the route onto the builder:

```rust title="src/main.rs"
.route("/about", Plan::of("shell#document").slot("content", Plan::of("routes/page.tsx#default")))
```

## Render a module yourself

An evaluator answers a set of module ids with a Rust renderer. It has one method, which returns a stream of chunks. Put this in `main.rs` above `main`:

```rust title="src/main.rs"
use futures_util::stream;
use snapfire_fsr_core::{ModuleId, Node};
use snapfire_fsr_runtime::evaluator::{Chunk, Evaluator, NodeChunks};

struct Banner;

impl Evaluator for Banner {
  fn evaluate(&self, _module: &ModuleId, _props: &Data) -> NodeChunks {
    let node = Node::raw("<p class=\"banner\">Rendered by Rust.</p>");
    Box::pin(stream::iter([Ok(Chunk::Node(node))]))
  }
}
```

Then chain it onto the builder with the module ids it answers:

```rust title="src/main.rs"
.evaluator(|m: &ModuleId| m.path == "src/Banner.tsx", std::sync::Arc::new(Banner))
```

That needs `snapfire_fsr_runtime` and `futures-util` in your `Cargo.toml`. `snapfire_fsr_tera` uses the same mechanism, which is how a Tera template and a React page compose into one document.

## Read the report

```shell-session
routes    /                      plan file
          /about                 rust
sources   $root                  lowered
          product.$id            rust override
actions   product.$id.addToCart  rust override
```

## Calling Rust without taking a name

Sometimes the loader is fine and one computation belongs in Rust. Mark an `impl` block instead. The build reads every `.rs` file under `src/`, so this can sit in `main.rs` above `main`. It needs `snapfire_fsr_macros` in your `Cargo.toml`:

```rust title="src/main.rs"
use snapfire_fsr_macros::native;

#[derive(Clone, Default)]
pub struct Digest;

#[native]
impl Digest {
  pub fn words(&self, bodies: Vec<String>) -> i64 {
    bodies.iter().map(|b| b.split_whitespace().count() as i64).sum()
  }
}
```

Register it on the builder under the name the TypeScript uses:

```rust title="src/main.rs"
.native("digest", Arc::new(Digest))
```

The build writes declarations into `generated/native.d.ts`, so a loader reads `native` off its context and calls the method with its arguments as an object:

```ts title="app/routes/product/[id]/page.loader.ts" {3,5}
import type { Ctx } from "@snapfire/fsr/host";

export async function load({ params, services, native }: Ctx<"/product/{id}">) {
  const product = await services.shop.getProduct({ id: params.id });
  return { product, words: native.digest.words({ bodies: [product.blurb] }) };
}
```

The work happens in Rust. A native call is synchronous, so it needs no `await`. This needs no override and doesn't take a source name.

## Services from Rust

Rust calls services through the same registry as the TypeScript loader did, checked against the same contract document.

Next up: [510. Add FSR to an existing Rust service](https://snapfirers.com/fsr/learn/510-add-fsr-to-an-existing-rust-service).
