From nothing to a rendered component.
# 1. Install both halves
The compiler is an npm package, run at build time; this crate is what its output calls at run time.
Use the same version of both.
```sh
pnpm add -D ferrovue # or npm / yarn; Node 22.18 or newer
cargo add ferrovue serde --features serde/derive
cargo add --dev serde_json
```
`serde` is needed because every generated props struct derives `serde::Serialize`: an island
carries its props to the client as JSON. `serde_json` is a dev-dependency because the generated
`mod.rs` holds a `#[cfg(test)]` function that renders a component from props written as JSON, for a
project's own conformance tests.
The crate that includes the generated code must use **edition 2024**, because that code uses
let-chains, and the latest stable Rust.
# 2. Configure
`ferrovue.config.json`, at the project root. Paths are relative to it.
```json
{
"components": "client/components",
"out": "src/generated",
"routes": "client/routes.json"
}
```
| `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()`. See [`routing`](crate::guide::routing) |
| `router` | no | Instead of `routes`: `{ routes, base?, linkActiveClass?, linkExactActiveClass? }`, matching `createWebHistory(base)` and `createRouter`'s options |
| `stores` | no | Directory of Pinia stores whose state components may read. See [`pinia`](crate::guide::pinia) |
| `trustedHtml` | no | Rust path of the type a `TrustedHtml` prop is, e.g. `crate::html::Sanitised`. Needed for `v-html`. See [`escaping`](crate::guide::escaping) |
| `helpers` | no | `{ module, functions }`: functions a template may call, each mapped to a Rust twin. See [`errors_and_limits`](crate::guide::errors_and_limits#helpers) |
| `i18n` | no | vue-i18n: `{ messages, locale?, fallbackLocale? }`: the directory of locale files (`en.json`, `nl.json`), the default locale and the fallbacks. See [`i18n`](crate::guide::i18n) |
| `clientDirectives` | no | Custom directives with no server output, 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`](crate::guide::scoped_styles) |
| `viteRoot` | no | Vite's root, relative to this file's directory, from which a component's path is hashed for its scope id (default `.`) |
# 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 # write the modules
npx ferrovue --check # write nothing; exit 1 if the committed modules are stale (for CI)
npx ferrovue --watch # write them, then again on every change
```
A Vite project can use the plugin in `ferrovue/vite` instead of `--watch`: it regenerates on change
and shows a refused construct in Vite's error overlay.
The compiler writes `src/generated/greeting.rs` and `src/generated/mod.rs`, which declares one
module per component. This is `greeting.rs`, exactly as written (only the reservation is an
estimate the compiler works out per component):
```rust
# 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)
}
# }
```
Note what the template became: `v-if` is an `if` with Vue's `<!---->` placeholder in the `else`,
the integer is written with [`push_int`](crate::push_int) as JavaScript writes numbers, and every
interpolation goes through [`escape_into`](crate::escape_into).
# 5. Render
Declare the generated module once. `#[rustfmt::skip]` keeps rustfmt from rewriting the files, which
`ferrovue --check` would then report as stale:
```rust,ignore
// src/main.rs or src/lib.rs
#[rustfmt::skip]
mod generated;
use generated::greeting::{self, Props};
```
Then build the props and 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};
// Borrowed strings cost nothing; owned ones (a `String` from a database) work as well.
let user = String::from("Ada");
let props = Props::new(user.as_str(), 3);
// Into a buffer the page is already being written to.
let mut page = String::from("<!doctype html><html><body>");
greeting::render(&mut page, &props);
page.push_str("</body></html>");
assert_eq!(
page,
r#"<!doctype html><html><body><p class="greeting">Hello, Ada!<b>3 new</b><!----></p></body></html>"#
);
// As an island the client hydrates from its own props.
let island = greeting::island(&props).into_string();
assert_eq!(
island,
concat!(
r#"<div data-island="Greeting" data-props="{"name":"Ada","unread":3}">"#,
r#"<p class="greeting">Hello, Ada!<b>3 new</b><!----></p></div>"#,
)
);
```
From here:
- [`generated_code`](crate::guide::generated_code) describes every module the compiler writes;
- [`islands_and_hydration`](crate::guide::islands_and_hydration) is the client side;
- the repository's
[`examples/fullstack`](https://github.com/Kinsmir/ferrovue/tree/main/examples/fullstack) is a
complete axum server and Vite client to copy.