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.
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.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.
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:
grit new shop --single --viteThe --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).
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.jsonCompare 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.
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.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.
# 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"/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 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:
| Resource | Purpose | Key Fields |
|---|---|---|
| Category | Organize products into groups | name, slug (auto from name), description, image |
| Product | Items for sale | name, slug, price, description (rich), stock, active, category |
| Order | Track purchases | customer name/email, total, status, notes |
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.
cd shop
docker compose up -d
go run main.gocd shop/frontend
pnpm install
pnpm devNow you have two servers:
- • Go API —
localhost:8080(serves JSON at /api/*, plus /studio, /docs, etc.) - • Vite —
localhost: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.
server: {
port: 5173,
proxy: {
'/api': {
target: 'http://localhost:8080',
changeOrigin: true,
},
},
}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.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.
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"}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
}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.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.
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.
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.
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)
}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.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.
// 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)
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.
// 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" messageThe checkout flow:
- 1. User reviews cart items and total
- 2. User enters name and email
- 3. User clicks "Place Order"
- 4.
useMutationsends a POST to/api/orders - 5. On success, cart is cleared and user is redirected to a confirmation page
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.
# 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 8080What happens during go build:
- 1. The Go compiler reads the
//go:embed frontend/dist/*directive inmain.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
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.
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.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
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.
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).
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?
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.
Enjoying the course?
Help us grow — star us on GitHub, subscribe on YouTube, and follow on LinkedIn.