Build an E-Commerce Store
Build a fully functional e-commerce platform with products, categories, orders, order items, image uploads, stock validation, order confirmation emails, a revenue dashboard, and Redis caching for popular products. This is the most comprehensive Grit tutorial and covers nearly every feature of the framework.
Prerequisites
- ✓Go 1.21+ installed
- ✓Node.js 18+ and pnpm installed
- ✓Docker and Docker Compose installed
- ✓Grit CLI installed globally (go install github.com/MUKE-coder/grit/cmd/grit@latest)
Create the project
Scaffold a new Grit project called shopgrit.
Start Docker services
Start PostgreSQL, Redis, MinIO, and Mailhog. MinIO is especially important for this project because we will use it for product image uploads.
Generate the Product resource
Products are the core of the store. Generate a resource with name, description, price, SKU (unique stock-keeping unit), stock count, and a published flag.
package modelsimport ("time""gorm.io/gorm")type Product struct {ID uint `gorm:"primarykey" json:"id"`Name string `gorm:"size:255;not null" json:"name" binding:"required"`Description string `gorm:"type:text" json:"description"`Price float64 `gorm:"not null" json:"price" binding:"required"`SKU string `gorm:"size:100;uniqueIndex;not null" json:"sku" binding:"required"`Stock int `gorm:"default:0" json:"stock"`Published bool `gorm:"default:false" json:"published"`CreatedAt time.Time `json:"created_at"`UpdatedAt time.Time `json:"updated_at"`DeletedAt gorm.DeletedAt `gorm:"index" json:"deleted_at,omitempty"`}
Generate the Category resource
Products are organized into categories. Generate a Category resource and add the relationship to Product.
Now add the category relationship to the Product model. Also add an ImageURL field for product images (we will handle uploads in Step 8):
package modelsimport ("time""gorm.io/gorm")type Product struct {ID uint `gorm:"primarykey" json:"id"`Name string `gorm:"size:255;not null" json:"name" binding:"required"`Description string `gorm:"type:text" json:"description"`Price float64 `gorm:"not null" json:"price" binding:"required"`SKU string `gorm:"size:100;uniqueIndex;not null" json:"sku" binding:"required"`Stock int `gorm:"default:0" json:"stock"`Published bool `gorm:"default:false" json:"published"`ImageURL string `gorm:"size:500" json:"image_url"`// Belongs to CategoryCategoryID uint `gorm:"index;not null" json:"category_id" binding:"required"`Category Category `gorm:"foreignKey:CategoryID" json:"category,omitempty"`CreatedAt time.Time `json:"created_at"`UpdatedAt time.Time `json:"updated_at"`DeletedAt gorm.DeletedAt `gorm:"index" json:"deleted_at,omitempty"`}
Generate the Order resource
Orders track customer purchases. Generate an Order resource with status, total, and notes fields. An order belongs to the authenticated user.
package modelsimport ("time""gorm.io/gorm")// Order status constantsconst (OrderStatusPending = "pending"OrderStatusProcessing = "processing"OrderStatusShipped = "shipped"OrderStatusDelivered = "delivered"OrderStatusCancelled = "cancelled")type Order struct {ID uint `gorm:"primarykey" json:"id"`Status string `gorm:"size:50;default:pending" json:"status"`Total float64 `gorm:"not null" json:"total"`Notes string `gorm:"type:text" json:"notes"`// Belongs to User (the customer)UserID uint `gorm:"index;not null" json:"user_id"`User User `gorm:"foreignKey:UserID" json:"user,omitempty"`// Has many order itemsItems []OrderItem `gorm:"foreignKey:OrderID" json:"items,omitempty"`CreatedAt time.Time `json:"created_at"`UpdatedAt time.Time `json:"updated_at"`DeletedAt gorm.DeletedAt `gorm:"index" json:"deleted_at,omitempty"`}
Generate the OrderItem resource
Each order contains one or more items. An OrderItem records the quantity and the price at the time of purchase (so price changes do not affect past orders).
package modelsimport ("time""gorm.io/gorm")type OrderItem struct {ID uint `gorm:"primarykey" json:"id"`Quantity int `gorm:"not null" json:"quantity" binding:"required,min=1"`Price float64 `gorm:"not null" json:"price"`// Belongs to OrderOrderID uint `gorm:"index;not null" json:"order_id"`Order Order `gorm:"foreignKey:OrderID" json:"order,omitempty"`// References a ProductProductID uint `gorm:"index;not null" json:"product_id" binding:"required"`Product Product `gorm:"foreignKey:ProductID" json:"product,omitempty"`CreatedAt time.Time `json:"created_at"`UpdatedAt time.Time `json:"updated_at"`DeletedAt gorm.DeletedAt `gorm:"index" json:"deleted_at,omitempty"`}
Run grit sync to regenerate all TypeScript types with the new relationships across all four resources.
Set up all relationships
Let's review the complete relationship diagram. Add the inverse relationships to the Category model:
Category└── has many ProductsProduct└── belongs to CategoryOrder├── belongs to User (customer)└── has many OrderItemsOrderItem├── belongs to Order└── belongs to ProductUser (built-in)└── has many Orders
package modelsimport ("time""gorm.io/gorm")type Category struct {ID uint `gorm:"primarykey" json:"id"`Name string `gorm:"size:255;uniqueIndex;not null" json:"name" binding:"required"`Slug string `gorm:"size:255;uniqueIndex;not null" json:"slug" binding:"required"`Description string `gorm:"type:text" json:"description"`Products []Product `gorm:"foreignKey:CategoryID" json:"products,omitempty"`CreatedAt time.Time `json:"created_at"`UpdatedAt time.Time `json:"updated_at"`DeletedAt gorm.DeletedAt `gorm:"index" json:"deleted_at,omitempty"`}
File uploads for product images
Use Grit's built-in storage service to upload product images to MinIO (S3-compatible). The storage service is already configured — you just need to add an upload handler that saves the file URL to the product.
// UploadImage handles product image upload.func (h *ProductHandler) UploadImage(c *gin.Context) {id, err := strconv.ParseUint(c.Param("id"), 10, 64)if err != nil {c.JSON(http.StatusBadRequest, gin.H{"error": gin.H{"code": "INVALID_ID", "message": "Invalid product ID"},})return}// Get the file from the multipart formfile, header, err := c.Request.FormFile("image")if err != nil {c.JSON(http.StatusBadRequest, gin.H{"error": gin.H{"code": "NO_FILE", "message": "No image file provided"},})return}defer file.Close()// Validate file typecontentType := header.Header.Get("Content-Type")if contentType != "image/jpeg" && contentType != "image/png" && contentType != "image/webp" {c.JSON(http.StatusBadRequest, gin.H{"error": gin.H{"code": "INVALID_TYPE","message": "Only JPEG, PNG, and WebP images are allowed",},})return}// Upload to storage (MinIO / S3)path := fmt.Sprintf("products/%d/%s", id, header.Filename)url, err := h.storage.Upload(c, path, file, contentType)if err != nil {c.JSON(http.StatusInternalServerError, gin.H{"error": gin.H{"code": "UPLOAD_FAILED", "message": "Failed to upload image"},})return}// Update the product record with the image URLresult := h.db.Model(&models.Product{}).Where("id = ?", id).Update("image_url", url)if result.Error != nil {c.JSON(http.StatusInternalServerError, gin.H{"error": gin.H{"code": "UPDATE_FAILED", "message": "Failed to update product"},})return}c.JSON(http.StatusOK, gin.H{"data": gin.H{"image_url": url},"message": "Image uploaded successfully",})}
Register the upload route:
// Inside the authenticated products groupproducts.POST("/:id/upload-image", productHandler.UploadImage)
Update the admin resource definition to include the file upload field and display the product image in the table:
import { defineResource } from '@grit/admin'export default defineResource({name: 'Product',endpoint: '/api/products',icon: 'Package',table: {columns: [{ key: 'id', label: 'ID', sortable: true },{ key: 'image_url', label: 'Image', format: 'image' },{ key: 'name', label: 'Name', sortable: true, searchable: true },{ key: 'sku', label: 'SKU', sortable: true },{ key: 'category.name', label: 'Category', relation: 'category' },{ key: 'price', label: 'Price', sortable: true, format: 'currency' },{ key: 'stock', label: 'Stock', sortable: true },{ key: 'published', label: 'Status', badge: {true: { color: 'green', label: 'Published' },false: { color: 'yellow', label: 'Draft' },}},],filters: [{ key: 'published', type: 'select', options: [{ label: 'Published', value: 'true' },{ label: 'Draft', value: 'false' },]},{ key: 'category_id', type: 'select', resource: 'categories',displayKey: 'name', label: 'Category' },{ key: 'price', type: 'number-range' },],actions: ['create', 'edit', 'delete', 'export'],bulkActions: ['delete', 'export'],},form: {fields: [{ key: 'name', label: 'Name', type: 'text', required: true },{ key: 'sku', label: 'SKU', type: 'text', required: true },{ key: 'description', label: 'Description', type: 'textarea' },{ key: 'category_id', label: 'Category', type: 'relation',resource: 'categories', displayKey: 'name', required: true },{ key: 'price', label: 'Price', type: 'number', prefix: '$', required: true },{ key: 'stock', label: 'Stock', type: 'number', default: 0 },{ key: 'image', label: 'Product Image', type: 'file',accept: 'image/*', uploadEndpoint: '/api/products/{id}/upload-image' },{ key: 'published', label: 'Published', type: 'toggle', default: false },],},})
Custom order creation with stock validation
The default CRUD handler is not sufficient for order creation. We need to validate stock availability, calculate totals, decrement stock, and create order items — all within a database transaction.
type CreateOrderInput struct {Notes string `json:"notes"`Items []struct {ProductID uint `json:"product_id" binding:"required"`Quantity int `json:"quantity" binding:"required,min=1"`} `json:"items" binding:"required,min=1"`}func (h *OrderHandler) CreateOrder(c *gin.Context) {user, _ := c.Get("user")currentUser := user.(*models.User)var input CreateOrderInputif err := c.ShouldBindJSON(&input); err != nil {c.JSON(http.StatusBadRequest, gin.H{"error": gin.H{"code": "VALIDATION_ERROR", "message": err.Error()},})return}// Use a transaction to ensure atomicitytx := h.db.Begin()defer func() {if r := recover(); r != nil {tx.Rollback()}}()var total float64var orderItems []models.OrderItemfor _, item := range input.Items {// Lock the product row for updatevar product models.Productif err := tx.Set("gorm:query_option", "FOR UPDATE").First(&product, item.ProductID).Error; err != nil {tx.Rollback()c.JSON(http.StatusBadRequest, gin.H{"error": gin.H{"code": "PRODUCT_NOT_FOUND","message": fmt.Sprintf("Product %d not found", item.ProductID),},})return}// Validate stockif product.Stock < item.Quantity {tx.Rollback()c.JSON(http.StatusBadRequest, gin.H{"error": gin.H{"code": "INSUFFICIENT_STOCK","message": fmt.Sprintf("Insufficient stock for %s: requested %d, available %d",product.Name, item.Quantity, product.Stock,),},})return}// Decrement stocktx.Model(&product).Update("stock", product.Stock-item.Quantity)// Calculate line totallineTotal := product.Price * float64(item.Quantity)total += lineTotalorderItems = append(orderItems, models.OrderItem{ProductID: product.ID,Quantity: item.Quantity,Price: product.Price, // snapshot the price at time of purchase})}// Create the orderorder := models.Order{UserID: currentUser.ID,Status: models.OrderStatusPending,Total: total,Notes: input.Notes,}if err := tx.Create(&order).Error; err != nil {tx.Rollback()c.JSON(http.StatusInternalServerError, gin.H{"error": gin.H{"code": "CREATE_FAILED", "message": "Failed to create order"},})return}// Create order itemsfor i := range orderItems {orderItems[i].OrderID = order.ID}if err := tx.Create(&orderItems).Error; err != nil {tx.Rollback()c.JSON(http.StatusInternalServerError, gin.H{"error": gin.H{"code": "CREATE_FAILED", "message": "Failed to create order items"},})return}tx.Commit()// Enqueue confirmation email (non-blocking)_ = h.jobClient.EnqueueOrderConfirmation(jobs.OrderConfirmationPayload{OrderID: order.ID,UserEmail: currentUser.Email,UserName: currentUser.Name,Total: total,ItemCount: len(orderItems),})// Reload with relationshipsh.db.Preload("Items.Product").Preload("User").First(&order, order.ID)c.JSON(http.StatusCreated, gin.H{"data": order,"message": "Order created successfully",})}
Register the custom order creation route:
// Replace the default POST /api/orders with the custom handlerorders := auth.Group("/orders"){orders.POST("", orderHandler.CreateOrder) // custom creationorders.GET("", orderHandler.GetAll) // generatedorders.GET("/:id", orderHandler.GetByID) // generated// No PUT or DELETE — orders are immutable once created}
Background job for order confirmation emails
When an order is placed, queue a background job that sends a confirmation email. This keeps the API response fast.
package jobsimport ("context""encoding/json""fmt""github.com/hibiken/asynq""shopgrit/apps/api/internal/mail")const TypeOrderConfirmation = "order:confirmation"type OrderConfirmationPayload struct {OrderID uint `json:"order_id"`UserEmail string `json:"user_email"`UserName string `json:"user_name"`Total float64 `json:"total"`ItemCount int `json:"item_count"`}// EnqueueOrderConfirmation creates a new order confirmation email job.func (c *Client) EnqueueOrderConfirmation(payload OrderConfirmationPayload) error {data, err := json.Marshal(payload)if err != nil {return fmt.Errorf("failed to marshal payload: %w", err)}task := asynq.NewTask(TypeOrderConfirmation, data)_, err = c.client.Enqueue(task, asynq.MaxRetry(5), asynq.Queue("critical"))return err}// HandleOrderConfirmation processes the order confirmation email.func HandleOrderConfirmation(mailer *mail.Mailer) asynq.HandlerFunc {return func(ctx context.Context, t *asynq.Task) error {var payload OrderConfirmationPayloadif err := json.Unmarshal(t.Payload(), &payload); err != nil {return fmt.Errorf("failed to unmarshal payload: %w", err)}return mailer.Send(ctx, mail.SendOptions{To: payload.UserEmail,Subject: fmt.Sprintf("Order #%d Confirmed", payload.OrderID),Template: "order-confirmation",Data: map[string]interface{}{"Name": payload.UserName,"OrderID": payload.OrderID,"Total": fmt.Sprintf("$%.2f", payload.Total),"ItemCount": payload.ItemCount,},})}}
<!DOCTYPE html><html><head><meta charset="utf-8"></head><body style="font-family: 'DM Sans', sans-serif; background: #0a0a0f; color: #e8e8f0; padding: 40px;"><div style="max-width: 500px; margin: 0 auto; background: #111118; border-radius: 12px; padding: 32px; border: 1px solid #2a2a3a;"><h2 style="color: #00b894; margin-top: 0;">Order Confirmed</h2><p>Hi {{.Name}},</p><p>Your order has been confirmed and is being processed.</p><div style="background: #1a1a24; border-radius: 8px; padding: 16px; margin: 16px 0; border: 1px solid #2a2a3a;"><p style="margin: 0;"><strong>Order #{{.OrderID}}</strong></p><p style="margin: 4px 0 0; color: #9090a8; font-size: 14px;">{{.ItemCount}} item(s) • Total: {{.Total}}</p></div><p style="color: #9090a8; font-size: 14px;">We'll send you another email when your order ships.</p></div></body></html>
Admin dashboard with revenue stats
Create a stats endpoint that returns revenue totals, order counts, and product inventory stats. Then build a dashboard widget for the admin panel.
// GetRevenueStats returns store-wide revenue and order statistics.func (h *OrderHandler) GetRevenueStats(c *gin.Context) {var totalRevenue float64var totalOrders int64var pendingOrders int64var totalProducts int64var lowStockProducts int64// Revenue from non-cancelled ordersh.db.Model(&models.Order{}).Where("status != ?", models.OrderStatusCancelled).Select("COALESCE(SUM(total), 0)").Scan(&totalRevenue)h.db.Model(&models.Order{}).Count(&totalOrders)h.db.Model(&models.Order{}).Where("status = ?", models.OrderStatusPending).Count(&pendingOrders)h.db.Model(&models.Product{}).Where("published = ?", true).Count(&totalProducts)// Products with stock below 10h.db.Model(&models.Product{}).Where("stock < ? AND published = ?", 10, true).Count(&lowStockProducts)// Revenue by day for the last 7 daystype DailyRevenue struct {Date string `json:"date"`Revenue float64 `json:"revenue"`}var dailyRevenue []DailyRevenueh.db.Model(&models.Order{}).Select("DATE(created_at) as date, SUM(total) as revenue").Where("created_at >= NOW() - INTERVAL '7 days' AND status != ?",models.OrderStatusCancelled).Group("DATE(created_at)").Order("date ASC").Scan(&dailyRevenue)c.JSON(http.StatusOK, gin.H{"data": gin.H{"total_revenue": totalRevenue,"total_orders": totalOrders,"pending_orders": pendingOrders,"total_products": totalProducts,"low_stock_products": lowStockProducts,"daily_revenue": dailyRevenue,},})}
'use client'import { useQuery } from '@tanstack/react-query'import { apiClient } from '@/lib/api-client'import { DollarSign, ShoppingCart, Package, AlertTriangle } from 'lucide-react'interface RevenueStats {total_revenue: numbertotal_orders: numberpending_orders: numbertotal_products: numberlow_stock_products: numberdaily_revenue: { date: string; revenue: number }[]}export function RevenueStatsWidget() {const { data } = useQuery<{ data: RevenueStats }>({queryKey: ['revenue-stats'],queryFn: async () => {const { data } = await apiClient.get('/api/orders/stats')return data},refetchInterval: 60000,})const stats = data?.dataconst cards = [{label: 'Total Revenue',value: stats ? `$${stats.total_revenue.toLocaleString('en-US', {minimumFractionDigits: 2 })}` : '$0.00',icon: DollarSign,color: 'text-emerald-400',},{label: 'Total Orders',value: stats?.total_orders ?? 0,icon: ShoppingCart,color: 'text-primary',},{label: 'Products Listed',value: stats?.total_products ?? 0,icon: Package,color: 'text-blue-400',},{label: 'Low Stock',value: stats?.low_stock_products ?? 0,icon: AlertTriangle,color: 'text-red-400',},]return (<div className="space-y-6"><div className="grid gap-4 md:grid-cols-2 lg:grid-cols-4">{cards.map((card) => (<divkey={card.label}className="rounded-xl border border-border/40 bg-card/50 p-5"><div className="flex items-center justify-between mb-3"><span className="text-xs font-medium text-muted-foreground/60uppercase tracking-wider">{card.label}</span><card.icon className={`h-4 w-4 ${card.color}`} /></div><p className="text-2xl font-bold tracking-tight">{card.value}</p></div>))}</div>{/* Pending orders callout */}{stats && stats.pending_orders > 0 && (<div className="rounded-xl border border-yellow-500/20 bg-yellow-500/5 p-4flex items-center gap-3"><ShoppingCart className="h-5 w-5 text-yellow-500" /><p className="text-sm text-yellow-200/80">You have <strong>{stats.pending_orders}</strong> pending order(s) waitingto be processed.</p></div>)}</div>)}
Cache popular products with Redis
High-traffic product pages should not hit the database on every request. Use Grit's built-in Redis cache service to cache popular products and invalidate the cache when a product is updated.
package servicesimport ("encoding/json""fmt""time""gorm.io/gorm""shopgrit/apps/api/internal/cache""shopgrit/apps/api/internal/models")type ProductService struct {db *gorm.DBcache *cache.Cache}func NewProductService(db *gorm.DB, c *cache.Cache) *ProductService {return &ProductService{db: db, cache: c}}// GetByID retrieves a product by ID, using the Redis cache.func (s *ProductService) GetByID(id uint) (*models.Product, error) {cacheKey := fmt.Sprintf("product:%d", id)// Try cache firstcached, err := s.cache.Get(cacheKey)if err == nil && cached != "" {var product models.Productif err := json.Unmarshal([]byte(cached), &product); err == nil {return &product, nil}}// Cache miss — query the databasevar product models.Productresult := s.db.Preload("Category").First(&product, id)if result.Error != nil {return nil, fmt.Errorf("product not found: %w", result.Error)}// Store in cache for 5 minutesdata, _ := json.Marshal(product)_ = s.cache.Set(cacheKey, string(data), 5*time.Minute)return &product, nil}// Update updates a product and invalidates the cache.func (s *ProductService) Update(id uint, input map[string]interface{}) (*models.Product, error) {result := s.db.Model(&models.Product{}).Where("id = ?", id).Updates(input)if result.Error != nil {return nil, fmt.Errorf("failed to update product: %w", result.Error)}// Invalidate the cache for this productcacheKey := fmt.Sprintf("product:%d", id)_ = s.cache.Delete(cacheKey)// Also invalidate the published products list cache_ = s.cache.DeletePattern("products:published:*")return s.GetByID(id)}// GetPublished returns published products with caching.func (s *ProductService) GetPublished(page, pageSize int) ([]models.Product, int64, error) {cacheKey := fmt.Sprintf("products:published:%d:%d", page, pageSize)// Try cache firsttype cachedResult struct {Products []models.Product `json:"products"`Total int64 `json:"total"`}cached, err := s.cache.Get(cacheKey)if err == nil && cached != "" {var result cachedResultif err := json.Unmarshal([]byte(cached), &result); err == nil {return result.Products, result.Total, nil}}// Cache miss — query the databasevar products []models.Productvar total int64query := s.db.Model(&models.Product{}).Where("published = ?", true)query.Count(&total)offset := (page - 1) * pageSizequery.Preload("Category").Order("created_at DESC").Offset(offset).Limit(pageSize).Find(&products)// Cache for 2 minutesdata, _ := json.Marshal(cachedResult{Products: products, Total: total})_ = s.cache.Set(cacheKey, string(data), 2*time.Minute)return products, total, nil}
The cache is automatically invalidated when a product is updated through the admin panel or API. Published product listings are cached for 2 minutes, and individual product pages are cached for 5 minutes. This dramatically reduces database load under high traffic.
Run and test everything
Start all services and test the complete e-commerce workflow.
Walk through this testing checklist:
- Open the admin panel at
http://localhost:3001and register an admin account. - Create a category: "Electronics" with slug "electronics".
- Create a product: "Wireless Keyboard", SKU "WK-001", price $49.99, stock 50, category "Electronics", published.
- Upload an image for the product using the image upload field.
- Verify the image appears in the product table and in MinIO at
http://localhost:9001. - Place an order via the API:curl -X POST http://localhost:8080/api/orders \-H "Authorization: Bearer <your-token>" \-H "Content-Type: application/json" \-d '{"items": [{ "product_id": 1, "quantity": 2 }],"notes": "Please gift wrap"}'
- Check that the product stock decreased from 50 to 48 in the admin panel.
- Check Mailhog at
http://localhost:8025for the order confirmation email. - Try ordering more than the available stock — the API should return an
INSUFFICIENT_STOCKerror. - View the revenue dashboard in the admin panel — it should show $99.98 in revenue.
- Request the same product twice via API and verify the second request is served from the Redis cache (check response times).
- Browse all tables in GORM Studio at
http://localhost:8080/studio.
What you've built
- ✓A complete e-commerce store with Go API and Next.js frontend
- ✓Product catalog with categories, pricing, SKU tracking, and stock management
- ✓Product image uploads to S3-compatible storage (MinIO in development)
- ✓Transactional order creation with stock validation and atomic database operations
- ✓Order items that snapshot product prices at time of purchase
- ✓Background job queue for order confirmation emails
- ✓HTML email templates styled to match the Grit dark theme
- ✓Revenue dashboard with daily revenue chart, order counts, and low-stock alerts
- ✓Redis caching for product pages and published product listings
- ✓Cache invalidation on product updates to keep data fresh
- ✓Admin panel with product image previews, currency formatting, and status badges
- ✓Five resources total: Product, Category, Order, OrderItem, and the built-in User
Next steps
You have a solid e-commerce foundation. Here are ideas to take it further:
- Stripe integration — add a payment processing step to the order creation flow using the Stripe Go SDK.
- Product variants — generate a
Variantresource (size, color) linked to products with separate stock tracking. - Wishlist — generate a
Wishlistresource linked to User and Product for saved items. - Search — add full-text search on product names and descriptions using PostgreSQL's
tsvector. - Reviews — generate a
Reviewresource with a rating field and link it to Product and User. - Discount codes — add a
Couponresource and apply discounts during order creation. - Shipping tracking — update order status through the workflow and send shipping notification emails.
- Analytics — use the Grit admin chart widgets to show sales by category, top-selling products, and customer retention.