Welcome to Chapter 7! In the previous chapters, we built a robust foundation for our Static Site Generator (SSG), capable of parsing Markdown, extracting front matter, and rendering static HTML using Tera templates, including custom components. While this provides excellent performance for static content, many modern web applications require interactivity. This is where partial hydration comes into play.
In this chapter, we will extend our SSG to support interactive components that are initially rendered as static HTML on the server and then “hydrated” on the client-side with JavaScript and WebAssembly (WASM) to become interactive. This approach, often called “Island Architecture” (popularized by frameworks like Astro), offers the best of both worlds: fast initial page loads for static content and dynamic interactivity where needed, without shipping heavy JavaScript bundles for the entire page. We will use the Yew framework for our client-side WebAssembly components, leveraging Rust’s power end-to-end.
By the end of this chapter, you will have a deep understanding of how to weave interactive, Rust-powered WebAssembly components into your statically generated sites. You’ll be able to define custom interactive elements directly within your Markdown content, have them rendered as performant static HTML, and then seamlessly brought to life on the client. This is a critical step towards building a truly modern and high-performance content platform.
Planning & Design
Implementing partial hydration requires careful coordination between our Rust SSG (server-side build) and our client-side WebAssembly components. The core idea is that our SSG will render a static HTML placeholder for each interactive component, embedding necessary data and instructions for the client. The client-side JavaScript, alongside the compiled WebAssembly, will then “find” these placeholders and mount the corresponding interactive components.
Component Architecture
Here’s a breakdown of the architectural flow:
SSG (Rust) Build Phase:
- Parses Markdown, identifying custom component tags (e.g.,
<MyCounter client:load initial=5 />). - Extracts component name (
MyCounter) and properties (initial=5). - Generates static HTML for the component’s initial state (e.g., a
divwith a static count). - Injects a unique identifier and serialized properties (JSON) into
data-attributes on the static HTML element. - Adds a
<script>tag to the final HTML output, pointing to a small JavaScript shim that loads the WebAssembly bundle. - Invokes
wasm-packto compile the client-side Yew application into WebAssembly and JavaScript glue code.
- Parses Markdown, identifying custom component tags (e.g.,
Client-Side (Yew/WASM) Runtime Phase:
- A small JavaScript shim loads the main WebAssembly bundle.
- The WebAssembly code (our Yew application’s entry point) scans the DOM for elements with our hydration markers (e.g.,
data-hydration-component). - For each identified marker, it deserializes the properties from the
data-hydration-propsattribute. - It then instantiates the corresponding Yew component with these properties and mounts it onto the static HTML element, taking over interactivity.
Data Flow and Hydration Strategy
- Props: Initial properties for interactive components will be serialized as JSON strings and embedded in
data-hydration-propsattributes on the server-rendered HTML. The client-side Yew component will then deserialize these props. - Hydration Strategy (
client:load): For simplicity, we’ll start with aclient:loadstrategy, meaning the component will hydrate as soon as the client-side JavaScript/WASM is loaded and executed. In a production system, you’d extend this withclient:idle,client:visible, etc., to defer hydration.
Mermaid Diagram: Build and Hydration Flow
Let’s visualize this process:
Step-by-Step Implementation
We will implement this incrementally, first setting up our client-side Yew project, then modifying our SSG to recognize and prepare for hydration, and finally tying it all together.
1. Setup Client-Side Yew Project
We’ll create a new Rust library crate named client within our existing project workspace. This crate will contain our Yew components and the hydration bootstrapping logic.
a) Update Cargo.toml in the project root:
Open your main Cargo.toml (the one at the root of your SSG project) and add client to the members array in the [workspace] section.
# Cargo.toml (project root)
[workspace]
members = [
".", # This refers to the main SSG crate
"client", # Our new client-side Yew/WASM crate
]
resolver = "2" # Recommended for workspaces
b) Create the client crate:
In your project root, run:
cargo new client --lib
This creates a new client directory with its own Cargo.toml and src/lib.rs.
c) Configure client/Cargo.toml:
Now, open client/Cargo.toml and add the necessary dependencies for Yew and wasm-bindgen. We’ll also add serde for deserializing props.
# client/Cargo.toml
[package]
name = "client"
version = "0.1.0"
edition = "2021"
[lib]
crate-type = ["cdylib", "rlib"] # cdylib for WASM, rlib for tests/other Rust usage
[dependencies]
yew = { version = "0.21", features = ["csr"] } # CSR feature for client-side rendering
wasm-bindgen = "0.2"
wasm-bindgen-futures = "0.4"
serde = { version = "1.0", features = ["derive"] }
serde_json = "1.0"
log = "0.4"
web-sys = { version = "0.3", features = [
"Document",
"Element",
"HtmlElement",
"Node",
"Window",
]}
d) Install wasm-pack:
wasm-pack is essential for compiling Rust to WebAssembly and generating the necessary JavaScript glue code.
cargo install wasm-pack
2. Define our First Interactive Component (Yew)
Let’s create a simple counter component in our client crate.
File: client/src/lib.rs
// client/src/lib.rs
use yew::prelude::*;
use serde::{Deserialize, Serialize};
use wasm_bindgen::prelude::*;
use web_sys::console;
use std::collections::HashMap;
// --- Component Props ---
// This struct defines the properties our Yew component expects.
// It must be Deserialize to receive data from the server.
#[derive(Properties, PartialEq, Clone, Deserialize, Debug)]
pub struct CounterProps {
#[prop_or(0)]
pub initial: i32,
#[prop_or_default]
pub label: String,
}
// --- Counter Component ---
#[function_component(Counter)]
pub fn counter(props: &CounterProps) -> Html {
let count = use_state(|| props.initial);
let onclick = {
let count = count.clone();
Callback::from(move |_| {
count.set(*count + 1);
console::log_1(&"Counter clicked!".into());
})
};
html! {
<div class="interactive-counter">
<p>{ format!("{} Current count: {}", props.label, *count) }</p>
<button {onclick}>{ "Increment" }</button>
</div>
}
}
// --- Hydration Entry Point ---
// This function will be called by the JavaScript shim to hydrate components.
#[wasm_bindgen]
pub fn hydrate_component(component_name: String, target_id: String, props_json: String) {
console::log_2(&"Attempting to hydrate component:".into(), &component_name.into());
console::log_2(&"Target ID:".into(), &target_id.into());
console::log_2(&"Props JSON:".into(), &props_json.into());
let window = web_sys::window().expect("no global `window` exists");
let document = window.document().expect("should have a document on window");
let target_element = document
.get_element_by_id(&target_id)
.expect("target element not found for hydration");
// We'll need a way to map component_name to the actual Yew component.
// For now, we'll hardcode 'Counter'. In a real SSG, this would be a registry.
match component_name.as_str() {
"Counter" => {
let props: CounterProps = serde_json::from_str(&props_json)
.unwrap_or_else(|e| {
console::error_2(
&"Failed to deserialize props for Counter:".into(),
&e.to_string().into()
);
CounterProps { initial: 0, label: "Error".to_string() }
});
console::log_2(&"Hydrating Counter with props:".into(), &format!("{:?}", props).into());
yew::Renderer::<Counter>::with_root_and_props(target_element, props).render();
},
_ => {
console::warn_2(
&"Unknown component for hydration:".into(),
&component_name.into()
);
}
}
}
Explanation:
CounterProps: Astructto define the properties ourCountercomponent can receive.#[derive(Properties, PartialEq, Clone, Deserialize, Debug)]is crucial for Yew andserdeintegration.Counterfunction component: A standard Yew component with state (use_state) and an event handler (onclick).hydrate_component: This is the crucial#[wasm_bindgen]function. It’s exposed to JavaScript.- It takes the
component_name,target_id(the ID of the static HTML element to hydrate), andprops_json(serialized initial props). - It finds the target element in the DOM.
- It deserializes the
props_jsoninto ourCounterPropsstruct. yew::Renderer::<Counter>::with_root_and_props(target_element, props).render();is the magic line that mounts our Yew component onto the existing static HTML, effectively hydrating it.- Error Handling: We include
unwrap_or_elsefor robust prop deserialization.
- It takes the
3. Modify SSG to Recognize and Prepare for Hydration
Now, we need to teach our SSG to identify these interactive components in Markdown, render their static HTML, and prepare the hydration instructions.
a) Update src/main.rs (or relevant build logic):
We need to modify the Markdown processing pipeline. In Chapter 5, we introduced custom component syntax. We’ll now enhance this to handle client:load attributes.
First, ensure your Parser struct (from previous chapters, likely in src/parser.rs or src/lib.rs) can handle custom attributes. We’ll assume a simplified approach here where we detect a specific pattern.
Let’s assume your component parsing currently looks for something like {{ component "MyComponent" prop="value" }}. We’ll evolve this to {{ interactive_component "Counter" client:load initial=5 label="My Label" }}.
Modify your Content struct and parse_markdown function:
We need to store information about components requiring hydration.
File: src/content.rs (or similar, where your Content struct is defined)
// src/content.rs
use std::collections::HashMap;
use serde::{Serialize, Deserialize};
use pulldown_cmark::{Parser, Event, Tag, Options};
use tera::{Context, Tera};
use anyhow::{Result, anyhow};
use log::{info, error};
// Assuming this struct already exists from previous chapters
#[derive(Debug, Serialize, Deserialize, Clone)]
pub struct FrontMatter {
pub title: String,
pub date: Option<String>,
pub draft: Option<bool>,
pub description: Option<String>,
pub slug: Option<String>,
pub weight: Option<u32>,
pub keywords: Option<Vec<String>>,
pub tags: Option<Vec<String>>,
pub categories: Option<Vec<String>>,
pub author: Option<String>,
#[serde(default)]
pub showReadingTime: bool,
#[serde(default)]
pub showTableOfContents: bool,
#[serde(default)]
pub showComments: bool,
#[serde(default)]
pub toc: bool,
#[serde(flatten)]
pub extra: HashMap<String, serde_json::Value>,
}
// Represents a page or content item
#[derive(Debug, Serialize, Clone)]
pub struct Content {
pub front_matter: FrontMatter,
pub content_html: String,
pub raw_markdown: String,
pub path: String, // Original file path
pub relative_url: String, // The URL where it will be served
pub output_path: String, // The file path where it will be written
pub hydration_components: Vec<HydrationComponent>, // NEW FIELD
}
// NEW STRUCT: Represents an interactive component found in Markdown
#[derive(Debug, Serialize, Clone)]
pub struct HydrationComponent {
pub id: String, // Unique ID for the HTML element
pub name: String, // Name of the Yew component (e.g., "Counter")
pub props_json: String, // JSON serialized props
pub initial_html: String, // Static HTML fallback
}
impl Content {
// This function needs to be updated to detect and process hydration components
// Assuming `parse_markdown_and_components` from Chapter 5.
// We'll simulate the component detection here. In a real system, this would
// involve a more robust custom Markdown parser or pre-processor.
pub fn parse_markdown_and_components(markdown: &str, file_path: &str, relative_url: &str, output_path: &str) -> Result<Self> {
let mut parts = markdown.splitn(3, "+++");
let _ = parts.next(); // Skip the first empty part
let front_matter_str = parts.next().ok_or_else(|| anyhow!("Missing front matter in {}", file_path))?;
let content_markdown = parts.next().ok_or_else(|| anyhow!("Missing content after front matter in {}", file_path))?;
let front_matter: FrontMatter = serde_yaml::from_str(front_matter_str)
.map_err(|e| anyhow!("Failed to parse front matter for {}: {}", file_path, e))?;
let mut html_output = String::new();
let mut hydration_components = Vec::new();
// Placeholder for a more advanced component parsing.
// For this chapter, we'll use a simple regex or string search
// to find our interactive component pattern.
// In a real system, you'd integrate this with your custom Markdown parser
// or a pre-processing step that transforms custom component syntax.
// Example: Detect a custom interactive component syntax
// E.g., `<!-- interactive:Counter initial=10 label="Clicks" -->`
// Or, more robustly, a custom pulldown-cmark extension.
// For simplicity and demonstrating the core concept, let's use a simpler marker that
// we can find and replace, and then render with Tera.
// For now, let's assume we are transforming the AST in `transform_markdown_to_html`
// to embed these components. The `transform_markdown_to_html` function will be
// responsible for finding these custom component placeholders.
let parser = Parser::new_ext(content_markdown, Options::all());
pulldown_cmark::html::push_html(&mut html_output, parser);
// --- Component Detection and Replacement (Simplified for this chapter) ---
// In a more robust system, this would be part of the AST transformation.
// Here, we'll do a string replacement after initial HTML generation,
// which is less ideal but demonstrates the concept for this chapter.
// A proper solution would involve customizing pulldown-cmark's renderer
// or a pre-processor that transforms custom syntax into special HTML elements.
let component_marker_regex = regex::Regex::new(r"<!-- interactive-component:(\w+)\s+(.*?)-->").unwrap();
let mut processed_html = String::new();
let mut last_match_end = 0;
for cap in component_marker_regex.captures_iter(&html_output) {
let full_match = cap.get(0).unwrap();
let component_name = cap.get(1).unwrap().as_str();
let props_str = cap.get(2).unwrap().as_str();
processed_html.push_str(&html_output[last_match_end..full_match.start()]);
last_match_end = full_match.end();
let component_id = format!("{}-{}", component_name.to_lowercase(), uuid::Uuid::new_v4().to_string().replace('-', ""));
let mut props_map: HashMap<String, serde_json::Value> = HashMap::new();
// Parse props_str (e.g., `initial=5 label="Hello"`)
let prop_regex = regex::Regex::new(r#"(\w+)=(?:\"(.*?)\"|(\S+))"#).unwrap();
for prop_cap in prop_regex.captures_iter(props_str) {
let key = prop_cap.get(1).unwrap().as_str();
let value = prop_cap.get(2).map_or_else(|| prop_cap.get(3).unwrap().as_str(), |m| m.as_str());
// Attempt to parse as number, then boolean, then string
let parsed_value = if let Ok(num) = value.parse::<i64>() {
serde_json::to_value(num).unwrap()
} else if let Ok(b) = value.parse::<bool>() {
serde_json::to_value(b).unwrap()
} else {
serde_json::to_value(value).unwrap()
};
props_map.insert(key.to_string(), parsed_value);
}
let props_json = serde_json::to_string(&props_map)
.map_err(|e| anyhow!("Failed to serialize props for {}: {}", component_name, e))?;
let initial_html = format!(
r#"<div id="{}" data-hydration-component="{}" data-hydration-props='{}'>
<!-- Static fallback for {} component -->
<p>Loading interactive {}...</p>
</div>"#,
component_id, component_name, props_json, component_name, component_name
);
processed_html.push_str(&initial_html);
hydration_components.push(HydrationComponent {
id: component_id,
name: component_name.to_string(),
props_json,
initial_html: initial_html.clone(), // Store for potential debugging/re-rendering
});
}
processed_html.push_str(&html_output[last_match_end..]);
html_output = processed_html;
Ok(Content {
front_matter,
content_html: html_output,
raw_markdown: markdown.to_string(),
path: file_path.to_string(),
relative_url: relative_url.to_string(),
output_path: output_path.to_string(),
hydration_components,
})
}
}
Dependencies for src/content.rs:
You’ll need regex and uuid for the component detection and ID generation. Add these to your main Cargo.toml.
# Cargo.toml (main SSG crate)
[dependencies]
# ... existing dependencies
regex = "1.10"
uuid = { version = "1.7", features = ["v4", "fast-rng"] } # For unique IDs
serde_json = "1.0" # Needed for serializing props
Explanation of changes in src/content.rs:
HydrationComponentstruct: Stores all necessary data for a client-side component.hydration_components: Vec<HydrationComponent>: Added to theContentstruct to collect all interactive components found on a page.parse_markdown_and_componentsmodification:- We’re introducing a simplified component detection mechanism using
regexto find<!-- interactive-component:ComponentName prop="value" -->comments. This is a pragmatic approach for this chapter, although a more robust solution would involve extendingpulldown-cmarkor a custom parser for true JSX-like syntax. - For each match:
- A unique
idis generated usinguuid. - Props are parsed from the string and serialized into a JSON string.
- A static
divelement is generated withid,data-hydration-component, anddata-hydration-propsattributes. Thisdivserves as the hydration target. - The
HydrationComponentstruct is populated and added to thehydration_componentsvector. - The original Markdown component marker is replaced with the generated static HTML.
- A unique
- We’re introducing a simplified component detection mechanism using
b) Modify src/builder.rs (or your build pipeline logic):
The builder needs to:
- Process each
Contentitem. - Generate the final HTML using Tera.
- Inject the client-side hydration script if any
hydration_componentsare present. - Run
wasm-packto build theclientcrate. - Copy the generated WASM and JS files to the output directory.
File: src/builder.rs (or where your build_site function resides)
// src/builder.rs
use std::fs;
use std::path::{Path, PathBuf};
use tera::{Tera, Context};
use anyhow::{Result, anyhow};
use log::{info, error, warn};
use crate::content::{Content, FrontMatter}; // Assuming Content is in crate::content
// ... other imports
pub struct SiteBuilder {
pub content_dir: PathBuf,
pub output_dir: PathBuf,
pub template_dir: PathBuf,
pub tera: Tera,
}
impl SiteBuilder {
pub fn new(content_dir: &Path, output_dir: &Path, template_dir: &Path) -> Result<Self> {
// ... (existing Tera initialization)
let mut tera = Tera::new(&format!("{}/**/*.html", template_dir.display()))?;
tera.autoescape_on(vec![]); // Disable autoescape for raw HTML components
// ...
Ok(SiteBuilder {
content_dir: content_dir.to_path_buf(),
output_dir: output_dir.to_path_buf(),
template_dir: template_dir.to_path_buf(),
tera,
})
}
pub fn build_site(&mut self) -> Result<()> {
info!("Starting site build...");
if self.output_dir.exists() {
fs::remove_dir_all(&self.output_dir)?;
}
fs::create_dir_all(&self.output_dir)?;
// 1. Process content files
let mut contents = Vec::new();
for entry in walkdir::WalkDir::new(&self.content_dir) {
let entry = entry?;
let path = entry.path();
if path.is_file() && path.extension().map_or(false, |ext| ext == "md") {
info!("Processing content file: {}", path.display());
let markdown = fs::read_to_string(path)?;
// Determine relative URL and output path
let relative_path = path.strip_prefix(&self.content_dir)?.to_path_buf();
let file_stem = path.file_stem().and_then(|s| s.to_str()).unwrap_or("index");
let parent_dir = relative_path.parent().unwrap_or(Path::new(""));
let relative_url = if file_stem == "index" {
format!("/{}", parent_dir.display())
} else {
format!("/{}/{}", parent_dir.display(), file_stem)
}.replace('\\', "/"); // Normalize path separators for URLs
let output_html_path = self.output_dir.join(
if file_stem == "index" {
parent_dir.join("index.html")
} else {
parent_dir.join(file_stem).join("index.html")
}
);
let content = Content::parse_markdown_and_components(
&markdown,
path.to_str().unwrap(),
&relative_url,
output_html_path.to_str().unwrap(),
)?;
contents.push(content);
}
}
// 2. Compile client-side WASM (NEW STEP)
self.compile_client_wasm()?;
// 3. Render pages
for content in contents {
info!("Rendering page: {}", content.relative_url);
let mut context = Context::new();
context.insert("page", &content);
context.insert("front_matter", &content.front_matter); // For direct access
// Add hydration components data to context for script injection
context.insert("hydration_components", &content.hydration_components);
let template_name = "page.html"; // Default template
// You might have logic here to choose a template based on front matter
let rendered_html = self.tera.render(template_name, &context)
.map_err(|e| anyhow!("Failed to render template {}: {}", template_name, e))?;
// Ensure output directory exists for this page
let output_path = PathBuf::from(&content.output_path);
fs::create_dir_all(output_path.parent().unwrap())?;
fs::write(&output_path, rendered_html)?;
info!("Wrote page to: {}", output_path.display());
}
// 4. Copy static assets (e.g., CSS, images, client WASM/JS)
self.copy_static_assets()?;
info!("Site build complete!");
Ok(())
}
// NEW FUNCTION: Compile client-side WASM
fn compile_client_wasm(&self) -> Result<()> {
info!("Compiling client-side WebAssembly...");
let client_crate_path = self.content_dir.parent().unwrap().join("client"); // Assuming 'client' is sibling to 'content' dir
if !client_crate_path.exists() {
warn!("Client crate path does not exist: {}", client_crate_path.display());
return Ok(()); // Or return an error if hydration is mandatory
}
let output_wasm_dir = self.output_dir.join("wasm");
fs::create_dir_all(&output_wasm_dir)?;
let status = std::process::Command::new("wasm-pack")
.arg("build")
.arg(&client_crate_path)
.arg("--target")
.arg("web") // Or "bundler" if integrating with a JS bundler
.arg("--out-dir")
.arg(&output_wasm_dir)
.status()?;
if !status.success() {
return Err(anyhow!("wasm-pack build failed with status: {:?}", status));
}
info!("Client-side WebAssembly compiled successfully to {}", output_wasm_dir.display());
Ok(())
}
// NEW FUNCTION: Copy static assets, including WASM output
fn copy_static_assets(&self) -> Result<()> {
info!("Copying static assets...");
// This is a simplified static copy. In a real system, you'd scan a dedicated
// `static` folder. For now, let's assume WASM files are copied here.
// We'll also copy a small JS shim.
// Copy WASM output from `output_dir/wasm` to `output_dir/static/wasm`
let wasm_source_dir = self.output_dir.join("wasm");
let wasm_target_dir = self.output_dir.join("static").join("wasm");
if wasm_source_dir.exists() {
fs::create_dir_all(&wasm_target_dir)?;
for entry in fs::read_dir(&wasm_source_dir)? {
let entry = entry?;
let path = entry.path();
if path.is_file() {
fs::copy(&path, wasm_target_dir.join(path.file_name().unwrap()))?;
}
}
fs::remove_dir_all(&wasm_source_dir)?; // Clean up temporary wasm-pack output
}
// Copy our JS hydration shim
let hydration_shim_path = self.output_dir.join("static").join("js").join("hydrate.js");
fs::create_dir_all(hydration_shim_path.parent().unwrap())?;
fs::write(&hydration_shim_path, include_str!("../static/js/hydrate.js"))?;
info!("Copied hydration shim to {}", hydration_shim_path.display());
Ok(())
}
}
Dependencies for src/builder.rs:
walkdir = "2"(if not already present)
Explanation of changes in src/builder.rs:
SiteBuilder::new: Disabled Tera autoescape for raw HTML components.build_site:- Calls
compile_client_wasm()before rendering pages. - Inserts
hydration_componentsinto the Tera context. - Calls
copy_static_assets()after rendering pages.
- Calls
compile_client_wasm():- Executes
wasm-pack buildfor ourclientcrate. - Outputs to a temporary
wasmdirectory within ouroutput_dir. - Includes error handling for
wasm-packfailures.
- Executes
copy_static_assets():- Moves the
wasm-packoutput (WASM and JS glue code) from the temporarywasmdirectory to a permanentstatic/wasmdirectory in ourpublicoutput. - Also copies a
hydrate.jsshim (which we’ll create next).
- Moves the
c) Create hydrate.js shim:
This JavaScript file will be responsible for loading the WASM bundle and initiating the hydration process.
File: static/js/hydrate.js (create the static/js directories in your project root)
// static/js/hydrate.js
import init, { hydrate_component } from '../wasm/client.js'; // Adjust path as needed
async function startHydration() {
try {
// Initialize the WASM module
await init('../wasm/client_bg.wasm'); // Adjust path to the actual WASM file
console.log('WASM module initialized. Starting hydration scan...');
// Find all elements marked for hydration
const hydrationElements = document.querySelectorAll('[data-hydration-component]');
hydrationElements.forEach(element => {
const componentName = element.dataset.hydrationComponent;
const targetId = element.id; // Assume element already has a unique ID
const propsJson = element.dataset.hydrationProps;
if (componentName && targetId && propsJson) {
console.log(`Found component '${componentName}' with ID '${targetId}' for hydration.`);
try {
// Call the Rust-WASM hydration function
hydrate_component(componentName, targetId, propsJson);
} catch (e) {
console.error(`Error hydrating component '${componentName}' (ID: ${targetId}):`, e);
}
} else {
console.warn('Skipping hydration for element with missing attributes:', element);
}
});
console.log('Hydration scan complete.');
} catch (e) {
console.error('Failed to load or initialize WASM module:', e);
}
}
// Ensure the DOM is ready before attempting to hydrate
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', startHydration);
} else {
startHydration();
}
Explanation of static/js/hydrate.js:
import init, { hydrate_component } from '../wasm/client.js';: Imports the WASM initialization function (init) and our exposed Rust function (hydrate_component) from thewasm-packgenerated JavaScript glue code.init('../wasm/client_bg.wasm');: Loads the actual WASM binary. The path is relative to thehydrate.jsfile.document.querySelectorAll('[data-hydration-component]'): Scans the DOM for all elements that our SSG marked for hydration.hydrate_component(componentName, targetId, propsJson): Calls the Rust function, passing the component details.- Error Handling: Includes
try-catchblocks for WASM loading and individual component hydration failures. DOMContentLoaded: Ensures the script runs only after the DOM is fully loaded.
d) Update your Tera template (templates/page.html):
We need to inject the hydrate.js script and potentially other client-side assets into our generated HTML.
File: templates/page.html
<!-- templates/page.html -->
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>{{ page.front_matter.title }}</title>
<!-- Your existing CSS links -->
<link rel="stylesheet" href="/static/css/main.css">
</head>
<body>
<header>
<h1>{{ page.front_matter.title }}</h1>
<p>By {{ page.front_matter.author | default(value="Anonymous") }} on {{ page.front_matter.date | date(format="%Y-%m-%d") }}</p>
</header>
<main>
{{ page.content_html | safe }} {# Render the processed Markdown HTML #}
</main>
<footer>
<p>© 2026 My Rust SSG</p>
</footer>
{# Conditional script injection for hydration #}
{% if page.hydration_components | length > 0 %}
<script type="module" src="/static/js/hydrate.js"></script>
{% endif %}
</body>
</html>
Explanation of changes in templates/page.html:
{{ page.content_html | safe }}: It’s CRITICAL to use thesafefilter here, as our SSG has already generated raw HTML with hydration markers, and we don’t want Tera to escape it.- Conditional Script: The
{% if page.hydration_components | length > 0 %}block ensures the hydration script is only injected if the page actually contains interactive components. This is a performance optimization. <script type="module" src="/static/js/hydrate.js"></script>: This loads our JavaScript shim as a module, allowingimportstatements. The path/static/js/hydrate.jsassumes ourcopy_static_assetsfunction places it there.
4. Testing This Component
a) Create a sample Markdown file with an interactive component:
File: content/my-interactive-page/index.md
+++
title = "My Interactive Page"
date = 2026-03-02
slug = "interactive-page"
+++
# Welcome to an Interactive Page!
This page demonstrates a client-side WebAssembly component.
Here's our interactive counter:
<!-- interactive-component:Counter initial=10 label="Page Views" -->
You can click the button below to increment the count. The initial value `10` is passed from the server.
Another counter, starting from 100:
<!-- interactive-component:Counter initial=100 label="Another Count" -->
More static content here...
b) Run the SSG build:
From your project root:
cargo run -- build
You should see output indicating wasm-pack compilation and file copying. Check your public directory. You should find:
public/my-interactive-page/index.htmlpublic/static/wasm/client.jspublic/static/wasm/client_bg.wasmpublic/static/js/hydrate.js
c) Serve the public directory:
You can use a simple HTTP server to test. If you have Python installed:
cd public
python -m http.server 8000
Then open your browser to http://localhost:8000/my-interactive-page/.
d) Verify behavior:
- Initial Load: The page should load quickly, displaying “Loading interactive Counter…” or the static fallback HTML.
- Hydration: Within a moment, the “Increment” button should appear, and clicking it should increment the count.
- Browser Dev Tools:
- Network Tab: Look for
client.jsandclient_bg.wasmbeing loaded. - Console Tab: You should see
WASM module initialized...,Found component..., andHydrating Counter...messages from ourconsole::log!calls in Rust andconsole.login JS. - Elements Tab: Inspect the
divelements that contained the counters. After hydration, you’ll see the Yew component’s structure inside them.
- Network Tab: Look for
Production Considerations
Performance:
- WASM Bundle Size: Keep Yew components lean. Use
cargo-bloatto analyze WASM size. Consider optimizations likewee_allocfor smaller binaries (thoughwee_allocmight sometimes be slower). - Lazy Loading: For
client:idleorclient:visiblehydration strategies, only load the WASM for a component when it’s needed (e.g., when it enters the viewport or after the main thread is idle). This requires more sophisticated client-side routing and dynamicimport()for WASM. - CDN: Serve WASM and JS assets from a CDN for faster global delivery.
- Compression: Ensure your web server compresses WASM (
application/wasm) and JS (application/javascript) files with Gzip/Brotli.
- WASM Bundle Size: Keep Yew components lean. Use
Security:
- Props Sanitization: Any data passed from the server (
data-hydration-props) should be treated as untrusted input on the client. Whileserde_jsondeserialization itself is safe, if you were to render these props directly into innerHTML without proper escaping in your Yew components, it could lead to XSS vulnerabilities. Yew’s templating usually handles this, but be mindful when manually inserting raw HTML. - Content Security Policy (CSP): Implement a strict CSP to control where scripts and WASM modules can be loaded from. This mitigates injection attacks. For example,
script-src 'self' 'unsafe-inline' 'unsafe-eval'; worker-src 'self' blob:; connect-src 'self';(adjustunsafe-inline/unsafe-evalwith nonces or hashes if possible). - Input Validation: If your interactive components allow user input, validate it thoroughly on both the client and any backend APIs they might communicate with.
- Props Sanitization: Any data passed from the server (
Error Handling & Monitoring:
- Client-side Error Boundaries: In Yew, you can implement error boundaries to gracefully handle runtime errors within components, preventing the entire application from crashing.
- Reporting: Integrate client-side error logging (e.g., Sentry, LogRocket) to capture and report JavaScript and WASM runtime errors.
- Fallback HTML: Ensure your static fallback HTML is always present and provides a reasonable user experience even if hydration fails completely (e.g., due to network issues, WASM parsing errors).
Configuration:
- Provide options in your SSG’s configuration (e.g.,
config.toml) to customizewasm-packarguments, client-side asset paths, or enable/disable hydration globally.
- Provide options in your SSG’s configuration (e.g.,
Code Review Checkpoint
At this point, we have significantly enhanced our SSG:
- New Crate: Introduced a
clientRust library crate for WebAssembly components. - Yew Component: Created a basic
Countercomponent using the Yew framework. - WASM Hydration Logic: Implemented
hydrate_componentin Rust (exposed viawasm-bindgen) to dynamically mount Yew components onto static HTML. - Markdown Component Detection: Modified
Content::parse_markdown_and_componentsto identify custom<!-- interactive-component: -->markers. - Static HTML Generation: The SSG now renders static
divelements with unique IDs anddata-hydration-component/data-hydration-propsattributes for interactive components. - Build System Integration: The
SiteBuildernow invokeswasm-packto compile theclientcrate, and copies the generated WASM and JS assets to the output directory. - Client-Side Shim: A
hydrate.jsfile is generated and injected into pages containing interactive components, responsible for loading WASM and triggering hydration. - Tera Template: Updated
page.htmlto use thesafefilter forcontent_htmland conditionally include the hydration script.
Files Created/Modified:
Cargo.toml(project root): Addedclientto workspace members.client/(new directory): Containsclient/Cargo.toml,client/src/lib.rs.src/content.rs: AddedHydrationComponentstruct,hydration_componentsfield toContent, and modifiedparse_markdown_and_componentsfor component detection and static HTML generation.src/builder.rs: Addedcompile_client_wasmandcopy_static_assetsfunctions, and integrated them intobuild_site.static/js/hydrate.js(new file): The JavaScript shim for WASM loading and hydration.templates/page.html: Added|safefilter and conditional script injection.
Common Issues & Solutions
WASM Module Not Found / Loading Errors:
- Issue: Browser console shows
Failed to load module: No network connectionorFailed to load WASM module: TypeError: Failed to fetch. - Debugging:
- Check Paths: Verify the paths in
static/js/hydrate.js(e.g.,../wasm/client.js,../wasm/client_bg.wasm) correctly point to the generated files relative tohydrate.js. - File Existence: Ensure
client.jsandclient_bg.wasmactually exist inpublic/static/wasm. Checkcopy_static_assetslogic. - MIME Types: Ensure your local development server (e.g., Python’s
http.server) serves.wasmfiles with the correctapplication/wasmMIME type. Production servers usually handle this correctly. wasm-packErrors: Check the output ofcargo run -- buildfor anywasm-packcompilation failures.
- Check Paths: Verify the paths in
- Prevention: Double-check all relative paths, use consistent naming conventions, and always inspect build logs.
- Issue: Browser console shows
Hydration Mismatch / Component Not Interactive:
- Issue: The static HTML appears, but the component doesn’t become interactive, or there are warnings/errors about hydration mismatches.
- Debugging:
- Console Logs: Look for messages from
hydrate_componentin the browser console. Did it find the target element? Did it deserialize props successfully? - Props Deserialization: Ensure the JSON string in
data-hydration-propsexactly matches what your Yew component’sCounterPropsexpects. Mismatched types or missing fields will causeserde_json::from_strto fail. - Component Name: Verify the
data-hydration-componentattribute matches the name used inhydrate_component’smatchstatement (e.g., “Counter”). - Unique IDs: Ensure each interactive component has a truly unique
idattribute. |safefilter: Make sure{{ page.content_html | safe }}is used in your Tera template. Without it, thedata-attributes might be HTML-escaped.
- Console Logs: Look for messages from
- Prevention: Implement robust
serdedeserialization with#[serde(default)]or#[prop_or_default]for optional fields, and clear logging in yourhydrate_componentfunction.
wasm-packBuild Failures:- Issue:
wasm-pack build failed with status: ... - Debugging:
- Dependencies: Check
client/Cargo.tomlfor correctyew,wasm-bindgen,serdeversions and features. - Rust Code Errors: The
wasm-packoutput often wraps standard Rust compiler errors. Look forerror[E...]:messages. Fix any Rust compilation issues inclient/src/lib.rs. - Toolchain: Ensure your Rust toolchain is up-to-date (
rustup update).
- Dependencies: Check
- Prevention: Test your
clientcrate independently withwasm-pack build client --target webbefore integrating it into the SSG’s build process to isolate issues.
- Issue:
Testing & Verification
To thoroughly test this chapter’s work:
- Build the site:
cargo run -- build - Serve the
publicdirectory: Usingpython -m http.server 8000or a similar tool. - Open
http://localhost:8000/my-interactive-page/in a modern browser. - Verify Static Render: The initial page load should show the “Loading interactive Counter…” text (or whatever static fallback you provided) instantly.
- Verify Hydration: After a brief moment, the “Increment” button should appear, and clicking it should update the count without a page reload. Test both instances of the counter on the page; they should function independently.
- Browser Developer Tools Check:
- Network Tab: Confirm
client.js,client_bg.wasm, andhydrate.jsare loaded successfully. - Console Tab: Look for
[info]messages from our Rust and JavaScript code confirming WASM initialization and hydration steps. Check for any[error]or[warn]messages. - Elements Tab: Inspect the
divelements where the counters are. After hydration, you should see the internal structure created by Yew (e.g.,<p>,<button>).
- Network Tab: Confirm
If all these checks pass, you have successfully implemented partial hydration in your Rust SSG!
Summary & Next Steps
In this chapter, we achieved a significant milestone by integrating partial hydration into our Rust SSG. We learned how to:
- Structure a client-side WebAssembly project using Yew.
- Expose Rust functions to JavaScript using
wasm-bindgen. - Modify our SSG’s content processing pipeline to detect interactive components and generate static HTML with hydration markers.
- Integrate
wasm-packinto our SSG’s build process to compile Rust to WebAssembly. - Create a JavaScript shim to load the WASM bundle and initiate client-side hydration.
- Render server-side HTML and then seamlessly re-hydrate it on the client.
This capability is crucial for building modern, high-performance web applications that demand both speed and interactivity. We now have a truly hybrid SSG!
In the next chapter, Chapter 8: Implementing Incremental Builds and Caching, we will tackle the efficiency of our build process. As our project grows, rebuilding the entire site for every small change becomes impractical. We will implement smart caching and incremental build strategies to only re-process changed files, significantly speeding up development workflows.