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:

  1. 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 div with 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-pack to compile the client-side Yew application into WebAssembly and JavaScript glue code.
  2. 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-props attribute.
    • 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-props attributes 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 a client:load strategy, 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 with client:idle, client:visible, etc., to defer hydration.

Mermaid Diagram: Build and Hydration Flow

Let’s visualize this process:

flowchart TD subgraph Build_Phase["Build Phase Rust SSG"] A[Content Files Markdown Components] --> B{Parse Markdown AST} B --> C{Identify Interactive Components} C --> D[Extract Component Name and Props] D --> E[Serialize Props to JSON] E --> F[Generate Static HTML with Hydration Markers] F --> G[Compile Client Side Yew to WASM via wasm] G --> H[Copy WASM JS Assets to Output] H --> I[Inject Hydration Script into Final HTML] I --> J[Output Static HTML Files] end subgraph Runtime_Phase["Runtime Phase Browser"] K[User Navigates to Page] --> L[Browser Loads Static HTML] L --> M[Browser Renders Static HTML] M --> N[Browser Loads Hydration Script JS] N --> O[Hydration Script Loads WASM Bundle] O --> P{WASM Scans DOM for Markers} P --> Q{Deserialize Props from data attributes} Q --> R[Mount Yew Component to Static Element] R --> S[Component Becomes Interactive] end J --> K

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: A struct to define the properties our Counter component can receive. #[derive(Properties, PartialEq, Clone, Deserialize, Debug)] is crucial for Yew and serde integration.
  • Counter function 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), and props_json (serialized initial props).
    • It finds the target element in the DOM.
    • It deserializes the props_json into our CounterProps struct.
    • 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_else for robust prop deserialization.

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:

  • HydrationComponent struct: Stores all necessary data for a client-side component.
  • hydration_components: Vec<HydrationComponent>: Added to the Content struct to collect all interactive components found on a page.
  • parse_markdown_and_components modification:
    • We’re introducing a simplified component detection mechanism using regex to find <!-- interactive-component:ComponentName prop="value" --> comments. This is a pragmatic approach for this chapter, although a more robust solution would involve extending pulldown-cmark or a custom parser for true JSX-like syntax.
    • For each match:
      • A unique id is generated using uuid.
      • Props are parsed from the string and serialized into a JSON string.
      • A static div element is generated with id, data-hydration-component, and data-hydration-props attributes. This div serves as the hydration target.
      • The HydrationComponent struct is populated and added to the hydration_components vector.
      • The original Markdown component marker is replaced with the generated static HTML.

b) Modify src/builder.rs (or your build pipeline logic):

The builder needs to:

  1. Process each Content item.
  2. Generate the final HTML using Tera.
  3. Inject the client-side hydration script if any hydration_components are present.
  4. Run wasm-pack to build the client crate.
  5. 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_components into the Tera context.
    • Calls copy_static_assets() after rendering pages.
  • compile_client_wasm():
    • Executes wasm-pack build for our client crate.
    • Outputs to a temporary wasm directory within our output_dir.
    • Includes error handling for wasm-pack failures.
  • copy_static_assets():
    • Moves the wasm-pack output (WASM and JS glue code) from the temporary wasm directory to a permanent static/wasm directory in our public output.
    • Also copies a hydrate.js shim (which we’ll create next).

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 the wasm-pack generated JavaScript glue code.
  • init('../wasm/client_bg.wasm');: Loads the actual WASM binary. The path is relative to the hydrate.js file.
  • 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-catch blocks 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>&copy; 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 the safe filter 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, allowing import statements. The path /static/js/hydrate.js assumes our copy_static_assets function 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.html
  • public/static/wasm/client.js
  • public/static/wasm/client_bg.wasm
  • public/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.js and client_bg.wasm being loaded.
    • Console Tab: You should see WASM module initialized..., Found component..., and Hydrating Counter... messages from our console::log! calls in Rust and console.log in JS.
    • Elements Tab: Inspect the div elements that contained the counters. After hydration, you’ll see the Yew component’s structure inside them.

Production Considerations

  1. Performance:

    • WASM Bundle Size: Keep Yew components lean. Use cargo-bloat to analyze WASM size. Consider optimizations like wee_alloc for smaller binaries (though wee_alloc might sometimes be slower).
    • Lazy Loading: For client:idle or client:visible hydration 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 dynamic import() 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.
  2. Security:

    • Props Sanitization: Any data passed from the server (data-hydration-props) should be treated as untrusted input on the client. While serde_json deserialization 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'; (adjust unsafe-inline/unsafe-eval with 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.
  3. 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).
  4. Configuration:

    • Provide options in your SSG’s configuration (e.g., config.toml) to customize wasm-pack arguments, client-side asset paths, or enable/disable hydration globally.

Code Review Checkpoint

At this point, we have significantly enhanced our SSG:

  • New Crate: Introduced a client Rust library crate for WebAssembly components.
  • Yew Component: Created a basic Counter component using the Yew framework.
  • WASM Hydration Logic: Implemented hydrate_component in Rust (exposed via wasm-bindgen) to dynamically mount Yew components onto static HTML.
  • Markdown Component Detection: Modified Content::parse_markdown_and_components to identify custom <!-- interactive-component: --> markers.
  • Static HTML Generation: The SSG now renders static div elements with unique IDs and data-hydration-component/data-hydration-props attributes for interactive components.
  • Build System Integration: The SiteBuilder now invokes wasm-pack to compile the client crate, and copies the generated WASM and JS assets to the output directory.
  • Client-Side Shim: A hydrate.js file is generated and injected into pages containing interactive components, responsible for loading WASM and triggering hydration.
  • Tera Template: Updated page.html to use the safe filter for content_html and conditionally include the hydration script.

Files Created/Modified:

  • Cargo.toml (project root): Added client to workspace members.
  • client/ (new directory): Contains client/Cargo.toml, client/src/lib.rs.
  • src/content.rs: Added HydrationComponent struct, hydration_components field to Content, and modified parse_markdown_and_components for component detection and static HTML generation.
  • src/builder.rs: Added compile_client_wasm and copy_static_assets functions, and integrated them into build_site.
  • static/js/hydrate.js (new file): The JavaScript shim for WASM loading and hydration.
  • templates/page.html: Added |safe filter and conditional script injection.

Common Issues & Solutions

  1. WASM Module Not Found / Loading Errors:

    • Issue: Browser console shows Failed to load module: No network connection or Failed 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 to hydrate.js.
      • File Existence: Ensure client.js and client_bg.wasm actually exist in public/static/wasm. Check copy_static_assets logic.
      • MIME Types: Ensure your local development server (e.g., Python’s http.server) serves .wasm files with the correct application/wasm MIME type. Production servers usually handle this correctly.
      • wasm-pack Errors: Check the output of cargo run -- build for any wasm-pack compilation failures.
    • Prevention: Double-check all relative paths, use consistent naming conventions, and always inspect build logs.
  2. 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_component in the browser console. Did it find the target element? Did it deserialize props successfully?
      • Props Deserialization: Ensure the JSON string in data-hydration-props exactly matches what your Yew component’s CounterProps expects. Mismatched types or missing fields will cause serde_json::from_str to fail.
      • Component Name: Verify the data-hydration-component attribute matches the name used in hydrate_component’s match statement (e.g., “Counter”).
      • Unique IDs: Ensure each interactive component has a truly unique id attribute.
      • |safe filter: Make sure {{ page.content_html | safe }} is used in your Tera template. Without it, the data- attributes might be HTML-escaped.
    • Prevention: Implement robust serde deserialization with #[serde(default)] or #[prop_or_default] for optional fields, and clear logging in your hydrate_component function.
  3. wasm-pack Build Failures:

    • Issue: wasm-pack build failed with status: ...
    • Debugging:
      • Dependencies: Check client/Cargo.toml for correct yew, wasm-bindgen, serde versions and features.
      • Rust Code Errors: The wasm-pack output often wraps standard Rust compiler errors. Look for error[E...]: messages. Fix any Rust compilation issues in client/src/lib.rs.
      • Toolchain: Ensure your Rust toolchain is up-to-date (rustup update).
    • Prevention: Test your client crate independently with wasm-pack build client --target web before integrating it into the SSG’s build process to isolate issues.

Testing & Verification

To thoroughly test this chapter’s work:

  1. Build the site: cargo run -- build
  2. Serve the public directory: Using python -m http.server 8000 or a similar tool.
  3. Open http://localhost:8000/my-interactive-page/ in a modern browser.
  4. Verify Static Render: The initial page load should show the “Loading interactive Counter…” text (or whatever static fallback you provided) instantly.
  5. 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.
  6. Browser Developer Tools Check:
    • Network Tab: Confirm client.js, client_bg.wasm, and hydrate.js are 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 div elements where the counters are. After hydration, you should see the internal structure created by Yew (e.g., <p>, <button>).

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-pack into 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.