ferrovue 0.2.0

Vue templates compiled to Rust render functions: server-side rendering with no JavaScript at run time
Documentation

ferrovue

crates.io docs.rs npm CI License: MIT OR Apache-2.0

Vue single-file components compiled to Rust render functions. With them, a Rust server renders a component's HTML with no JavaScript at run time, and the browser hydrates the result with the same .vue file.

 Button.vue ──(ferrovue compiler, Node)──▶ button.rs ──(your Rust server)──▶ HTML ──▶ Vue hydrates it

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. If a single byte differed, the browser would report a hydration mismatch, so anything outside that vocabulary is a compile error that names the component and the construct. ferrovue never guesses.

The project has two halves:

What it is Published as
packages/ferrovue The compiler and the ferrovue command (TypeScript, runs on Node) npm: ferrovue
crates/ferrovue The runtime the generated Rust calls: escaping, slots, islands, <RouterLink> matching crates.io: ferrovue (API docs)

Quick start

1. Install both halves

pnpm add -D ferrovue          # or npm / yarn
cargo add ferrovue serde --features serde/derive
cargo add --dev serde_json    # the generated conformance helper uses it under cfg(test)

ferrovue supports the latest stable Rust, currently 1.99. Generated code uses let-chains, so the crate that includes it needs edition 2024.

2. Configure

ferrovue.config.json at your project root:

{
  "components": "client/components",
  "out": "src/generated"
}
Key Required Meaning
components yes Directory of .vue files to compile
out yes Directory the Rust modules are written to. Everything in it is replaced.
routes no JSON file listing the app's routes: each a vue-router path, or { "path", "name", "children" }. Needed for <RouterLink>, <RouterView> and useRoute()
router no Instead of routes: { routes, base?, linkActiveClass?, linkExactActiveClass? }, matching createWebHistory(base) and createRouter's options
stores no Directory of Pinia option stores whose state components may read
trustedHtml no Rust type of a TrustedHtml prop, e.g. crate::html::Sanitised (needed for v-html)
helpers no { module, functions }: functions a template may call, each mapped to a Rust twin
i18n no vue-i18n: { messages, locale?, fallbackLocale? }, the directory of locale files (en.json, nl.json), the default locale and the fallbacks
clientDirectives no Custom directives with no server output (no getSSRProps), by name without v-: ["focus"]
scopeId no How a <style scoped> id is hashed, as @vitejs/plugin-vue hashes it: "filepath-source" (the default, the plugin's in a production build) or "filepath". See Scoped styles
viteRoot no Vite's root, relative to this file's directory, from which a component's path is hashed (default .)

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 and render

npx ferrovue            # writes src/generated/{greeting.rs, mod.rs}
npx ferrovue --check    # in CI: exits 1 if the committed modules are stale
#[rustfmt::skip]
mod generated;

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:
let html: String = greeting::html(&props).into_string();

// …or as an island the client hydrates: wrapped in
// <div data-island="Greeting" data-props="{…}">…</div>
let island = greeting::island(&props).into_string();

// (`island()` exists for a component that renders from its props alone: the client rebuilds it from
// `data-props`. One that also takes slots, the route, stores, translations or teleports has `html()`,
// and the page's own app mounts it — see examples/fullstack.)

// …or straight into a buffer you already hold:
let mut page = String::from("<!doctype html><body>");
greeting::render(&mut page, &props);

With routes configured, the generated route_table::router() builds the router once. Resolve each request's location with router.at(path) and pass the result to the components that take a route.

With the maud feature, ferrovue::Html implements maud::Render, so (greeting::html(&props)) can go straight into a maud::html! page.

What a component may use

ferrovue compiles <script setup lang="ts"> components. Props are declared by type, with defineProps<{ … }>(), an interface, or a type alias.

Area Supported
Prop types string, number (integers, i64, written and computed as JavaScript does — exact within ±2⁵³), Float from ferrovue/types (fractions, f64), boolean, string-literal unions ("sm" | "md"), T | undefined, arrays (T[], Array<T>, readonly T[]), interfaces and object type aliases (recursive ones too), dictionaries (Record<string, T>, { [key: string]: T }, ferrovue::Record in Rust, which keeps JavaScript's order of keys), another component's exported Props, TrustedHtml
Shared types Interfaces and type aliases imported from .ts files (generated once, into types.rs), from a store's file, or from another component's .vue file; objects of a shared type can be passed between components
Props withDefaults, destructured props with defaults (const { size = "md" } = defineProps<…>()), optional booleans (Vue casts an absent one to false), defineModel (named, required, with defaults), components with no props
Setup ref/shallowRef, computed (an expression or a single return), plain const/let, .value in script code, helpers with Rust twins, a plain <script> block beside setup. Lifecycle hooks, watch (not immediate), defineEmits, defineSlots, defineOptions, defineExpose, provide, template refs (ref(null), useTemplateRef) and functions are client-only. The template may name them only from event handlers, which the server drops
Text {{ }} of strings, integers and booleans; +, -, *, /, %, with integers and fractions as JavaScript computes them; <, >, <=, >= (between numbers, or between strings by UTF-16 code unit), ===, !==; unary -, !; ??, ||, &&, ?:; template literals; optional chaining a?.b; .length; String(), .toString(), .toFixed(), Math.max/min/abs/round/floor/ceil/trunc; Number(), parseInt() (no radix, 10 or 16), parseFloat(); JSON.stringify() of strings, numbers, booleans and lists of those; array literals
Strings .trim(), .trimStart(), .trimEnd(), .toUpperCase(), .toLowerCase(), .includes(), .startsWith(), .endsWith(), .indexOf(), .lastIndexOf(), .slice(), .substring(), .at(), .charAt(), .split(), .replace() and .replaceAll() with string patterns ($&, $`, $', $$ read as JavaScript reads them), .padStart(), .padEnd(), .repeat(); indices and lengths count UTF-16 code units, as JavaScript's do (see Strings)
Conditions v-if / v-else-if / v-else, ?:, && and `
Lists v-for over arrays (of strings, numbers, objects or child props), array literals and number ranges (n in 5); with an index; with destructured items ({ id, name } in rows); nested; on <template>. .includes(), .join(), and .filter(), .map(), .some(), .every(), .find(), .findIndex() with an arrow function of the item (a name, or destructured) and its index, whose body is an expression, narrowing inside as outside; .slice(); chained and nested; a computed list read by v-for, .join(), .length, .includes(), kept by computed, or handed to a child
Dictionaries v-for="(value, key, index) in r" over a Record<string, T>, and ([key, value], index) in Object.entries(r), in JavaScript's order: keys that are array indices first, in numeric order, then the others as the props give them; Object.keys(r), Object.values(r), Object.entries(r).length; handed to a child
Attributes static and bound attributes, boolean attributes, :hidden, data-* and aria-*, v-bind objects
class strings, arrays, objects ({ active: on }, computed keys), cond && "x", cond ? "x" : null, merged with a static class
style objects (camelCase or kebab-case keys, --custom properties), arrays of objects, strings, merged with a static style, and v-show; later values override earlier ones as in Vue. A global <style> block is allowed
Scoped styles <style scoped>: the id on every element, on child components' roots (a root that is itself a component, fragments, recursion and inheritAttrs: false as Vue renders them) and, from a component with :slotted() rules, on the slot content it is given, forwarded slots included; inside <Transition>, <KeepAlive>, <Teleport> and v-if; on <RouterLink> and what it holds, as vue-router renders them
Components imported child components, v-bind of a child's own Props, v-model on a child's defineModel, recursion
Slots default and named slots, fallbacks, $slots.name tests, scoped slots (<slot :item="x"> and #item="{ item }" or v-slot="props"), whose props a parent can hand to its own children
Forms v-model on text inputs, checkboxes, radios, <select> and <textarea> (renders the initial state)
Built-ins <Transition>, <TransitionGroup>, <KeepAlive>, <Suspense> (synchronous content), <Teleport> (to any target, nested, disabled: see below), v-text, v-once, v-pre, v-memo, custom directives listed in clientDirectives
Vue Router <RouterLink> (resolved by name or imported) with a string to or { name, params, query, hash } / { path, query, hash }, active-class, exact-active-class, aria-current-value, replace; vue-router's own encoding and active-link matching, nested routes included (a parent link is active on its children's pages, exact only on its own); a history base. useRoute() and $route: path, fullPath, hash, name, params, and query (a value written once, without =, or repeated, exactly as vue-router parses it). <RouterView>, at the top and in nested route components: each takes the page it shows as a slot
Pinia option stores with a typed state, and setup stores (defineStore(id, () => { … })) whose returned refs are typed by ref<T>() or their initial literal; read through useX() or storeToRefs, in the template or in computed; getters that are an expression of the state, and a setup store's computeds, which may read each other
vue-i18n $t and useI18n()'s t and locale: named and list values, plurals by vue-i18n's rule, literals, linked messages with upper/lower/capitalize, nested and flat keys, fallback locales, a missing key shown as itself. Messages are parsed at build time by vue-i18n's own compiler
v-html only on a TrustedHtml prop (import type { TrustedHtml } from "ferrovue/types")

Refused at compile time, each with an error that names the construct:

  • <style module>, and v-bind() in CSS
  • <RouterView> in a component with <style scoped>, which would give the page that component's id; and, since vue-router renders a link from virtual nodes, a <slot> inside a <RouterLink> that takes scope ids, or an element inside one in slot content given a :slotted() id
  • <component :is>
  • <RouterLink custom>, slot props that are array literals, defaults in destructured slot props, and outlets of one slot that pass different props
  • custom directives not listed in clientDirectives
  • watchEffect, watch with immediate, onServerPrefetch, top-level await, and statements in setup that change state
  • route.meta and route.matched
  • Pinia getters that read this or return a function
  • ordering comparisons between a string and a number, which JavaScript makes numeric
  • regular expressions (.replace(/x/g, …), .split(/,/)), replacement functions, a search's starting position or a split's limit (.includes(x, 3), .split(",", 2)), .toLocaleUpperCase() and .toLocaleLowerCase() (the server's locale is not the browser's), parseInt with another radix or of a number, .repeat() by a negative literal
  • array methods given anything but an arrow function whose body is an expression (.filter(Boolean), x => { return … }), .map() to optional values, a computed list as a slot prop
  • a dictionary's field read by name (r.key, r[key]), which may be absent although TypeScript says it is not; Object.entries() anywhere but as a v-for's source; dictionaries of optional values
  • two strings that may each hold half of a surrogate pair compared, searched or joined (see Strings)
  • null
  • any method call without a Rust twin

An object prop handed to a child component is cloned. Its strings are Cows, so borrowed ones cost nothing to copy.

Scoped styles

A <style scoped> component's elements carry data-v-<id>, and its CSS is rewritten by the client build to select them. The server has to write the id the client build chose, which is not something the browser checks when it hydrates: a wrong id hydrates cleanly and leaves the styles unapplied. So ferrovue computes it as @vitejs/plugin-vue does — the first 8 hex digits of a SHA-256 of the .vue file's path from Vite's root, followed by its source unless only the path is hashed — and the two must be configured alike:

@vitejs/plugin-vue ferrovue.config.json
vite build with the default options "scopeId": "filepath-source" (the default)
features: { componentIdGenerator: "filepath" } (any mode), or the dev server "scopeId": "filepath"

Set viteRoot when Vite's root is not the directory holding ferrovue.config.json. The Vite plugin (ferrovue/vite) compares the two when a component has scoped styles: a build in which they differ fails, and the dev server warns.

The default is the plugin's production behaviour because the production build is the one readers get: with every option left alone, its styles apply. But plugin-vue hashes the path alone in its dev server, so with the default a page rendered during development carries other ids than the dev client and shows unstyled. The recommended setup is componentIdGenerator: "filepath" with "scopeId": "filepath", as examples/fullstack does: the ids are then the same in development and production, and do not change, nor change the generated Rust, whenever a component's source does.

A component a parent may hand ids to has a render_scoped(…, attrs) beside render, which generated parents call. A component whose outlets pass a slot scope id (:slotted()) takes slot content that is given it: Slot::slotted for a slot, a third &str parameter for a scoped slot's closure. render, and content from Rust, need none of it: markup written from Rust carries no ids.

Where Vue's own server render gives other ids than its client render, ferrovue writes the server's: a :slotted() component's slot fallback, which only the client gives the slot scope id, and a component with inheritAttrs: false that is another component's root, whose root only the client gives the ids that other component inherits.

Strings

String methods count as JavaScript counts: in UTF-16 code units, so "🦀".length is 2 and "🦀 crab".slice(3) is "crab". Strings are ordered by code unit too, which puts every character from U+E000 to U+FFFF after one beyond U+FFFF. Each runtime routine is held to vectors recorded from JavaScript (crates/ferrovue/tests/vectors/).

A JavaScript string can hold half of a surrogate pair — "🦀".slice(0, 1), .charAt(1), .split("") — and a Rust string cannot. ferrovue writes U+FFFD in its place, which is exactly what the page carries anyway: a server sends Vue's string as UTF-8, and UTF-8 writes each half as U+FFFD (res.end, Buffer.from and TextEncoder all do). Its length is the same. Where the half itself would decide the result, ferrovue refuses at compile time: two strings that may each hold a half compared or ordered, side by side (a.slice(0, 1) + b.slice(1), a .join(""), a class object's names), one searched for in another (.includes(a.charAt(0))), repeated or padded where halves would join, ordered against anything but a literal below U+D800, or written by JSON.stringify (which escapes it) or into a <RouterLink> (whose encoding throws). Three corners remain, by design:

  • a string holding U+FFFD in the props compares equal to a half, or finds one, where JavaScript would tell them apart;
  • a half handed to a child component or a helper is U+FFFD there, which matters only if that component then compares or joins it as above;
  • Vue itself cannot hydrate such text cleanly: the browser reads U+FFFD where its own render holds the half, and reports a mismatch.

Where JavaScript throws, the generated code panics, as Vue's render rejects: a negative or infinite .repeat() count, and a .repeat(), .padStart() or .padEnd() past the longest string V8 makes (2²⁹ − 24 code units).

Dictionaries

A Record<string, T> prop is a ferrovue::Record, built from pairs — [("b", 1), ("10", 2)].into_iter().collect() — or read from JSON. It holds its keys in the order a JavaScript object does, array indices ("0" to "4294967294") first in numeric order, so v-for walks them as Vue does, and it is written back as JSON in that order, which the browser reads back the same.

Translating

With i18n configured, the generated i18n module holds every locale's messages. Build an I18n per request, in the reader's locale, and pass it to the components that translate:

let i18n = generated::i18n::i18n("nl"); // falls back as configured
page::render(&mut out, &props, &i18n);

Teleports

A component that renders a <Teleport> takes a ferrovue::Teleports. After rendering the page body, write what was teleported where each target is, as Vue's ssrContext.teleports is written:

let teleports = ferrovue::Teleports::new();
page::render(&mut body, &props, &teleports);
let modals = teleports.get("#modals").unwrap_or_default(); // inside <div id="modals">

As Vue recommends, teleport to a dedicated element (#modals) rather than body: the browser hydrates a target from its first node, and body also holds the app.

Scoped slots from Rust

A scoped slot's Slots field takes a closure that receives the props its outlet passes, as a generated …SlotProps struct borrowed for the render. The closure returns whether it wrote content; returning false shows the slot's fallback, as empty content does in Vue.

let row = |out: &mut String, p: &data_list::RowSlotProps<'_>| {
    ferrovue::escape_into(out, p.label);
    true
};
data_list::render(&mut page, &props, data_list::Slots { row: Some(&row), ..Default::default() });

Hydrating Pinia state

ferrovue::state_script_into(&mut page, "__pinia", &stores);   // a <script type="application/json">
import { hydrateState } from "ferrovue/client";
hydrateState(pinia); // before app.mount(): every store starts from what the server rendered

Streaming with holes

ferrovue::hole() is a slot whose content you write later. Render a layout with holes, split_holes the output, and stream the pieces with each hole's content between them, in whatever order the content is ready.

Performance

Time to render one component to an HTML string. On the left is Vue's renderToString on Node, and on the right is the Rust that ferrovue generated from the same .vue file. Both get the same props, and both write the same bytes: each side checks its output against crates/ferrovue/benches/expected/<scenario>.html before anything is timed.

Scenario What renders Output Vue renderToString ferrovue Speed-up
small Nav: two <RouterLink>s resolved against the current route 206 B 34.57 µs 0.950 µs 36.4×
list Lists: 1,000 words, 1,000 numbers, 100 groups of 10 members 102 KiB 368.1 µs 45.95 µs 8.0×
tree Tree: a recursive component, binary tree 8 levels deep (255 nodes) 9.5 KiB 484.6 µs 3.72 µs 130.2×
page Dashboard: 22 Panels with named slots, a Text, 20 Frames holding loops 8.0 KiB 181.6 µs 4.45 µs 40.9×

These are mean times per render. The Vue column is tinybench's mean, with 5 s per scenario after a 1 s warm-up. The ferrovue column is criterion's mean point estimate, using its defaults of a 3 s warm-up and 100 samples.

What each side measures:

  • Vue: one awaited renderToString(app). Vue is the production build (NODE_ENV=production). Each component uses the ssrRender its SSR build would have. The app is created once outside the loop, along with its Pinia instance and, for small, a vue-router already at /users/me.
  • ferrovue: a new String plus one call of the generated render, built with the release profile. The props, the router and the Route are built once outside the loop.

Measured on an AMD Ryzen 5 3600XT (6 cores / 12 threads, up to 3.8 GHz) with 62 GiB of RAM, running Linux 7.2.8-2-cachyos (CachyOS, x86_64). Software: Node 26.10.0, Vue 3.5.43, rustc 1.99.0. The machine was lightly loaded (a load average of about 1) but not idle, so treat the numbers as indicative.

The speed-up says as much about Vue as about ferrovue. Most of Vue's time goes on component instances and their virtual nodes, of which tree has 255 and list one: per byte written, Vue renders list some fourteen times faster than tree, while ferrovue writes the two at a similar rate.

To reproduce:

pnpm bench          # both halves: pnpm bench:rust, then pnpm bench:js
pnpm bench:rust     # cargo bench -p ferrovue --bench render   (crates/ferrovue/benches/render.rs)
pnpm bench:js       # Vue's renderToString with tinybench      (packages/ferrovue/bench/ssr.bench.ts)
pnpm bench:record   # rewrite benches/expected/*.html from Vue, after changing a scenario

The benchmarks are not part of pnpm test. CI runs both in a short mode only to check that they still build, run and agree on the HTML. It never compares their timings.

Versions

Byte-identical output depends on Vue's own SSR compiler, so the package pins vue, @vue/compiler-sfc and @vue/server-renderer to an exact version (currently 3.5.43). Upgrading Vue means re-running the conformance suite (see TESTING.md) and releasing a new ferrovue version.

Repository layout

crates/ferrovue/             the Rust runtime crate
  src/                       runtime (escaping, JS numbers, router, i18n, teleports) + unit tests
  tests/conformance/         components, fixtures, recorded HTML, generated Rust
  tests/vectors/             vectors recorded from JavaScript, vue-router and vue-i18n
  tests/properties.rs        property-based tests of the runtime
  benches/                   criterion benchmarks of generated renderers (see Performance)
packages/ferrovue/           the compiler (npm package)
  src/compiler.ts            the API: `generate`, `write`
  src/script.ts, template.ts, expr.ts, attrs.ts
                             <script setup>, the compiled template, expressions, class/style
  src/router.ts, stores.ts, i18n.ts
                             vue-router, Pinia and vue-i18n
  src/rust.ts, emitter.ts    the Rust source written out
  src/cli.ts, vite.ts        the `ferrovue` command and the Vite plugin
  src/client.ts              browser-side helpers: `mountIslands`, `hydrateState`
  src/testing.ts             utilities for a project's own conformance suite
  src/types.ts               `ferrovue/types`: `TrustedHtml`, `Float`
  test/                      compiler, CLI, router, vector, island, Vite and conformance tests
  browser/                   conformance fixtures hydrated in real browsers (`pnpm test:browser`)
  bench/                     Vue renderToString benchmarks, the other half of Performance
  fuzz/                      the randomised differential tester (`pnpm fuzz`)
examples/greeting/           the smallest setup: one component rendered from Rust
examples/fullstack/          axum + Vite: islands, Pinia state, routes and streaming
scripts/release.ts           the release version bump (see RELEASING.md)

Development

pnpm install
pnpm test          # compiler, CLI and the Vue half of the conformance suite
pnpm typecheck
cargo test --all-features

TESTING.md explains how the suite fits together and how to add a case, CONTRIBUTING.md how to add support for a Vue construct, and RELEASING.md how a release is cut.

examples/fullstack is a complete app to copy: an axum server, a Vite client, islands, Pinia, vue-router and streaming, with a test that it hydrates.

Licence

Licensed under either of Apache License, Version 2.0 or MIT licence, at your option. Unless you explicitly state otherwise, any contribution you intentionally submit for inclusion in this work, as defined in the Apache-2.0 licence, is dual-licensed as above, without any additional terms or conditions.