DEV Community

Cover image for Build a Full E-Commerce App with Flask & React (Complete Guide)
smith
smith

Posted on

Build a Full E-Commerce App with Flask & React (Complete Guide)

By the end of this tutorial you will have a working full-stack e-commerce application with:

  • A Flask REST API (products, auth, orders, admin)
  • A React + Vite frontend (shop, cart, account, orders, admin dashboard)
  • bcrypt password hashing
  • SQLite database (zero config, runs locally)
  • A cart persisted in localStorage

No prior Flask or React experience is assumed, but you should be comfortable running commands in a terminal.


Table of Contents


Project Structure

ecommerce/
├── app.py              ← entire Flask backend
├── shop.db             ← SQLite DB (auto-created on first run)
└── frontend/
    ├── index.html
    └── src/
        ├── api.js
        ├── main.jsx
        ├── App.jsx
        └── pages/
            ├── Home.jsx
            ├── Shop.jsx
            ├── Cart.jsx
            ├── Account.jsx
            ├── Orders.jsx
            └── Admin.jsx
Enter fullscreen mode Exit fullscreen mode

Backend — Flask

Install & bootstrap

pip install flask flask-cors bcrypt
Enter fullscreen mode Exit fullscreen mode

Create app.py and add the imports and app object:

from flask import Flask, request, jsonify
from flask_cors import CORS
import sqlite3
import bcrypt
from datetime import datetime

app = Flask(__name__)
CORS(app)  # allows the React dev server (different port) to call the API

DB = "shop.db"
Enter fullscreen mode Exit fullscreen mode

flask-cors is required because the React dev server runs on localhost:5173 while Flask runs on localhost:5000 — the browser blocks cross-origin requests unless the server sends the right headers.


Database setup

SQLite creates the file automatically. The helper db() returns a connection with row_factory set so rows behave like dictionaries.

def db():
    conn = sqlite3.connect(DB)
    conn.row_factory = sqlite3.Row
    return conn


def init_db():
    conn = db()
    cur = conn.cursor()

    cur.execute("""
    CREATE TABLE IF NOT EXISTS users (
        id         INTEGER PRIMARY KEY AUTOINCREMENT,
        username   TEXT,
        email      TEXT UNIQUE,
        password   TEXT,
        first_name TEXT,
        last_name  TEXT,
        role       TEXT DEFAULT 'user'
    )""")

    cur.execute("""
    CREATE TABLE IF NOT EXISTS products (
        id    INTEGER PRIMARY KEY AUTOINCREMENT,
        name  TEXT,
        price REAL,
        stock INTEGER,
        image TEXT
    )""")

    cur.execute("""
    CREATE TABLE IF NOT EXISTS orders (
        id         INTEGER PRIMARY KEY AUTOINCREMENT,
        user_id    INTEGER,
        total      REAL,
        status     TEXT DEFAULT 'pending',
        created_at TEXT
    )""")

    cur.execute("""
    CREATE TABLE IF NOT EXISTS order_items (
        id         INTEGER PRIMARY KEY AUTOINCREMENT,
        order_id   INTEGER,
        product_id INTEGER,
        qty        INTEGER,
        price      REAL
    )""")

    conn.commit()
    conn.close()


init_db()
Enter fullscreen mode Exit fullscreen mode
Table What it stores
users Accounts with hashed passwords and a role field (user or admin)
products Catalogue — name, price, stock count, image URL
orders One row per placed order, linked to a user
order_items Line items (product, quantity, price snapshot at time of order)

Auth routes

POST /register

The password is hashed with bcrypt before it ever reaches the database. Parameterised queries (?) protect against SQL injection.

@app.route("/register", methods=["POST"])
def register():
    d = request.json
    hashed = bcrypt.hashpw(d["password"].encode(), bcrypt.gensalt())

    conn = db()
    conn.execute("""
        INSERT INTO users(username, email, password, first_name, last_name)
        VALUES (?, ?, ?, ?, ?)
    """, (d["username"], d["email"], hashed.decode(), d["first_name"], d["last_name"]))
    conn.commit()
    conn.close()

    return jsonify({"msg": "registered"})
Enter fullscreen mode Exit fullscreen mode

POST /login

bcrypt.checkpw compares the plain-text input against the stored hash. The API returns user_id, username, and role — the frontend stores this in localStorage.

@app.route("/login", methods=["POST"])
def login():
    d = request.json

    conn = db()
    user = conn.execute("SELECT * FROM users WHERE email=?", (d["email"],)).fetchone()
    conn.close()

    if not user:
        return jsonify({"error": "not found"}), 404

    if not bcrypt.checkpw(d["password"].encode(), user["password"].encode()):
        return jsonify({"error": "wrong password"}), 401

    return jsonify({
        "user_id": user["id"],
        "username": user["username"],
        "role": user["role"]
    })
Enter fullscreen mode Exit fullscreen mode

Product routes

# List all products
@app.route("/products", methods=["GET"])
def get_products():
    conn = db()
    rows = conn.execute("SELECT * FROM products").fetchall()
    conn.close()
    return jsonify([dict(r) for r in rows])


# Add a product (should be admin-only in production)
@app.route("/products", methods=["POST"])
def add_product():
    d = request.json
    conn = db()
    conn.execute("""
        INSERT INTO products(name, price, stock, image)
        VALUES (?, ?, ?, ?)
    """, (d["name"], d["price"], d["stock"], d["image"]))
    conn.commit()
    conn.close()
    return jsonify({"msg": "added"})


# Delete a product (should be admin-only in production)
@app.route("/products/<int:pid>", methods=["DELETE"])
def delete_product(pid):
    conn = db()
    conn.execute("DELETE FROM products WHERE id=?", (pid,))
    conn.commit()
    conn.close()
    return jsonify({"msg": "deleted"})
Enter fullscreen mode Exit fullscreen mode

Checkout & orders

The checkout endpoint validates stock before writing anything, then creates the order, writes line items, and decrements stock in one commit.

@app.route("/checkout", methods=["POST"])
def checkout():
    d = request.json
    user_id = d["user_id"]
    cart    = d["cart"]

    conn  = db()
    total = 0

    # Validate stock first — fail before writing anything
    for item in cart:
        p = conn.execute("SELECT * FROM products WHERE id=?", (item["id"],)).fetchone()
        if p["stock"] < item["qty"]:
            return jsonify({"error": "out of stock"}), 400
        total += p["price"] * item["qty"]

    cur = conn.cursor()
    cur.execute("""
        INSERT INTO orders(user_id, total, created_at)
        VALUES (?, ?, ?)
    """, (user_id, total, datetime.now().isoformat()))
    order_id = cur.lastrowid

    for item in cart:
        conn.execute("""
            INSERT INTO order_items(order_id, product_id, qty, price)
            VALUES (?, ?, ?, ?)
        """, (order_id, item["id"], item["qty"], item["price"]))

        conn.execute("""
            UPDATE products SET stock = stock - ? WHERE id=?
        """, (item["qty"], item["id"]))

    conn.commit()
    conn.close()
    return jsonify({"msg": "order created"})


# Order history for a single user
@app.route("/orders/<int:user_id>")
def user_orders(user_id):
    conn   = db()
    orders = conn.execute("SELECT * FROM orders WHERE user_id=?", (user_id,)).fetchall()
    result = []

    for o in orders:
        items = conn.execute(
            "SELECT * FROM order_items WHERE order_id=?", (o["id"],)
        ).fetchall()
        result.append({"order": dict(o), "items": [dict(i) for i in items]})

    conn.close()
    return jsonify(result)
Enter fullscreen mode Exit fullscreen mode

Admin routes

# All orders
@app.route("/admin/orders")
def admin_orders():
    conn   = db()
    orders = conn.execute("SELECT * FROM orders").fetchall()
    result = []

    for o in orders:
        items = conn.execute(
            "SELECT * FROM order_items WHERE order_id=?", (o["id"],)
        ).fetchall()
        result.append({"order": dict(o), "items": [dict(i) for i in items]})

    conn.close()
    return jsonify(result)


# Update order status
@app.route("/admin/orders/<int:oid>", methods=["PUT"])
def update_order(oid):
    d = request.json
    conn = db()
    conn.execute("UPDATE orders SET status=? WHERE id=?", (d["status"], oid))
    conn.commit()
    conn.close()
    return jsonify({"msg": "updated"})


# Delete an order and its line items
@app.route("/admin/orders/<int:oid>", methods=["DELETE"])
def delete_order(oid):
    conn = db()
    conn.execute("DELETE FROM order_items WHERE order_id=?", (oid,))
    conn.execute("DELETE FROM orders WHERE id=?", (oid,))
    conn.commit()
    conn.close()
    return jsonify({"msg": "deleted"})
Enter fullscreen mode Exit fullscreen mode

Full backend code

Here is app.py in its entirety — copy this into a new file and run python app.py.

from flask import Flask, request, jsonify
from flask_cors import CORS
import sqlite3
import bcrypt
from datetime import datetime

app = Flask(__name__)
CORS(app)

DB = "shop.db"


# ── Database ──────────────────────────────────────────────────────────────────

def db():
    conn = sqlite3.connect(DB)
    conn.row_factory = sqlite3.Row
    return conn


def init_db():
    conn = db()
    cur  = conn.cursor()

    cur.execute("""
    CREATE TABLE IF NOT EXISTS users (
        id         INTEGER PRIMARY KEY AUTOINCREMENT,
        username   TEXT,
        email      TEXT UNIQUE,
        password   TEXT,
        first_name TEXT,
        last_name  TEXT,
        role       TEXT DEFAULT 'user'
    )""")

    cur.execute("""
    CREATE TABLE IF NOT EXISTS products (
        id    INTEGER PRIMARY KEY AUTOINCREMENT,
        name  TEXT,
        price REAL,
        stock INTEGER,
        image TEXT
    )""")

    cur.execute("""
    CREATE TABLE IF NOT EXISTS orders (
        id         INTEGER PRIMARY KEY AUTOINCREMENT,
        user_id    INTEGER,
        total      REAL,
        status     TEXT DEFAULT 'pending',
        created_at TEXT
    )""")

    cur.execute("""
    CREATE TABLE IF NOT EXISTS order_items (
        id         INTEGER PRIMARY KEY AUTOINCREMENT,
        order_id   INTEGER,
        product_id INTEGER,
        qty        INTEGER,
        price      REAL
    )""")

    conn.commit()
    conn.close()


init_db()


# ── Auth ──────────────────────────────────────────────────────────────────────

@app.route("/register", methods=["POST"])
def register():
    d      = request.json
    hashed = bcrypt.hashpw(d["password"].encode(), bcrypt.gensalt())

    conn = db()
    conn.execute("""
        INSERT INTO users(username, email, password, first_name, last_name)
        VALUES (?, ?, ?, ?, ?)
    """, (d["username"], d["email"], hashed.decode(), d["first_name"], d["last_name"]))
    conn.commit()
    conn.close()

    return jsonify({"msg": "registered"})


@app.route("/login", methods=["POST"])
def login():
    d = request.json

    conn = db()
    user = conn.execute("SELECT * FROM users WHERE email=?", (d["email"],)).fetchone()
    conn.close()

    if not user:
        return jsonify({"error": "not found"}), 404

    if not bcrypt.checkpw(d["password"].encode(), user["password"].encode()):
        return jsonify({"error": "wrong password"}), 401

    return jsonify({
        "user_id":  user["id"],
        "username": user["username"],
        "role":     user["role"]
    })


# ── Products ──────────────────────────────────────────────────────────────────

@app.route("/products", methods=["GET"])
def get_products():
    conn = db()
    rows = conn.execute("SELECT * FROM products").fetchall()
    conn.close()
    return jsonify([dict(r) for r in rows])


@app.route("/products", methods=["POST"])
def add_product():
    d = request.json
    conn = db()
    conn.execute("""
        INSERT INTO products(name, price, stock, image)
        VALUES (?, ?, ?, ?)
    """, (d["name"], d["price"], d["stock"], d["image"]))
    conn.commit()
    conn.close()
    return jsonify({"msg": "added"})


@app.route("/products/<int:pid>", methods=["DELETE"])
def delete_product(pid):
    conn = db()
    conn.execute("DELETE FROM products WHERE id=?", (pid,))
    conn.commit()
    conn.close()
    return jsonify({"msg": "deleted"})


# ── Checkout & orders ─────────────────────────────────────────────────────────

@app.route("/checkout", methods=["POST"])
def checkout():
    d       = request.json
    user_id = d["user_id"]
    cart    = d["cart"]

    conn  = db()
    total = 0

    for item in cart:
        p = conn.execute("SELECT * FROM products WHERE id=?", (item["id"],)).fetchone()
        if p["stock"] < item["qty"]:
            return jsonify({"error": "out of stock"}), 400
        total += p["price"] * item["qty"]

    cur = conn.cursor()
    cur.execute("""
        INSERT INTO orders(user_id, total, created_at)
        VALUES (?, ?, ?)
    """, (user_id, total, datetime.now().isoformat()))
    order_id = cur.lastrowid

    for item in cart:
        conn.execute("""
            INSERT INTO order_items(order_id, product_id, qty, price)
            VALUES (?, ?, ?, ?)
        """, (order_id, item["id"], item["qty"], item["price"]))

        conn.execute("""
            UPDATE products SET stock = stock - ? WHERE id=?
        """, (item["qty"], item["id"]))

    conn.commit()
    conn.close()
    return jsonify({"msg": "order created"})


@app.route("/orders/<int:user_id>")
def user_orders(user_id):
    conn   = db()
    orders = conn.execute("SELECT * FROM orders WHERE user_id=?", (user_id,)).fetchall()
    result = []

    for o in orders:
        items = conn.execute(
            "SELECT * FROM order_items WHERE order_id=?", (o["id"],)
        ).fetchall()
        result.append({"order": dict(o), "items": [dict(i) for i in items]})

    conn.close()
    return jsonify(result)


# ── Admin ─────────────────────────────────────────────────────────────────────

@app.route("/admin/orders")
def admin_orders():
    conn   = db()
    orders = conn.execute("SELECT * FROM orders").fetchall()
    result = []

    for o in orders:
        items = conn.execute(
            "SELECT * FROM order_items WHERE order_id=?", (o["id"],)
        ).fetchall()
        result.append({"order": dict(o), "items": [dict(i) for i in items]})

    conn.close()
    return jsonify(result)


@app.route("/admin/orders/<int:oid>", methods=["PUT"])
def update_order(oid):
    d = request.json
    conn = db()
    conn.execute("UPDATE orders SET status=? WHERE id=?", (d["status"], oid))
    conn.commit()
    conn.close()
    return jsonify({"msg": "updated"})


@app.route("/admin/orders/<int:oid>", methods=["DELETE"])
def delete_order(oid):
    conn = db()
    conn.execute("DELETE FROM order_items WHERE order_id=?", (oid,))
    conn.execute("DELETE FROM orders WHERE id=?", (oid,))
    conn.commit()
    conn.close()
    return jsonify({"msg": "deleted"})


# ── Run ───────────────────────────────────────────────────────────────────────

app.run(debug=True)
Enter fullscreen mode Exit fullscreen mode

Frontend — React + Vite

Create the project

npm create vite@latest frontend
cd frontend
npm install
npm install react-router-dom
npm run dev
Enter fullscreen mode Exit fullscreen mode

The dev server starts on http://localhost:5173.


App shell

src/api.js

One place to change if you deploy the backend to a real server.

export const API = "http://127.0.0.1:5000";
Enter fullscreen mode Exit fullscreen mode

src/main.jsx

import React from "react";
import ReactDOM from "react-dom/client";
import App from "./App.jsx";

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

src/App.jsx

Sets up the router. The Admin link is only rendered when the stored user has role === "admin".

import { BrowserRouter, Routes, Route, Link } from "react-router-dom";
import Home    from "./pages/Home.jsx";
import Shop    from "./pages/Shop.jsx";
import Cart    from "./pages/Cart.jsx";
import Account from "./pages/Account.jsx";
import Orders  from "./pages/Orders.jsx";
import Admin   from "./pages/Admin.jsx";

export default function App() {
  const user = JSON.parse(localStorage.getItem("user") || "{}");

  return (
    <BrowserRouter>
      <nav>
        <Link to="/">Home</Link>{" | "}
        <Link to="/shop">Shop</Link>{" | "}
        <Link to="/cart">Cart</Link>{" | "}
        <Link to="/account">Account</Link>{" | "}
        <Link to="/orders">Orders</Link>{" | "}
        {user.role === "admin" && <Link to="/admin">Admin</Link>}
      </nav>

      <Routes>
        <Route path="/"       element={<Home />} />
        <Route path="/shop"   element={<Shop />} />
        <Route path="/cart"   element={<Cart />} />
        <Route path="/account" element={<Account />} />
        <Route path="/orders" element={<Orders />} />
        <Route path="/admin"  element={<Admin />} />
      </Routes>
    </BrowserRouter>
  );
}
Enter fullscreen mode Exit fullscreen mode

Pages

src/pages/Home.jsx

export default function Home() {
  return (
    <div>
      <h1>Welcome Store</h1>
      <p>Best products online</p>
    </div>
  );
}
Enter fullscreen mode Exit fullscreen mode

src/pages/Shop.jsx

Fetches products on mount. "Add to cart" pushes into localStorage, incrementing qty if the item already exists.

import { useEffect, useState } from "react";
import { API } from "../api";

export default function Shop() {
  const [products, setProducts] = useState([]);

  useEffect(() => {
    fetch(API + "/products")
      .then(r => r.json())
      .then(setProducts);
  }, []);

  const add = (p) => {
    const cart  = JSON.parse(localStorage.getItem("cart") || "[]");
    const found = cart.find(i => i.id === p.id);
    if (found) found.qty++;
    else cart.push({ ...p, qty: 1 });
    localStorage.setItem("cart", JSON.stringify(cart));
  };

  return (
    <div>
      {products.map(p => (
        <div key={p.id}>
          <h3>{p.name} — £{p.price}</h3>
          <p>In stock: {p.stock}</p>
          <button onClick={() => add(p)}>Add to cart</button>
        </div>
      ))}
    </div>
  );
}
Enter fullscreen mode Exit fullscreen mode

src/pages/Cart.jsx

Reads the cart from localStorage, displays items, and sends the full cart to POST /checkout. The cart is wiped from storage after a successful order.

import { API } from "../api";

export default function Cart() {
  const cart = JSON.parse(localStorage.getItem("cart") || "[]");
  const user = JSON.parse(localStorage.getItem("user") || "{}");

  const checkout = async () => {
    const res = await fetch(API + "/checkout", {
      method:  "POST",
      headers: { "Content-Type": "application/json" },
      body:    JSON.stringify({ user_id: user.user_id, cart })
    });

    const data = await res.json();

    if (res.ok) {
      localStorage.removeItem("cart");
      alert("Order placed!");
    } else {
      alert(data.error);
    }
  };

  if (cart.length === 0) return <p>Your cart is empty.</p>;

  return (
    <div>
      {cart.map(i => (
        <div key={i.id}>
          {i.name} x{i.qty} — £{(i.price * i.qty).toFixed(2)}
        </div>
      ))}
      <button onClick={checkout}>Checkout</button>
    </div>
  );
}
Enter fullscreen mode Exit fullscreen mode

src/pages/Account.jsx

Handles login. The API response is stored in localStorage so user_id and role persist across page loads.

import { useState } from "react";
import { API } from "../api";

export default function Account() {
  const [email,    setEmail]    = useState("");
  const [password, setPassword] = useState("");
  const [msg,      setMsg]      = useState("");

  const login = async () => {
    const res  = await fetch(API + "/login", {
      method:  "POST",
      headers: { "Content-Type": "application/json" },
      body:    JSON.stringify({ email, password })
    });
    const data = await res.json();

    if (res.ok) {
      localStorage.setItem("user", JSON.stringify(data));
      setMsg("Logged in as " + data.username);
    } else {
      setMsg(data.error);
    }
  };

  return (
    <div>
      <h2>Login</h2>
      <input
        type="email"
        placeholder="Email"
        onChange={e => setEmail(e.target.value)}
      />
      <input
        type="password"
        placeholder="Password"
        onChange={e => setPassword(e.target.value)}
      />
      <button onClick={login}>Login</button>
      {msg && <p>{msg}</p>}
    </div>
  );
}
Enter fullscreen mode Exit fullscreen mode

src/pages/Orders.jsx

Fetches the logged-in user's order history using their stored user_id.

import { useEffect, useState } from "react";
import { API } from "../api";

export default function Orders() {
  const [orders, setOrders] = useState([]);

  useEffect(() => {
    const user = JSON.parse(localStorage.getItem("user") || "{}");
    if (!user.user_id) return;

    fetch(API + "/orders/" + user.user_id)
      .then(r => r.json())
      .then(setOrders);
  }, []);

  if (orders.length === 0) return <p>No orders yet.</p>;

  return (
    <div>
      {orders.map(o => (
        <div key={o.order.id}>
          <strong>Order #{o.order.id}</strong> — £{o.order.total.toFixed(2)}{o.order.status}
          <ul>
            {o.items.map(i => (
              <li key={i.id}>Product #{i.product_id} × {i.qty}</li>
            ))}
          </ul>
        </div>
      ))}
    </div>
  );
}
Enter fullscreen mode Exit fullscreen mode

src/pages/Admin.jsx

Fetches all orders. Admins can mark an order as "shipped" or delete it. In production this page should be behind a route guard that checks role.

import { useEffect, useState } from "react";
import { API } from "../api";

export default function Admin() {
  const [orders, setOrders] = useState([]);

  const load = () =>
    fetch(API + "/admin/orders")
      .then(r => r.json())
      .then(setOrders);

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

  const update = async (id, status) => {
    await fetch(API + "/admin/orders/" + id, {
      method:  "PUT",
      headers: { "Content-Type": "application/json" },
      body:    JSON.stringify({ status })
    });
    load();
  };

  const del = async (id) => {
    await fetch(API + "/admin/orders/" + id, { method: "DELETE" });
    load();
  };

  return (
    <div>
      <h2>All Orders</h2>
      {orders.map(o => (
        <div key={o.order.id} style={{ marginBottom: "1rem", borderBottom: "1px solid #ccc" }}>
          <strong>Order #{o.order.id}</strong> — £{o.order.total.toFixed(2)}{o.order.status}
          <ul>
            {o.items.map(i => (
              <li key={i.id}>Product #{i.product_id} × {i.qty}</li>
            ))}
          </ul>
          <button onClick={() => update(o.order.id, "shipped")}>Mark shipped</button>{" "}
          <button onClick={() => del(o.order.id)}>Delete</button>
        </div>
      ))}
    </div>
  );
}
Enter fullscreen mode Exit fullscreen mode

Full frontend code

All files together for easy copying.

src/api.js

export const API = "http://127.0.0.1:5000";
Enter fullscreen mode Exit fullscreen mode

src/main.jsx

import React from "react";
import ReactDOM from "react-dom/client";
import App from "./App.jsx";

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

src/App.jsx

import { BrowserRouter, Routes, Route, Link } from "react-router-dom";
import Home    from "./pages/Home.jsx";
import Shop    from "./pages/Shop.jsx";
import Cart    from "./pages/Cart.jsx";
import Account from "./pages/Account.jsx";
import Orders  from "./pages/Orders.jsx";
import Admin   from "./pages/Admin.jsx";

export default function App() {
  const user = JSON.parse(localStorage.getItem("user") || "{}");

  return (
    <BrowserRouter>
      <nav>
        <Link to="/">Home</Link>{" | "}
        <Link to="/shop">Shop</Link>{" | "}
        <Link to="/cart">Cart</Link>{" | "}
        <Link to="/account">Account</Link>{" | "}
        <Link to="/orders">Orders</Link>{" | "}
        {user.role === "admin" && <Link to="/admin">Admin</Link>}
      </nav>
      <Routes>
        <Route path="/"        element={<Home />} />
        <Route path="/shop"    element={<Shop />} />
        <Route path="/cart"    element={<Cart />} />
        <Route path="/account" element={<Account />} />
        <Route path="/orders"  element={<Orders />} />
        <Route path="/admin"   element={<Admin />} />
      </Routes>
    </BrowserRouter>
  );
}
Enter fullscreen mode Exit fullscreen mode

src/pages/Home.jsx

export default function Home() {
  return (
    <div>
      <h1>Welcome Store</h1>
      <p>Best products online</p>
    </div>
  );
}
Enter fullscreen mode Exit fullscreen mode

src/pages/Shop.jsx

import { useEffect, useState } from "react";
import { API } from "../api";

export default function Shop() {
  const [products, setProducts] = useState([]);

  useEffect(() => {
    fetch(API + "/products").then(r => r.json()).then(setProducts);
  }, []);

  const add = (p) => {
    const cart  = JSON.parse(localStorage.getItem("cart") || "[]");
    const found = cart.find(i => i.id === p.id);
    if (found) found.qty++;
    else cart.push({ ...p, qty: 1 });
    localStorage.setItem("cart", JSON.stringify(cart));
  };

  return (
    <div>
      {products.map(p => (
        <div key={p.id}>
          <h3>{p.name} — £{p.price}</h3>
          <p>In stock: {p.stock}</p>
          <button onClick={() => add(p)}>Add to cart</button>
        </div>
      ))}
    </div>
  );
}
Enter fullscreen mode Exit fullscreen mode

src/pages/Cart.jsx

import { API } from "../api";

export default function Cart() {
  const cart = JSON.parse(localStorage.getItem("cart") || "[]");
  const user = JSON.parse(localStorage.getItem("user") || "{}");

  const checkout = async () => {
    const res  = await fetch(API + "/checkout", {
      method:  "POST",
      headers: { "Content-Type": "application/json" },
      body:    JSON.stringify({ user_id: user.user_id, cart })
    });
    const data = await res.json();
    if (res.ok) { localStorage.removeItem("cart"); alert("Order placed!"); }
    else alert(data.error);
  };

  if (cart.length === 0) return <p>Your cart is empty.</p>;

  return (
    <div>
      {cart.map(i => (
        <div key={i.id}>{i.name} x{i.qty} — £{(i.price * i.qty).toFixed(2)}</div>
      ))}
      <button onClick={checkout}>Checkout</button>
    </div>
  );
}
Enter fullscreen mode Exit fullscreen mode

src/pages/Account.jsx

import { useState } from "react";
import { API } from "../api";

export default function Account() {
  const [email,    setEmail]    = useState("");
  const [password, setPassword] = useState("");
  const [msg,      setMsg]      = useState("");

  const login = async () => {
    const res  = await fetch(API + "/login", {
      method:  "POST",
      headers: { "Content-Type": "application/json" },
      body:    JSON.stringify({ email, password })
    });
    const data = await res.json();
    if (res.ok) { localStorage.setItem("user", JSON.stringify(data)); setMsg("Logged in as " + data.username); }
    else setMsg(data.error);
  };

  return (
    <div>
      <h2>Login</h2>
      <input type="email"    placeholder="Email"    onChange={e => setEmail(e.target.value)} />
      <input type="password" placeholder="Password" onChange={e => setPassword(e.target.value)} />
      <button onClick={login}>Login</button>
      {msg && <p>{msg}</p>}
    </div>
  );
}
Enter fullscreen mode Exit fullscreen mode

src/pages/Orders.jsx

import { useEffect, useState } from "react";
import { API } from "../api";

export default function Orders() {
  const [orders, setOrders] = useState([]);

  useEffect(() => {
    const user = JSON.parse(localStorage.getItem("user") || "{}");
    if (!user.user_id) return;
    fetch(API + "/orders/" + user.user_id).then(r => r.json()).then(setOrders);
  }, []);

  if (orders.length === 0) return <p>No orders yet.</p>;

  return (
    <div>
      {orders.map(o => (
        <div key={o.order.id}>
          <strong>Order #{o.order.id}</strong> — £{o.order.total.toFixed(2)}{o.order.status}
          <ul>{o.items.map(i => <li key={i.id}>Product #{i.product_id} × {i.qty}</li>)}</ul>
        </div>
      ))}
    </div>
  );
}
Enter fullscreen mode Exit fullscreen mode

src/pages/Admin.jsx

import { useEffect, useState } from "react";
import { API } from "../api";

export default function Admin() {
  const [orders, setOrders] = useState([]);

  const load = () => fetch(API + "/admin/orders").then(r => r.json()).then(setOrders);
  useEffect(() => { load(); }, []);

  const update = async (id, status) => {
    await fetch(API + "/admin/orders/" + id, {
      method: "PUT", headers: { "Content-Type": "application/json" },
      body: JSON.stringify({ status })
    });
    load();
  };

  const del = async (id) => {
    await fetch(API + "/admin/orders/" + id, { method: "DELETE" });
    load();
  };

  return (
    <div>
      <h2>All Orders</h2>
      {orders.map(o => (
        <div key={o.order.id} style={{ marginBottom: "1rem", borderBottom: "1px solid #ccc" }}>
          <strong>Order #{o.order.id}</strong> — £{o.order.total.toFixed(2)}{o.order.status}
          <ul>{o.items.map(i => <li key={i.id}>Product #{i.product_id} × {i.qty}</li>)}</ul>
          <button onClick={() => update(o.order.id, "shipped")}>Mark shipped</button>{" "}
          <button onClick={() => del(o.order.id)}>Delete</button>
        </div>
      ))}
    </div>
  );
}
Enter fullscreen mode Exit fullscreen mode

Running the app

Open two terminals:

# Terminal 1 — backend
python app.py
# → Running on http://127.0.0.1:5000

# Terminal 2 — frontend
cd frontend
npm run dev
# → Local: http://localhost:5173
Enter fullscreen mode Exit fullscreen mode

To seed a test product, call the API directly:

curl -X POST http://127.0.0.1:5000/products \
  -H "Content-Type: application/json" \
  -d '{"name":"Widget","price":9.99,"stock":50,"image":"https://placehold.co/200"}'
Enter fullscreen mode Exit fullscreen mode

To create an admin user, register normally then open the SQLite database and set the role:

sqlite3 shop.db "UPDATE users SET role='admin' WHERE email='you@example.com';"
Enter fullscreen mode Exit fullscreen mode

Security checklist before going live

This tutorial is a learning scaffold. Here are the issues to fix before any real deployment:

Issue Fix
Admin routes are unprotected Verify a JWT or session token on every /admin/* request and check role === "admin"
Product write routes are unprotected Same — admin check on POST /products and DELETE /products/<id>
user_id comes from the client at checkout Derive it server-side from the session token, not the request body
localStorage for auth state Use httpOnly cookies to prevent XSS from reading the token
No input validation Validate all JSON fields with a library like marshmallow or pydantic before writing to the DB
SQLite in production Switch to PostgreSQL or MySQL; SQLite doesn't handle concurrent writes well
debug=True in production Set debug=False and run behind Gunicorn: gunicorn app:app

Found a bug or want to add registration, search, or Stripe payments? Drop a comment below.

Top comments (0)