Vue single-file components compiled to Rust render functions.
ferrovue lets a Rust server render a Vue 3 component's HTML with no JavaScript at run time, while
the browser hydrates that HTML with the same `.vue` file. It has two halves, released together
under the same version number:
| The compiler | Reads `<script setup lang="ts">` components and writes one Rust module per component (TypeScript, runs on Node) | npm: [`ferrovue`](https://www.npmjs.com/package/ferrovue) |
| The runtime | This crate: what the generated modules call, from escaping to `<RouterLink>` matching | crates.io: [`ferrovue`](https://crates.io/crates/ferrovue) |
```text
Greeting.vue ──(ferrovue compiler, Node)──▶ greeting.rs ──(your Rust server)──▶ HTML ──▶ Vue hydrates it
```
# The guarantee
ferrovue doesn't interpret templates. It takes what `@vue/compiler-sfc` compiles a template to for
server rendering, straight-line `_push` calls plus a few `@vue/server-renderer` helpers, and
translates that small, closed vocabulary into Rust that writes **the same bytes**. Vue still decides
element structure, attribute order, whitespace and fragment markers.
A single differing byte would be a hydration mismatch in the browser, so anything the compiler
can't translate exactly is a compile error that names the component and the construct. It never
guesses. The repository's conformance suite renders every fixture with Vue's `renderToString` and
with the generated Rust and compares the bytes; Vue then hydrates the recorded HTML with no
mismatch. A randomised differential tester does the same with components nobody wrote by hand.
# Quick start
The [guide](crate::guide) covers each step in detail; this is the whole path in brief.
**1. Install both halves.** The generated structs derive `serde::Serialize`, and the generated
`mod.rs` has a test-only helper that reads props from JSON.
```sh
pnpm add -D ferrovue # or npm / yarn
cargo add ferrovue serde --features serde/derive
cargo add --dev serde_json
```
**2. Configure.** `ferrovue.config.json` at the project root names the components and where the
Rust goes. Everything in `out` is replaced on each run.
```json
{
"components": "client/components",
"out": "src/generated"
}
```
**3. Write a component.**
```vue
<script setup lang="ts">
defineProps<{ name: string; unread: number; note?: string }>();
</script>
<template>
<p class="greeting">Hello, {{ name }}!<b v-if="unread">{{ unread }} new</b><i v-if="note">{{ note }}</i></p>
</template>
```
**4. Generate.**
```sh
npx ferrovue # writes src/generated/greeting.rs and src/generated/mod.rs
npx ferrovue --check # in CI: exits 1 if the committed modules are stale
```
**5. Include the modules and render.** Declare the module once, with `#[rustfmt::skip]` so that
`ferrovue --check` keeps comparing the files byte for byte:
```rust,ignore
#[rustfmt::skip]
mod generated;
```
Each component's module has a `Props` struct, built with `new` and a setter per optional prop, and
three ways to render:
```rust
# mod generated { pub mod greeting {
# // @generated by ferrovue from components/Greeting.vue. Do not edit: change the `.vue` file and run
# // `ferrovue`.
#
# use std::borrow::Cow;
#
# use ferrovue as fv;
#
# /// The component's name, as `data-island` carries it.
# pub const NAME: &str = "Greeting";
#
# /// The props `Greeting.vue` declares.
# #[derive(Debug, Clone, serde::Serialize)]
# #[cfg_attr(test, derive(serde::Deserialize))]
# pub struct Props<'a> {
# #[serde(rename = "name")]
# pub name: Cow<'a, str>,
# #[serde(rename = "unread")]
# pub unread: i64,
# #[serde(rename = "note", default, skip_serializing_if = "Option::is_none")]
# pub note: Option<Cow<'a, str>>,
# }
#
# impl<'a> Props<'a> {
# /// Props with its required fields, every optional one absent.
# pub fn new(name: impl Into<Cow<'a, str>>, unread: i64) -> Self {
# Props { name: name.into(), unread, note: None }
# }
#
# /// Set `note`, which is absent otherwise.
# pub fn note(mut self, note: impl Into<Cow<'a, str>>) -> Self {
# self.note = Some(note.into());
# self
# }
# }
#
#
# /// Write the component's server render into `out`.
# pub fn render(out: &mut String, props: &Props<'_>) {
# out.reserve(70 + props.name.len() + props.note.as_deref().map_or(0, str::len));
# out.push_str("<p class=\"greeting\">Hello, ");
# fv::escape_into(out, &props.name);
# out.push('!');
# if props.unread != 0 {
# out.push_str("<b>");
# fv::push_int(out, props.unread);
# out.push_str(" new</b>");
# } else {
# out.push_str("");
# }
# if let Some(n1) = props.note.as_deref().filter(|v| !v.is_empty()) {
# out.push_str("<i>");
# fv::escape_into(out, n1);
# out.push_str("</i>");
# } else {
# out.push_str("");
# }
# out.push_str("</p>");
# }
#
# /// The component's markup, for a maud page that shows it without hydrating it.
# pub fn html<'p, 'a>(props: &'p Props<'a>) -> fv::Html<'p, Props<'a>> {
# fv::Html::markup(props, render)
# }
#
# /// The component as an island the client hydrates.
# pub fn island<'p, 'a>(props: &'p Props<'a>) -> fv::Html<'p, Props<'a>> {
# fv::Html::island(NAME, props, render)
# }
# } }
use generated::greeting::{self, Props};
// `new` takes the required props; each optional one has a setter.
let props = Props::new("Ada", 3).note("welcome back");
// The markup alone, which the client never hydrates.
let html = greeting::html(&props).into_string();
assert_eq!(html, r#"<p class="greeting">Hello, Ada!<b>3 new</b><i>welcome back</i></p>"#);
// An island: the same markup in an element the client mounts on, with the props as JSON.
let island = greeting::island(&props).into_string();
assert!(island.starts_with(r#"<div data-island="Greeting" data-props="{"name":"Ada""#));
// Or straight into a buffer you already hold: no allocation of its own.
let mut page = String::from("<!doctype html><body>");
greeting::render(&mut page, &props);
// Every interpolated value is escaped.
let hostile = greeting::html(&Props::new("<script>", 0)).into_string();
assert_eq!(hostile, r#"<p class="greeting">Hello, <script>!<!----><!----></p>"#);
```
A component with `<style scoped>` writes the same `data-v-` ids as Vue's server renderer, computed
as `@vitejs/plugin-vue` computes them; see [`guide::scoped_styles`](crate::guide::scoped_styles).
On the client, the same component hydrates the page: `mountIslands` from `ferrovue/client` mounts
every island, or the page's own Vue app hydrates the whole document. See
[`guide::islands_and_hydration`](crate::guide::islands_and_hydration).
# What the generated code calls
You rarely call most of this crate yourself; the generated modules do. The parts you do touch are
the values a render needs besides its props.
| [`Slot`], a generated `Slots` struct, scoped-slot closures | renders `<slot>` or `<RouterView>` |
| [`Router`] (from the generated `route_table::router()`), then a [`Route`] per request | renders `<RouterLink>` or reads `useRoute()` |
| A generated `stores::Stores` | reads Pinia state |
| [`I18n`] (from the generated `i18n::i18n(locale)`) | calls `$t` or `useI18n()` |
| [`Teleports`] | renders `<Teleport>` |
| Your own type implementing [`TrustedHtml`] | renders `v-html` |
A prop declared `Record<string, T>` is a [`Record`], which keeps JavaScript's order of keys.
and these, to assemble a page:
- [`Html`]: a component applied to its props, borrowed (`html(&props)`) or held
(`into_html(props)`), written into a buffer with [`Html::render_to`] or returned with
[`Html::into_string`];
- [`state_script_into`]: the stores' state for the client to hydrate from;
- [`hole`] and [`split_holes`]: render a layout once and stream its parts;
- `HtmlStream` (the `stream` feature): a page with holes as an HTTP body, each hole's content
sent as soon as it is ready;
- [`escape_into`]: text written into your own markup as Vue would write it.
The rest are Rust twins of JavaScript and Vue routines that generated code calls so that it writes
what Vue writes:
- numbers: [`push_int`], [`push_number`], [`Js`], [`js_to_fixed`], [`js_round`], [`js_max`],
[`js_min`], [`js_number`], [`js_parse_int`], [`js_parse_float`], [`js_json_number`];
- strings, counted in UTF-16 code units: [`js_length`], [`js_trim`], [`js_trim_start`],
[`js_trim_end`], [`js_slice`], [`js_substring`], [`js_at`], [`js_char_at`], [`js_index_of`],
[`js_last_index_of`], [`js_split`], [`js_replace`], [`js_replace_all`], [`js_pad_start`],
[`js_pad_end`], [`js_repeat`], [`js_cmp`], [`js_json_string`];
- lists: [`js_slice_range`], [`js_slice_items`];
- Vue's own: [`class_into`], [`class_object`], [`slot_into`], [`slot_into_slotted`],
[`scoped_slot_into`], [`scoped_slot_into_slotted`], [`scope_attrs`], [`teleport_into`],
[`trusted_into`] and [`query_into`].
# Feature flags
| `maud` | off | `impl maud::Render for Html`, so `(greeting::html(&props))` can go straight into a `maud::html!` page |
| `stream` | off | `HtmlStream`: a page rendered with holes as a stream of byte chunks, each hole filled by a future |
| `axum` | off | `stream`, and `IntoResponse` for `Html` and `HtmlStream`, so a handler returns `greeting::into_html(props)` ([`guide::web_frameworks`](crate::guide::web_frameworks)) |
| `actix-web` | off | `stream`, and `Responder` for `Html` and `HtmlStream` |
| `dioxus` | off | The `dioxus` module: `Html::to_element` and `impl IntoDynNode for Html`, so `{greeting::island(&props)}` can go straight into Dioxus 0.7's `rsx!`, and `dioxus::state_script`; the guide's `dioxus` page shows a page |
# Rust version
ferrovue supports the latest stable Rust (the `rust-version` in its manifest). The generated code
uses let-chains, so the crate that includes it must use **edition 2024**.
# Versions
Use the same version of the npm package and of this crate: generated code is written against the
runtime of its own release. Byte-identical output also depends on Vue's own SSR compiler, so the
npm package pins `vue`, `@vue/compiler-sfc` and `@vue/server-renderer` to an exact version; your
client should use that version of Vue too.
# Links
- The repository, with the compiler's full documentation and a complete axum + Vite example:
<https://github.com/Kinsmir/ferrovue>
- The compiler on npm: <https://www.npmjs.com/package/ferrovue>
- [The guide](crate::guide), in this documentation.