Rust is a popular system programming language, known for its robust memory safety features and exceptional performance. While Rust was originally a system programming language, its application has evolved. Now you can see Rust in different app platforms, mobile apps, and of course, in web apps — both in the frontend and backend, with frameworks like Rocket, Axum, and Actix making it even easier to build web applications with Rust.
Reactive web frameworks with server-side rendering (SSR), mostly JavaScript frameworks like Next.js, Nuxt.js, and Svelte, are gaining increasing popularity. Interestingly, developers from the Rust community are also building alternatives that leverage the safety and performance of both Rust and WebAssembly combined to build reactive frontends. Perseus is the newest Rust framework for building frontend components.
Perseus is a fast frontend web development framework for Rust with built-in support for reactivity using Sycamore, server-side rendering, and much more. Sycamore is a frontend library that allows you to build interactive user interfaces with Rust. I’d say that Perseus is to Sycamore as Next.js is to React, so it’ll be helpful for you to have a fair understanding of Sycamore before jumping into using Perseus — although it’s not necessary to follow along in this article.
In this article, we’ll learn how to build a Rust web application with Perseus and how to deploy it to your server. The project we’ll build is a to-do application that allows the user to add and delete tasks. Here is what the finished project will look like:
Jump ahead:
To follow along with this tutorial, you should:
Before you can start building your Rust application with Perseus, you’ll need to set up your environment. This involves installing the Perseus command-line interface (CLI) and initializing your Perseus app. Let’s walk through the installation process step by step.
To begin, install the Perseus CLI, which allows you to manage your Perseus application seamlessly. Open your terminal and run the following command:
cargo install perseus-cli
This command will download and install the Perseus CLI on your system and make it available globally. To confirm that your installation went well, run perseus --version
; the response should be perseus-cli 0.4.2
.
Once the installation is complete, navigate to the directory where you want your Perseus application to reside. From there, initialize your app by running the following code:
perseus init my-todo-app
This will scaffold a Perseus boilerplate code that will serve as a starting point for your application. Think of it as the equivalent of create-react-app
in React.js. The initialization process might take some time, depending on your network strength, and, once it’s done, you can proceed to preview your setup by running:
perseus serve -w
Notice the -w
flag. It’s optional. While perseus serve
runs your code, the -w
flag watches your code changes and updates the UI accordingly. When the app build is complete, navigate to http://localhost:8080 to see the demo welcome to Perseus app:
At this point, if you open the Cargo.toml
file, you’ll see everything that’s been set up for you to ensure that Perseus works as expected. It should look like this:
# Cargo.toml [package] name = "simple-todo-app" version = "0.1.0" edition = "2021" # See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html # Dependencies for the engine and the browser go here [dependencies] perseus = { version = "=0.4.2", features = [ "hydrate" ] } sycamore = "^0.8.1" serde = { version = "1", features = [ "derive" ] } serde_json = "1" # Engine-only dependencies go here [target.'cfg(engine)'.dependencies] tokio = { version = "1", features = [ "macros", "rt", "rt-multi-thread" ] } perseus-axum = { version = "=0.4.2", features = [ "dflt-server" ] } # Browser-only dependencies go here [target.'cfg(client)'.dependencies]
From the Cargo.toml
file above, we can see that the Perseus version at the time of publication is 0.4.2
and has the following dependencies that are common to both the engine side (server-side) and client side of a Perseus application: sycamore
, serde
, and serde_json
.
Perseus applications also make use of tokio
, a Rust asynchronous runtime, and perseus-axum
— an integration that streamlines the use of the Perseus frontend framework with the Axum web framework, specifically on the engine side. The configuration file also shows a section for browser-only dependencies. Even though there are no default dependencies in this section, it indicates a place where such dependencies could be added as your application evolves.
Now that the app setup is complete, let’s tour the app structure we just created and start building.
Our app structure will look like this at the beginning:
├── Cargo.lock ├── Cargo.toml └── src ├── main.rs └── templates ├── index.rs └── mod.rs
src/main.rs
is the natural main entry point of the app and it contains a single function, the main function that returns the PerseusApp:
mod templates; use perseus::prelude::*; #[perseus::main(perseus_axum::dflt_server)] pub fn main<G: Html>() -> PerseusApp<G> { PerseusApp::new() .template(crate::templates::index::get_template()) }
Notice that the main function has an attribute: [perseus::main(perseus_axum::dflt_server)]
. Attributes are metadata applied to modules, crates, structs, or functions to enhance their functionality.
In the case of the Perseus main function attribute, the attribute essentially makes the main function the entry point of the Perseus app. The attribute takes perseus_axum::dflt_server
as an argument, which sets up an Axum web server for your application and yes, Perseus uses the Axum server by default to set up the Perseus engine.
Additionally, the main function returns a PerseusApp, which is like the root node for all the templates in your Perseus application. Now, let’s create some views.
In a Perseus application, the template directory is essentially where all your app views are located by default. Each file in the template directory represents a page and returns a Perseus template.
For instance, let’s consider the following code:
pub fn get_template<G: Html>() -> Template<G> { Template::build("index").view(index_page).head(head).build() }
Here, we’re creating a template for the homepage (or index
) of our web app. And then we go ahead and assign the index_page
(a view function) as the view we want to display. We’ll then pass the template to the Perseus app in the main
function as we’ve seen earlier, like so:
mod templates; PerseusApp::new().template(crate::templates::index::get_template())
For most of it, in this tutorial, we’ll write our code in the index.rs
template file. Let’s get into defining the state of the application.
In a Perseus application, a “state” refers to the data that a view or template depends on to render correctly. The state comprises different data types defined in a struct. And it’s crucial to the reactivity of the app as the application updates each time there is a change in the state.
Let’s define a TodoListState
. This state will serve as a data store for all the todos we’ll create:
use perseus::prelude::*; use serde::{Deserialize, Serialize}; use std::rc::Rc; use sycamore::prelude::*; use web_sys::Event; #[derive(Serialize, Deserialize, ReactiveState, Clone)] #[rx(alias = "TodoListStateRx")] struct TodoListState { todos: Vec<String>, new_todo: String, }
There is a lot of abstraction in this code, so let’s break it down.
In this TodoListState
struct, we have two parts: todos
and new_todo
. The todos
part is a list of your tasks — a vector of strings. The new_todo
is also a string, and it’s used to hold the new task that you want to add to the vector of strings (todos).
Now, let’s talk about those attributes applied to the struct
:
Serialize
and Deserialize
: These mean that your TodoListState
can be turned/serialized into a format like JSON and also be converted back into the structure that your program can understand (that’s Deserialize
). Remember the Serde library we added to the dependency list initially? This is exactly where we use itReactiveState
: This is a custom derive attribute specific to Perseus. It tells the program that if anything in TodoListState
changes, like adding a new task, Perseus should automatically update the parts of the app that display this informationClone
: The Clone
trait in Rust allows for the duplication of an object. This simply means that the TodoListState
can be duplicated, or cloned. So, you can create an exact copy of your task list if you want to#[rx(alias = "TodoListStateRx")]
: This is a custom attribute related to the reactive system of Perseus, which is built on Sycamore. This attribute allows you to provide an alias for the reactive version of the state. In this case, the reactive version of TodoListState
can be referred to as TodoListStateRx
. We’ll assign it to functions that contain reactive state codeAlso, on the engine side, we initialize the state with some data as shown below:
#[engine_only_fn] async fn get_build_state(_info: StateGeneratorInfo<()>) -> TodoListState { TodoListState { todos: vec!["Grocery shopping".to_string(), "Exercise for 30 minutes every morning".to_string()], new_todo: "".to_string(), } }
Now, let’s add a heading and the form that we’ll use to add a new to-do to the list of to-dos:
fn header<G: Html>(cx: Scope) -> View<G> { view! { cx, h1 { "A simple Reactive Todo App" } } } #[auto_scope] fn todo_form<G: Html>(cx: Scope, state: &TodoListStateRx) -> View<G> { let new_todo_form = new_todo(cx.clone(), state); view! { cx, div { (new_todo_form) } } }
Let’s demystify each of the functions in the code above; we’ll start with the header
function:
fn header<G: Html>(cx: Scope) -> View<G> { view! { cx, h1 { "A simple Reactive Todo App" } } }
The header
view function takes a scope, shorthand as cx
; this convention is borrowed from Sycamore and is a requirement for every view function we pass to Sycamore. For simplicity, Scope
is used to track any reactive variables or computations within a view, so it can automatically update the view whenever those values change.
In the code above, we leverage the view!
macro to return the view that will be rendered. In the view, we pass an h1
element with its contents in the curly braces. This is a pretty simple view function, so let’s take a look at the todo_form
view function, which is a bit more complex:
#[auto_scope] fn todo_form<G: Html>(cx: Scope, state: &TodoListStateRx) -> View<G> { let new_todo_form = new_todo(cx.clone(), state); view! { cx, div { (new_todo_form) } } }
Notice that beyond the Scope
argument, the todo_form
view accepts a state as the second argument. Now, that’s a little complicated and Perseus needs to do a lot more work on this new view in the background. Therefore, we use the #[auto_scope]
to abstract and simplify it.
Essentially, if not for the #[auto_scope]
attribute, the todo_form
function argument would look like this:
fn todo_form<'page, G: Html>(cx: BoundedScope<'_, 'page>, state: &'page &TodoListStateRx) -> View<G>
Thankfully, we don’t have to write that anymore, thanks to #[auto_scope]
.
So, in the todo_form
view, we are calling the new_todo
view and passing the state to it. It then does its magic and displays it in the form inside a div element as shown below:
let new_todo_form = new_todo(cx.clone(), state); view! { cx, div { (new_todo_form) } }
Now that we have a form to create a to-do, let’s add the to-do to the list of to-dos in the state. First, let’s create the view that adds an item to the list of to-dos. Add the piece of code below in your src/templates/index.rs
file:
#[auto_scope] fn new_todo<G: Html>(cx: Scope, state: &TodoListStateRx) -> View<G> { view! {cx, form(on:submit = move |e: Event| { e.prevent_default(); let new_todo: Rc<String> = state.new_todo.get().clone(); if !new_todo.is_empty() { let new_todo_str: String = (*new_todo).clone(); let mut todos = state.todos.get().as_ref().clone(); todos.push(new_todo_str); state.todos.set(todos.to_vec()); state.new_todo.set(String::new()); } }) { input(id = "todo-input", type = "text", bind:value = state.new_todo) button(id = "todo-button") { "Add Item" } } } }
In the above code, the new_todo
function creates a form view for a new to-do item where the user can input a new to-do. When the form is submitted, it adds the new to-do to the list and clears the input field.
Let’s take a quick overview of some key parts of the function:
let new_todo_str: String = (*new_todo).clone();
: This part of the code clones the new to-do the user just typed so it can be added to the to-do listlet mut todo: Vec<String> = state.todos.get().as_ref().clone();
: This part also retrieves the current to-do list from the state. But what is actually going on here?state.todos.get()
is of type Rc<T>
, reference counting; this type allows multiple owners of the same data, which is great for shared data in memory, but it doesn’t allow you to directly clone the inner data. That’s why we introduce the .as_ref()
method to get a reference to the value inside the Rc<T>
, which is a type of Vec<String>
. Then, we clone it with the .clone()
methodtodos.push(new_todo_str);
: Here, we add the new to-do to the to-do liststate.todos.set(todos.to_vec());
: This updates the to-do list in the state. Essentially, we replaced the entire existing todos in the state with the updated todosstate.new_todo.set(String::new());
: Finally, we reset the new to-do string in the stateAs we described in the previous section, we want to add the new to-dos to the list, display them, and also give the user a chance to delete the to-do. Here is the function for it:
#[auto_scope] fn todo_list<G: Html>(cx: Scope, state: &TodoListStateRx) -> View<G> { view! { cx, div(id = "todo-items") { ul { (View::new_fragment( state.todos.get() .iter() .rev() .enumerate() .map(|(index, item)| { let item = item.clone(); view! { cx, li(class = "todo-item") { (item) button( class = "remove-button", on:click = move |_| { let mut todos = state.todos.get().as_ref().clone(); todos.retain(|todo| *todo != item); state.todos.set(todos.to_vec()); }) { "x" } } } }) .collect(), )) } } } }
Basically, this view function takes in the state of the to-do list, and it generates a dynamic, interactive HTML list of to-do items. Each item comes with a button that allows for the removal of the item from the list.
Let’s examine the code:
(View::new_fragment(
: This creates a new DocumentFragment, which is a set of HTML elements without a parent element. It’s used as a temporary container to wrap and manipulate DOM nodes. It’s used here to create a list of li
elements, one for each to-do itemstate.todos.get().iter().rev().enumerate().map(|(index, item)| {
: This line is iterating over the to-dos in the state, in reverse order so the most recent to-do appears first, and for each to-do item, it creates a new li
elementbutton( class = "remove-button", on:click = move |_| {
: This creates a button for each to-do item, with an attached click event handler. When this button is clicked, it will trigger the code that removes the item from the list and resets the to-do list in this part of the code:
let mut todos = state.todos.get().asref().clone(); todos.retain(|todo| *todo != item); // remove the todo state.todos.set(todos.tovec());
Now, we can now take all the views we’ve created and put them together in a todo_list_view
view function:
#[auto_scope] fn todo_list_view<G: Html>(cx: Scope, state: &TodoListStateRx) -> View<G> { view! { cx, div(id = "todo-container") { (header(cx.clone())) (todo_form(cx.clone(), state)) (todo_list(cx, state)) } } }
Finally, we can add the todo_list_view
to the main template that we’ll pass to the Perseus app. Here is how the code will look:
#[engine_only_fn] fn head(cx: Scope) -> View<SsrNode> { view! { cx, title { "Test App" } link(rel="stylesheet", href=".perseus/static/styles.css") {} } } pub fn get_template<G: Html>() -> Template<G> { Template::build("index") .build_state_fn(get_build_state) .view_with_state(todo_list_view) .head(head) .build() }
Notice the head
function? That’s like your regular HTML <head>
. That’s where you define everything you want to be in the head. In our case, I am only linking the CSS file to it.
Make sure you put the CSS file in the static directory at the root of your application, not in .perseus/static/styles.css
but in static/style.css
. Perseus will do the rest. Note that the static directory might not be there at first; you’ll have to create it. For this project, you can get all the source code, including the CSS, from GitHub.
If you got to this point, congratulations! 🙏
Go ahead and run the app by running perseus serve--w
to ensure that our implementation works as expected at this point. Finally, let’s proceed to prepare the app for deployment.
Deploying a Perseus app is easy and straightforward. You just need to run the command perseus deploy
, and wait for a couple of minutes. After the build process is complete, you can now deploy the build you’ll find in the /pkg
directory to any server.
But wait — Perseus doesn’t allow you to deploy the default error pages to production. You must create your own custom error views. So, create an additional view in the template directory src/error.rs
to handle all the possible errors that could happen. You can customize it as you want and add this error view template to it:
use perseus::errors::ClientError; use perseus::prelude::*; use sycamore::prelude::*; pub fn get_error_views<G: Html>() -> ErrorViews<G> { ErrorViews::new(|cx, err, _err_info, _err_pos| { match err { ClientError::ServerError { status, message: _ } => match status { 404 => ( view! { cx, title { "Page not found" } }, view! { cx, p { "Page now found" } }, ), // 4xx is a client error _ if (400..500).contains(&status) => ( view! { cx, title { "Error" } }, view! { cx, p { "bad request" } }, ), // 5xx is a server error _ => ( view! { cx, title { "Error" } }, view! { cx, p { "Sorry, internal server error." } }, ), }, ClientError::Panic(_) => ( view! { cx, title { "Critical error" } }, view! { cx, p { "Sorry, something went wrong" } }, ), ClientError::FetchError(_) => ( view! { cx, title { "Error" } }, view! { cx, p { "Network error, ensur you have stable internet connection)" } }, ), _ => ( view! { cx, title { "Error" } }, view! { cx, p { (format!("Internal server error: '{}'.", err)) } }, ), } }) }
The code above returns an error view depending on the type of error that occurs in the application. You can customize each error view as you see it. You can now add the error view to the main function using the error_views
method, as shown below:
mod templates; use perseus::prelude::*; #[perseus::main(perseus_axum::dflt_server)] pub fn main<G: Html>() -> PerseusApp<G> { PerseusApp::new() .template(crate::templates::index::get_template()) .error_views(crate::templates::error::get_error_views()) }
Once this step is complete, you can now run the perseus deploy
command. Successful output will look like this:
Your application is now ready for deployment! The distribution files are located in the /pkg
directory. To launch the application in a production environment, you’ll need to run the server
executable found in the same directory.
If you’re using a Unix-based system (like Linux or macOS), navigate to the directory in your terminal and run the server using the following commands:
cd /pkg ./server
If you’re on a Windows system, you’ll also need to navigate to the pkg
directory but the command to run the server will be slightly different:
cd /pkg server.exe
Your app should be live now on port 8080
:
The output should look like this:
I hope what we’ve learned so far can help make your journey to learning and building applications with Perseus a little easier. For further reading, check out the Perseus documentation.
Happy hacking!
Debugging Rust applications can be difficult, especially when users experience issues that are hard to reproduce. If you’re interested in monitoring and tracking the performance of your Rust apps, automatically surfacing errors, and tracking slow network requests and load time, try LogRocket.
LogRocket is like a DVR for web and mobile apps, recording literally everything that happens on your Rust application. Instead of guessing why problems happen, you can aggregate and report on what state your application was in when an issue occurred. LogRocket also monitors your app’s performance, reporting metrics like client CPU load, client memory usage, and more.
Modernize how you debug your Rust apps — start monitoring for free.
Hey there, want to help make our blog better?
Join LogRocket’s Content Advisory Board. You’ll help inform the type of content we create and get access to exclusive meetups, social accreditation, and swag.
Sign up nowOptimize search parameter handling in React and Next.js with nuqs for SEO-friendly, shareable URLs and a better user experience.
Learn how Remix enhances SSR performance, simplifies data fetching, and improves SEO compared to client-heavy React apps.
Explore Fullstory competitors, like LogRocket, to find the best product analytics tool for your digital experience.
Learn how to balance vibrant visuals with accessible, user-centered options like media queries, syntax, and minimized data use.