In the previous article, we set up Tuono and created a simple form. We also covered routing, handlers, and built an API route to process form data. In this tutorial, we’ll extend version 1 with CRUD operations using an SQL database. I’ll be using Turso (SQLite), but the same example works with PostgreSQL or MySQL—just use the right client and update the API route accordingly, as the command flow is always the same. To follow this tutorial exactly, you’ll need:
- A Turso account
- The project from the previous article
- Turso CLI installed
- Basic knowledge of SQL
Showcase:
![[object Object]](/_next/image?url=https%3A%2F%2Fcdn.sanity.io%2Fimages%2Fd9iesoru%2Fproduction%2F7f0d29ac38a37f8073abed207229a04b77452a17-1642x1386.png&w=640&q=75)
After successfully logging into Turso (or another database provider), create a Group (the grey button in the image) — a shared space on the provider’s server. Name the group and choose a region.
![[object Object]](/_next/image?url=https%3A%2F%2Fcdn.sanity.io%2Fimages%2Fd9iesoru%2Fproduction%2F98d99a62dbe6527b0b24e605cd0431d35df1266b-1604x648.png&w=640&q=75)
Now, create a database by clicking the green button. Name your database and select the group you created earlier.
![[object Object]](/_next/image?url=https%3A%2F%2Fcdn.sanity.io%2Fimages%2Fd9iesoru%2Fproduction%2F5936f8b2340cd8d213ad4ec63faf793f5d18f862-1290x1022.png&w=640&q=75)
After creation, you’ll be redirected to the database dashboard. We need to save url of our database that you can see on top of page for further operations. Save it into your env.local. Click the “Edit Database” button and choose one of the two available GUI DB editors.
1//.env.local
2TURSO_DB_URL=libsql://example-...![[object Object]](/_next/image?url=https%3A%2F%2Fcdn.sanity.io%2Fimages%2Fd9iesoru%2Fproduction%2Ff5649acf7ffb5e95b2bae8a1bae8d59f56cc1734-1604x506.png&w=640&q=75)
![[object Object]](/_next/image?url=https%3A%2F%2Fcdn.sanity.io%2Fimages%2Fd9iesoru%2Fproduction%2F4100e35c796432b668fd997473896fa584850c62-1628x1198.png&w=640&q=75)
I used Outerbase Studio, but the choice is up to you. As you’ve likely noticed, the right section is where you write queries (the terminal at the bottom runs them). In the center, you’ll find your tables, and on the left is a sidebar with options to view tables, the ER diagram, and OpenAI integration. Let’s create our first table!
1CREATE TABLE users (
2 id INTEGER PRIMARY KEY AUTOINCREMENT,
3 name TEXT NOT NULL,
4 age INTEGER,
5 email TEXT NOT NULL UNIQUE,
6 password TEXT NOT NULL,
7 salt TEXT NOT NULL,
8);Enter the SQL query into your chosen editor or terminal and run it! Then, view the table in the middle section of the editor to see its header. Now let's move on to setting up the libSQL client. Jump into terminal!
Start with login to Turso:
1turso auth login1turso db tokens create your-db-nameSave key that will show your terminal.
1TURSO_AUTH_TOKEN=ey...Now install libsql:
1cargo add libsqlSetup client in src/lib/utils.rs:
1//src/lib/utils.rs
2use libsql::Builder;
3use libsql::Connection;
4
5pub async fn connect_to_db() -> Connection{
6 let url = std::env::var("TURSO_DB_URL").expect("TURSO_DB_URL must be set");
7 let token = std::env::var("TURSO_AUTH_TOKEN").expect("TURSO_AUTH_TOKEN must be set");
8 let client = Builder::new_remote(url, token).build().await.expect("Problems with connection!");
9 let connection = client.connect().expect("Problems with connection!");
10 return {
11 connection
12 }
13}and create lib.rs in src directory:
1//src/lib.rs
2
3pub mod lib {
4 pub mod utils;
5}In your api route formdata.rs import connect_to_db():
1//src/rotes/api/formdata.rs
2use tuono_lib::{axum::response::Redirect, Request};
3use serde::Deserialize;
4use with_tailwind::lib::utils::connect_to_db;
5
6#[derive(Deserialize)]
7struct FormData {
8 name: String,
9 age: i8,
10 email: String,
11 password: String,
12}
13
14
15#[tuono_lib::api(POST)]
16async fn send_data(_req: Request) -> Redirect {
17 let data: FormData = _req.form_data::<FormData>().unwrap();
18 let mut message: String= "/?message=Hello ".to_string();
19 println!("Name: {}, Age: {}, Email: {}, Password. {}", data.name, data.age, data.email, data.password);
20
21 let conn: libsql::Connection = connect_to_db().await;
22
23 let mut _rows: u64 = conn
24 .execute(
25 "INSERT INTO users (name, age, email, password, salt)
26 VALUES (:name, :age, :email, :password, :salt);", libsql::named_params! {
27 ":name": data.name.clone(),
28 ":age": data.age.clone(),
29 ":email": data.email.clone(),
30 ":password": data.password.clone(),
31 ":salt": "sssss".to_string()})
32 .await
33 .expect("Problem with query");
34
35 message.push_str(&data.name);
36
37 Redirect::to(&message)
38}Create variable conn and execute query. If you check you table in database, you should see new row!
Reading data!
1use tuono_lib::{Response,Props, Request, Type};
2use serde::Serialize;
3use with_tailwind::lib::utils::connect_to_db;
4
5#[derive(Debug,Serialize)]
6struct FormData {
7 id: i64,
8 name: String,
9 age: i64,
10 email: String,
11}
12#[derive(Debug,Serialize, Type)]
13struct FormDatas {
14 form_data: Vec<FormData>
15}
16
17#[tuono_lib::handler]
18async fn get_all_users(_req: Request) -> Response{
19 let mut users = Vec::new();
20 let conn = connect_to_db().await;
21 let mut res = conn.query("SELECT id,name,age,email FROM users", ()).await.expect("Cannot read data");
22 while let Some(row) = res.next().await.unwrap(){
23 let id: i64 = row.get(0).unwrap();
24 let name: String = row.get(1).unwrap();
25 let age: i64 = row.get(2).unwrap();
26 let email: String = row.get(3).unwrap();
27
28 users.push(FormData {
29 id,
30 name,
31 age,
32 email,
33 })
34 }
35 println!("{:#?}", users);
36 Response::Props(Props::new(FormDatas{
37 form_data: users
38 }))
39}For reading data in Tuono, a .rs file with the same name as the route is used — for example, index.tsx will have a corresponding index.rs. Using the #[tuono_lib::handler] macro, we inform the server that this file defines a GET route. If you're familiar with Next.js 13 or earlier, this is similar to getServerSideProps. In the file, I defined a structure for the database data and imported the DB client. Then I iterated over each row and stored the data in a users variable. Finally, I passed the data into Props, which is a Tuono structure used to send data to the frontend.
Updating and deleting
Since Tuono doesn't allow receiving form_data with methods other than POST and only supports one HTTP method per file, and I don't want to create a separate file for each operation, we'll use an old trick. In the existing form, simply add a name and value to the button, like in the example.
1<button name='action' value={"create"} type='submit'>Submit</button>
2 </form>Next, it's necessary to create an interface for the data coming from the index.rs file and add TuonoRouteProps as the type for our component’s props. I also created a table for our data, which I placed inside a <form>—this is because we’ll be deleting or updating the data directly from the table. We’ll also need useState to toggle the "mode" between editing and deleting.
1//src/routes/index.tsx
2interface IndexProps {
3
4 form_data: Array<{
5 id: number,
6 name: string,
7 age: number,
8 email: string,
9}>
10}
11export default function IndexPage({
12 data,
13}: TuonoRouteProps<IndexProps>): JSX.Element {
14 const [edit, setEdit] = useState<boolean>(false)
15...1{users && users.map((u) => (
2 <form className='w-full col-span-5 grid grid-cols-5' action={"/api/formdata"}
3 method='POST'>
4 <button type='button' onClick={() => setEdit(true)} className="rounded-full bg-black text-white w-fit p-1"><Pen/></button>
5 <input name='name' type='text' readOnly={!edit} defaultValue={u.name}/>
6 <input name='age' type='number' readOnly={!edit} defaultValue={u.age}/>
7 <input name='email' type='email' readOnly={!edit} defaultValue={u.email}/>
8 {edit ?
9 <button type='submit' name='action' value={"update"} className="rounded-full bg-red-500 text-white w-fit p-1"><Save/></button>
10 :<button type='submit' name='action' value={"delete"} className="rounded-full bg-amber-400 text-white w-fit p-1"><X/></button>
11 }
12 </form>
13 ))}As you can see in the form, I used name and value on the buttons so I can tell on the backend whether to delete, update, or insert the data.
1//full file src/rotes/apiformdata.rs
2use tuono_lib::{axum::response::Redirect, Request};
3use serde::Deserialize;
4use with_tailwind::lib::utils::connect_to_db;
5
6#[derive(Deserialize)]
7struct FormData {
8 name: Option<String>,
9 age: Option<i8>,
10 email: Option<String>,
11 password: Option<String>,
12 action: String,
13}
14
15
16#[tuono_lib::api(POST)]
17async fn send_data(_req: Request) -> Redirect {
18 let data: FormData = _req.form_data::<FormData>().unwrap();
19 let mut message: String= "/?message=Hello ".to_string();
20 println!("Name: {:?}, Age: {:?}, Email: {:?}, Password: {:?}, Action: {}", data.name, data.age, data.email, data.password, data.action);
21
22 let conn: libsql::Connection = connect_to_db().await;
23
24 if data.action == "create" {
25 let mut _rows: u64 = conn.execute("INSERT INTO users (name, age, email, password, salt) VALUES (:name, :age, :email, :password, :salt)", libsql::named_params! {":name": data.name.clone(), ":age": data.age.clone(), ":email": data.email.clone(), ":password": data.password.clone(), ":salt": "sssss".to_string()}).await.expect("Problem with query");
26 message.push_str(&data.name.as_deref().unwrap_or(""));
27 message.push_str(", your registered!");
28 }
29
30 if data.action == "update" {
31 let mut _rows = conn.execute("UPDATE users SET name=?, age=? WHERE email = ?",&[data.name.clone(), Some(data.age.unwrap_or(0).to_string().clone()), data.email.clone()])
32 .await.expect("Problem with query");
33 message.push_str(&data.name.as_deref().unwrap_or(""));
34 message.push_str(", data were updated!");
35 }
36
37 if data.action == "delete" {
38 let mut _rows = conn.execute("DELETE FROM users WHERE email = ?",&[data.email.clone()])
39 .await.expect("Problem with query");
40 message.push_str("Data were deleted!");
41 }
42
43 Redirect::to(&message)
44}I added an action of type String to the interface. At the same time, I changed all the attributes to Option types because sometimes you receive all the data (create), other times you only need the email (delete where email = ?), and in some cases you don't need the password at all (update). On the backend, after receiving the data, I connect to the database client and, based on the action, either create, update, or delete the data.
