Building a SaaS with Rust and Next.js (2023)

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

Building a SaaS with Rust and Next.js (1)

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!):

(Video) 5 Micro SaaS Ideas You Can Start In 2023 (...and Replace Your Job)

Building a SaaS with Rust and Next.js (2)

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:

Building a SaaS with Rust and Next.js (3)

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:

Building a SaaS with Rust and Next.js (4)

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 tableCREATE 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 tableCREATE 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 tableCREATE 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 tableCREATE 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);


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.


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:

(Video) Working on Saas Side Project (Next.js, Tailwind, Prisma, Planetscsale, AI Dall-E)

[package]name = "my-app"version = "0.1.0"edition = "2021"publish = false# See more keys and their definitions at[dependencies]# work with Stripe = { version = "0.21.0", features = ["runtime-tokio-hyper"] }# axum web framework = "0.6.15"axum-extra = { version = "0.7.3", features = ["cookie-private"] }# password hashing for authentication purposes = "0.14.0"# required for setting up CORS layer = "0.2.9"# random generation - good for random password resets = "0.8.5"# make POST requests to external services = "0.11.16"# deserialize and serialize to/from JSON format = { version = "1.0.160", features = ["derive"] }# shuttle deps = "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 = { version = "0.6.3", features = ["runtime-tokio-native-tls", "postgres"] }# required for setting up cookies = "0.3.20"# required to get static files working with axum = "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/ 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/ retrieve all customers from the databasepub 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( 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:

// get a single customer from the database based on path IDpub 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( 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/ create a customer record in the databasepub 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( else { return Err(StatusCode::INTERNAL_SERVER_ERROR)}; Ok(StatusCode::INTERNAL_SERVER_ERROR)}// edit a customer columnpub 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( else { return Err(StatusCode::INTERNAL_SERVER_ERROR)}; Ok(StatusCode::OK)}// delete a customerpub 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( 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:

// 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.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 = WHERE c.owner_id = (SELECT id FROM users WHERE email = $1)").bind( { 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.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 = WHERE c.owner_id = (SELECT id FROM users WHERE email = $1) AND = $2" ).bind( { Ok(res) => Ok(Json(res)), Err(err) => Err((StatusCode::INTERNAL_SERVER_ERROR, err.to_string())) }}
(Video) Build an AI-driven SaaS Application: FULLSTACK Tutorial with Python, React, and AWS

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 recordpub 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( { Ok(_) => Ok(StatusCode::OK), Err(err) => Err((StatusCode::INTERNAL_SERVER_ERROR, err.to_string())) }}// delete a customer recordpub 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( else { return Err(StatusCode::INTERNAL_SERVER_ERROR)};Ok(StatusCode::OK)}


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/ 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.rspub async fn subscribe( State(state): State<AppState>, Json(req): Json<EmailRequest>,) -> Result<StatusCode, StatusCode> {// initialise a reqwest non-blocking clientlet ctx = reqwest::Client::new();// create a string for the correct API endpoint we'll be posting tolet api_endpoint = format!("{}/members", &state.mailgun_url);let params = sub_params(;let post ="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 requestfn 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/[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.rsfn 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", "", "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.rspub 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 customerlet customer = create_customer(ctx,,;let payment_method = {// create payment methodlet pm = create_payment_method(ctx, req.card, req.expyear, req.expmonth, req.cvc).await;// attach the payment method to our customerPaymentMethod::attach( &ctx, &, AttachPaymentMethod { customer:, }, ) .await .unwrap();pm};// initialise checkout parameters using the id of the customer we createdlet mut params = create_checkout_params(;// make the default payment method for the parameters the payment method we created earlierparams.default_payment_method = Some(&;// attempt to connect to Stripe and actually process the subscription creation// if it fails, return internal server errorlet 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.


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/[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:

(Video) The VANS stack is THE choice for building a SaaS

// src/auth.rspub 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 unsafelet hashed_password = bcrypt::hash(newuser.password, 10).unwrap();// set up querylet query = sqlx::query("INSERT INTO users (name, email, password) values ($1, $2, $3)").bind(;// 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 codematch 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.rspub 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 islet query = sqlx::query("SELECT * FROM users WHERE email = $1").bind(&;// if the query is OK, attempt to verify bcrypt hashmatch 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 databaselet session_id = rand::random::<u64>().to_string();// create the session entry in our database tablesqlx::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 sessionlet 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 statusOk((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.rspub 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 Forbiddenprintln!("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 islet 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 Forbiddenmatch find_session {Ok(_) => (jar,,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.rspub 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!


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.rspub 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/[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) =>, 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}


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).

(Video) Next.js 13… this changes everything

Building a SaaS with Rust and Next.js (5)

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.


Is rust faster than node JS? ›

Compared to Python and Javascript (NodeJS), the Rust services are more performant, use less CPU, use less memory, and can handle far more requests per second. To give you a rough ballpark, our Python services average about 50 req/s, NodeJS around 100 req/s, and Rust hits about 690 req/s.

Can I use Next.js instead of node JS? ›

Node. js is ideal for fast and scalable server-side and networking applications, while Next. js provides a robust framework for building high-performing React-based web applications with server-side rendering. Ultimately, the best option for your next web project will depend on your specific needs and requirements.

Does Next.js need a server? ›

By default, Next.js includes its own server with next start . If you have an existing backend, you can still use it with Next.js (this is not a custom server).

What does Next.js actually do? ›

Next.js is a React framework that gives you building blocks to create web applications. By framework, we mean Next.js handles the tooling and configuration needed for React, and provides additional structure, features, and optimizations for your application.

Is Rust just as fast as C++? ›

In a nutshell, while Rust code and C++ code are comparable in terms of overall speed and performance, Rust often outranks C++ in multiple instances when we consider unbiased benchmarking.

Is Rust really as fast as C? ›

Rust incorporates a memory ownership model enforced at a compile time. Since this model involves zero runtime overhead, programs written in Rust are not only memory-safe but also fast, leading to performance comparable to C and C++.

Can Next.js be used for full stack? ›

Next. js is a flexible React framework that gives you building blocks to create fast web applications. It is often called the fullstack React framework as it makes it possible to have both frontend and backend applications on the same codebase doing so with serverless functions.

Can I use Next.js for backend only? ›

One of the main features of Next. js is its versatility, since, although it is technically a library for frontend development, it can also be considered as a backend tool, since it has server-side rendering capabilities.

Can Next.js run serverless? ›

Since v8, Next. js has included Serverless Mode. With Serverless Mode in Next. js, developers can continue to use the developer-friendly, integrated environment of Next.

Is Next.js good for web applications? ›

Next. js is a popular React-based web framework that has gained popularity and a growing community in recent years. It's a powerful tool for building fast and SEO-friendly web applications with dynamic pages that work great on mobile devices.

Should I use Next.js for frontend? ›

Next. js is a front-end framework that makes it easy to build fast websites with React—which is a free and open-source front-end JavaScript library for building user interfaces based on UI components.

Why is Next.js so great? ›

One of the main benefits of Next. js is that it enables server-side rendering. This means the server can generate the HTML for a page and send it to the client, rather than the client generating the HTML using JavaScript. This can improve the performance and SEO of your app.

Is Next.js best for SEO? ›

Next. js provides better SEO performance by rendering web pages on the server (server-side) instead of rendering in the browser (client-side). Server-side rendering allows search engine crawlers and bots to scan and index web pages, detect metadata, and properly understand the information a website contains.

Will Rust eventually replace C++? ›

There are several reasons why Rust will replace C++ in the future. First, Rust is a newer language and thus has many modern features that C++ lacks. For example, Rust has a powerful type system that can prevent many types of errors at compile time.

Is Rust programming language dying? ›

Rust is hardly dead. It is one of the fastest growing programming languages and has been ranked as the most liked language by its users for two years running in StackOverflow surveys.

Should I learn Rust or Julia? ›

Rust's strong static typing and memory safety make it a good choice for tasks that require low-level control and performance, such as systems programming or high-concurrency applications. Julia is often used for numerical and scientific computing, as well as machine learning and data analysis.

How fast is Rust compared to js? ›

Input: 500000
3 more rows
May 4, 2023

What is faster than node JS? ›

Out of the gate, Go beats Node. js in terms of scalability because it supports concurrency, which helps handle side-by-side tasks. Go can manage 1000 concurrent requests per second, making Go superior.

Is Dotnet faster than Nodejs? ›

NET can boast slightly higher performance characteristics. At the same time, Node. js is more lightweight, so you have to consider what is more important to you.

Why Rust is faster than JavaScript? ›

The major benefit of the Rust implementation is the low memory footprint, all the extra RAM can be used for things like caching and distributed in-memory stores. That means it can be even faster in production by reducing I/O overhead, which is probably a bigger win than the modest CPU performance gains.


1. Build your entire tech stack in Rust
(Let's Get Rusty)
2. My Ultimate Tech Stack for 2023
3. Why I'm moving my side project from Vercel to AWS
(Web Dev Cody)
4. This Is The PERFECT Tech Stack For a SaaS Product
(Simon Høiberg)
5. Tailwind CSS is the worst…
6. Did RSCs Really Turn React Into PHP?
(Jack Herrington)


Top Articles
Latest Posts
Article information

Author: Jonah Leffler

Last Updated: 09/10/2023

Views: 5609

Rating: 4.4 / 5 (65 voted)

Reviews: 80% of readers found this page helpful

Author information

Name: Jonah Leffler

Birthday: 1997-10-27

Address: 8987 Kieth Ports, Luettgenland, CT 54657-9808

Phone: +2611128251586

Job: Mining Supervisor

Hobby: Worldbuilding, Electronics, Amateur radio, Skiing, Cycling, Jogging, Taxidermy

Introduction: My name is Jonah Leffler, I am a determined, faithful, outstanding, inexpensive, cheerful, determined, smiling person who loves writing and wants to share my knowledge and understanding with you.