Tuono part II: CRUD with libsqlđź’˝

Learn how to connect Tuono with an SQL database and perform basic CRUD operations. Let’s set up the DB and dive in🤜!

Tue, May, 06, 2025
Adam Hitzger's blog - Learn how to connect Tuono with an SQL database and perform basic CRUD operations. Let’s set up the

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:

  1. A Turso account
  2. The project from the previous article
  3. Turso CLI installed
  4. Basic knowledge of SQL

Showcase:

[object Object]

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]

Now, create a database by clicking the green button. Name your database and select the group you created earlier.

[object Object]

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.

example.sh
1//.env.local 2TURSO_DB_URL=libsql://example-...
[object Object][object Object]

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!

example.mysql
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:

example.sh
1turso auth login
example.sh
1turso db tokens create your-db-name

Save key that will show your terminal.

example.sh
1TURSO_AUTH_TOKEN=ey...

Now install libsql:

example.sh
1cargo add libsql

Setup client in src/lib/utils.rs:

example.undefined
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:

example.undefined
1//src/lib.rs 2 3pub mod lib { 4 pub mod utils; 5}

In your api route formdata.rs import connect_to_db():

example.undefined
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!

example.undefined
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.

example.html
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.

example.tsx
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...
example.typescript
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.

example.undefined
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.

Subscribe to my newsletter !

and be updated with lastest libraries. Enter your email address!