Debug School

rakesh kumar
rakesh kumar

Posted on

Clean Rust Backend Folder Structure

Clean Rust Backend Folder Structure (handlers / services / repos)
React CRUD Application with Rust (Axum) Backend and Laravel Blade Integration

Clean Rust Backend Folder Structure (handlers / services / repos)

What Each Folder Means (Simple & Practical)

handlers/ (HTTP layer)

Purpose: Handle HTTP details only

Parse request body/query/path params

Call the service layer

Convert service result into HTTP response
Enter fullscreen mode Exit fullscreen mode
Keep handlers thin.
Enter fullscreen mode Exit fullscreen mode

services/ (Business logic layer)

Purpose: Rules + workflows

Validate business rules (not just syntax)

Decide what repo calls are needed

Orchestrate operations (create user, send event, etc.)
Enter fullscreen mode Exit fullscreen mode
This is the “brain” of your application.
Enter fullscreen mode Exit fullscreen mode

repos/ (Data access layer)

Purpose: Database/Cache access only

CRUD queries

Transactions

Mapping DB rows to models
Enter fullscreen mode Exit fullscreen mode

No business rules here.
Enter fullscreen mode Exit fullscreen mode

Step-by-Step Implementation Example (Minimal, Realistic)

Below is a small example of a User Create + Get API using this structure.

1) models/user.rs

use serde::Serialize;

#[derive(Debug, Clone, Serialize)]
pub struct User {
    pub id: String,
    pub name: String,
    pub email: String,
}
Enter fullscreen mode Exit fullscreen mode

2) dto/user_dto.rs (request/response bodies)

use serde::{Deserialize, Serialize};

#[derive(Debug, Deserialize)]
pub struct CreateUserRequest {
    pub name: String,
    pub email: String,
}

#[derive(Debug, Serialize)]
pub struct ApiResponse<T> {
    pub ok: bool,
    pub data: Option<T>,
    pub error: Option<String>,
}
Enter fullscreen mode Exit fullscreen mode

3) errors/api_error.rs (central API error)

use axum::http::StatusCode;

#[derive(Debug)]
pub struct ApiError {
    pub status: StatusCode,
    pub message: String,
}

impl ApiError {
    pub fn bad_request(msg: impl Into<String>) -> Self {
        Self { status: StatusCode::BAD_REQUEST, message: msg.into() }
    }

    pub fn not_found(msg: impl Into<String>) -> Self {
        Self { status: StatusCode::NOT_FOUND, message: msg.into() }
    }
}
Enter fullscreen mode Exit fullscreen mode

4) repos/user_repo.rs (in-memory example)

Later you replace this with DB (Postgres/MySQL) without touching handlers much.

use std::{collections::HashMap, sync::{Arc, RwLock}};
use crate::models::user::User;

#[derive(Clone)]
pub struct UserRepo {
    users: Arc<RwLock<HashMap<String, User>>>,
}

impl UserRepo {
    pub fn new() -> Self {
        Self { users: Arc::new(RwLock::new(HashMap::new())) }
    }

    pub fn insert(&self, user: User) {
        let mut map = self.users.write().unwrap();
        map.insert(user.id.clone(), user);
    }

    pub fn find_by_id(&self, id: &str) -> Option<User> {
        let map = self.users.read().unwrap();
        map.get(id).cloned()
    }
}
Enter fullscreen mode Exit fullscreen mode

services/user_service.rs (business logic)

use uuid::Uuid;
use crate::{
    dto::user_dto::CreateUserRequest,
    errors::api_error::ApiError,
    models::user::User,
    repos::user_repo::UserRepo,
};

#[derive(Clone)]
pub struct UserService {
    repo: UserRepo,
}

impl UserService {
    pub fn new(repo: UserRepo) -> Self {
        Self { repo }
    }

    pub fn create_user(&self, req: CreateUserRequest) -> Result<User, ApiError> {
        let name = req.name.trim();
        let email = req.email.trim();

        if name.is_empty() {
            return Err(ApiError::bad_request("Name is required"));
        }
        if !email.contains('@') {
            return Err(ApiError::bad_request("Email looks invalid"));
        }

        let user = User {
            id: Uuid::new_v4().to_string(),
            name: name.to_string(),
            email: email.to_string(),
        };

        self.repo.insert(user.clone());
        Ok(user)
    }

    pub fn get_user(&self, id: &str) -> Result<User, ApiError> {
        self.repo
            .find_by_id(id)
            .ok_or_else(|| ApiError::not_found("User not found"))
    }
}
Enter fullscreen mode Exit fullscreen mode

state/app_state.rs (shared app dependencies)

use crate::{repos::user_repo::UserRepo, services::user_service::UserService};

#[derive(Clone)]
pub struct AppState {
    pub user_service: UserService,
}

impl AppState {
    pub fn new() -> Self {
        let user_repo = UserRepo::new();
        let user_service = UserService::new(user_repo);

        Self { user_service }
    }
}
Enter fullscreen mode Exit fullscreen mode

handlers/user_handler.rs (HTTP glue only)

use axum::{
    extract::{Path, State},
    http::StatusCode,
    Json,
};
use crate::{
    dto::user_dto::{ApiResponse, CreateUserRequest},
    state::app_state::AppState,
};

pub async fn create_user(
    State(state): State<AppState>,
    Json(req): Json<CreateUserRequest>,
) -> (StatusCode, Json<ApiResponse<crate::models::user::User>>) {
    match state.user_service.create_user(req) {
        Ok(user) => (
            StatusCode::CREATED,
            Json(ApiResponse { ok: true, data: Some(user), error: None }),
        ),
        Err(e) => (
            e.status,
            Json(ApiResponse { ok: false, data: None, error: Some(e.message) }),
        ),
    }
}

pub async fn get_user(
    State(state): State<AppState>,
    Path(id): Path<String>,
) -> (StatusCode, Json<ApiResponse<crate::models::user::User>>) {
    match state.user_service.get_user(&id) {
        Ok(user) => (
            StatusCode::OK,
            Json(ApiResponse { ok: true, data: Some(user), error: None }),
        ),
        Err(e) => (
            e.status,
            Json(ApiResponse { ok: false, data: None, error: Some(e.message) }),
        ),
    }
}
Enter fullscreen mode Exit fullscreen mode

handlers/health_handler.rs

use axum::{http::StatusCode, Json};
use crate::dto::user_dto::ApiResponse;

pub async fn health() -> (StatusCode, Json<ApiResponse<&'static str>>) {
    (
        StatusCode::OK,
        Json(ApiResponse { ok: true, data: Some("ok"), error: None }),
    )
}
Enter fullscreen mode Exit fullscreen mode

routes.rs (route wiring)

use axum::{routing::{get, post}, Router};
use crate::{handlers::{health_handler, user_handler}, state::app_state::AppState};

pub fn create_router(state: AppState) -> Router {
    Router::new()
        .route("/health", get(health_handler::health))
        .route("/users", post(user_handler::create_user))
        .route("/users/:id", get(user_handler::get_user))
        .with_state(state)
}
Enter fullscreen mode Exit fullscreen mode

main.rs (bootstrap)

mod routes;
mod handlers;
mod services;
mod repos;
mod models;
mod dto;
mod errors;
mod state;

use crate::state::app_state::AppState;

#[tokio::main]
async fn main() {
    let state = AppState::new();
    let app = routes::create_router(state);

    let listener = tokio::net::TcpListener::bind("127.0.0.1:3000")
        .await
        .unwrap();

    axum::serve(listener, app).await.unwrap();
}
Enter fullscreen mode Exit fullscreen mode

React CRUD Application with Rust (Axum) Backend and Laravel Blade Integration

Below is a simple, optimized CRUD for Vehicle with:

Database Table (MySQL)

Run this once:

CREATE TABLE vehicles (
  id BIGINT PRIMARY KEY AUTO_INCREMENT,
  title VARCHAR(150) NOT NULL,
  brand VARCHAR(100) NULL,
  model VARCHAR(100) NULL,
  price_per_day DECIMAL(10,2) NOT NULL DEFAULT 0,
  is_active TINYINT(1) NOT NULL DEFAULT 1,
  created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
  updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP
);
Enter fullscreen mode Exit fullscreen mode

B) Backend (Rust) — Folder Structure

backend/
├── Cargo.toml
├── .env
└── src/
    ├── main.rs
    ├── app.rs
    ├── config.rs
    ├── db.rs
    ├── state.rs
    ├── models/
    │   ├── mod.rs
    │   └── vehicle.rs
    ├── repositories/
    │   ├── mod.rs
    │   └── vehicle_repo.rs
    ├── handlers/
    │   ├── mod.rs
    │   └── vehicle_handler.rs
    └── routes/
        ├── mod.rs
        └── vehicle_routes.rs
Enter fullscreen mode Exit fullscreen mode

1) backend/Cargo.toml

[package]
name = "backend"
version = "0.1.0"
edition = "2021"

[dependencies]
axum = "0.7"
tokio = { version = "1", features = ["full"] }
serde = { version = "1", features = ["derive"] }
serde_json = "1"
sqlx = { version = "0.7", features = ["mysql", "runtime-tokio", "macros", "chrono"] }
dotenvy = "0.15"
tracing = "0.1"
tracing-subscriber = { version = "0.3", features = ["env-filter"] }
Enter fullscreen mode Exit fullscreen mode

2) backend/.env

DATABASE_URL=mysql://DB_USER:DB_PASS@127.0.0.1:3306/DB_NAME
HOST=127.0.0.1
PORT=3000
Enter fullscreen mode Exit fullscreen mode

3) backend/src/main.rs

mod app;
mod config;
mod db;
mod state;

mod models;
mod repositories;
mod handlers;
mod routes;

#[tokio::main]
async fn main() {
    app::run().await;
}
Enter fullscreen mode Exit fullscreen mode

4) backend/src/config.rs

use std::env;

#[derive(Clone)]
pub struct Config {
    pub database_url: String,
    pub host: String,
    pub port: u16,
}

impl Config {
    pub fn from_env() -> Self {
        dotenvy::dotenv().ok();

        let database_url = env::var("DATABASE_URL").expect("DATABASE_URL missing");
        let host = env::var("HOST").unwrap_or_else(|_| "127.0.0.1".to_string());
        let port: u16 = env::var("PORT").ok().and_then(|v| v.parse().ok()).unwrap_or(3000);

        Self { database_url, host, port }
    }
}
Enter fullscreen mode Exit fullscreen mode

5) backend/src/db.rs

use sqlx::{MySql, Pool};
use std::time::Duration;

pub async fn create_pool(database_url: &str) -> Pool<MySql> {
    sqlx::mysql::MySqlPoolOptions::new()
        .max_connections(20)
        .min_connections(2)
        .acquire_timeout(Duration::from_secs(5))
        .connect(database_url)
        .await
        .expect("DB connect failed")
}
Enter fullscreen mode Exit fullscreen mode

6) backend/src/state.rs

use sqlx::{MySql, Pool};

#[derive(Clone)]
pub struct AppState {

Enter fullscreen mode Exit fullscreen mode

pub db: Pool,
}

7) backend/src/models/mod.rs

pub mod vehicle;
Enter fullscreen mode Exit fullscreen mode

8) backend/src/models/vehicle.rs

use serde::{Deserialize, Serialize};

#[derive(Debug, Serialize, Deserialize, sqlx::FromRow)]
pub struct Vehicle {
    pub id: i64,
    pub title: String,
    pub brand: Option<String>,
    pub model: Option<String>,
    pub price_per_day: f64,
    pub is_active: bool,
}

#[derive(Debug, Deserialize)]
pub struct CreateVehicle {
    pub title: String,
    pub brand: Option<String>,
    pub model: Option<String>,
    pub price_per_day: f64,
    pub is_active: bool,
}

#[derive(Debug, Deserialize)]
pub struct UpdateVehicle {
    pub title: Option<String>,
    pub brand: Option<String>,
    pub model: Option<String>,
    pub price_per_day: Option<f64>,
    pub is_active: Option<bool>,
}
Enter fullscreen mode Exit fullscreen mode

9) backend/src/repositories/mod.rs

pub mod vehicle_repo;
Enter fullscreen mode Exit fullscreen mode

10) backend/src/repositories/vehicle_repo.rs

use sqlx::{MySql, Pool};
use crate::models::vehicle::{Vehicle, CreateVehicle, UpdateVehicle};

pub async fn list(db: &Pool<MySql>) -> Result<Vec<Vehicle>, sqlx::Error> {
    sqlx::query_as::<_, Vehicle>(
        r#"
        SELECT id, title, brand, model, price_per_day, is_active
        FROM vehicles
        ORDER BY id DESC
        "#
    )
    .fetch_all(db)
    .await
}

pub async fn get(db: &Pool<MySql>, id: i64) -> Result<Option<Vehicle>, sqlx::Error> {
    sqlx::query_as::<_, Vehicle>(
        r#"
        SELECT id, title, brand, model, price_per_day, is_active
        FROM vehicles
        WHERE id = ?
        LIMIT 1
        "#
    )
    .bind(id)
    .fetch_optional(db)
    .await
}

pub async fn create(db: &Pool<MySql>, data: CreateVehicle) -> Result<Vehicle, sqlx::Error> {
    let result = sqlx::query(
        r#"
        INSERT INTO vehicles (title, brand, model, price_per_day, is_active)
        VALUES (?, ?, ?, ?, ?)
        "#
    )
    .bind(&data.title)
    .bind(&data.brand)
    .bind(&data.model)
    .bind(data.price_per_day)
    .bind(data.is_active)
    .execute(db)
    .await?;

    let id = result.last_insert_id() as i64;

    // Return the created record
    Ok(get(db, id).await?.expect("created row missing"))
}

pub async fn update(db: &Pool<MySql>, id: i64, data: UpdateVehicle) -> Result<Option<Vehicle>, sqlx::Error> {
    // Fetch existing first
    let existing = match get(db, id).await? {
        Some(v) => v,
        None => return Ok(None),
    };

    let new_title = data.title.unwrap_or(existing.title);
    let new_brand = data.brand.or(existing.brand);
    let new_model = data.model.or(existing.model);
    let new_price = data.price_per_day.unwrap_or(existing.price_per_day);
    let new_active = data.is_active.unwrap_or(existing.is_active);

    sqlx::query(
        r#"
        UPDATE vehicles
        SET title = ?, brand = ?, model = ?, price_per_day = ?, is_active = ?
        WHERE id = ?
        "#
    )
    .bind(new_title)
    .bind(new_brand)
    .bind(new_model)
    .bind(new_price)
    .bind(new_active)
    .bind(id)
    .execute(db)
    .await?;

    Ok(get(db, id).await?)
}

pub async fn delete(db: &Pool<MySql>, id: i64) -> Result<bool, sqlx::Error> {
    let result = sqlx::query("DELETE FROM vehicles WHERE id = ?")
        .bind(id)
        .execute(db)
        .await?;

    Ok(result.rows_affected() > 0)
}
Enter fullscreen mode Exit fullscreen mode

11) backend/src/handlers/mod.rs

pub mod vehicle_handler;
Enter fullscreen mode Exit fullscreen mode

12) backend/src/handlers/vehicle_handler.rs

use axum::{
    extract::{Path, State},
    response::IntoResponse,
    Json,
};
use tracing::error;

use crate::{
    models::vehicle::{CreateVehicle, UpdateVehicle},
    repositories::vehicle_repo,
    state::AppState,
};

pub async fn list_vehicles(State(state): State<AppState>) -> impl IntoResponse {
    match vehicle_repo::list(&state.db).await {
        Ok(rows) => (axum::http::StatusCode::OK, Json(rows)).into_response(),
        Err(e) => {
            error!("DB error: {:?}", e);
            (axum::http::StatusCode::INTERNAL_SERVER_ERROR, Json(err_json("Server error"))).into_response()
        }
    }
}

pub async fn get_vehicle(State(state): State<AppState>, Path(id): Path<i64>) -> impl IntoResponse {
    match vehicle_repo::get(&state.db, id).await {
        Ok(Some(row)) => (axum::http::StatusCode::OK, Json(row)).into_response(),
        Ok(None) => (axum::http::StatusCode::NOT_FOUND, Json(err_json("Not found"))).into_response(),
        Err(e) => {
            error!("DB error: {:?}", e);
            (axum::http::StatusCode::INTERNAL_SERVER_ERROR, Json(err_json("Server error"))).into_response()
        }
    }
}

pub async fn create_vehicle(
    State(state): State<AppState>,
    Json(payload): Json<CreateVehicle>,
) -> impl IntoResponse {
    match vehicle_repo::create(&state.db, payload).await {
        Ok(row) => (axum::http::StatusCode::CREATED, Json(row)).into_response(),
        Err(e) => {
            error!("DB error: {:?}", e);
            (axum::http::StatusCode::INTERNAL_SERVER_ERROR, Json(err_json("Server error"))).into_response()
        }
    }
}

pub async fn update_vehicle(
    State(state): State<AppState>,
    Path(id): Path<i64>,
    Json(payload): Json<UpdateVehicle>,
) -> impl IntoResponse {
    match vehicle_repo::update(&state.db, id, payload).await {
        Ok(Some(row)) => (axum::http::StatusCode::OK, Json(row)).into_response(),
        Ok(None) => (axum::http::StatusCode::NOT_FOUND, Json(err_json("Not found"))).into_response(),
        Err(e) => {
            error!("DB error: {:?}", e);
            (axum::http::StatusCode::INTERNAL_SERVER_ERROR, Json(err_json("Server error"))).into_response()
        }
    }
}

pub async fn delete_vehicle(
    State(state): State<AppState>,
    Path(id): Path<i64>,
) -> impl IntoResponse {
    match vehicle_repo::delete(&state.db, id).await {
        Ok(true) => (axum::http::StatusCode::OK, Json(serde_json::json!({ "deleted": true }))).into_response(),
        Ok(false) => (axum::http::StatusCode::NOT_FOUND, Json(err_json("Not found"))).into_response(),
        Err(e) => {
            error!("DB error: {:?}", e);
            (axum::http::StatusCode::INTERNAL_SERVER_ERROR, Json(err_json("Server error"))).into_response()
        }
    }
}

fn err_json(msg: &str) -> serde_json::Value {
    serde_json::json!({ "message": msg })
}
Enter fullscreen mode Exit fullscreen mode

13) backend/src/routes/mod.rs

pub mod vehicle_routes;
Enter fullscreen mode Exit fullscreen mode

14) backend/src/routes/vehicle_routes.rs

use axum::{routing::{get, post, put, delete}, Router};
use crate::{handlers::vehicle_handler, state::AppState};

pub fn routes() -> Router<AppState> {
    Router::new()
        .route("/api/vehicles", get(vehicle_handler::list_vehicles).post(vehicle_handler::create_vehicle))
        .route("/api/vehicles/:id", get(vehicle_handler::get_vehicle).put(vehicle_handler::update_vehicle).delete(vehicle_handler::delete_vehicle))
}
Enter fullscreen mode Exit fullscreen mode

15) backend/src/app.rs

use axum::{routing::get, Json, Router};
use tracing::info;

use crate::{config::Config, db::create_pool, routes, state::AppState};

pub async fn run() {
    tracing_subscriber::fmt()
        .with_env_filter(
            tracing_subscriber::EnvFilter::try_from_default_env()
                .unwrap_or_else(|_| "info".into()),
        )
        .init();

    let cfg = Config::from_env();
    let db = create_pool(&cfg.database_url).await;
    let state = AppState { db };

    let app = Router::new()
        .route("/api/health", get(health))
        .merge(routes::vehicle_routes::routes())
        .with_state(state);

    let addr = format!("{}:{}", cfg.host, cfg.port);
    info!("Rust API running at http://{}", addr);

    let listener = tokio::net::TcpListener::bind(addr).await.unwrap();
    axum::serve(listener, app).await.unwrap();
}

async fn health() -> Json<serde_json::Value> {
    Json(serde_json::json!({ "ok": true }))
}
Enter fullscreen mode Exit fullscreen mode

Run backend

cd backend
cargo run
Enter fullscreen mode Exit fullscreen mode

Frontend (React + Vite) — Simple CRUD UI
Folder structure


frontend/package.json
{
  "name": "frontend",
  "private": true,
  "type": "module",
  "scripts": {
    "dev": "vite",
    "build": "vite build"
  },
  "dependencies": {
    "react": "^18.3.0",
    "react-dom": "^18.3.0"
  },
  "devDependencies": {
    "@vitejs/plugin-react": "^4.3.0",
    "vite": "^5.4.0"
  }
}
Enter fullscreen mode Exit fullscreen mode

2) frontend/vite.config.js (IMPORTANT: proxy to Rust)

import { defineConfig } from "vite";
import react from "@vitejs/plugin-react";

export default defineConfig({
  plugins: [react()],
  server: {
    proxy: {
      "/api": "http://127.0.0.1:3000"
    }
  }
});
Enter fullscreen mode Exit fullscreen mode

✅ Now React can call /api/... and it goes to Rust.

3) frontend/src/main.jsx

import React from "react";
import { createRoot } from "react-dom/client";
import App from "./app.jsx";

createRoot(document.getElementById("root")).render(<App />);
Enter fullscreen mode Exit fullscreen mode

4) frontend/src/api/client.js

export async function api(url, options = {}) {
  const res = await fetch(url, {
    headers: { "Content-Type": "application/json", ...(options.headers || {}) },
    ...options
  });

  if (!res.ok) {
    const body = await res.json().catch(() => ({}));
    throw new Error(body.message || `HTTP ${res.status}`);
  }
  return res.status === 204 ? null : res.json();
}
Enter fullscreen mode Exit fullscreen mode

5) frontend/src/api/vehicles.js

import { api } from "./client";

export const VehiclesApi = {
  list: () => api("/api/vehicles"),
  get: (id) => api(`/api/vehicles/${id}`),
  create: (payload) => api("/api/vehicles", { method: "POST", body: JSON.stringify(payload) }),
  update: (id, payload) => api(`/api/vehicles/${id}`, { method: "PUT", body: JSON.stringify(payload) }),
  remove: (id) => api(`/api/vehicles/${id}`, { method: "DELETE" })
};
Enter fullscreen mode Exit fullscreen mode

6) frontend/src/components/VehicleForm.jsx

import React, { useEffect, useState } from "react";

const empty = { title: "", brand: "", model: "", price_per_day: 0, is_active: true };

export default function VehicleForm({ mode, initial, onSubmit, onCancel }) {
  const [form, setForm] = useState(empty);

  useEffect(() => {
    if (mode === "edit" && initial) {
      setForm({
        title: initial.title || "",
        brand: initial.brand || "",
        model: initial.model || "",
        price_per_day: Number(initial.price_per_day || 0),
        is_active: Boolean(initial.is_active)
      });
    } else {
      setForm(empty);
    }
  }, [mode, initial]);

  function set(k, v) {
    setForm((p) => ({ ...p, [k]: v }));
  }

  function submit(e) {
    e.preventDefault();
    onSubmit({
      title: form.title.trim(),
      brand: form.brand.trim() || null,
      model: form.model.trim() || null,
      price_per_day: Number(form.price_per_day || 0),
      is_active: Boolean(form.is_active)
    });
  }

  return (
    <form onSubmit={submit} style={{ border: "1px solid #ddd", padding: 12, borderRadius: 8 }}>
      <h3 style={{ marginTop: 0 }}>{mode === "edit" ? "Edit Vehicle" : "Create Vehicle"}</h3>

      <div style={{ display: "grid", gap: 10, gridTemplateColumns: "1fr 1fr" }}>
        <label>
          Title *
          <input value={form.title} onChange={(e) => set("title", e.target.value)} required style={{ width: "100%", padding: 8 }} />
        </label>

        <label>
          Price/Day *
          <input type="number" value={form.price_per_day} onChange={(e) => set("price_per_day", e.target.value)} required style={{ width: "100%", padding: 8 }} />
        </label>

        <label>
          Brand
          <input value={form.brand} onChange={(e) => set("brand", e.target.value)} style={{ width: "100%", padding: 8 }} />
        </label>

        <label>
          Model
          <input value={form.model} onChange={(e) => set("model", e.target.value)} style={{ width: "100%", padding: 8 }} />
        </label>

        <label style={{ display: "flex", gap: 8, alignItems: "center" }}>
          <input type="checkbox" checked={form.is_active} onChange={(e) => set("is_active", e.target.checked)} />
          Active
        </label>
      </div>

      <div style={{ marginTop: 12, display: "flex", gap: 8 }}>
        <button type="submit" style={{ padding: "8px 12px" }}>
          {mode === "edit" ? "Update" : "Create"}
        </button>
        {mode === "edit" && (
          <button type="button" onClick={onCancel} style={{ padding: "8px 12px" }}>
            Cancel
          </button>
        )}
      </div>
    </form>
  );
}
Enter fullscreen mode Exit fullscreen mode

7) frontend/src/components/VehicleTable.jsx

import React from "react";

export default function VehicleTable({ rows, onEdit, onDelete }) {
  return (
    <table width="100%" cellPadding="8" style={{ borderCollapse: "collapse", marginTop: 12 }}>
      <thead>
        <tr style={{ background: "#f6f6f6" }}>
          <th align="left">ID</th>
          <th align="left">Title</th>
          <th align="left">Brand</th>
          <th align="left">Model</th>
          <th align="left">Price/Day</th>
          <th align="left">Active</th>
          <th align="left">Actions</th>
        </tr>
      </thead>
      <tbody>
        {rows.map((v) => (
          <tr key={v.id} style={{ borderTop: "1px solid #eee" }}>
            <td>{v.id}</td>
            <td>{v.title}</td>
            <td>{v.brand || "-"}</td>
            <td>{v.model || "-"}</td>
            <td>{v.price_per_day}</td>
            <td>{v.is_active ? "Yes" : "No"}</td>
            <td style={{ display: "flex", gap: 8 }}>
              <button onClick={() => onEdit(v)}>Edit</button>
              <button onClick={() => onDelete(v)} style={{ color: "crimson" }}>
                Delete
              </button>
            </td>
          </tr>
        ))}
        {rows.length === 0 && (
          <tr>
            <td colSpan="7" style={{ padding: 16, color: "#666" }}>
              No vehicles found.
            </td>
          </tr>
        )}
      </tbody>
    </table>
  );
}
Enter fullscreen mode Exit fullscreen mode

8) frontend/src/app.jsx (CRUD page)

import React, { useEffect, useState } from "react";
import { VehiclesApi } from "./api/vehicles";
import VehicleForm from "./components/VehicleForm";
import VehicleTable from "./components/VehicleTable";

export default function App() {
  const [rows, setRows] = useState([]);
  const [mode, setMode] = useState("create"); // create | edit
  const [editing, setEditing] = useState(null);
  const [error, setError] = useState("");
  const [loading, setLoading] = useState(false);

  async function load() {
    setLoading(true);
    setError("");
    try {
      const data = await VehiclesApi.list();
      setRows(data);
    } catch (e) {
      setError(e.message || "Error");
    } finally {
      setLoading(false);
    }
  }

  useEffect(() => {
    load();
  }, []);

  async function onCreate(payload) {
    setError("");
    try {
      await VehiclesApi.create(payload);
      await load();
    } catch (e) {
      setError(e.message || "Create failed");
    }
  }

  async function onUpdate(payload) {
    if (!editing) return;
    setError("");
    try {
      await VehiclesApi.update(editing.id, payload);
      setMode("create");
      setEditing(null);
      await load();
    } catch (e) {
      setError(e.message || "Update failed");
    }
  }

  async function onDelete(v) {
    if (!confirm(`Delete vehicle #${v.id}?`)) return;
    setError("");
    try {
      await VehiclesApi.remove(v.id);
      if (editing?.id === v.id) {
        setMode("create");
        setEditing(null);
      }
      await load();
    } catch (e) {
      setError(e.message || "Delete failed");
    }
  }

  return (
    <div style={{ maxWidth: 980, margin: "24px auto", fontFamily: "Arial", padding: 12 }}>
      <h2 style={{ marginTop: 0 }}>Vehicle CRUD (React + Rust)</h2>

      {error && <div style={{ background: "#ffe8e8", padding: 10, borderRadius: 6, marginBottom: 12 }}>{error}</div>}
      {loading && <div style={{ color: "#666" }}>Loading...</div>}

      <VehicleForm
        mode={mode}
        initial={editing}
        onSubmit={mode === "edit" ? onUpdate : onCreate}
        onCancel={() => { setMode("create"); setEditing(null); }}
      />

      <VehicleTable
        rows={rows}
        onEdit={(v) => { setMode("edit"); setEditing(v); }}
        onDelete={onDelete}
      />
    </div>
  );
}

Enter fullscreen mode Exit fullscreen mode

Run frontend
cd frontend

npm install
npm run dev
Enter fullscreen mode Exit fullscreen mode

API Endpoints (Rust)

Laravel Blade (no change)
resources/views/vehicles.blade.php

<!doctype html>
<html>
<head>
    <meta charset="utf-8">
    <title>Vehicle CRUD</title>
    @vite('resources/js/app.jsx')
</head>
<body>
    <div id="app"></div>
</body>
</html>
Enter fullscreen mode Exit fullscreen mode

Laravel route only to show the page (NOT API)

routes/web.php

use Illuminate\Support\Facades\Route;

Route::get('/vehicles', function () {
    return view('vehicles');
});
Enter fullscreen mode Exit fullscreen mode

✅ Laravel now only renders the page.
Rust will handle /api/*.

2) React (keep in Laravel resources/js)
resources/js/app.jsx

In your React API calls use:


fetch("/api/vehicles")       // ✅ NOT http://127.0.0.1:3000
fetch("/api/vehicles/1")
Enter fullscreen mode Exit fullscreen mode

That’s it.

3) Run Rust backend on a separate port

Rust runs like:

http://127.0.0.1:3000
Enter fullscreen mode Exit fullscreen mode

Rust routes:

/api/vehicles

/api/vehicles/:id

4) IMPORTANT: Proxy /api to Rust (No CORS)

Now you choose based on your server:

A) If you use Nginx (recommended)

Inside your site config:

location /api/ {
proxy_pass http://127.0.0.1:3000;
proxy_http_version 1.1;

proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
Enter fullscreen mode Exit fullscreen mode

}

✅ Now:

https://yourdomain.com/api/vehicles → Rust

https://yourdomain.com/vehicles → Laravel Blade + React

B) If you use Apache (works well)

Enable modules:

a2enmod proxy proxy_http

In VirtualHost:

ProxyPreserveHost On
ProxyPass /api/ http://127.0.0.1:3000/api/
ProxyPassReverse /api/ http://127.0.0.1:3000/api/

✅ Same result: /api/* goes to Rust.

Top comments (0)