By the end of this tutorial, you will understand how to create a simple yet complete full-stack application using the following technologies:
For the Frontend:
- Rust - Core Programming Language
- Web Assembly - For running Rust in the browser
- Yew - Rust Framework for building client web apps
- Trunk - For serving the frontend app
- Tailwind CSS - For styling the frontend
For the Backend:
- Rust - Core Programming Language
- Rocket - Rust Framework for building web servers
For the Database:
- Postgres - Relational Database
- Docker - Dockerfile and Docker Compose for running Postgres
Wow, so many technologies! But we'll keep the example as basic as possible to help you understand the core concepts. Let's get started!
We will proceed with a bottom-up approach, starting with the database, then the backend, and finally the frontend.
If you prefer a video tutorial, you can watch it here.
All the code is available on GitHub (link in video description)
Architecture
Before we start, here is a simple architecture diagram of the application we are going to build:
The front end will be built using Yew, a new Rust framework for building client web apps. Yew is inspired by Elm and React and is designed to be simple and easy to use. We will use Trunk to serve the frontend and Tailwind CSS for styling. All this will be compiled to Web Assembly and run in the browser.
The Backend will be built using Rocket, a web framework for Rust. Rocket is designed to maximize the Developer Experience. We will use Rocket to build a simple REST API that will interact with the database.
The Database will be Postgres, a relational database. We will use Docker to run Postgres in a container, and we will use no ORM to keep things simple. We will interact with the database using SQL queries written directly in the Rocket handlers.
Prerequisites
Before we start, make sure you have the following installed on your machine:
- Rust
- Docker
That's it! If you have never used WASM or Trunk, no worries; I will show you the commands you need to run.
Preparation.
We will have a folder that will contain the following subfolders:
- backend
- frontend
So, let's create a new folder, navigate it, and open it in any IDE you want.
I will use Visual Studio Code.
mkdir rustfs
cd rustfs
code .
From the root folder, initialize a git repository.
git init
And create a compose.yml
file (this will be used to run the Postgres database)
And you should have something like this:
We are now ready to build our application. In the next section, we will set up the database.
Setting up the Database
We will use Docker to run a Postgres database in a container. This will make it easy to run the database locally without installing Postgres on your machine.
Open the compose.yml
file and add the following:
services:
db:
container_name: db
image: postgres:12
ports:
- "5432:5432"
environment:
POSTGRES_USER: postgres
POSTGRES_PASSWORD: postgres
POSTGRES_DB: postgres
volumes:
- pgdata:/var/lib/postgresql/data
volumes:
pgdata: {}
-
db
is the name of the service -
container_name
is the name of the container, we will usedb
-
image
is the Postgres image (we will use Postgres 12) -
ports
is the port mapping (5432:5432) -
environment
is the environment variables for the Postgres instance -
volumes
is the volume mapping for the Postgres data
We also define a volume pgdata
that will be used to store the Postgres data.
Now, run the following command to start the Postgres database:
docker compose up
You should see the Postgres logs in the terminal. If you see database system is ready to accept connections
, the database is probably running successfully.
To make another test, you can go on the terminal and type:
docker ps -a
And you should see the database running:
You can also step into the database container by running:
docker exec -it db psql -U postgres
You can check the current databases by running:
\dt
And you should see the following output (Did not find any relations):
This is because we have not created any tables yet. We will do that in the next section.
Setting up the Backend
We will use Rocket to build the backend.
Rocket is a web framework for Rust that is designed to maximize the Developer Experience. We will use Rocket to build a simple REST API that will interact with the database.
Create a new Rust project called backend
, without initializing a git repository:
cargo new backend --vcs none
Your project structure should look like this:
Open the Cargo.toml
file and add the following dependencies:
rocket = { version = "0.5", features = ["json"] }
serde = { version = "1.0", features = ["derive"] }
serde_json = "1.0"
tokio = { version = "1", features = ["full"] }
tokio-postgres = "0.7.11"
rocket_cors = { version = "0.6.0", default-features = false }
-
rocket
is the Rocket web framework we will use to build the backend -
serde
is a serialization/deserialization library -
serde_json
is a JSON serialization/deserialization library -
tokio
is an asynchronous runtime for Rust -
tokio-postgres
is a Postgres client for Tokio -
rocket_cors
is a CORS library for Rocket
Now, open the /backend/main.rs
file and replace the contents with the following (explanation below):
#[macro_use]
extern crate rocket;
use rocket::serde::{ Deserialize, Serialize, json::Json };
use rocket::{ State, response::status::Custom, http::Status };
use tokio_postgres::{ Client, NoTls };
use rocket_cors::{ CorsOptions, AllowedOrigins };
#[derive(Serialize, Deserialize, Clone)]
struct User {
id: Option<i32>,
name: String,
email: String,
}
#[post("/api/users", data = "<user>")]
async fn add_user(
conn: &State<Client>,
user: Json<User>
) -> Result<Json<Vec<User>>, Custom<String>> {
execute_query(
conn,
"INSERT INTO users (name, email) VALUES ($1, $2)",
&[&user.name, &user.email]
).await?;
get_users(conn).await
}
#[get("/api/users")]
async fn get_users(conn: &State<Client>) -> Result<Json<Vec<User>>, Custom<String>> {
get_users_from_db(conn).await.map(Json)
}
async fn get_users_from_db(client: &Client) -> Result<Vec<User>, Custom<String>> {
let users = client
.query("SELECT id, name, email FROM users", &[]).await
.map_err(|e| Custom(Status::InternalServerError, e.to_string()))?
.iter()
.map(|row| User { id: Some(row.get(0)), name: row.get(1), email: row.get(2) })
.collect::<Vec<User>>();
Ok(users)
}
#[put("/api/users/<id>", data = "<user>")]
async fn update_user(
conn: &State<Client>,
id: i32,
user: Json<User>
) -> Result<Json<Vec<User>>, Custom<String>> {
execute_query(
conn,
"UPDATE users SET name = $1, email = $2 WHERE id = $3",
&[&user.name, &user.email, &id]
).await?;
get_users(conn).await
}
#[delete("/api/users/<id>")]
async fn delete_user(conn: &State<Client>, id: i32) -> Result<Status, Custom<String>> {
execute_query(conn, "DELETE FROM users WHERE id = $1", &[&id]).await?;
Ok(Status::NoContent)
}
async fn execute_query(
client: &Client,
query: &str,
params: &[&(dyn tokio_postgres::types::ToSql + Sync)]
) -> Result<u64, Custom<String>> {
client
.execute(query, params).await
.map_err(|e| Custom(Status::InternalServerError, e.to_string()))
}
#[launch]
async fn rocket() -> _ {
let (client, connection) = tokio_postgres
::connect("host=localhost user=postgres password=postgres dbname=postgres", NoTls).await
.expect("Failed to connect to Postgres");
tokio::spawn(async move {
if let Err(e) = connection.await {
eprintln!("Failed to connect to Postgres: {}", e);
}
});
//Create the table if it doesn't exist
client
.execute(
"CREATE TABLE IF NOT EXISTS users (
id SERIAL PRIMARY KEY,
name TEXT NOT NULL,
email TEXT NOT NULL
)",
&[]
).await
.expect("Failed to create table");
let cors = CorsOptions::default()
.allowed_origins(AllowedOrigins::all())
.to_cors()
.expect("Error while building CORS");
rocket
::build()
.manage(client)
.mount("/", routes![add_user, get_users, update_user, delete_user])
.attach(cors)
}
In this part of the video, I explain the code above.
Explanation
- We make all the imports at the top of the file. We also definte a
macro_use
attribute to import therocket
macro. - We define a
User
struct that will represent the user data. This struct will be serialized/deserialized to/from JSON (Note: The id is anOption
because we don't want to provide an id when creating a new user, it will be assigned by the database). - We define the
add_user
route that will insert a new user into the database. We use theexecute_query
function to execute the SQL query. We then call theget_users
function to return all the users. - We define the
get_users
route that will return all the users from the database. - We define the
update_user
route that will update a user in the database. We use theexecute_query
function to execute the SQL query. We then call theget_users
function to return all the users. - We define the
delete_user
route that will delete a user from the database. We use theexecute_query
function to execute the SQL query. - We define the
execute_query
function that will execute a SQL query on the database. - We define the
rocket
function that will create the Rocket instance. We connect to the Postgres database and create theusers
table if it doesn't exist, using a SQL query. We then create the CORS options and attach them to the Rocket instance. Even if we are running the frontend and back end on the same machine, we need to enable CORS to allow the frontend to make requests to the backend.
We can now run the backend by running:
cargo run
And we should see the following output:
You can visit the following URL: http://127.0.0.1:8000/api/users
and you should see an empty array []
:
Testing the APIs with Postman
You can test the APIs using Postman.
You can get the list of the users by sending a GET
request to http://127.0.0.1:8000/api/users
:
You can create a new user by sending a POST
request to http://127.0.0.1:8000/api/users
with the following JSON body:
{
"name": "AAA",
"email": "aaa@mail.com"
}
You can create 2 more users:
{
"name": "BBB",
"email": "
}
{
"name": "CCC",
"email": "
}
You should see the following output:
To Update a user, you can send a PUT
request to http://127.0.0.1:8000/api/users/2
with the following JSON body:
{
"name": "Francesco",
"email": "francesco@mail"
}
And we should see the updated user:
To delete a user, you can send a DELETE
request to http://127.0.0.1:8000/api/users/1
:
And we should get a 204 response (Resource was deleted):
And if we try to get all the users, we should see the following output:
We can see that this is consistent if we use the browser to check the users at the address http://127.0.0.1:8000/api/users
:
We can also test it directly in the Postgres database by running:
(If you closed the terminal, you can step into the container by running docker exec -it db psql -U postgres
)
\dt
select * from users;
Congratulations! You have successfully set up the backend. In the next section, we will set up the frontend.
Setting up the Frontend
Now, let's work on the front end. We will use Yew to build it. Yew is a Rust framework for building client web apps. We will use Trunk to build and bundle the front end and Tailwind CSS for styling. All this will be compiled to Web Assembly and run in the browser.
IMPORTANT! If you never used Wasm for Rust on your machine, you can install it by running:
rustup target add wasm32-unknown-unknown
IMPORTANT! You must also have trunk
installed on your machine. You can install it by running:
cargo install trunk
You can verify that trunk
is installed by running:
trunk --version
Now you can create a new Rust project called frontend
(be sure to be in the rustfs
folder):
cargo new frontend --vcs none
Now open the frontend/Cargo.toml
file and add the following dependencies:
[package]
name = "frontend"
version = "0.1.0"
edition = "2021"
[dependencies]
yew = { version = "0.21", features = ["csr"] }
wasm-bindgen = "0.2"
web-sys = { version = "0.3", features = ["console"] }
gloo = "0.6"
wasm-bindgen-futures = "0.4"
serde = { version = "1.0", features = ["derive"] }
serde_json = "1.0"
-
yew
is the Yew framework (Rust framework for building client web apps) -
wasm-bindgen
is a library that facilitates communication between WebAssembly and JavaScript -
web-sys
is a library that provides bindings to Web APIs -
gloo
is a library that provides utilities for WebAssembly -
wasm-bindgen-futures
is a library that provides utilities for working with futures in WebAssembly -
serde
is a serialization/deserialization library -
serde_json
is a JSON serialization/deserialization library
Now create a new file called index.html
in the frontend
folder and add the following:
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Yew + Tailwind</title>
<script src="https://cdn.tailwindcss.com"></script>
</head>
<body>
<div id="app"></div>
<script type="module">
import init from './pkg/frontend.js';
init();
</script>
</body>
</html>
- We import the Tailwind CSS CDN in the head of the HTML file
- We create a div with the id
app
where the Yew app will be mounted - We import the
frontend.js
file that will be generated by Trunk
Now open the frontend/src/main.rs
file and replace the contents with the following:
use yew::prelude::*;
use serde::{ Deserialize, Serialize };
use gloo::net::http::Request;
use wasm_bindgen_futures::spawn_local;
#[derive(Serialize, Deserialize, Clone, Debug)]
struct User {
id: i32,
name: String,
email: String,
}
fn main() {
yew::Renderer::<App>::new().render();
}
- We import the necessary dependencies
- We define a
User
struct that will represent the user data - We define the
main
function that will render the Yew app
But this is not enough. We need to add the App
component. We can use an external file, but here for simplicity, we will add it directly in the main.rs
file.
Below is the code you should add to the main.rs file.
This code defines a Yew function component named App that manages user data and interactions within a web application. The use_state hooks initialize states for managing user information (user_state)
, messages (message)
, and the list of users (users)
.
The component defines several callbacks for interacting with a backend API:
-
get_users:
Fetches the list of users from the backend API and updates the users state. If the request fails, it sets an error message. -
create_user:
Sends a POST request to create a new user using the data from user_state. On success, it triggers the get_users callback to refresh the user list. -
update_user:
Updates an existing user's information by sending a PUT request to the backend. If successful, it refreshes the user list and resets the user_state. -
delete_user:
Deletes a user based on their ID by sending a DELETE request to the backend. On success, it refreshes the user list. -
edit_user:
Prepares a user's information for editing by updating the user_state with the selected user's details.
These callbacks utilize asynchronous operations (spawn_local)
to handle network requests without blocking the UI thread, ensuring a responsive user experience.
...
#[function_component(App)]
fn app() -> Html {
let user_state = use_state(|| ("".to_string(), "".to_string(), None as Option<i32>));
let message = use_state(|| "".to_string());
let users = use_state(Vec::new);
let get_users = {
let users = users.clone();
let message = message.clone();
Callback::from(move |_| {
let users = users.clone();
let message = message.clone();
spawn_local(async move {
match Request::get("http://127.0.0.1:8000/api/users").send().await {
Ok(resp) if resp.ok() => {
let fetched_users: Vec<User> = resp.json().await.unwrap_or_default();
users.set(fetched_users);
}
_ => message.set("Failed to fetch users".into()),
}
});
})
};
let create_user = {
let user_state = user_state.clone();
let message = message.clone();
let get_users = get_users.clone();
Callback::from(move |_| {
let (name, email, _) = (*user_state).clone();
let user_state = user_state.clone();
let message = message.clone();
let get_users = get_users.clone();
spawn_local(async move {
let user_data = serde_json::json!({ "name": name, "email": email });
let response = Request::post("http://127.0.0.1:8000/api/users")
.header("Content-Type", "application/json")
.body(user_data.to_string())
.send().await;
match response {
Ok(resp) if resp.ok() => {
message.set("User created successfully".into());
get_users.emit(());
}
_ => message.set("Failed to create user".into()),
}
user_state.set(("".to_string(), "".to_string(), None));
});
})
};
let update_user = {
let user_state = user_state.clone();
let message = message.clone();
let get_users = get_users.clone();
Callback::from(move |_| {
let (name, email, editing_user_id) = (*user_state).clone();
let user_state = user_state.clone();
let message = message.clone();
let get_users = get_users.clone();
if let Some(id) = editing_user_id {
spawn_local(async move {
let response = Request::put(&format!("http://127.0.0.1:8000/api/users/{}", id))
.header("Content-Type", "application/json")
.body(serde_json::to_string(&(id, name.as_str(), email.as_str())).unwrap())
.send().await;
match response {
Ok(resp) if resp.ok() => {
message.set("User updated successfully".into());
get_users.emit(());
}
_ => message.set("Failed to update user".into()),
}
user_state.set(("".to_string(), "".to_string(), None));
});
}
})
};
let delete_user = {
let message = message.clone();
let get_users = get_users.clone();
Callback::from(move |id: i32| {
let message = message.clone();
let get_users = get_users.clone();
spawn_local(async move {
let response = Request::delete(
&format!("http://127.0.0.1:8000/api/users/{}", id)
).send().await;
match response {
Ok(resp) if resp.ok() => {
message.set("User deleted successfully".into());
get_users.emit(());
}
_ => message.set("Failed to delete user".into()),
}
});
})
};
let edit_user = {
let user_state = user_state.clone();
let users = users.clone();
Callback::from(move |id: i32| {
if let Some(user) = users.iter().find(|u| u.id == id) {
user_state.set((user.name.clone(), user.email.clone(), Some(id)));
}
})
};
...
You can check writing the code line by line in this part of the video
Now we need to add the HTML code rendered by the Yew component. Below is the code you should add in the main.rs file.
If you know React, this is similar to what happens in a JSX file.
This HTML part written using Yew's html! macro, defines the user interface of the Yew application. It consists of several key sections that provide functionalities for managing users.
- A main container with some padding and a nice layout using Tailwind CSS.
- A big title at the top says “User Management” to let users know what the app is about.
- Two input fields: one for the user's name and another for their email. As you type, it updates the state to track what’s entered.
- A button that changes its action and label depending on whether you're creating a new user or updating an existing one. It says
Create User
if you're adding a new one orUpdate User
if you're editing. - A space for messages to appear—like success or error messages—just below the input fields (the text color is always green; feel free to make it red in case of errors).
- A
Fetch User List
button that, when clicked, pulls the latest user data from the backend. - A section that lists all the users fetched from the backend, showing their ID, name, and email.
- Each user in the list has a "Delete" button to remove them and an "Edit" button to load their details into the input fields for editing.
...
html! {
<div class="container mx-auto p-4">
<h1 class="text-4xl font-bold text-blue-500 mb-4">{ "User Management" }</h1>
<div class="mb-4">
<input
placeholder="Name"
value={user_state.0.clone()}
oninput={Callback::from({
let user_state = user_state.clone();
move |e: InputEvent| {
let input = e.target_dyn_into::<web_sys::HtmlInputElement>().unwrap();
user_state.set((input.value(), user_state.1.clone(), user_state.2));
}
})}
class="border rounded px-4 py-2 mr-2"
/>
<input
placeholder="Email"
value={user_state.1.clone()}
oninput={Callback::from({
let user_state = user_state.clone();
move |e: InputEvent| {
let input = e.target_dyn_into::<web_sys::HtmlInputElement>().unwrap();
user_state.set((user_state.0.clone(), input.value(), user_state.2));
}
})}
class="border rounded px-4 py-2 mr-2"
/>
<button
onclick={if user_state.2.is_some() { update_user.clone() } else { create_user.clone() }}
class="bg-blue-500 hover:bg-blue-700 text-white font-bold py-2 px-4 rounded"
>
{ if user_state.2.is_some() { "Update User" } else { "Create User" } }
</button>
if !message.is_empty() {
<p class="text-green-500 mt-2">{ &*message }</p>
}
</div>
<button
onclick={get_users.reform(|_| ())}
class="bg-gray-500 hover:bg-gray-700 text-white font-bold py-2 px-4 rounded mb-4"
>
{ "Fetch User List" }
</button>
<h2 class="text-2xl font-bold text-gray-700 mb-2">{ "User List" }</h2>
<ul class="list-disc pl-5">
{ for (*users).iter().map(|user| {
let user_id = user.id;
html! {
<li class="mb-2">
<span class="font-semibold">{ format!("ID: {}, Name: {}, Email: {}", user.id, user.name, user.email) }</span>
<button
onclick={delete_user.clone().reform(move |_| user_id)}
class="ml-4 bg-red-500 hover:bg-red-700 text-white font-bold py-1 px-2 rounded"
>
{ "Delete" }
</button>
<button
onclick={edit_user.clone().reform(move |_| user_id)}
class="ml-4 bg-yellow-500 hover:bg-yellow-700 text-white font-bold py-1 px-2 rounded"
>
{ "Edit" }
</button>
</li>
}
})}
</ul>
</div>
}
...
You can check writing the code line by line in this part of the video
Here is the complete code for the /frontend/src/main.rs
file:
use yew::prelude::*;
use serde::{ Deserialize, Serialize };
use gloo::net::http::Request;
use wasm_bindgen_futures::spawn_local;
#[function_component(App)]
fn app() -> Html {
let user_state = use_state(|| ("".to_string(), "".to_string(), None as Option<i32>));
let message = use_state(|| "".to_string());
let users = use_state(Vec::new);
let get_users = {
let users = users.clone();
let message = message.clone();
Callback::from(move |_| {
let users = users.clone();
let message = message.clone();
spawn_local(async move {
match Request::get("http://127.0.0.1:8000/api/users").send().await {
Ok(resp) if resp.ok() => {
let fetched_users: Vec<User> = resp.json().await.unwrap_or_default();
users.set(fetched_users);
}
_ => message.set("Failed to fetch users".into()),
}
});
})
};
let create_user = {
let user_state = user_state.clone();
let message = message.clone();
let get_users = get_users.clone();
Callback::from(move |_| {
let (name, email, _) = (*user_state).clone();
let user_state = user_state.clone();
let message = message.clone();
let get_users = get_users.clone();
spawn_local(async move {
let user_data = serde_json::json!({ "name": name, "email": email });
let response = Request::post("http://127.0.0.1:8000/api/users")
.header("Content-Type", "application/json")
.body(user_data.to_string())
.send().await;
match response {
Ok(resp) if resp.ok() => {
message.set("User created successfully".into());
get_users.emit(());
}
_ => message.set("Failed to create user".into()),
}
user_state.set(("".to_string(), "".to_string(), None));
});
})
};
let update_user = {
let user_state = user_state.clone();
let message = message.clone();
let get_users = get_users.clone();
Callback::from(move |_| {
let (name, email, editing_user_id) = (*user_state).clone();
let user_state = user_state.clone();
let message = message.clone();
let get_users = get_users.clone();
if let Some(id) = editing_user_id {
spawn_local(async move {
let response = Request::put(&format!("http://127.0.0.1:8000/api/users/{}", id))
.header("Content-Type", "application/json")
.body(serde_json::to_string(&(id, name.as_str(), email.as_str())).unwrap())
.send().await;
match response {
Ok(resp) if resp.ok() => {
message.set("User updated successfully".into());
get_users.emit(());
}
_ => message.set("Failed to update user".into()),
}
user_state.set(("".to_string(), "".to_string(), None));
});
}
})
};
let delete_user = {
let message = message.clone();
let get_users = get_users.clone();
Callback::from(move |id: i32| {
let message = message.clone();
let get_users = get_users.clone();
spawn_local(async move {
let response = Request::delete(
&format!("http://127.0.0.1:8000/api/users/{}", id)
).send().await;
match response {
Ok(resp) if resp.ok() => {
message.set("User deleted successfully".into());
get_users.emit(());
}
_ => message.set("Failed to delete user".into()),
}
});
})
};
let edit_user = {
let user_state = user_state.clone();
let users = users.clone();
Callback::from(move |id: i32| {
if let Some(user) = users.iter().find(|u| u.id == id) {
user_state.set((user.name.clone(), user.email.clone(), Some(id)));
}
})
};
//html
html! {
<div class="container mx-auto p-4">
<h1 class="text-4xl font-bold text-blue-500 mb-4">{ "User Management" }</h1>
<div class="mb-4">
<input
placeholder="Name"
value={user_state.0.clone()}
oninput={Callback::from({
let user_state = user_state.clone();
move |e: InputEvent| {
let input = e.target_dyn_into::<web_sys::HtmlInputElement>().unwrap();
user_state.set((input.value(), user_state.1.clone(), user_state.2));
}
})}
class="border rounded px-4 py-2 mr-2"
/>
<input
placeholder="Email"
value={user_state.1.clone()}
oninput={Callback::from({
let user_state = user_state.clone();
move |e: InputEvent| {
let input = e.target_dyn_into::<web_sys::HtmlInputElement>().unwrap();
user_state.set((user_state.0.clone(), input.value(), user_state.2));
}
})}
class="border rounded px-4 py-2 mr-2"
/>
<button
onclick={if user_state.2.is_some() { update_user.clone() } else { create_user.clone() }}
class="bg-blue-500 hover:bg-blue-700 text-white font-bold py-2 px-4 rounded"
>
{ if user_state.2.is_some() { "Update User" } else { "Create User" } }
</button>
if !message.is_empty() {
<p class="text-green-500 mt-2">{ &*message }</p>
}
</div>
<button
onclick={get_users.reform(|_| ())}
class="bg-gray-500 hover:bg-gray-700 text-white font-bold py-2 px-4 rounded mb-4"
>
{ "Fetch User List" }
</button>
<h2 class="text-2xl font-bold text-gray-700 mb-2">{ "User List" }</h2>
<ul class="list-disc pl-5">
{ for (*users).iter().map(|user| {
let user_id = user.id;
html! {
<li class="mb-2">
<span class="font-semibold">{ format!("ID: {}, Name: {}, Email: {}", user.id, user.name, user.email) }</span>
<button
onclick={delete_user.clone().reform(move |_| user_id)}
class="ml-4 bg-red-500 hover:bg-red-700 text-white font-bold py-1 px-2 rounded"
>
{ "Delete" }
</button>
<button
onclick={edit_user.clone().reform(move |_| user_id)}
class="ml-4 bg-yellow-500 hover:bg-yellow-700 text-white font-bold py-1 px-2 rounded"
>
{ "Edit" }
</button>
</li>
}
})}
</ul>
</div>
}
}
#[derive(Serialize, Deserialize, Clone, Debug)]
struct User {
id: i32,
name: String,
email: String,
}
fn main() {
yew::Renderer::<App>::new().render();
}
Building the Frontend
It's now time to run the frontend.
You can type:
cargo build --target wasm32-unknown-unknown
And then you can run the frontend by running:
trunk serve
You can now visit http://127.0.0.1:8080
and click the Fetch User List
button to fetch the users from the backend:
As you can see, we fetch users from the backend and display them in the front end. The front end also allows you to create, update, and delete users.
For example, we can create a user with name yew
and email yes@mail.com
:
The user should be displayed correctly on the frontend, with the message User created successfully
:
To check the consistency of data, we can use Postman, making a GET
request to http://127.0.0.1:8000/api/users
:
We can also update a user, for example, the one with ID 3, by changing the name to subscribe
and the email to subscribe@mail.com
. Notice that when we hit the Edit
button, the form is populated with the user data, and the button label is changed to Update User
:
After hitting the Update User
button, we should see the message User updated successfully
:
The last test is to delete a user, for example, the one with ID 3. After hitting the Delete
button, we should see the message User deleted successfully
:
After hitting the Delete
button, we should see the message User deleted successfully
:
Note: You should be able to see all the HTTP requests in the backend logs.
Let's create the last user, and let's call it last
and let's use as email last@mail.com
If we use Postman and make a GET
request to http://127.0.0.1:8000/api/users
, we should see the following output:
We can also see the data by opening a new tab and visiting http://127.0.0.1:8000/api/users
:
The last test is to check it directly in the Postgres container. You can step into the container by running docker exec -it db psql -U postgres
and then run:
\dt
select * from users;
Well done!
Conclusion
In this tutorial, we built a full-stack web application using Rust. We built a backend using Rocket and Postgres and a frontend using Yew, Tailwind CSS, and Trunk. We created, read, update, and deleted users from the database using the front end. We also tested the APIs using Postman and checked
If you prefer a video version:
All the code is available on GitHub (link in video description)
You can find me here: https://francescociulla.com