Svelte, Tauri and Rust
2026-10-21 · Claude
qed's first screen is tiny: you type a page title, and below it appears the
slug, the part of the URL a page will live at. "Über uns & Team" becomes
/ueber-uns-team.
That screen exists to prove that the three layers of the app work together. Following one keystroke through them is a good way to see how qed is built.
The three layers
Svelte (TypeScript) Tauri Rust (qed-core)
src/routes/+page.svelte → src-tauri/src/lib.rs → crates/qed-core/src/slug.rs
invoke("slugify", …) #[tauri::command] pub fn slugify(…)
- qed-core is a plain Rust library. It knows nothing about windows or
buttons, so it can be tested with
cargo testalone. Everything qed actually does will live here: reading site folders, rendering pages, publishing. - src-tauri is the desktop shell, built with Tauri. It opens a window with the system's web view and offers selected Rust functions to the UI as commands.
- src is the UI, written in Svelte with TypeScript, using SvelteKit for structure and Tailwind CSS with daisyUI for styling. It runs inside that web view, like a website, but without a server: it calls Rust directly.
If you know Phoenix, a rough mapping helps: qed-core is the context modules
(Accounts, Content), src-tauri is the thin controller layer, and the
Svelte page is the LiveView template. The big difference: there's no network
in between. invoke is a function call across a language border, not an
HTTP request.
Step 1: Svelte holds the state
<script lang="ts">
import { invoke } from "@tauri-apps/api/core";
let title = $state("Über uns & Team");
let slug = $state("");
$effect(() => {
const current = title;
invoke<string>("slugify", { title: current }).then((result) => {
if (current === title) slug = result;
});
});
</script>
<input id="page-title" class="input w-full" bind:value={title} />
<code id="page-slug">/{slug}</code>
Svelte 5 marks reactive things with
runes, functions starting with $:
$state(…)declares a value Svelte watches. When it changes, every part of the page that uses it updates. That's whatassign(socket, :title, …)does in LiveView, except that here you just writetitle = "…".bind:value={title}connects the input field and the variable in both directions. Typing changestitle; changingtitlein code changes the field.$effect(() => …)runs its function once, notes which state it read (title), and runs again whenever that state changes. It's the place for side effects, like asking Rust for a slug.
lang="ts" turns on TypeScript. invoke<string>(…) is a
generic: the
<string> tells TypeScript that this call's answer is a string, so result
is typed as one, and slug = result is checked. In Elixir terms it's like a
@spec that the compiler enforces before the code runs.
Why const current = title and the if? invoke is asynchronous: the answer
arrives a moment later. If you type fast, an older answer could arrive after a
newer one and overwrite it. Remembering which title we asked about and
dropping stale answers avoids that.
Step 2: Tauri passes the call to Rust
#[tauri::command]
fn slugify(title: &str) -> String {
qed_core::slug::slugify(title)
}
#[tauri::command] is an
attribute, Rust's form of annotation. It generates
the code that takes the JSON { title: "…" } from the UI, hands the string to
the function, and sends the returned String back as JSON. The command is
registered once with generate_handler![slugify] in run(). Tauri's guide
Calling Rust from the frontend
covers commands in depth.
The shell does nothing else. Keeping logic out of it means everything worth testing stays in qed-core.
Step 3: Rust does the work, and meets borrowing
Here is the start of the real function in crates/qed-core/src/slug.rs:
pub fn slugify(title: &str) -> String {
let mut slug = String::with_capacity(title.len());
// … push characters into `slug` …
slug
}
Look at the two types: it takes a &str and returns a String. They are both
text, so why two?
Ownership.
In Rust, every value has exactly one owner, the variable
responsible for it. When the owner goes out of scope, the value is freed. No
garbage collector, no reference counting by default. A String is owned text:
whoever holds it decides what happens to it, may change it, and frees it in
the end.
Borrowing.
Often a function only needs to look at data. Handing over
ownership would be wasteful (the caller would lose the value) and copying
would cost time. So the caller lends it: &str is a borrowed view of text
that someone else owns. The & means "reference": you may read it, you may
not keep it beyond the call, and the owner gets it back unchanged.
So the signature tells the whole story without reading the body:
title: &str: "I'll read your title, I won't keep or change it."-> String: "I'll give you a brand-new piece of text, and it's yours now."
The compiler enforces both promises. If slugify tried to store title
somewhere that outlives the call, the program wouldn't compile. That check is
the borrow checker, and it's what people mean when they say Rust is strict.
Compare Elixir: data is immutable, so passing a binary around is always safe, and the runtime shares and frees it for you. Rust gets the same safety with no runtime cost, but you state the rules in the types.
Inside the function, let mut slug is the one value we change. Rust variables
are immutable unless declared mut, a familiar idea from Elixir, where
nothing changes at all. Here we build the result step by step with
slug.push(…), and at the end slug (without a semicolon) is the return
value, handing ownership to the caller. In Tauri's case, that's the code that
turns it into JSON for the UI.
One more detail: match on characters
let spelled_out = match c {
'ä' | 'Ä' => "ae",
'ö' | 'Ö' => "oe",
'ü' | 'Ü' => "ue",
'ß' => "ss",
other => deunicode::deunicode_char(other).unwrap_or(""),
};
This should look at home to an Elixir developer: it's case with pattern
matching (see match in
the Rust book). | combines patterns, and other binds anything else. Two
differences:
matchmust be exhaustive. Without theotherarm, the compiler would refuse: not everycharis covered.deunicode_char(from the deunicode crate) returns anOption: eitherSome(text)orNone, Rust's version of{:ok, value}versusnil.unwrap_or("")says: take the text, or an empty string if there is none. There's nonilin Rust; a value that may be missing always says so in its type.
Tests live next to the code
At the bottom of slug.rs:
#[cfg(test)]
mod tests {
use super::slugify;
#[test]
fn spells_out_german_letters() {
assert_eq!(slugify("Größe für Bären"), "groesse-fuer-baeren");
}
}
#[cfg(test)] means "compile this only for tests", so the module never ends
up in the app. It's ExUnit, but inside the source file
(Writing tests).
The example in the function's doc comment (/// assert_eq!(…)) is a test
too, a
doctest, just
like Elixir's iex> examples.
Run them with:
cargo test -p qed-core
What's next
The next step makes qed read a real site folder: site.toml and pages as
Markdown files. That brings structs, serde for reading TOML, and Rust's
error handling with Result, the cousin of Elixir's {:ok, _} and
{:error, _} tuples.
Further reading
- The Rust Programming Language, "the book": free, and the best start. Chapter 4 is ownership and borrowing.
- Rust by Example: the same ideas as short runnable snippets.
- Svelte tutorial: interactive, in the browser.
- TypeScript handbook.
- Tauri 2 docs: architecture, commands, permissions.