Full-Stack Rust Project using Tide and Postgres
Building a production-ready, full-stack CRUD web application in Rust utilizing the lightweight Tide web framework, SQLx for asynchronous PostgreSQL database operations, and Tera for server-side HTML template rendering. This project also integrates Google OAuth for user authentication and session management.
1. Why Do We Need This? (Problem Statement & Status Quo)
When building modern web applications, developers often face a dilemma: choose a bloated framework with a steep learning curve or cobble together disparate libraries. In the Rust ecosystem, heavy frameworks like Actix-web are extremely popular, but smaller, highly modular micro-frameworks like Tide provide an ergonomic, async-first approach with minimal boilerplate.
Pairing Tide with SQLx (compile-time checked queries without an ORM overhead) and Tera (a Jinja2-inspired template engine for Rust) creates a robust, performant full-stack stack capable of handling authentication, database migrations, and dynamic HTML rendering efficiently.
2. Core Mental Model & Architecture Flow
The application follows a clean Controller-Handler-View separation of concerns, decoupling HTTP routing, business logic/database interaction, and presentation rendering.
Real-Life Analogy: The Restaurant Kitchen & Front-of-House
- The Main Router (
main.rs): Acts as the Host/Maître d' who greets customers at the door, checks their reservation (sessions/OAuth), and guides them to the correct table. - The Controllers (
controllers/): Act as Waiters who take the order from the customer (parse HTTP JSON bodies, extract query parameters) and pass the ticket to the kitchen. - The Handlers (
handlers/): Act as Line Cooks who interact directly with the pantry (PostgreSQL database via SQLx) to fetch, create, update, or delete raw ingredients (Dinosaur records). - The Views (
views/): Act as the Plating Station where the cooked data is arranged onto fine porcelain dinnerware (Tera HTML templates) before being served back to the browser.
Request-Response Execution Pipeline
3. Project Structure & Directory Layout
The project source code (src/) is strictly modularized to maintain clean boundaries between routing, view rendering, request controllers, and database handlers.
4. Environment Configuration & Database Setup
Before launching the server, the PostgreSQL database and environment variables must be properly configured.
Environment Variables (.env)
DATABASE_URL=postgres://username:password@localhost:5432/tide
TIDE_SECRET=some_random_secure_secret_string_for_sessions
GOOGLE_CLIENT_ID=your_google_oauth_client_id.apps.googleusercontent.com
GOOGLE_CLIENT_SECRET=your_google_oauth_client_secret
Database Schema (schema.sql)
Execute the following SQL commands in your PostgreSQL instance to create the required database and table structure:
-- Create database
CREATE DATABASE tide;
-- Connect to 'tide' and create the dinos table
CREATE TABLE IF NOT EXISTS dinos (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
name VARCHAR(255) NOT NULL,
weight INT NOT NULL,
diet VARCHAR(255) NOT NULL,
user_id VARCHAR(255)
);
5. State Management & Server Initialization (main.rs)
The application state is shared across async handlers using an initialized struct containing the database connection pool, the Tera template engine instance, and OAuth credentials.
// Core application state shared across Tide endpoints
#[derive(Clone)]
pub struct State {
db: sqlx::PgPool,
tera: tera::Tera,
// OAuth client state omitted for brevity
}
#[async_std::main]
async fn main() -> Result<(), Box<dyn std::error::Error>> {
// Load environment variables from .env
dotenv::dotenv().ok();
// Initialize logging middleware
tide::log::start();
let database_url = std::env::var("DATABASE_URL")
.expect("Missing DATABASE_URL environment variable");
// Create SQLx PostgreSQL connection pool
let db_pool = sqlx::PgPool::connect(&database_url).await?;
// Initialize Tera templates from the templates directory
let tera = tera::Tera::new("templates/**/*")?;
// Wrap dependencies in application state
let state = State { db: db_pool, tera };
// Initialize Tide application with state
let mut app = tide::with_state(state);
// Register routes
app.at("/").get(controllers::views::index);
app.at("/dinos/new").get(controllers::views::new_form).post(controllers::dinos::create);
app.at("/dinos").get(controllers::dinos::list);
app.at("/dinos/:id").get(controllers::dinos::get_by_id);
app.at("/dinos/:id/edit").get(controllers::views::edit_form).put(controllers::dinos::update);
app.at("/dinos/:id").delete(controllers::dinos::delete);
// Start server listener on port 8080
app.listen("127.0.0.1:8080").await?;
Ok(())
}
6. Controller Layer: Request Orchestration
Controllers act as intermediaries. They extract parameters, parse JSON payloads from incoming HTTP requests, pull user session tokens, and invoke the underlying database handlers.
Example Controller Implementation (src/controllers/dinos.rs)
use tide::{Request, Response, StatusCode};
use crate::State;
// Create a new dinosaur record
pub async fn create(mut req: Request<State>) -> tide::Result {
let state = req.state();
// Deserialize incoming JSON body into Dinosaur struct
let dino: crate::handlers::dinos::NewDino = req.body_json().await?;
// Extract user session if authenticated
let user_id = req.session().get::<String>("user_id").unwrap_or_default();
// Call database handler
match crate::handlers::dinos::create_dino(&state.db, dino, user_id).await {
Ok(created) => {
let mut res = Response::new(StatusCode::Created);
res.set_body(tide::utils::json(&created)?);
Ok(res)
}
Err(_) => Ok(Response::new(StatusCode::InternalServerError))
}
}
// Fetch all dinosaur records
pub async fn list(req: Request<State>) -> tide::Result {
let state = req.state();
let dinos = crate::handlers::dinos::list_dinos(&state.db).await?;
let mut res = Response::new(StatusCode::Ok);
res.set_body(tide::utils::json(&dinos)?);
Ok(res)
}
7. Handler & Database Layer: SQLx Queries
The handler layer contains raw asynchronous SQL queries executed safely via sqlx. Compile-time query verification ensures type safety between PostgreSQL columns and Rust structs.
Database Operations (src/handlers/dinos.rs)
use sqlx::{FromRow, PgPool};
use uuid::Uuid;
use serde::{Serialize, Deserialize};
#[derive(Serialize, Deserialize, FromRow, Clone)]
pub struct Dino {
pub id: Uuid,
pub name: String,
pub weight: i32,
pub diet: String,
pub user_id: Option<String>,
}
#[derive(Deserialize)]
pub struct NewDino {
pub name: String,
pub weight: i32,
pub diet: String,
}
// Insert a new dinosaur into PostgreSQL
pub async fn create_dino(pool: &PgPool, dino: NewDino, user_id: String) -> Result<Dino, sqlx::Error> {
let rec = sqlx::query_as!(
Dino,
r#"
INSERT INTO dinos (name, weight, diet, user_id)
VALUES ($1, $2, $3, $4)
RETURNING id, name, weight, diet, user_id
"#,
dino.name,
dino.weight,
dino.diet,
Some(user_id)
)
.fetch_one(pool)
.await?;
Ok(rec)
}
// Retrieve all dinosaur records from PostgreSQL
pub async fn list_dinos(pool: &PgPool) -> Result<Vec<Dino>, sqlx::Error> {
let dinos = sqlx::query_as!(
Dino,
r#"SELECT id, name, weight, diet, user_id FROM dinos"#
)
.fetch_all(pool)
.await?;
Ok(dinos)
}
// Retrieve a single dinosaur record by UUID
pub async fn get_dino_by_id(pool: &PgPool, dino_id: Uuid) -> Result<Option<Dino>, sqlx::Error> {
let dino = sqlx::query_as!(
Dino,
r#"SELECT id, name, weight, diet, user_id FROM dinos WHERE id = $1"#,
dino_id
)
.fetch_optional(pool)
.await?;
Ok(dino)
}
// Delete a dinosaur record by UUID
pub async fn delete_dino(pool: &PgPool, dino_id: Uuid) -> Result<bool, sqlx::Error> {
let result = sqlx::query!(
r#"DELETE FROM dinos WHERE id = $1"#,
dino_id
)
.execute(pool)
.await?;
Ok(result.rows_affected() > 0)
}
8. View Layer & Template Rendering
The view controllers leverage Tera to inject dynamic data retrieved from the database into HTML templates before serving them to the client browser.
use tide::{Request, Response, StatusCode};
use tera::Context;
use crate::State;
// Render the main index page with all dinosaurs listed
pub async fn index(req: Request<State>) -> tide::Result {
let state = req.state();
// Fetch all dinos from database
let dinos = crate::handlers::dinos::list_dinos(&state.db).await.unwrap_or_default();
// Set up Tera template context
let mut ctx = Context::new();
ctx.insert("dinos", &dinos);
ctx.insert("title", "Dinosaur Inventory");
// Render index.html template
let body = state.tera.render("index.html", &ctx)?;
let mut res = Response::new(StatusCode::Ok);
res.set_content_type(tide::http::mime::HTML);
res.set_body(body);
Ok(res)
}
9. Tooling & Technology Stack Comparison
10. Summary and Key Takeaways
- Modular Rust Design: Clean separation of routing (
main.rs), request validation (controllers/), database queries (handlers/), and presentation (views/) produces maintainable enterprise code. - Lightweight Routing with Tide: Tide provides an ergonomic, expressive macro-free API for defining async HTTP endpoints and middleware integration.
- Compile-Time Safety via SQLx: Using
sqlx::query_as!catches SQL syntax and type errors at compile time rather than during runtime production failures. - Server-Side Rendering with Tera: Tera empowers Rust applications to serve dynamic HTML pages efficiently without requiring a separate single-page application (SPA) frontend framework.
- Full CRUD Capabilities: The application successfully implements Create, Read, Update, and Delete operations backed by a persistent PostgreSQL database.
- Session & OAuth Integration: Secure session cookies combined with Google OAuth enable robust user identification across protected routes.
- Production Readiness: Docker support and environment-based configuration (
.env) make deployment straightforward across cloud platforms.