Courses/E-commerce SPA
Standalone Course~30 min14 challenges

Build an E-commerce Store: Single App Architecture

Build a complete e-commerce store using Grit's single app architecture. One Go binary serves both the REST API and the React frontend. You'll design a product catalog, build a shopping cart, implement checkout, and deploy as a single binary.


Why Single App?

Grit's default architecture is "triple" — separate API, web app, and admin panel. But sometimes you want something simpler. The single app architecture puts everything in one Go binary: the API serves JSON endpoints, and the same binary serves the React frontend as static files.

Single App Architecture: A deployment model where one binary serves both the API and the frontend. The React app is built into static files (HTML, JS, CSS) and embedded into the Go binary using go:embed. When you run the binary, it serves API routes at /api/* and the React app at every other route. One process, one port, one deployment.
go:embed: A Go compiler directive that embeds files directly into the compiled binary. Instead of reading files from disk at runtime, the files are baked into the binary at compile time. This means your Go binary is completely self-contained — it doesn't need a separate folder of frontend files to serve.

When to choose single app architecture:

  • Simple deployment — one binary, one systemd service, one port. No separate frontend hosting
  • Internal tools — dashboards, admin panels, or tools where SEO doesn't matter
  • Self-hosted products — users download one binary and run it. No Docker, no Node.js
  • Prototypes and MVPs — get to market fast with minimal infrastructure

This is similar to how Laravel (PHP) or Django (Python) work — one server handles both the API and the rendered pages. The difference is that Grit uses a proper React SPA instead of server-rendered templates, giving you the full power of client-side React.

1

Challenge: Single vs Triple

Think of 3 projects where single app architecture would be the right choice and 3 where triple would be better. Consider: Does the project need SEO? Will the frontend and backend scale independently? Is simple deployment a priority? Write down your reasoning for each.

Scaffold

Scaffold a single app e-commerce project with Vite as the frontend bundler:

Terminal
grit new shop --single --vite

The --single flag creates a flat structure instead of a monorepo. The --vite flag uses Vite + React (TanStack Router) instead of Next.js (which requires its own server and doesn't work in single-binary mode).

Project Structure
shop/
├── main.go                     # Entry point (serves API + embedded frontend)
├── go.mod
├── go.sum
├── .env
├── .gitignore
├── docker-compose.yml          # PostgreSQL, Redis, MinIO, Mailhog
├── internal/
│   ├── config/                 # Environment config
│   ├── database/               # DB connection + migrations
│   ├── handler/                # HTTP handlers
│   ├── middleware/              # Auth, CORS, logging
│   ├── model/                  # GORM models
│   ├── router/                 # Route definitions
│   └── service/                # Business logic
└── frontend/
    ├── src/
    │   ├── routes/             # File-based routing (TanStack Router)
    │   ├── components/         # React components
    │   ├── hooks/              # Custom hooks
    │   └── lib/                # API client, utils
    ├── index.html
    ├── package.json
    ├── vite.config.ts
    ├── tailwind.config.ts
    └── tsconfig.json

Compare this to a triple project: no apps/ folder, no turbo.json, no pnpm-workspace.yaml. The Go code lives in the root (main.go + internal/) and the frontend lives in frontend/. Simple and flat.

In single app mode, the main.go file uses go:embed to embed the frontend/dist/ directory into the binary. During development, the frontend runs on its own Vite dev server with a proxy to the Go API. For production, you run go build and get a single binary.
2

Challenge: Scaffold and Compare

Run grit new shop --single --vite. Count the total files and folders. Now scaffold a triple project (grit new shop-triple) in a different directory and count its files. How many fewer files does the single app have? What's missing from the single app that the triple project has?

Design the Store

An e-commerce store needs three core resources: categories to organize products, products to sell, and orders to track purchases.

Generate Resources
# Category — product organization
grit generate resource Category --fields "name:string,slug:slug:name,description:text:optional,image:string:optional"

# Product — what you sell
grit generate resource Product --fields "name:string,slug:slug:name,price:float,description:richtext,stock:int,active:bool,category_id:belongs_to:Category,image:string:optional"

# Order — customer purchases
grit generate resource Order --fields "customer_name:string,customer_email:string,total:float,status:string,notes:text:optional"
slug: A URL-friendly version of a name. "Running Shoes Pro" becomes "running-shoes-pro."Slugs are used in URLs (/products/running-shoes-pro) instead of IDs (/products/42). They're human-readable, SEO-friendly, and look professional. The slug:slug:name syntax tells Grit to auto-generate the slug from the name field.
richtext: A field type for HTML content. Product descriptions often need formatting — bold text, lists, links, images. A richtext field stores HTML and renders it properly in the UI. In the database, it's stored as TEXT just like a regular text field.

Let's look at what each resource represents:

ResourcePurposeKey Fields
CategoryOrganize products into groupsname, slug (auto from name), description, image
ProductItems for salename, slug, price, description (rich), stock, active, category
OrderTrack purchasescustomer name/email, total, status, notes
3

Challenge: Generate the Resources

Generate all 3 resources using the commands above. Restart the Go API. Open GORM Studio and verify the tables exist. How many columns does the products table have? What data type is the price column?

Start in Dev Mode

In development, the Go API and Vite dev server run separately. Vite proxies API requests to the Go server, so the frontend can call /api/* endpoints without CORS issues.

Terminal 1 — Start Infrastructure + Go API
cd shop
docker compose up -d
go run main.go
Terminal 2 — Start Vite Dev Server
cd shop/frontend
pnpm install
pnpm dev

Now you have two servers:

  • Go APIlocalhost:8080 (serves JSON at /api/*, plus /studio, /docs, etc.)
  • Vitelocalhost:5173 (serves the React frontend with hot reload)

Vite is configured to proxy /api/* requests to localhost:8080, so your React code can call fetch("/api/products") and it will transparently reach the Go API.

frontend/vite.config.ts (proxy section)
server: {
  port: 5173,
  proxy: {
    '/api': {
      target: 'http://localhost:8080',
      changeOrigin: true,
    },
  },
}
During development, always open localhost:5173 (Vite), not localhost:8080 (Go). Vite gives you hot module replacement — changes to React components appear instantly without a page refresh. The Go server doesn't serve the frontend in dev mode.
4

Challenge: Start Both Servers

Start Docker Compose, the Go API, and the Vite dev server. Open localhost:5173 — do you see the React app? Open localhost:8080/docs — do you see the API docs? From the React app, does calling an API endpoint (like listing products) work through the Vite proxy?

Seed Products

An empty store is not very convincing. Let's create categories and products through the API docs. Start with 5 categories and 20 products spread across them.

Create Categories via API
POST /api/categories
{"name": "Electronics", "description": "Phones, laptops, tablets, and accessories"}

POST /api/categories
{"name": "Clothing", "description": "Men and women apparel"}

POST /api/categories
{"name": "Home & Kitchen", "description": "Furniture, cookware, and home decor"}

POST /api/categories
{"name": "Books", "description": "Fiction, non-fiction, and technical books"}

POST /api/categories
{"name": "Sports", "description": "Equipment, apparel, and accessories"}
Create Products via API
POST /api/products
{
  "name": "Wireless Noise-Cancelling Headphones",
  "price": 149.99,
  "description": "<p>Premium wireless headphones with active noise cancellation. 30-hour battery life, Bluetooth 5.3, and memory foam ear cushions.</p>",
  "stock": 50,
  "active": true,
  "category_id": 1
}

POST /api/products
{
  "name": "Mechanical Keyboard",
  "price": 89.99,
  "description": "<p>RGB mechanical keyboard with Cherry MX switches. Hot-swappable, aluminum frame, USB-C connection.</p>",
  "stock": 30,
  "active": true,
  "category_id": 1
}
The slug field is auto-generated from the name field."Wireless Noise-Cancelling Headphones" becomes "wireless-noise-cancelling-headphones."You don't need to set it manually — Grit handles slugification.
5

Challenge: Seed 20 Products

Create 5 categories and at least 20 products using the API docs at /docs. Make them realistic — real product names, real prices, proper descriptions. Spread them across categories. After seeding, call GET /api/products?page_size=10 — do you get 10 products on the first page? What's the slug for your most expensive product?

Build the Product Listing

The product listing page is the storefront — a grid of products with images, names, and prices. Users can filter by category using tabs at the top.

frontend/src/routes/index.tsx
import { useState } from 'react'
import { useQuery } from '@tanstack/react-query'
import { Link } from '@tanstack/react-router'

export default function ProductListing() {
  const [activeCategory, setActiveCategory] = useState(null)

  const { data: categories } = useQuery({
    queryKey: ['categories'],
    queryFn: () => api.get('/api/categories'),
  })

  const { data: products, isLoading } = useQuery({
    queryKey: ['products', activeCategory],
    queryFn: () => api.get('/api/products?category_id=' + (activeCategory || '')),
  })

  return (
    <div>
      {/* Category filter tabs */}
      <div className="flex gap-2">
        <button onClick={() => setActiveCategory(null)}>All</button>
        {/* Map categories to tab buttons */}
      </div>

      {/* Product grid */}
      <div className="grid grid-cols-4 gap-6">
        {/* Map products to cards with image, name, price */}
        {/* Each card links to /products/[slug] */}
      </div>
    </div>
  )
}

The component fetches both categories (for the filter tabs) and products (for the grid). When a category tab is clicked, the activeCategory state changes, which triggers a new query with the category_id filter.

6

Challenge: Build the Product Grid

Create the product listing page with category filter tabs and a responsive grid. Click different category tabs — does the product list filter correctly? How many products appear in each category? Does the grid look good on both desktop and mobile screen sizes?

Build the Product Detail

When a user clicks a product, they should see a detail page with the full description, price, stock status, and an "Add to Cart" button. The URL uses the slug: /products/wireless-noise-cancelling-headphones.

frontend/src/routes/products/$slug.tsx
import { useParams } from '@tanstack/react-router'
import { useQuery } from '@tanstack/react-query'

export default function ProductDetail() {
  const { slug } = useParams({ from: '/products/$slug' })

  const { data, isLoading } = useQuery({
    queryKey: ['product', slug],
    queryFn: () => api.get('/api/products?slug=' + slug),
  })

  // Display: product image, name, price, stock status
  // Category name as breadcrumb
  // Add to Cart button (disabled if out of stock)
  // Rich text description rendered with dangerouslySetInnerHTML
  // Price formatted with .toFixed(2)
}
The dangerouslySetInnerHTML prop renders the rich text HTML description. In production, always sanitize HTML from the server to prevent XSS attacks. Libraries like DOMPurify can sanitize HTML on the client side before rendering.
7

Challenge: Build the Product Detail Page

Create the product detail page that loads by slug. Click a product from the listing — does it navigate to /products/your-product-slug? Is the full description rendered? Does the "Add to Cart" button appear? Test with a product that has 0 stock — does the button show "Out of Stock"?

Build a Simple Cart

For an MVP e-commerce store, the cart doesn't need a backend. React state is enough. The cart is an array of items stored in component state — when the user adds a product, it goes into the array. When they remove it, it comes out. The total is calculated from the array.

Client-Side Cart: A shopping cart that lives entirely in the browser's memory (React state). No API calls are needed to add or remove items — it's instant. The cart is submitted to the server only at checkout when the user places an order. This is the simplest approach and works well for most e-commerce stores.
frontend/src/hooks/useCart.ts
// useCart() custom hook — client-side cart management
// No backend needed for MVP cart
//
// State: CartItem[] with id, name, price, quantity, image
//
// addToCart(product):
//   - Find existing item by ID
//   - If found: increment quantity
//   - If new: add with quantity 1
//
// removeFromCart(id): filter out by ID
// updateQuantity(id, qty): map and update matching item
// clearCart(): reset to empty array
//
// total: items.reduce(sum + price * quantity)
// itemCount: items.reduce(sum + quantity)
//
// Usage in components:
//   const { items, addToCart, removeFromCart, total } = useCart()
//   addToCart({ id: 1, name: "Widget", price: 9.99 })
//   removeFromCart(1)

The useCart hook exposes everything the UI needs:

  • addToCart(product) — adds a product (or increments quantity if already in cart)
  • removeFromCart(id) — removes a product entirely
  • updateQuantity(id, qty) — sets a specific quantity (0 removes the item)
  • total — the sum of (price * quantity) for all items
  • itemCount — total number of items in the cart
  • clearCart() — empties the cart (used after successful checkout)
8

Challenge: Build the Cart

Create the useCart hook and a cart page that displays all items with quantity controls (+/-), individual item totals, and a grand total. Test it: add 3 different products, increase the quantity of one, remove another. Does the total update correctly? Add the same product twice — does it increment the quantity instead of adding a duplicate?

Checkout

Checkout is where the cart becomes an order. The user fills in their name and email, reviews the cart, and submits. We create an Order via the API with the cart total.

frontend/src/routes/checkout.tsx (simplified)
// Checkout page flow:
// 1. Get cart items and total from useCart() hook
// 2. Show order summary: each item with qty, name, price
// 3. Show form: customer name + email inputs
// 4. On submit: POST /api/orders with customer info + total
//    notes field: items.map(i => i.quantity + 'x ' + i.name).join(', ')
// 5. On success: clearCart() and navigate to confirmation page
//
// useMutation for the API call:
//   mutationFn: () => api.post('/api/orders', orderData)
//   onSuccess: () => { clearCart(); navigate('/order-confirmation') }
//
// If cart is empty, show "Your cart is empty" message

The checkout flow:

  • 1. User reviews cart items and total
  • 2. User enters name and email
  • 3. User clicks "Place Order"
  • 4. useMutation sends a POST to /api/orders
  • 5. On success, cart is cleared and user is redirected to a confirmation page
For a real production store, you'd integrate a payment processor (Stripe, PayPal) before creating the order. The order status would be "pending" until payment is confirmed. For this MVP, we skip payment and create the order directly.
9

Challenge: Complete Checkout Flow

Build the full checkout flow: cart page → checkout form → order confirmation. Add 3 products to your cart, proceed to checkout, fill in your name and email, and place the order. After the order is placed, check the API: call GET /api/orders — is your order there? Does the notes field contain the item list? Is the cart empty after ordering?

Build for Production

This is where single app architecture truly shines. Two commands turn your entire application — Go API + React frontend — into a single binary file. Copy it to any server, run it, and your store is live.

Terminal
# Step 1: Build the React frontend into static files
cd frontend
pnpm build
# Output: frontend/dist/ (HTML, JS, CSS)

# Step 2: Build the Go binary (embeds frontend/dist/ automatically)
cd ..
go build -o shop main.go
# Output: shop (or shop.exe on Windows)

# Step 3: Run the production binary
./shop
# Serves API at /api/* and frontend at /* on port 8080

What happens during go build:

  • 1. The Go compiler reads the //go:embed frontend/dist/* directive in main.go
  • 2. It packages every file in frontend/dist/ into the binary
  • 3. At runtime, the Go server serves these embedded files for non-API routes
  • 4. API routes (/api/*) go to your handlers as usual
main.go (embed directive)
import "embed"

//go:embed frontend/dist/*
var frontendFS embed.FS

func main() {
    // ... setup router, middleware, handlers

    // Serve embedded frontend for non-API routes
    router.NoRoute(gin.WrapH(http.FileServer(http.FS(frontendFS))))

    router.Run(":8080")
}

The resulting binary is completely self-contained. No Node.js runtime, no separate frontend files, no reverse proxy needed. Just the binary, a .env file, and a database.

You can deploy this binary to any Linux server with a simple scp command. Or use grit deploy which handles systemd, Caddy, and HTTPS automatically. The binary size is typically 15-25 MB depending on your frontend assets.
10

Challenge: Build the Binary

Build the frontend (pnpm build) and then build the Go binary (go build -o shop main.go). How large is the binary file? Run it with ./shop and open localhost:8080 in your browser. Does it serve both the API and the frontend from the same port? Test: can you browse products, add to cart, and place an order — all from the single binary?

Summary

Here's everything you learned in this course:

  • Single app architecture serves API + frontend from one Go binary
  • go:embed bakes frontend static files into the compiled binary at build time
  • The --single --vite flags create a flat project with Go + Vite/React
  • Slugs create human-readable URLs (/products/wireless-headphones instead of /products/42)
  • Richtext fields store HTML content for product descriptions
  • Vite proxies /api/* requests to the Go server during development
  • Client-side cart using React state is sufficient for MVP e-commerce
  • useCart hook provides add, remove, update quantity, total, and clear
  • Checkout creates an Order via the API with cart data
  • go build produces a single self-contained binary for deployment
11

Challenge: Add Product Search

Add a search bar to the product listing page. As the user types, filter products by name. Implement debouncing — don't send an API request on every keystroke, wait 300ms after the user stops typing. You can use the ?search= query parameter or filter client-side.

12

Challenge: Add Order History

Build an order history page at /orders. When a user enters their email, fetch their orders from GET /api/orders?customer_email=their@email.com. Display each order with its date, total, status, and item list (from the notes field).

13

Challenge: Add Stock Management

When an order is placed, the product stock should decrease. Currently, ordering doesn't affect stock. Think about how you'd solve this: should the frontend update stock, or should the API handle it automatically? (Hint: the API should handle it — never trust the client with business logic.) How would you prevent overselling when two users order the last item simultaneously?

14

Challenge: Deploy Your Store

Build the production binary and deploy it. If you have a server, use grit deploy. If not, write down the exact steps you'd take: build frontend, build Go binary, copy to server, create systemd service, configure Caddy for HTTPS. Your e-commerce store is a single file — deployment doesn't get simpler than this.