A tale of Rust, Svelte, and islands of interactivity
If you’re building a modern website, you’ve probably felt the pull between two different goals. On one hand, you need raw performance and fast load times, which points toward server-side rendering. On the other, you want the dynamic, app-like feel that client-side JavaScript frameworks provide.
Traditionally, you had to lean one way or the other. But what if you could combine the strengths of both?
This post explores an idea which I had in my head for a few months: integrating Svelte with Rust. I’ll tackle the main challenge head-on: how to take a Svelte component, render it to static HTML on the server using Rust, and then seamlessly “hydrate” it on the client to bring it to life. This “islands of interactivity” approach gives you the speed benefits of a static site with the rich user experience of a single-page application.
Getting Svelte to speak HTML
Before I could think about interactivity, I needed a way to simply turn a Svelte component into static HTML during my Rust project’s build process. This is the core of Server-Side Rendering (SSR): create the HTML on the server so the browser gets a fully-formed page right away. I started with a basic counter button.
To simplify the basic proof of concept I started by handling the Svelte stuff in
the build.rs
script. For the actual JavaScript bundling, I reached for
esbuild
because of its incredible speed. The plan was to have the build.rs
script orchestrate esbuild to handle the Svelte compilation in two distinct
passes:
The server-side build:
The first task was to compile Button.svelte
into code that could run on the
server (in a Node.js environment) and spit out raw HTML. A neat trick here was
encoding the bundle as URI component and immediately importing it to avoid
writing temporary files. The Rust build script tells Node to execute esbuild
,
which compiles the Svelte component. The resulting JavaScript code is then
imported directly from the data, its render function is called, and the final
HTML is saved to a file like Button.html
.
// tools/svelte_bundle.mjs (SSR part - simplified)
// ...
const ssrBundle = await build({
entryPoints: [file],
bundle: true,
format: "esm",
platform: "node",
write: false, // Don't write to disk yet
plugins: [svelte({ compilerOptions: { generate: "ssr" } })],
});
// Import from data URI to get the SSR component
const { default: Comp } = await import(
"data:text/javascript," + encodeURIComponent(ssrBundle.outputFiles[0].text)
);
// The Svelte SSR component's render function is a bit unusual
// It takes an object and populates the `out` key with the HTML
const data = { out: "" };
Comp(data);
const html = data.out;
// ... then this HTML is saved to Button.html
The client-side build
At the same time, a second esbuild process compiled the exact same
Button.svelte
component, but this time for the browser. This created a
standard, self-contained JavaScript file (Button.iife.js
) which holds all the
code needed for client-side interactivity.
Back in the Rust application, I used the maud
templating library to assemble
the final page. The include_str!
macro was perfect for this, letting me embed
the contents of the generated Button.html
and its corresponding script
directly into the final Rust binary at compile time.
// src/templates.rs (initial approach)
use maud::{html, Markup, PreEscaped, DOCTYPE};
pub fn page() -> Markup {
const BTN_HTML: &str = include_str!(concat!(env!("OUT_DIR"), "/Button.html"));
const BTN_JS: &str = include_str!(concat!(env!("OUT_DIR"), "/Button.iife.js"));
html! {
(DOCTYPE)
html {
head {
meta charset="utf-8";
title { "Maud × Svelte Demo" }
}
body {
// Directly inject the pre-rendered HTML
(PreEscaped(BTN_HTML))
// The script for hydration is included, but we're not
// calling it just yet. One step at a time.
// script type="module" { (PreEscaped(BTN_JS)) }
}
}
}
}
Running the project confirmed the first success. The page loaded, and there was my button, showing its initial state of “0”. The server-side rendering worked. But, of course, it was just an unresponsive and static button. We had our island, but it was still lifeless, waiting for that spark of hydration.
The hydration riddle
With the static HTML already rendered, the next crucial step was to infuse it
with interactivity. I uncommented the commented-out <script>
tag, and reloaded
the page, however an error appeared in the browser’s console:
Uncaught ReferenceError: Button is not defined
<anonymous> http://127.0.0.1:3000/:2654
This error, a classic JavaScript scoping problem, revealed a fundamental
disconnect. Esbuild’s IIFE bundle, designed for encapsulation, had effectively
hidden the Button
component within its own private scope. The small, inline
JavaScript snippet, intended to kickstart hydration by instantiating Button
,
simply couldn’t find it on the global window
object.
The immediate, seemingly logical fix was to use esbuild’s globalName
option.
By setting globalName: 'SvelteButton'
in the client bundle configuration, the
Button
component’s default export should, in theory, be exposed globally as
window.SvelteButton
.
// tools/svelte_bundle.mjs (client part - attempting globalName)
// ...
await build({
// ...
format: 'iife',
globalName: 'SvelteButton', // Expose the component globally
// ...
footer: {
js: `
const target = document.getElementById('button-root');
if (target) {
new SvelteButton({ target, hydrate: true }); // Attempt to use the global name
}
`
}
});
The ReferenceError
vanished as expected. A small victory. But this was merely
a prelude to a more insidious and cryptic error that quickly took its place:
Uncaught TypeError: can't access property "call", first_child_getter is undefined
get_first_child http://127.0.0.1:3000/:960
from_html http://127.0.0.1:3000/:2009
Button http://127.0.0.1:3000/:2641
<anonymous> http://127.0.0.1:3000/:2654
This error was an indicator of a deeper, architectural misunderstanding of
Svelte 5’s hydration mechanism. It was attempting to “mount” a component (i.e.,
create new DOM nodes) onto a pre-existing DOM tree that was generated by the
server. The component’s internal code, compiled without the proper hydration
flag, was expecting to build the DOM from scratch. When it encountered existing
nodes, its internal helper functions (like first_child_getter
), crucial for
traversing and attaching to the existing DOM, had not been initialized. This led
to an immediate runtime crash.
The critical insight was this: for Svelte to correctly “hydrate” an existing DOM tree, it needs to be explicitly told at compile time to generate a special kind of bundle. One that understands how to attach itself to and update existing DOM rather than simply creating a new one.
Initially, I tried simply passing hydrate: true
to the component constructor.
However, Svelte 5’s API changed: the component’s default export is now a
function, not a class to be new
-ed up. Correcting this to
SvelteButton.default(target, { hydrate: true })
still didn’t fix the core
first_child_getter is undefined
issue. This strongly indicated that the
compiled output itself was not configured for hydration.
Next I tried looking into esbuild’s svelte
plugin options. I came across the
compilerOptions.mode: 'hydrate'
flag, which instructs the Svelte compiler to
emit code specifically designed for attaching to and re-using existing DOM.
// tools/svelte_bundle.mjs (client part - adding hydration mode)
// ...
await build({
// ...
plugins: [
svelte({
compilerOptions: {
mode: 'hydrate'
}
})
],
// ...
});
It instructed Svelte to generate the necessary internal hydration markers and
logic. Yet, even with mode: 'hydrate'
, the invocation still needed to be
correct. Svelte 5 introduced a top-level hydrate
function from its main
export.
The Astro introspection
When faced with a fundamental challenge, the wisest approach is often to study how other frameworks have already solved it. My investigation turned to Astro, a framework famous for its “Islands Architecture,” a pattern precisely aligned with our goal: shipping minimal JavaScript while progressively enhancing parts of the page. How did Astro achieve its seamless pre-rendering and hydration of Svelte components?
Analyzing Astro’s build output and source code proved to be a good learning experience, with some key insights:
- Astro
<astro-island>
wrapper
Astro doesn’t merely dump raw HTML. Each interactive component is intelligently
wrapped in a custom <astro-island>
HTML element. This element serves as a
manifest, containing vital metadata as HTML attributes: component-url
(path to
the component’s browser JavaScript), renderer-url
(path to a shared Svelte
renderer/bootloader), props
(serialized data for the component), and a
client
directive (client:load
, client:idle
, client:visible
,
client:media
, or client:only
) that dictates when the component should
hydrate.
<!-- Example Astro output for a Svelte component -->
<astro-island
uid="byYzy"
component-url="/_astro/Test.dfb48ca8.js"
component-export="default"
renderer-url="/_astro/client.c4e17359.js"
client="load"
props='{"count":0}'
ssr>
<button data-h="byYzy">0</button>
</astro-island>
The inner <button>
is the SSR’d content, immediately visible to users and
search engine bots without JavaScript. The data-h
attribute contains Svelte’s
internal hydration markers, which are emitted unconditionally in Svelte 5.
- True two-bundle system
Similar to our initial attempt, Astro generates separate builds for the server (for SSR) and the client (for hydration). However, the client-side bundle isn’t just the raw Svelte component.
- Dedicated hydration entrypoint (bootloader)
This was the pivotal missing piece. Astro doesn’t directly ship the raw Svelte
component’s JavaScript to the browser for hydration. Instead, it ships a tiny,
framework-specific bootloader script (renderer-url
). This bootloader’s
sole responsibility is to orchestrate the hydration. When the client:
directive’s trigger fires, the bootloader performs these critical steps:
- Dynamically
import()
the shared Svelte renderer (if not already loaded). - Dynamically
import()
the specific Svelte component’s browser bundle (component-url
). - Calls the renderer’s internal
hydrate()
helper, which is fundamentally:
import { hydrate } from 'svelte'; // The crucial part!
// ...
hydrate(Component, {
target: island.firstElementChild, // The pre-rendered DOM
props: JSON.parse(island.getAttribute('props'))
});
The fundamental mistake wasn’t just in esbuild’s globalName
or mode
flags;
it was in the entire orchestration. I was attempting to make the Svelte
component hydrate itself from an inline script. The correct pattern, as
demonstrated by Astro, was to have an external orchestrator (a dedicated
entrypoint script) manage the hydration process by explicitly calling
hydrate()
from Svelte’s runtime. This external call correctly initializes
Svelte’s internal state (like first_child_getter
), allowing it to attach to
the existing DOM without errors.
Deno detour
Armed with this critical new understanding, the project embarked on a slight
detour: migrating the build script from Node.js to Deno. Deno, a modern and
secure runtime for JavaScript and TypeScript, offered compelling advantages in
terms of built-in tooling, security, and a cleaner programming model. I decided
to go with it mainly because Deno allows specifying dependencies from npm
directly in import statements.
My first attempt to integrate the Astro-inspired bootloader involved a “virtual” entrypoint. The idea was to generate the simple hydration script as a string in memory and feed it directly to esbuild by faking the resolution process.
import { build } from "npm:esbuild";
import svelte from "npm:esbuild-svelte";
const componentPath = Deno.args[0]; // The .svelte file
const VIRTUAL_ENTRY = "virtual-entry.js";
// Virtual entry module content
const entryContents = `
import { hydrate } from "svelte";
import Component from ${JSON.stringify(componentPath)};
const target = document.getElementById("button-root");
if (target) hydrate(Component, { target });
`;
const result = await build({
entryPoints: [VIRTUAL_ENTRY],
bundle: true,
format: "esm",
platform: "browser",
write: false,
plugins: [
{
name: "virtual-entry",
setup(build) {
build.onResolve({ filter: /^virtual-entry\.js$/ }, () => ({
path: VIRTUAL_ENTRY,
namespace: "virtual",
}));
build.onLoad({ filter: /.*/, namespace: "virtual" }, () => ({
contents: entryContents,
loader: "js",
}));
},
},
svelte({
compilerOptions: {
css: "external",
},
}),
],
});
This idea quickly led to a fresh set of cryptic errors:
✘ [ERROR] Could not resolve "svelte"
virtual:___virtual.js:2:36:
2 │ import { hydrate } from "svelte";
╵ ~~~~~~~~
The plugin "virtual" didn't set a resolve directory for the file "virtual:___virtual.js", so esbuild did not search for "svelte" on the file system.
✘ [ERROR] Could not resolve "js/search/src/App.svelte"
virtual:___virtual.js:3:34:
3 │ import Component from "js/search/src/App.svelte";
╵ ~~~~~~~~~~~~~~~~~~~~~~~~~~
The plugin "virtual" didn't set a resolve directory for the file "virtual:___virtual.js", so esbuild did not search for "js/search/src/App.svelte" on the file system.
I didn’t know enough about the esbuild build process to be able to resolve this issue, and it seemed too brittle anyway, so I decided to try a different approach.
More robust architecture
Armed with the hard-won lessons from Astro and the battle scars from esbuild module resolution quirks, the final, elegant, and robust solution began to take shape. It seamlessly integrates Rust’s build system with Deno’s runtime, all while adhering to the core architectural needs of Svelte’s hydration model.
Here’s the detailed anatomy of the final implementation:
Simplicity on the surface
I’ve integrated this solution into my static site generator hauchiwa
. The
complexity of the underlying build process is encapsulated behind a clean,
high-level Rust API: glob_svelte
. This function provides a simple interface
for users to register Svelte components within their Rust-powered application.
// src/loader/mod.rs (Simplified for clarity)
// Represents a pre-rendered Svelte component with client-side hydration.
pub struct Svelte<P = ()>
where
P: serde::Serialize,
{
/// Function that renders the component to an HTML string given props.
pub html: Prerender<P>, // Prerender is a Box<dyn Fn(&P) -> Result<String> + Send + Sync>
/// Path to a JavaScript file that bootstraps client-side hydration.
pub init: Utf8PathBuf, // Points to a file in the build cache
}
/// Constructs a loader that processes Svelte components into pre-renderable assets.
pub fn glob_svelte<P>(path_base: &'static str, path_glob: &'static str) -> Loader
where
P: serde::Serialize + 'static,
{
// This `Loader` is an implementation detail from my SSG library you can ignore this...
Loader::with(move |_| {
LoaderGenericMultifile::new(
path_base,
path_glob,
|path| {
// Phase 1: Compile SSR bundle & generate a unique ID for the component
let server_bundle_text = compile_svelte_server(path)?;
let anchor_hash_id = Hash32::hash(server_bundle_text.as_bytes());
// Phase 2: Compile client hydration bundle
let client_bundle_text = compile_svelte_init(path, anchor_hash_id)?;
// Calculate a content hash for the client bundle for caching
let client_bundle_hash = Hash32::hash(client_bundle_text.as_bytes());
// Create the HTML rendering closure
let html_renderer = Box::new({
let anchor_id_hex = anchor_hash_id.to_hex();
let server_bundle_text_cloned = server_bundle_text.clone();
move |props: &P| {
let json_props = serde_json::to_string(props)?;
// Use the previously compiled SSR bundle and dynamic props to render HTML
let rendered_html = run_ssr(&server_bundle_text_cloned, &json_props)?;
// Wrap with a unique class for client-side targeting and embed props
Ok(format!("<div class='_{anchor_id_hex}' data-props='{json_props}'>{rendered_html}</div>"))
}
});
// Return the hash of the client bundle and a tuple of the html_renderer and client bundle text
Ok((client_bundle_hash, (html_renderer, client_bundle_text)))
},
|rt, (html_renderer, client_bundle_text)| {
// Store the client bundle text to disk in the build cache
let client_init_path = rt.store(client_bundle_text.as_bytes(), "js")?;
// Return the Svelte struct
Ok(Svelte { html: html_renderer, init: client_init_path })
},
)
})
}
Rust orchestrates Deno
The core of the Svelte integration lies in three distinct Deno calls,
meticulously orchestrated by Rust’s Command
API. This pattern allows us to
delegate the complex JavaScript bundling tasks to Deno (and esbuild) while
maintaining full control and error handling within Rust.
compile_svelte_server(file: &Utf8Path) -> anyhow::Result<String>
This function is responsible for compiling the Svelte component into its
Server-Side Renderable (SSR) JavaScript bundle. Instead of writing the bundle to
a file, esbuild is configured to output directly to stdout
. The Rust Command
captures this output.
// src/loader/mod.rs
fn compile_svelte_server(file: &Utf8Path) -> anyhow::Result<String> {
const JS: &str = r#"
import { build } from "npm:esbuild@0.25.6";
import svelte from "npm:esbuild-svelte@0.9.3";
import * as path from "node:path"; // Use Node.js path module for Deno
const componentFilePath = Deno.args[0]; // Absolute path from Rust
const ssr = await build({
entryPoints: [componentFilePath], // Use the absolute path directly
format: "esm",
platform: "node", // Compile for Node.js environment
minify: true,
bundle: true,
write: false, // Output to memory
mainFields: ["svelte", "module", "main"],
conditions: ["svelte"],
plugins: [
svelte({
compilerOptions: { generate: "server" }, // Generate SSR code
css: false, // Don't emit CSS as separate file from SSR
emitCss: false, // Crucial to prevent "fakecss:" errors for SSR
}),
],
});
// The SSR bundle's text is URI-encoded for safe transport via stdout
const encodedText = encodeURIComponent(ssr.outputFiles[0].text);
await Deno.stdout.write(new TextEncoder().encode(encodedText));
await Deno.stdout.close();
"#;
// ... Rust Command invocation for Deno, capturing stdout and stderr ...
// Use `file.canonicalize()?` to ensure absolute path for Deno.args[0]
}
Notice the encodeURIComponent
on the SSR bundle’s text. This is a crucial
trick. The raw SSR JavaScript might contain characters that could interfere with
command-line arguments or data:
URIs. By encoding it, we ensure safe
transmission from the first Deno process to the second, which will import it.
platform: "node"
is used to ensure the Svelte compiler produces code
compatible with a Node.js-like environment, even though we’re running it within
Deno’s Node.js compatibility layer.
run_ssr(server_bundle: &str, props_json: &str) -> anyhow::Result<String>
This function takes the URI-encoded SSR bundle (obtained from
compile_svelte_server
) and the component’s props
(as a JSON string). It
spawns another Deno process, which then imports the SSR bundle via a data:
URI
and calls its render
function with the provided props to produce the final
HTML.
// src/loader/mod.rs
fn run_ssr(server_bundle: &str, props: &str) -> anyhow::Result<String> {
// The Deno script that imports the SSR bundle and renders it
let js_runner = format!(
r#"
const jsonProps = Deno.args[0];
const parsedProps = JSON.parse(jsonProps);
// Import the SSR bundle directly from the data URI
const {{ default: SSRComponent }} = await import("data:text/javascript,{}");
let renderedOutput = null;
// Svelte 5 SSR might return different structures (e.g., { out: [] } or { out: "" })
// This attempts both common patterns for flexibility.
try {{
const data = {{ out: [] }}; // For components that render to an array of nodes
SSRComponent(data, parsedProps);
renderedOutput = data.out.join('');
}} catch (e) {{
// Fallback for components rendering directly to a string
try {{
const data = {{ out: "" }};
SSRComponent(data, parsedProps);
renderedOutput = data.out;
}} catch (e2) {{
throw new Error("Failed to produce prerendered component. Original: " + e.message + " Fallback: " + e2.message);
}}
}}
if (renderedOutput === null) {{
throw new Error("Failed to produce prerendered component, check Svelte 5 SSR usage.");
}}
await Deno.stdout.write(new TextEncoder().encode(renderedOutput));
await Deno.stdout.close();
"#,
server_bundle // The URI-encoded bundle from compile_svelte_server
);
// ... Rust Command invocation for Deno, passing props as Deno.args[0] ...
// Crucially, this Deno process doesn't need --allow-read because it's working with in-memory data.
}
The run_ssr
function includes a try...catch
block. This is a subtle but
important detail for Svelte 5. Depending on how a Svelte 5 component is
structured and compiled for SSR (especially with or without slots), its render
function (accessed via Comp(data, props)
) might populate data.out
as an
array of strings or a single string. The try...catch
gracefully handles both
scenarios, making the SSR rendering more robust. This also demonstrates how you
can pass dynamic props from your Rust application to the Svelte component at
render time.
compile_svelte_init(file: &Utf8Path, anchor_hash_id: Hash32) -> anyhow::Result<String>
This function is the culmination of all the hydration struggles. It compiles the
client-side JavaScript bundle responsible for hydration. Instead of generating a
temporary file for the entrypoint, it constructs a “stub” entrypoint as a string
and feeds it to esbuild’s stdin
option.
// src/loader/mod.rs
fn compile_svelte_init(file: &Utf8Path, hash_class: Hash32) -> anyhow::Result<String> {
let abs_file_path = file.canonicalize()?; // Resolve to absolute path on Rust side
let hash_hex = hash_class.to_hex();
// The in-memory "stub" entrypoint for client-side hydration
let stub_entrypoint = format!(
r#"
import {{ hydrate }} from "svelte"; // Svelte 5's global hydrate function
import App from {}; // Import the Svelte component using its absolute path
const query = document.querySelectorAll('._{}'); // Target elements by unique class
for (const target of query) {{
const attrs = target.getAttribute('data-props');
const props = JSON.parse(attrs) ?? {{}}; // Parse props from data-attribute
hydrate(App, {{ target, props }}); // Hydrate each instance
}}
"#,
// Use JSON.stringify for safe embedding of the path into JS string
serde_json::to_string(&abs_file_path.to_string_lossy())?,
hash_hex // The unique class ID generated from SSR bundle's hash
);
const JS_RUNNER: &str = r#"
import * as path from "node:path";
import { build } from "npm:esbuild@0.25.6";
import svelte from "npm:esbuild-svelte@0.9.3";
const stubContents = Deno.args[0]; // The entrypoint stub from Rust
const componentFilePath = Deno.args[1]; // The original component path, for resolveDir
const ssr = await build({
stdin: {
contents: stubContents,
// Critical: resolve relative imports from the original component's directory
resolveDir: path.dirname(path.resolve(componentFilePath)),
sourcefile: "__virtual_client_entry.ts", // For better debug traces
loader: "ts", // Use TS loader if you're using `!` non-null assertions etc.
},
platform: "browser",
format: "esm",
bundle: true,
minify: true,
write: false, // Output to memory
mainFields: ["svelte", "browser", "module", "main"],
conditions: ["svelte", "browser"],
plugins: [
svelte({
compilerOptions: {
css: "external",
},
}),
],
});
await Deno.stdout.write(new TextEncoder().encode(ssr.outputFiles[0].text));
await Deno.stdout.close();
"#;
// ... Rust Command invocation for Deno, passing stub_entrypoint and abs_file_path ...
}
- Rust-side path canonicalization: The component’s file path is
canonicalized to an absolute path (
file.canonicalize()?
) in Rust. This ensures absolute path stability regardless of the Rust process’s CWD. The absolute path is then passed to the Deno subprocess. - In-memory stub: The
stub_entrypoint
string is built in Rust, containing thehydrate
import and the component import. The component’s absolute path is embedded directly into this stub, removing any ambiguity for esbuild. stdin
for esbuild: The Deno subprocess then uses esbuild’sstdin
feature to feed thisstub_entrypoint
directly to the bundler.resolveDir
is paramount: Even with an absolute component path,resolveDir
is set to thedirname
of the original component’s absolute path (path.dirname(path.resolve(componentFilePath))
). This is still crucial foresbuild
to correctly resolve any other relative imports within the Svelte component itself (e.g., ifApp.svelte
imports./store.js
).- Querying by class: Instead of targeting a single
id
, thestub_entrypoint
usesdocument.querySelectorAll('._${hash}')
. This allows for multiple instances of the same Svelte component on a single page, each independently hydrated. The_
prefix for the class is a convention to avoid creating invalid CSS class names and to signify its internal purpose. data-props
for per-instance data: Thedata-props
attribute on the SSR’ddiv
is critical. It allows each hydrated instance to receive its unique set of props from the server, enabling dynamic content and behavior without additional network requests. The client-side JavaScript thenJSON.parse
s this attribute.
Bringing it all together
The html
closure within the Svelte<P>
struct is where the final pieces of
the puzzle align. It creates the final piece of HTML, which contains prerendered
component, as well as props and hash class.
// Inside the glob_svelte loader, within the mapping function
// ...
let html = Box::new({
let anchor = anchor_hash_id.to_hex();
let server_bundle_text_cloned = server_bundle_text.clone();
move |props: &P| {
let json = serde_json::to_string(props)?;
let html_content = run_ssr(&server_bundle_text_cloned, &json)?;
// Render the wrapper div with a unique class and data-props
Ok(format!(
"<div class='_{anchor}' data-props='{}'>{html_content}</div>",
&json
))
}
});
// ... later, in the template ...
// The `Svelte` struct `my_svelte_component` would be retrieved from loader
let Svelte { html: render_html_fn, init: init_js_path } = my_svelte_component;
// Call the function to get the pre-rendered HTML for this instance
let rendered_component_html = render_html_fn(&my_props_instance)?;
html! {
(DOCTYPE)
html {
head {
meta charset="utf-ob";
title { "My Awesome Svelte App" }
}
body {
(PreEscaped(rendered_component_html)) // Inject the SSR'd HTML
script type="module" src=(init_js_path) {} // Link to the hydration script
}
}
}
When the browser receives this HTML:
- The static content (e.g.,
<button>0</button>
) is immediately displayed. - The
<script type="module" src="..."
loads theinit
JavaScript bundle. - This script executes, finds all
div
s with the unique class (e.g.,_a1b2c3d4
), parses theirdata-props
, and then callshydrate(App, { target, props })
for each. - Svelte takes over, attaching event listeners and making the component interactive, without re-rendering the entire DOM.
Some future considerations
The journey to a fully hydrated Svelte component in a Rust application is complete. However, the path of web development is ever-evolving. Here are some advanced considerations and future directions for this architecture:
- CSS Handling: I haven’t yet figured out how to process the CSS which is included directly in Svelte files, so this would have to be done to be able to use all the features of Svelte. For now I was just writing CSS separately from Svelte.
- Source Maps: For effective debugging of client-side Svelte code,
generating and serving source maps is essential. Esbuild has options for this
(
sourcemap: true
). I’d probably need to store these maps alongside.js
files and configure the HTTP server to serve them correctly. - Progressive Hydration Strategies: Astro’s
client:idle
,client:visible
,client:media
directives offer more granular control over when hydration occurs. Maybe it would be possible to extend thecompile_svelte_init
to generate different hydration stubs based on aHydrationStrategy
enum passed from Rust.
Conclusion
My quest to integrate Svelte with a Rust backend, aiming for truly interactive “islands” on a fast, server-rendered site, led me through some significant hurdles. The core challenge wasn’t just getting Svelte to spit out HTML, but making its client-side code gracefully “hydrate” that pre-existing HTML without errors.
My breakthrough came from dissecting how frameworks like Astro achieved this seamless pre-rendering and hydration. It wasn’t about a single magic flag, but a sophisticated orchestration: a dedicated bootloader script and Svelte’s hydrate function. This realization reshaped my entire build process.
I ended up building a robust system where Rust commands Deno to handle the complex Svelte compilation, both for server-side rendering and client-side hydration. By carefully passing component paths and props between these environments, I created a pipeline that generates static HTML first, then precisely attaches the necessary JavaScript for interactivity. This “islands of interactivity” approach now gives me the best of both worlds: the raw performance of server-side rendering and the rich, dynamic feel of a Svelte application.