Expand description
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:
| What it is | Where | |
|---|---|---|
| The compiler | Reads <script setup lang="ts"> components and writes one Rust module per component (TypeScript, runs on Node) | npm: ferrovue |
| The runtime | This crate: what the generated modules call, from escaping to <RouterLink> matching | crates.io: ferrovue |
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 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.
pnpm add -D ferrovue # or npm / yarn
cargo add ferrovue serde --features serde/derive
cargo add --dev serde_json2. Configure. ferrovue.config.json at the project root names the components and where the
Rust goes. Everything in out is replaced on each run.
{
"components": "client/components",
"out": "src/generated"
}3. Write a component.
<!-- client/components/Greeting.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.
pnpm ferrovue # writes src/generated/greeting.rs and src/generated/mod.rs
pnpm ferrovue --check # in CI: exits 1 if the committed modules are stale5. Include the modules and render. Declare the module once, with #[rustfmt::skip] so that
ferrovue --check keeps comparing the files byte for byte:
#[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:
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.
On the client, the same component hydrates the page: mountIslands from ferrovue mounts
every island, mountPage hydrates a layout and the parts a Page recorded in its
slots, or the page’s own Vue app hydrates the whole document. See
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.
| You build | To render a component that |
|---|---|
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> |
A generated provides::Provides, empty or holding what the client app gives app.provide | calls provide or inject, or renders one that does (guide::provide_inject) |
Head | calls useHead or useSeoMeta, or renders one that does (guide::head) |
BasicHtml, InlineHtml, Sanitised (the ammonia feature), or 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 withHtml::render_toor returned withHtml::into_string;Html::hydratewith aHydrate: an island that hydrates once visible, when the browser is idle, on interaction or when a media query matches;Head::render: the tags and attributes of the page’s<head>,<html>and<body>;state_script_into: the stores’ state for the client to hydrate from;holeandsplit_holes: render a layout once and stream its parts;HtmlStream(thestreamfeature): 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.
and, in tests, conformance! and check_fixtures, which render an application’s fixtures
through its generated code and compare the bytes with the HTML recorded from Vue
(guide::testing).
The rest are Rust twins of JavaScript and Vue routines that generated code calls so that it writes what Vue writes. Those a hand-written twin or page may call too are documented here:
- numbers:
push_int,push_number,Js,js_to_fixed,js_round,js_maxandjs_min; - Vue’s own:
slot_into,attrs_into,passed_attrs_intoandquery_into.
The others are #[doc(hidden)]: JavaScript’s string methods counted in UTF-16 code units
(js_length, js_slice, js_split and the rest the strings page
lists), Number, parseInt, parseFloat and JSON.stringify (js_number, js_parse_int,
js_parse_float, js_json_number, js_json_string), dates as time values in UTC
(js_date_parse, js_time_clip, js_to_iso_string and js_utc_full_year to
js_utc_milliseconds), a list’s slice (js_slice_range, js_slice_items), and Vue’s class, style, scope id, slot outlet, teleport and v-html helpers
(class_into, class_object, class_names, style_text_into, merge_props, scope_attrs,
slot_into_slotted, scoped_slot_into, scoped_slot_into_slotted, teleport_into,
trusted_into). Only generated code calls them, so their signatures follow the version of
generated code (the __compat! check at the top of a generated mod.rs) and may change in any
release; the generated_code page says what is
stable.
§Feature flags
| Feature | Default | What it adds |
|---|---|---|
router | on | Router, Route, Link, Query, RouteDef and query_into, for components that render <RouterLink> or <RouterView> or read useRoute(), from the ferrovue-router crate |
i18n | on | The i18n module and I18n, for components that call $t or useI18n(), from the ferrovue-i18n crate |
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) |
actix-web | off | stream, and Responder for Html and HtmlStream |
ammonia | off | Sanitised, HTML cleaned by ammonia, to name as trustedHtml for v-html props (guide::escaping shows a policy of your own, with ammonia 4 as your own dependency) |
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 |
An application with neither routes nor translations can turn the default features off
(default-features = false) and builds neither ferrovue-router nor ferrovue-i18n. Generated
code is the same either way: it names the router and translations only when the project configures
them.
§Crates
The runtime is four crates, released together at one version; this one re-exports the others at its root, which is the one path generated code and this documentation use:
| Crate | What it holds |
|---|---|
ferrovue | This crate: what generated code calls, and the framework integrations |
ferrovue-core | escape_into, and JavaScript’s numbers (push_int, push_number, Js, …), which the other three share |
ferrovue-router | Router and what goes with it (the router feature) |
ferrovue-i18n | i18n and I18n (the i18n feature) |
§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 compiles with an exact @vue/compiler-sfc, and its fixtures are recorded from one
version of Vue and of each integration. Your client may use any later patch of the same minor
(vue ~3.5.44); the repository’s README lists the range of every peer.
§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, in this documentation.
Modules§
- dioxus
dioxus - Components and islands as Dioxus elements, for a page that Dioxus
renders on the server (
dioxus-ssr, or a fullstack app). Dioxus 0.7. - guide
- A guide to ferrovue: from a
.vuefile to a page a browser hydrates. - i18n
i18n - vue-i18n’s
t(), for components that call$toruseI18n().t: part of ferrovue’s runtime.
Macros§
- conformance
- Write the test that renders every fixture through the generated
render_jsonand compares the bytes with the HTML Vue recorded:check_fixturesover a directory relative to the crate’s manifest, failing when fewer thanat_leastfixtures (one when not given) are found.
Structs§
- Attrs
- What a parent passes a component beyond its props (
$attrs, in the order given) and the scope ids its root inherits,data-v-…each, which Vue keeps after them. - Basic
Html - Untrusted text with a few formatting tags: a
TrustedHtmlforv-htmlthat needs no sanitiser. - Chunks
- The chunks of a client build, read from Vite’s manifest (
.vite/manifest.json, written byvite buildwithbuild.manifest: true): which scripts an island needs, so the page can name them up front with<link rel="modulepreload">in place of the browser finding them one import at a time once the client runs. - Head
- The head of one render: what every
useHeadanduseSeoMetain the component tree asked for, in the order they ran. A page renders its components first, then writesrenderinto the document. It reproduces@unhead/vue’s server head: its tag order, deduplication, title template and escaping. - Head
Html - What unhead’s server renderer writes for a page’s head:
renderSSRHead’s five strings. - Html
- A component applied to its props, ready to be written: what a generated
html()orisland()returns, borrowing the props, andinto_html()orinto_island(), holding them. - Html
Stream stream - A page rendered with
holes, sent in pieces: the markup up to the first hole at once, then each hole’s content as soon as it and every hole before it are ready, each followed by the markup up to the next hole.guide::streamingexplains holes. - I18n
i18n - The messages and the locale one request renders in.
- Inline
Html - Untrusted text with a few inline formatting tags: a
TrustedHtmlthatv-htmlmay write inside a<p>. - Js
- A number written as JavaScript writes it, for
format!in generated code:${n}in a template literal,"#" + n,n.toString(). - Link
router - A
<RouterLink>’sto, resolved: whatRoute::link,Route::link_namedandRoute::link_pathreturn. - Manifest
Error - A manifest or a list of islands
Chunks::from_manifestcannot read. - Page
- A page the client hydrates as one app: a layout whose slots hold components rendered from their
props. Each part goes into a slot as a
Part, made from the sameHtmlvalue that writes its markup, so the record of the page the client rebuilds is made from the values that rendered it.mountPagefromferrovue/clientreads the record back and hydrates the layout.guide::islands_and_hydrationshows a whole page, client included. - Page
Hole - A slot of a
Pagewhose parts are rendered later, as anHtmlStream(thestreamfeature) hole is: give the layout itsslot, andfillit once its data is ready. A hole dropped without being filled is recorded as empty, asHtmlStreamleaves a hole given no content empty. - Page
Record - The record of a rendered
Page, which the client reads back:script_intofor a page written at once,scriptfor a streamed one. - Page
Script - The record’s
<script>once everyPageHoleof the page is filled or dropped: the content of the last hole of anHtmlStream(thestreamfeature). Made byPageRecord::script. - Page
Slot - The parts of one slot of a
Page, ready to be given to the layout withslot. - Part
- One component in a slot of a
Page: its name, as the client’s loaders know it (the generatedNAME), and itsHtml, which writes the markup and gives the record its props. - Preloads
- The
<link rel="modulepreload">s of one page, each URL written once however many islands need it. Made byChunks::preloads. - Record
- A
Record<string, T>(or{ [key: string]: T }) prop: entries in the order JavaScript gives an object’s keys, which is whatv-forandObject.keyswalk. Keys that are array indices ("0","12") come first, in numeric order, then the others in the order they were added; a key given twice keeps its first place and its last value, asJSON.parsekeeps it. - Route
router - Where the reader is, which is what every link on the page is compared with.
- Route
Def router - A route as the routes file lists it: a path, its name if it has one, whether it shows a
component, and the routes nested in it, rendered by the
<RouterView>in its component. - Router
router - The routes, in the order vue-router tries them.
- Sanitised
ammonia - HTML cleaned by ammonia: a
TrustedHtmlforv-html, with theammoniafeature. - Slot
- What a parent puts in one of a component’s slots.
- Teleports
- The teleported content of one render, by target, in the order Vue collects it.
Enums§
- Attr
- One attribute’s value, as Vue holds it once the parent’s virtual node is made:
createVNodehas already normalised a class given as an array or an object, and a style given as an array. - Head
Value - A value given to
useHead, as JavaScript holds it: what the generated code builds from a component’suseHead({ … }), and what a page pushes withHead::push. - Hydrate
- When the client hydrates an island, and so when it fetches the island’s code: what
Html::hydratewrites as the island’sdata-hydrate. An island without one is hydrated as soon asmountIslandsruns. - Query
router - One value of
route.query, as vue-router parses it: a key may be absent, given without a value (?flag, which isnull), given once, or given more than once, which makes an array.
Traits§
- Trusted
Html - HTML that is safe to write into a page as it is: what
v-htmlmay render.
Functions§
- attrs_
into ssrRenderAttrs(mergeProps(...sources))for an element given fallthrough attributes, with the scope ids written after the attributes the firstids_at + 1sources give, where Vue’s_attrs, which holds them as keys after the attributes, sits among the sources.- check_
fixtures - Render every fixture under
fixtureswithrender_jsonand compare the bytes with the HTML Vue recorded beside it: the Rust half of an application’s conformance suite, of whichconformanceSuiteinferrovue/testingis the Vue half. - escape_
into escapeHtml:",&,',<and>, and nothing else.- hole
- A slot whose content the caller writes itself, later: render with holes,
split_holesthe output, and write the pieces with each hole’s content between them, which is how a page streams its parts in the order they are ready. A hole is content to the slot, so its fallback never shows.guide::streamingshows a whole streamed page. - js_max
Math.maxof two numbers:NaNif either is, where Rust’sf64::maxignores aNaN.- js_min
Math.minof two numbers:NaNif either is, where Rust’sf64::minignores aNaN.- js_
round Math.round: the nearest integer, a half rounding up toward +∞ (-2.5to-2, where Rust’sf64::roundgives-3), and-0from-0.5up to zero, which1 / Math.round(x)shows.- js_
to_ fixed Number.prototype.toFixed(digits): the number rounded todigitsplaces, from its exact binary value. An exact tie rounds away from zero, where Rust’s formatting rounds it to even; at 10²¹ and beyond, JavaScript writes the number asString(x)does.- passed_
attrs_ into ssrRenderAttrs(attrs)of a single list, merged with nothing: each value as the parent gave it (a class not trimmed formergeProps, a style given as text written as it is), then the scope ids.- push_
int String(n)for an integer, which is whattoDisplayStringandescapeHtmlmake of a number.- push_
number Number.prototype.toString(): JavaScript’s shortest round-trip digits, laid out as ECMAScript lays them out:0.30000000000000004,1e+21,1.5e-7,NaN,-Infinity.- query_
into router stringifyQuery, one key and value at a time:key=value, each encoded as vue-router encodes them, after a&whensearchalready holds a pair.- slot_
into ssrRenderSlot: the slot’s content between fragment markers, or its fallback when it was given none, or only comments, which is what Vue reads as nothing.- split_
holes - The pieces of a render between its holes, in order: one more than there were holes. The pieces
borrow from
rendered. - state_
script_ into - The stores’ state as the client reads it back before it hydrates: a
<script type="application/json">, which ascript-src 'self'policy does not run, so the page needs no nonce for it.<,>,&and the two line separators are written as JSON escapes, so no value can end the element or be read as markup inside it.