Building a SaaS with Rust and Next.js

·

22 min read

Rust for web development recently has come a long way from where it used to be; though the ecosystem may not be as sprawling as popular languages like JavaScript, its promises of memory safety, low memory footprint, expressive syntax combined with highly competent error handling (and of course speed!) have become much easier to realise as the crate ecosystem has gotten bigger. There's support for things like Stripe, SMTP (via lettre), AWS-SES and other mail webservice providers, websockets and SSR, subdomains, and much more.

By the end of this article, we'll have an easily extendable SaaS starter pack modelled on a CRM that has the following:

  • Stripe API usage for taking payments through subscriptions

  • Mailgun API for newsletter subscriptions

  • PostgresQL database (using SQLx to access it)

  • Authenticated session-based login

  • Working CRUD for customers and sales records

  • Dashboard analytics

If you get stuck with the code in this article (or just want to see the final result), a GitHub repo with the final code can be found here. This article uses a frontend template and assumes knowledge of React/Next.js if you do decide to use it. You can also find a live deployment of this template at https://shuttle-saas-example.shuttleapp.rs/.

SaaS frontend example collage picture

Getting Started

We'll be deploying via Shuttle, which is a Rust-native cloud dev platform that aims to make deploying Rust web service as simple as possible by foregoing complicated configuration files and allowing you to use code annotations (through Rust macros). Databases and static files? No problem. Just write the relevant macro as a parameter in your main function, and it works! There is no vendor lock-in, and databases can be reached from your favourite database admin tools like pgAdmin.

We can get started with writing our full-stack application by using create-shuttle-app, which is an npm package that installs cargo-shuttle (shuttle's CLI for deployment management), Rust with cargo if not installed already and then bootstraps a Next.js application plus an initialised backend folder with all the basics we need to get started. We can initialise the app by using the following command:

npx create-shuttle-app --ts

We will want to log in by going to Shuttle's website and logging in via GitHub then writing shuttle login in your favourite terminal and following the instructions. This will allow us to be able to deploy to Shuttle.

Next we will want the following:

  • A Stripe account with an API key so we can make payments remotely (you can sign up for Stripe here)

  • A Mailgun account with an API key so we can subscribe people to our Mailgun mailing list remotely (you can sign up for Mailgun here - you can just untick "add payment info now" and it'll let you use the indefinite free trial!)

The API key for Stripe can be found here (make sure you have Test mode on for development!):

Stripe API key location

You'll want to create a Stripe subscription item with a monthly recurring cost and save the item price ID somewhere which we'll use later. You can find more about this here.

You'll be able to find the Mailgun API key after you log in by going to the bottom of the dashboard and the API Keys section should be there:

Mailgun API keys location

You'll want to grab the Private API key - we'll need this key for authentication any time that we want to make an API call to Mailgun so that we can interact with the service remotely from our own web service.

You'll also want to get your Mailgun domain - you can find this by clicking 'Sending', then 'Domains' as illustrated below:

Mailgun domain location

You'll also want to create a mailing list on Mailgun and save your mailgun domain, private key and mailing list name somewhere. Your mailgun domain and key will be stored privately in a secrets file, but you can use your mail list name in the regular file as that doesn't need to be private (since it can be named literally anything). You can find more about this here.

We'll be using the API keys and Mailgun domain later on in our web service, so you'll want to find somewhere secure to keep note of your API keys and Mailgun domain that can't be read by anyone else - we'll be using these later.

You'll also want Docker installed - you can find more about how to install Docker here. Docker is a great utility, and will mainly be used to be able for Shuttle to run its own local database instance, which means you can avoid having to set anything up yourself.

Next, we will want to install the SQLx command-line tool so we can make our migrations without much effort. Thankfully, as a Rust crate it's pretty simple to install so we can just use a one-liner and then get on with writing our migrations:

cargo install sqlx-cli

When we need to write our SQLx migrations, we want to run sqlx migrate add <name> in the project root directory and it'll make a migration file so we can add our migrations. These will be the migrations we'll be using for our web service:

-- Feel free to paste this into your own SQL schema file
-- set up users table
CREATE TABLE IF NOT EXISTS users (
    id SERIAL PRIMARY KEY,
    name VARCHAR(100) NOT NULL,
    email VARCHAR(255) NOT NULL,
    password VARCHAR NOT NULL,
    created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP
);

-- set up sessions table
CREATE TABLE IF NOT EXISTS sessions (
    id SERIAL PRIMARY KEY,
    session_id VARCHAR NOT NULL UNIQUE,
    user_id int NOT NULL UNIQUE,
    expires TIMESTAMP WITH TIME ZONE,
    CONSTRAINT fk_user FOREIGN KEY (user_id) REFERENCES users(id)
);

-- set up customers table
CREATE TABLE IF NOT EXISTS customers (
    id SERIAL PRIMARY KEY,
    firstname VARCHAR NOT NULL,
    lastname VARCHAR NOT NULL,
    email VARCHAR NOT NULL,
    phone VARCHAR(14) NOT NULL,
    priority SMALLINT NOT NULL CHECK (priority >= 1 AND priority <= 5),
    owner_id int NOT NULL,
    is_archived BOOLEAN DEFAULT FALSE,
    created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP,
    CONSTRAINT FK_customer FOREIGN KEY(owner_id) REFERENCES users(id)
);

-- set up deals table
CREATE TABLE IF NOT EXISTS deals (
    id SERIAL PRIMARY KEY,
    estimate_worth INT,
    actual_worth INT,
    status VARCHAR NOT NULL,
    closed VARCHAR NOT NULL,
    created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP,
    last_updated TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP,
    customer_id int NOT NULL,
    owner_id int NOT NULL,
    CONSTRAINT FK_deal FOREIGN KEY(owner_id) REFERENCES users(id),
    CONSTRAINT FK_owner FOREIGN KEY(owner_id) REFERENCES users(id),
    is_archived BOOLEAN DEFAULT FALSE
);

Frontend

Currently I'm using a template to flesh my frontend out! It has pages for logging in and registering, basic dashboard and CRUD record pages as well a tier pricing page and monthly subscription checkout. You can find the example here (make sure to plug in a backend, as it won't work otherwise!). The repo will assume you have knowledge of React/Next.js.

Backend

To get started, let's go to our backend folder with cd backend. We'll want to get started by adding all of our dependencies, which you can do with this simple one-liner:

cargo add async-stripe axum-extra bcrypt http lettre rand reqwest serde shuttle-secrets shuttle-shared-db shuttle-static-folder sqlx time tower tower-http --features async-stripe/runtime-tokio-hyper,axum-extra/cookie-private,serde/derive,shuttle-shared-db/postgres,sqlx/runtime-tokio-native-tls,sqlx/postgres,tower-http/cors,tower-http/fs

The Cargo.toml file this article uses has the following dependencies:

[package]
name = "my-app"
version = "0.1.0"
edition = "2021"
publish = false
# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html

[dependencies]
# work with Stripe https://www.payments.rs/
async-stripe = { version = "0.21.0", features = ["runtime-tokio-hyper"] }
# axum web framework https://github.com/tokio-rs/axum/
axum = "0.6.15"
axum-extra = { version = "0.7.3", features = ["cookie-private"] }
# password hashing for authentication purposes https://github.com/Keats/rust-bcrypt
bcrypt = "0.14.0"
# required for setting up CORS layer https://github.com/hyperium/http
http = "0.2.9"
# random generation - good for random password resets https://github.com/rust-random/rand
rand = "0.8.5"
# make POST requests to external services https://github.com/seanmonstar/reqwest
reqwest = "0.11.16"
# deserialize and serialize to/from JSON format https://github.com/serde-rs/serde
serde = { version = "1.0.160", features = ["derive"] }
# shuttle deps https://www.shuttle.rs/
shuttle-axum = "0.14.0"
shuttle-runtime = "0.14.0"
shuttle-secrets = "0.14.0"
shuttle-shared-db = { version = "0.14.0", features = ["postgres"] }
shuttle-static-folder = "0.14.0"
tokio = "1.27.0"
# interact with SQL databases https://github.com/launchbadge/sqlx
sqlx = { version = "0.6.3", features = ["runtime-tokio-native-tls", "postgres"] }
# required for setting up cookies https://github.com/time-rs/time
time = "0.3.20"
# required to get static files working with axum https://github.com/tower-rs/tower
tower = "0.4.13"
tower-http = { version = "0.4.0", features = ["cors", "fs"] }

Now we can get started!

Customers/Sales Records

To get started, we'll want to define some structs that will act as as the response or request type models that we want to use. If the request type or response type doesn't match the model the HTTP request will automatically fail, so we'll want to make sure we have everything we need. See below:

// src/customers.rs

// the Customer type which we can use as a response type (with JSON)
#[derive(Deserialize, sqlx::FromRow, Serialize)]
pub struct Customer {
pub firstname: String,
pub lastname: String,
pub email: String,
pub phone: String,
pub priority: i32,
}

// struct required for editing records
#[derive(Deserialize)]
pub struct ChangeRequest {
pub columnname: String,
pub new_value: String,
pub email: String,
}

// struct required for creating a record
#[derive(Serialize, Deserialize)]
pub struct NewCustomer {
pub first_name: String,
pub last_name: String,
pub email: String,
pub phone: String,
pub priority: i32,
pub user_email: String,
}

Then we'll want to create our endpoints! To illustrate below, we've created a short function to get all customers for a given user from the database. Let's look at what this function will look like:

// src/customers.rs

// retrieve all customers from the database
pub async fn get_all_customers(
    State(state): State<AppState>,
    Json(req): Json<UserRequest>,
) -> Result<Json<Vec<Customer>>, StatusCode> {
let Ok(customers) = sqlx::query_as::<_, Customer>("SELECT firstname, lastname, email, 
phone, priority FROM customers WHERE owner_id = (SELECT id FROM users WHERE email = $1)")
.bind(req.email)
.fetch_all(&state.postgres)
.await else {
    return Err(StatusCode::INTERNAL_SERVER_ERROR)
};

Ok(Json(customers))
}

This will be a POST request as it's more secure - the client side will send the user's email along with their authentication cookie which we will be looking at later. We can also create a function that only returns one particular customer by their given ID by using the fetch_one() method, which returns exactly 1 row and will ignore any additional rows, returning an error if something is wrong:

// customers.rs
// get a single customer from the database based on path ID
pub async fn get_one_customer(
  State(state): State<AppState>,
  Path(id): Path<i32>,
  Json(req): Json<UserRequest>,
) -> Result<Json<Customer>, StatusCode> {
let Ok(customer) = sqlx::query_as::<_, Customer>("SELECT firstname, lastname, email, phone, \
priority FROM customers WHERE owner_id = (SELECT id FROM users WHERE email = $1) AND id = $2")
.bind(req.email)
.bind(id)
.fetch_one(&state.postgres)
.await else {
    return Err(StatusCode::INTERNAL_SERVER_ERROR)
};

Ok(Json(customer))
}

As you can see, this function takes a "Path" type. That basically means that if we go to this localhost:8000/api/customers/1 for example, we will be able to see that it returns a record where the ID of the customer is 1 if it exists. We can create, edit and delete customers in a similar manner by writing the relevant SQL queries, binding our used variables and running it against the database connection:

// src/customers.rs
// create a customer record in the database
pub async fn create_customer(
    State(state): State<AppState>,
    Json(req): Json<NewCustomer>,
) -> Result<StatusCode, StatusCode> {
let Ok(_) = sqlx::query("INSERT INTO CUSTOMERS (first_name, last_name, email, phone, 
priority, owner_id) VALUES ($1, $2, $3, $4, $5, (SELECT id FROM users WHERE email = $6))")
.bind(req.firstname)
.bind(req.lastname)
.bind(req.email)
.bind(req.phone)
.bind(req.priority)
.bind(req.user_email)
.execute(&state.postgres)
.await else {
   return Err(StatusCode::INTERNAL_SERVER_ERROR)
};

    Ok(StatusCode::INTERNAL_SERVER_ERROR)
}

// edit a customer column
pub async fn edit_customer(
    State(state): State<AppState>,
    Path(id): Path<i32>,
    Json(req): Json<ChangeRequest>,
) -> Result<StatusCode, StatusCode> {
let Ok(_) = sqlx::query("UPDATE customers SET $1 = $2 WHERE owner_id = (SELECT user_id FROM users 
WHERE email = $3) AND id = $4")
.bind(req.columnname)
.bind(req.new_value)
.bind(req.email)
.bind(id)
.fetch_one(&state.postgres)
.await else {
    return Err(StatusCode::INTERNAL_SERVER_ERROR)
};

    Ok(StatusCode::OK)
}

// delete a customer
pub async fn destroy_customer(
    State(state): State<AppState>,
    Path(id): Path<i32>,
    Json(req): Json<UserRequest>,
) -> Result<StatusCode, StatusCode> {
    let Ok(_) = sqlx::query("DELETE FROM customers WHERE owner_id = (SELECT user_id FROM users
 WHERE email = $1) AND id = $2")
.bind(req.email)
.bind(id)
.execute(&state.postgres)
.await else {
    return Err(StatusCode::INTERNAL_SERVER_ERROR)
};

    Ok(StatusCode::OK)
}

Our users will need to be able to create sales deals records that use customers, and we can create them just like above by defining what our request and response models should look like, and then we can build our endpoints.

Let's define our response models:

// deals.rs
// the response type for getting all or one sales record
#[derive(Deserialize, Serialize, sqlx::FromRow)]
pub struct Deal {
    pub id: i32,
    pub estimate_worth: i32,
    pub status: String,
    pub closed: String,
    pub customer_name: String,
}

// the request type for getting data
#[derive(Deserialize)]
pub struct UserRequest {
    pub email: String,
}

// the request type for creating a new sales record
#[derive(Deserialize)]
pub struct NewDeal {
    pub estimatedworth: i32,
    pub cust_id: i32,
    pub useremail: String,
}

// the request type for changing the status of a sales record
#[derive(Deserialize)]
pub struct ChangeRequest {
    pub new_value: String,
    pub email: String,
}

Getting the sales records will be slightly more complicated than just getting the customers as we'll need to carry out an SQL join to be able to grab the customer name for the sales record - the customer name isn't stored on the sales record itself as the sales record is connected to the customer record, so we can easily grab it from the customer record:

pub async fn get_all_deals(
    State(state): State<AppState>,
    Json(req): Json<UserRequest>,
) -> Result<Json<Vec<Deal>>, impl IntoResponse> {
// the deals table is defined as "d"
// the customer table is defined as "c"
    match sqlx::query_as::<_, Deal>("SELECT 
d.id, 
d.estimate_worth, 
d.status, 
d.closed, 
(select concat(c.firstname, ' ', c.lastname) from customers WHERE id = d.customer_id) as 
customer_name FROM deals d LEFT JOIN customers c ON d.customer_id = c.id 
WHERE c.owner_id = (SELECT id FROM users WHERE email = $1)")
.bind(req.email)
.fetch_all(&state.postgres)
.await {
    Ok(res) => Ok(Json(res)),
    Err(err) => Err((StatusCode::INTERNAL_SERVER_ERROR, err.to_string()))
    }
}

As you can see, although the raw SQL query is a bit more complicated than our previous endpoints where we did a simple SELECT because of the LEFT JOIN, it is not too complicated. We are selecting columns from the deals table, then using a subquery to concatenate the first and last name of a customer from the customer table and add it to our query results.

Similarly, we can grab a single sales record from the database and return it at our endpoints by adding a WHERE condition for the deal ID (from the path):

pub async fn get_one_deal(
    State(state): State<AppState>,
    Path(id): Path<i32>,
    Json(req): Json<UserRequest>,
) -> Result<Json<Deal>, StatusCode> {
match sqlx::query_as::<_, DealDetailed>(
"SELECT 
d.id, 
d.estimate_worth, 
d.status, 
d.closed, 
(select concat(c.firstname, ' ', c.lastname) from customers WHERE id = d.customer_id) as 
customer_name FROM deals d LEFT JOIN customers c ON d.customer_id = c.id WHERE c.owner_id =
 (SELECT id FROM users WHERE email = $1) AND d.id = $2"
    )
.bind(req.email)
.bind(id)
.fetch_one(&state.postgres)
.await  {
        Ok(res) => Ok(Json(res)),
        Err(err) => Err((StatusCode::INTERNAL_SERVER_ERROR, err.to_string()))
    }
}

Thankfully, creating, editing and deleting sales records is not quite as difficult as the previous queries above! We can write the endpoints for them similarly to our API endpoints for our users' customers, as we don't need to reference any data from other tables.

// create a sales record
pub async fn create_deal(
    State(state): State<AppState>,
    Json(req): Json<NewDeal>,
) -> Result<StatusCode, StatusCode> {
let Ok(_) = sqlx::query("INSERT INTO DEALS (status, closed, customer_id, owner_id, 
estimate_worth) VALUES ('open', 'closed', $1, (SELECT id FROM users WHERE email = $2), $3)")
.bind(req.cust_id)
.bind(req.useremail)
.bind(req.estimatedworth)
.execute(&state.postgres)
.await else {
    return Err(StatusCode::INTERNAL_SERVER_ERROR)
};

Ok(StatusCode::OK)
}

// edit the status of a sales record (open, closed, awaiting response)
pub async fn edit_deal(
    State(state): State<AppState>,
    Path(id): Path<i32>,
    Json(req): Json<ChangeRequest>,
) -> Result<StatusCode, impl IntoResponse> {
match sqlx::query("UPDATE deals SET status = $1, last_updated = NOW() WHERE owner_id =
 (SELECT id FROM users WHERE email = $2) AND id = $3")
.bind(req.new_value)
.bind(req.email)
.bind(id)
.execute(&state.postgres)
.await {
    Ok(_) => Ok(StatusCode::OK),
    Err(err) => Err((StatusCode::INTERNAL_SERVER_ERROR, err.to_string()))
    }
}

// delete a customer record
pub async fn destroy_deal(
    State(state): State<AppState>,
    Path(id): Path<i32>,
    Json(req): Json<UserRequest>,
) -> Result<StatusCode, StatusCode> {
let Ok(_) = sqlx::query("DELETE FROM deals WHERE
 owner_id = (SELECT user_id FROM users WHERE email = $1) AND id = $2")
.bind(req.email)
.bind(id)
.execute(&state.postgres)
.await else {
    return Err(StatusCode::INTERNAL_SERVER_ERROR)
};

Ok(StatusCode::OK)
}

Mail

For mail, we will be using Mailgun, which has a generous free plan that we can use to keep a mailing list and easily send mail to new users as well as our members. This part will assume you have a To start, we'll want to make a struct that takes an email address that we can deserialize to and from JSON with Serde:

// src/mail.rs
// define a struct that takes an email
#[derive(Deserialize, Serialize)]
pub struct EmailRequest {
    email: String,
}

Once we've defined our struct, we can create a function that makes a hashmap of parameters for our POST request to the Mailgun API, and finally our own API endpoint for being able to subscribe people to our Mailgun mailing list:

// src/mail.rs
pub async fn subscribe(
    State(state): State<AppState>,
    Json(req): Json<EmailRequest>,
) -> Result<StatusCode, StatusCode> {
// initialise a reqwest non-blocking client
let ctx = reqwest::Client::new();

// create a string for the correct API endpoint we'll be posting to
let api_endpoint = format!("https://api.mailgun.net/v3/lists/mail@{}/members", &state.mailgun_url);

let params = sub_params(req.email);
let post = ctx
.post(api_endpoint)
.basic_auth("api", Some(&state.mailgun_key))
.form(&params);

match post.send().await {
    Ok(_) => Ok(StatusCode::OK),
    Err(_) => Err(StatusCode::INTERNAL_SERVER_ERROR),
  }
}

// create a hashmap of headers that we'll be using in our POST request
fn sub_params(recipient: String) -> HashMap<&'static str, String> {
let mut params = HashMap::new();

params.insert("address", recipient);
params.insert("subscribed", "True".to_string());

params
}

Taking Payments

For taking payments, we can use the Stripe API to create subscriptions easily by using the async-stripe library. We'll want to start by initialising a struct for our API endpoint to be able to take payment information:

// src/payments.rs
#[derive(Deserialize, Serialize)]
pub struct PaymentInfo {
name: String,
email: String,
card: String,
expyear: i32,
expmonth: i32,
cvc: String,
}

Next, we'll want to write our parameter list for our subscription plan (you'll need to create a subscription plan with a recurring monthly price on Stripe and grab the product ID, which we did earlier):

// src/payments.rs
fn create_checkout_params(customer_id: CustomerId) -> CreateSubscription<'static> {
// create a new subscription object with the customer ID (passed in from endpoint function)
    let mut params = CreateSubscription::new(customer_id);
    params.items = Some(vec![CreateSubscriptionItems {
// price ID goes below
        price: Some(<PRICE_ID_GOES_HERE>.to_string()),
        ..Default::default()
    }]);
    params.expand = &["items", "items.data.price.product", "schedule"];

    params
}

We will want functions for creating a Customer and Payment Method object on Stripe. This part is quite simple - we can just add whatever parameters we need to for the respective objects and then just call everything else as Default, as below:

pub async fn create_customer(
    ctx: Client,
    name: String, 
    email: String) -> Customer {

Customer::create(
&ctx,
CreateCustomer {
    name: Some(name),
    email: Some(email),
    ..Default::default()
        },
    )
.await
.unwrap()
}

pub async fn create_payment_method(
    ctx: Client, 
    card: String, 
    expyear: i32, 
    expmonth: i32, 
    cvc: String) -> PaymentMethod {
PaymentMethod::create(
&ctx,
CreatePaymentMethod {
    type_: Some(PaymentMethodTypeFilter::Card),
    card: Some(CreatePaymentMethodCardUnion::CardDetailsParams(
        CardDetailsParams {
        number: card,
        exp_year: expyear,
        exp_month: expmonth,
        cvc: Some(cvc),
        },
     )),
     ..Default::default()
     },
)
.await
.unwrap()
}

Then we can create the final function that will act as the endpoint:

// src/payments.rs
pub async fn create_checkout(State(state): State<AppState>, Json(req): Json<PaymentInfo>)
 -> Result<StatusCode, StatusCode> {
let ctx = stripe::Client::new(&state.stripe_key);

// Create a new customer
let customer = create_customer(ctx, req.name, req.email).await;

let payment_method = {
// create payment method
let pm = create_payment_method(ctx, req.card, req.expyear, req.expmonth, req.cvc).await;

// attach the payment method to our customer
PaymentMethod::attach(
    &ctx,
    &pm.id,
    AttachPaymentMethod {
        customer: customer.id.clone(),
        },
    )
    .await
    .unwrap();

pm
};

// initialise checkout parameters using the id of the customer we created
let mut params = create_checkout_params(customer.id);

// make the default payment method for the parameters the payment method we created earlier
params.default_payment_method = Some(&payment_method.id);

// attempt to connect to Stripe and actually process the subscription creation
// if it fails, return internal server error
let Ok(_) = Subscription::create(&ctx, params).await else {
   return Err(StatusCode::INTERNAL_SERVER_ERROR)
};

Ok(StatusCode::OK)
}

Now if we try and send a POST request to it with the relevant form data, it should return a subscription on our Stripe account! We can then redirect the user (on the frontend) to either a Success page or Failed page depending on if the subscription attempt was successful or not.

Auth

We'll want to start by creating the structs we want to use. We'll need a struct that will act as a request type type for user registration details and then a similar struct for user logins:

// src/auth.rs
#[derive(Deserialize)]
pub struct RegisterDetails {
name: String,
email: String,
password: String,
}

#[derive(Deserialize)]
pub struct LoginDetails {
email: String,
password: String,
}

Like the other examples as previously, the following functions below will take a State and a Json type and will return something that resolves to a response in Axum. Let's have a look:

// src/auth.rs
pub async fn register(
    State(state): State<AppState>,
    Json(newuser): Json<RegisterDetails>,
) -> impl IntoResponse {
// attempt to hash the password from request body - this is required as 
// otherwise plaintext passwords in the database is unsafe
let hashed_password = bcrypt::hash(newuser.password, 10).unwrap();

// set up query
let query = sqlx::query("INSERT INTO users (name, email, password) values ($1, $2, $3)")
.bind(newuser.name)
.bind(newuser.email)
.bind(hashed_password)
.execute(&state.postgres);

// if the query is OK, return the Created status code along with a response to confirm it
// if not, return a Bad Request status code along with the error code
match query.await {
Ok(_) => (StatusCode::CREATED, "Account created!".to_string()).into_response(),
Err(e) => (
    StatusCode::BAD_REQUEST,
    format!("Something went wrong: {e}"),
).into_response(),
}
}

We can also write our login function similarly; however, the login function will also take a type of PrivateCookieJar from axum_extra, which is an abstraction for you to be able to easily handle cookies safely. Let's have a look at what that would look like:

// src/auth.rs
pub async fn login(
    State(state): State<AppState>,
    jar: PrivateCookieJar,
    Json(login): Json<LoginDetails>,
) -> Result<(PrivateCookieJar, StatusCode), StatusCode> {
// attempt to find a user based on what the request body email is
let query = sqlx::query("SELECT * FROM users WHERE email = $1")
.bind(&login.email)
.fetch_one(&state.postgres);

// if the query is OK, attempt to verify bcrypt hash
match query.await {
Ok(res) => {
if bcrypt::verify(login.password, res.get("password")).is_err() {
    return Err(StatusCode::BAD_REQUEST);
}

// if the hash matches, create a session ID and attempt to write a session to the database
let session_id = rand::random::<u64>().to_string();

// create the session entry in our database table
sqlx::query("INSERT INTO sessions (session_id, user_id) VALUES ($1, $2) ON CONFLICT (user_id)
 DO UPDATE SET session_id = EXCLUDED.session_id")
.bind(&session_id)
.bind(res.get::<i32, _>("id"))
.execute(&state.postgres)
.await
.expect("Couldn't insert session :(");

// build a cookie and add it to the cookiejar as a response, which sends a cookie to the user
// we will be using this later on to validate a user session
let cookie = Cookie::build("foo", session_id)
.secure(true)
.same_site(SameSite::Strict)
.http_only(true)
.path("/")
.max_age(Duration::WEEK)
.finish();

// return cookie and OK status
Ok((jar.add(cookie), StatusCode::OK))
}

// return only Bad Request - this is somewhat vague, but helps deter would-be
// hackers and is good for security as we know what the control flow is
    Err(_) => Err(StatusCode::BAD_REQUEST),
    }
}

Now let's look at validating a session. It's not too difficult: we attempt to grab the cookie with the name that we declared for the session and attempt to match it against what's in our database. If it matches, then we allow the user to continue - if not, we return 403 Forbidden.

// src/auth.rs
pub async fn validate_session<B>(
    jar: PrivateCookieJar,
    State(state): State<AppState>,
    request: Request<B>,
    next: Next<B>,
) -> (PrivateCookieJar, Response) {
// grab token value by mapping the token and getting the value 
let Some(cookie) = jar.get("foo").map(|cookie| cookie.value().to_owned()) else {
// if the cookie doesn't exist or has no value, print a line and return Forbidden
println!("Couldn't find a cookie in the jar");
return (jar,(StatusCode::FORBIDDEN, "Forbidden!".to_string()).into_response())
};

// set up the query to match session against what our cookie session ID value is
let find_session = sqlx::query("SELECT * FROM sessions WHERE session_id = $1")
.bind(cookie)
.execute(&state.postgres)
.await;

// if it matches, return the jar and run the request the user wants to make
// if not, return 403 Forbidden
match find_session {
Ok(_) => (jar, next.run(request).await),
Err(_) => (
    jar,
    (StatusCode::FORBIDDEN, "Forbidden!".to_string()).into_response(),
    ),
    }
}

Some users will also want to be able to log out. All we need to do for that is to write a function to set up an SQL query to delete from the sessions database table where the session ID is the value of the user's cookie, then return the cookie deletion like so:

// src/auth.rs
pub async fn logout(
    State(state): State<AppState>,
    jar: PrivateCookieJar,
) -> Result<PrivateCookieJar, StatusCode> {
let Some(cookie) = jar.get("sessionid").map(|cookie| cookie.value().to_owned()) else {
     return Ok(jar)
};

let query = sqlx::query("DELETE FROM sessions WHERE session_id = $1")
.bind(cookie)
.execute(&state.postgres);

match query.await {
Ok(_) => Ok(jar.remove(Cookie::named("foo"))),
Err(_) => Err(StatusCode::INTERNAL_SERVER_ERROR),
}
}

Now our auth functions are pretty much done!

Router

Now we can combine all of our functions! We can finally combine all of our functions into a final API router that we can use, like so:

// src/router.rs
pub fn create_api_router(state: AppState) -> Router {

let cors = CorsLayer::new()
.allow_credentials(true)
.allow_methods(vec![Method::GET, Method::POST, Method::PUT, Method::DELETE])
.allow_headers(vec![ORIGIN, AUTHORIZATION, ACCEPT])
.allow_origin(&state.domain_url.parse().unwrap());

let payments_router = Router::new().route("/pay", post(create_checkout));

let customers_router = Router::new()
.route("/", post(get_all_customers))
.route(
    "/:id",
    post(get_one_customer)
    .put(edit_customer)
    .delete(destroy_customer),
)
        .route("/create", post(create_customer));

let deals_router = Router::new()
.route("/", post(get_all_deals))
.route(
      "/:id",
      post(get_one_deal)
     .put(edit_deal)
     .delete(destroy_deal),
)
.route("/create", post(create_deal));

let auth_router = Router::new()
.route("/register", post(register))
.route("/login", post(login))
.route("/logout", get(logout));

Router::new()
.nest("/customers", customers_router)
.nest("/deals", deals_router)
.nest("/payments", payments_router)
.nest("/auth", auth_router)
.route("/subscribe", post(subscribe))
.with_state(state)
.layer(cors)
}

Then we can just put it back into our main function, like so:

// src/main.rs
#[shuttle_runtime::main]
async fn axum(
    #[shuttle_shared_db::Postgres] postgres: PgPool,
    #[shuttle_secrets::Secrets] secrets: shuttle_secrets::SecretStore,
    #[shuttle_static_folder::StaticFolder] public: PathBuf
) -> shuttle_axum::ShuttleAxum {

sqlx::migrate!().run(&postgres).await.expect("Something went wrong while running migrations :(");

    let (stripe_key, mailgun_key, mailgun_url, domain) = grab_secrets(secrets);

    let state = AppState {
        postgres,
        stripe_key,
        mailgun_key,
        mailgun_url,
        domain,
        key: Key::generate(),
    };

        let router = Router::new()
            .nest("/api", api_router)
            .fallback_service(get(|req| async move {
            match ServeDir::new(public).oneshot(req).await {
                Ok(res) => res.map(boxed),
                Err(err) => Response::builder()
                   .status(StatusCode::INTERNAL_SERVER_ERROR)
                 .body(boxed(Body::from(format!("error: {err}"))))
                 .expect("error response"),
         }
     }));


    Ok(router.into())
}

fn grab_secrets(secrets: shuttle_secrets::SecretStore) -> (String, String, String, String) {
    let stripe_key = secrets
        .get("STRIPE_KEY")
        .expect("Couldn't get STRIPE_KEY, did you remember to set it in Secrets.toml?");

    let mailgun_key = secrets
        .get("MAILGUN_KEY")
        .expect("Couldn't get MAILGUN_KEY, did you remember to set it in Secrets.toml?");

    let mailgun_url = secrets
        .get("MAILGUN_URL")
        .expect("Couldn't get MAILGUN_URL, did you remember to set it in Secrets.toml?");

    let domain = secrets
        .get("DOMAIN_URL")
        .expect("Couldn't get DOMAIN_URL, did you remember to set it in Secrets.toml?");

    (stripe_key, mailgun_key, mailgun_url, domain)
}

Combining Frontend and Backend

With Next.js, this part is quite simple compared to the rest of the webapp: all we need to do is to compile our Next.js frontend into the Rust backend. For that, we simply need to make sure our next.config.js file looks like so (in the root of the project directory):

// next.config.js
/** @type {import('next').NextConfig} */
const nextConfig = {
  reactStrictMode: true,
  output: "export",
  trailingSlash: true,
  distDir: "./api/public",
// ... any other things you'd like to add 
}

module.exports = nextConfig

Now all you need to do is write npm run build in your terminal and it should do all the work for you! Now you can use your frontend with your backend without any hassle. You can even run npm run build while your Rust backend is running and it'll re-compile the assets for you, allowing you to not need to fully re-run your app.

You'll probably also want to set up an npm script to run the backend from npm instead of having to go into your backend folder every single time, as well as a script for easy deployment. We can set that up like so:

 // package.json
"scripts": {
//... your other scripts
    "full": "npm run build && cargo shuttle run --working-directory api",
    "deploy": "npm run build && cargo shuttle deploy --working-directory api --allow-dirty"
//... your other scripts
}

Deployment

Now we just need to run npm run deploy and if there's no errors, Shuttle will deploy our SaaS to the live servers! You should get a deployment ID, as well as a database connection string and a list of secrets and any other resources your app uses (in our case, the public folder).

Rust & Web Dev with Josh Mo

Finishing Up

Thank you for reading! I hope you can take away some ideas about how to build your own SaaS in Rust from start to finish, and deploy it with one command. If you are looking to bootstrap this web app so you can extend it with your own ideas, you can easily do so by running npx create-shuttle-app --fullstack-examples saas.

Rust is a brilliant language for writing memory safe programs with expressive syntax at a lower memory footprint that lowers your overheads and lets you deploy more for less, which is great for (potential) SaaS makers.`