After a full-stack blog, a weather dashboard, a desktop app, and a mobile app, we close Part 2 with something different: a backend microservice in Go.

Go is interesting for vibe coding because the language is simple and opinionated. There is usually one way to do things. Claude should have an easier time generating idiomatic Go than, say, idiomatic Rust.

Let us find out.

Total time: 2 hours 14 minutes. 6 prompts. Go’s simplicity made this the fastest Part 2 project.

What We’re Building

A URL shortener service with:

  • POST /api/shorten — accepts a URL, returns a short code (e.g., abc123)
  • GET /:code — redirects to the original URL (301 redirect)
  • GET /api/stats/:code — returns click count, creation date, last accessed
  • Custom aliases — optional alias field in POST request
  • Click analytics — track referrer, user agent, country (from IP), timestamp
  • Rate limiting — 10 requests/minute per IP
  • Simple web UI — input field, shorten button, copy-to-clipboard
  • Health check and Prometheus metrics endpoint
  • Docker Compose — Go app + Redis, one command to run

Tech stack: Go, Gin framework, Redis, Docker Compose, HTML/JS frontend

Prerequisites

  • Claude Code installed and configured
  • Go 1.22+ installed
  • Docker and Docker Compose installed
  • Redis (Docker handles this, no local install needed)

The Build Session

Total time: 2 hours 14 minutes. 6 prompts.

Prompt 1: Core API and Redis Storage (Minute 0)

Create a URL shortener in Go using the Gin framework with Redis for storage.

Project structure:
- cmd/server/main.go — entry point
- internal/handler/ — HTTP handlers
- internal/service/ — business logic
- internal/storage/ — Redis operations
- internal/middleware/ — rate limiting, logging
- internal/model/ — data types
- web/ — static HTML/JS frontend

Core endpoints:
1. POST /api/shorten — body: { "url": "https://...", "alias": "optional" }
   Response: { "short_url": "http://localhost:8080/abc123", "code": "abc123" }
   Generate 6-char alphanumeric code if no alias provided.

2. GET /:code — 301 redirect to original URL. Increment click count.

3. GET /api/stats/:code — Response: { "url": "...", "code": "...",
   "clicks": 42, "created_at": "...", "last_clicked": "..." }

Redis keys:
- url:{code} — stores the original URL
- meta:{code} — stores metadata (created_at, clicks, last_clicked)

Use Go modules. Include go.mod with: gin, go-redis, uuid.

What Claude generated:

A clean Go project:

url-shortener/
  cmd/
    server/
      main.go
  internal/
    handler/
      url_handler.go
    service/
      url_service.go
    storage/
      redis.go
    middleware/
      rate_limiter.go
    model/
      url.go
  web/
    index.html
  go.mod
  go.sum
  .env.example
  Dockerfile
  docker-compose.yml

The main entry point:

package main

import (
    "log"
    "os"

    "github.com/gin-gonic/gin"
    "github.com/kemalcodes/url-shortener/internal/handler"
    "github.com/kemalcodes/url-shortener/internal/storage"
)

func main() {
    redisAddr := getEnv("REDIS_ADDR", "localhost:6379")
    port := getEnv("PORT", "8080")
    baseURL := getEnv("BASE_URL", "http://localhost:8080")

    store, err := storage.NewRedisStore(redisAddr)
    if err != nil {
        log.Fatalf("Failed to connect to Redis: %v", err)
    }
    defer store.Close()

    r := gin.Default()

    // Serve static files
    r.Static("/static", "./web")
    r.GET("/", func(c *gin.Context) {
        c.File("./web/index.html")
    })

    h := handler.NewURLHandler(store, baseURL)

    api := r.Group("/api")
    {
        api.POST("/shorten", h.Shorten)
        api.GET("/stats/:code", h.Stats)
    }

    r.GET("/:code", h.Redirect)

    log.Printf("Server starting on :%s", port)
    if err := r.Run(":" + port); err != nil {
        log.Fatalf("Failed to start server: %v", err)
    }
}

func getEnv(key, fallback string) string {
    if val := os.Getenv(key); val != "" {
        return val
    }
    return fallback
}

The URL service:

package service

import (
    "crypto/rand"
    "fmt"
    "math/big"
    "net/url"
    "strings"
)

const charset = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789"

func GenerateCode(length int) (string, error) {
    var sb strings.Builder
    for i := 0; i < length; i++ {
        idx, err := rand.Int(rand.Reader, big.NewInt(int64(len(charset))))
        if err != nil {
            return "", fmt.Errorf("failed to generate code: %w", err)
        }
        sb.WriteByte(charset[idx.Int64()])
    }
    return sb.String(), nil
}

func ValidateURL(rawURL string) error {
    if rawURL == "" {
        return fmt.Errorf("URL is required")
    }

    u, err := url.ParseRequestURI(rawURL)
    if err != nil {
        return fmt.Errorf("invalid URL: %w", err)
    }

    if u.Scheme != "http" && u.Scheme != "https" {
        return fmt.Errorf("URL must start with http:// or https://")
    }

    if u.Host == "" {
        return fmt.Errorf("URL must have a valid host")
    }

    return nil
}

func ValidateAlias(alias string) error {
    if len(alias) < 3 || len(alias) > 20 {
        return fmt.Errorf("alias must be 3-20 characters")
    }

    for _, c := range alias {
        if !((c >= 'a' && c <= 'z') || (c >= 'A' && c <= 'Z') ||
            (c >= '0' && c <= '9') || c == '-' || c == '_') {
            return fmt.Errorf("alias can only contain letters, numbers, hyphens, and underscores")
        }
    }

    return nil
}

The Redis storage:

package storage

import (
    "context"
    "encoding/json"
    "fmt"
    "time"

    "github.com/redis/go-redis/v9"
)

type RedisStore struct {
    client *redis.Client
}

type URLMetadata struct {
    OriginalURL string    `json:"original_url"`
    Code        string    `json:"code"`
    CreatedAt   time.Time `json:"created_at"`
    Clicks      int64     `json:"clicks"`
    LastClicked *time.Time `json:"last_clicked,omitempty"`
}

func NewRedisStore(addr string) (*RedisStore, error) {
    client := redis.NewClient(&redis.Options{
        Addr:     addr,
        Password: "",
        DB:       0,
    })

    ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
    defer cancel()

    if err := client.Ping(ctx).Err(); err != nil {
        return nil, fmt.Errorf("redis connection failed: %w", err)
    }

    return &RedisStore{client: client}, nil
}

func (s *RedisStore) SaveURL(ctx context.Context, code, originalURL string) error {
    meta := URLMetadata{
        OriginalURL: originalURL,
        Code:        code,
        CreatedAt:   time.Now(),
        Clicks:      0,
    }

    data, err := json.Marshal(meta)
    if err != nil {
        return err
    }

    pipe := s.client.Pipeline()
    pipe.Set(ctx, "url:"+code, originalURL, 0)
    pipe.Set(ctx, "meta:"+code, data, 0)
    _, err = pipe.Exec(ctx)
    return err
}

func (s *RedisStore) GetURL(ctx context.Context, code string) (string, error) {
    url, err := s.client.Get(ctx, "url:"+code).Result()
    if err == redis.Nil {
        return "", fmt.Errorf("short URL not found")
    }
    return url, err
}

func (s *RedisStore) IncrementClicks(ctx context.Context, code string) error {
    metaKey := "meta:" + code

    data, err := s.client.Get(ctx, metaKey).Result()
    if err != nil {
        return err
    }

    var meta URLMetadata
    if err := json.Unmarshal([]byte(data), &meta); err != nil {
        return err
    }

    meta.Clicks++
    now := time.Now()
    meta.LastClicked = &now

    updated, err := json.Marshal(meta)
    if err != nil {
        return err
    }

    return s.client.Set(ctx, metaKey, updated, 0).Err()
}

func (s *RedisStore) GetStats(ctx context.Context, code string) (*URLMetadata, error) {
    data, err := s.client.Get(ctx, "meta:"+code).Result()
    if err == redis.Nil {
        return nil, fmt.Errorf("short URL not found")
    }
    if err != nil {
        return nil, err
    }

    var meta URLMetadata
    if err := json.Unmarshal([]byte(data), &meta); err != nil {
        return nil, err
    }
    return &meta, nil
}

func (s *RedisStore) CodeExists(ctx context.Context, code string) (bool, error) {
    exists, err := s.client.Exists(ctx, "url:"+code).Result()
    return exists > 0, err
}

func (s *RedisStore) Close() error {
    return s.client.Close()
}

The HTTP handler:

package handler

import (
    "net/http"

    "github.com/gin-gonic/gin"
    "github.com/kemalcodes/url-shortener/internal/service"
    "github.com/kemalcodes/url-shortener/internal/storage"
)

type URLHandler struct {
    store   *storage.RedisStore
    baseURL string
}

type ShortenRequest struct {
    URL   string `json:"url" binding:"required"`
    Alias string `json:"alias,omitempty"`
}

type ShortenResponse struct {
    ShortURL string `json:"short_url"`
    Code     string `json:"code"`
}

func NewURLHandler(store *storage.RedisStore, baseURL string) *URLHandler {
    return &URLHandler{store: store, baseURL: baseURL}
}

func (h *URLHandler) Shorten(c *gin.Context) {
    var req ShortenRequest
    if err := c.ShouldBindJSON(&req); err != nil {
        c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid request body"})
        return
    }

    if err := service.ValidateURL(req.URL); err != nil {
        c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
        return
    }

    var code string
    var err error

    if req.Alias != "" {
        if err := service.ValidateAlias(req.Alias); err != nil {
            c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
            return
        }

        exists, err := h.store.CodeExists(c.Request.Context(), req.Alias)
        if err != nil {
            c.JSON(http.StatusInternalServerError, gin.H{"error": "Internal error"})
            return
        }
        if exists {
            c.JSON(http.StatusConflict, gin.H{"error": "Alias already taken"})
            return
        }
        code = req.Alias
    } else {
        for attempts := 0; attempts < 5; attempts++ {
            code, err = service.GenerateCode(6)
            if err != nil {
                c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to generate code"})
                return
            }

            exists, err := h.store.CodeExists(c.Request.Context(), code)
            if err != nil {
                c.JSON(http.StatusInternalServerError, gin.H{"error": "Internal error"})
                return
            }
            if !exists {
                break
            }
        }
    }

    if err := h.store.SaveURL(c.Request.Context(), code, req.URL); err != nil {
        c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to save URL"})
        return
    }

    c.JSON(http.StatusCreated, ShortenResponse{
        ShortURL: h.baseURL + "/" + code,
        Code:     code,
    })
}

func (h *URLHandler) Redirect(c *gin.Context) {
    code := c.Param("code")

    url, err := h.store.GetURL(c.Request.Context(), code)
    if err != nil {
        c.JSON(http.StatusNotFound, gin.H{"error": "Short URL not found"})
        return
    }

    // Increment clicks in background
    go func() {
        _ = h.store.IncrementClicks(c.Request.Context(), code)
    }()

    c.Redirect(http.StatusMovedPermanently, url)
}

func (h *URLHandler) Stats(c *gin.Context) {
    code := c.Param("code")

    stats, err := h.store.GetStats(c.Request.Context(), code)
    if err != nil {
        c.JSON(http.StatusNotFound, gin.H{"error": "Short URL not found"})
        return
    }

    c.JSON(http.StatusOK, stats)
}

My review: Solid Go code. Proper error handling with %w for wrapping, context passing, and clean package separation. The code generation used crypto/rand instead of math/rand, which is more secure. The Redis pipeline for saving URL + metadata was efficient.

One bug: the Redirect handler used go func() to increment clicks in the background, but passed c.Request.Context() which could be canceled after the redirect. I noted it for later.

go mod tidy
docker compose up -d redis
go run cmd/server/main.go

The server started. I tested with curl:

curl -X POST http://localhost:8080/api/shorten \
  -H "Content-Type: application/json" \
  -d '{"url": "https://kemalcodes.com"}'

Response:

{"short_url": "http://localhost:8080/kR4m2x", "code": "kR4m2x"}

Redirect worked. Stats endpoint returned the click count. Everything functional on the first try.

Time spent: 15 minutes

Prompt 2: Click Analytics (Minute 15)

Add detailed click analytics:
1. When a redirect happens, record: timestamp, referrer, user agent,
   IP address, and country (resolve from IP using ip-api.com free endpoint)
2. Store each click event in a Redis list: clicks:{code}
3. Add GET /api/stats/:code/clicks — returns last 50 click events
4. Add GET /api/stats/:code/summary — returns: total clicks, clicks per day
   for last 7 days, top referrers, top countries
5. Use a goroutine for analytics recording so it does not slow down redirects

What Claude generated:

A click event model and analytics storage:

type ClickEvent struct {
    Timestamp time.Time `json:"timestamp"`
    Referrer  string    `json:"referrer"`
    UserAgent string    `json:"user_agent"`
    IP        string    `json:"ip"`
    Country   string    `json:"country"`
}

func (s *RedisStore) RecordClick(ctx context.Context, code string, event ClickEvent) error {
    data, err := json.Marshal(event)
    if err != nil {
        return err
    }

    pipe := s.client.Pipeline()
    pipe.LPush(ctx, "clicks:"+code, data)
    pipe.LTrim(ctx, "clicks:"+code, 0, 999) // Keep last 1000 clicks
    _, err = pipe.Exec(ctx)
    return err
}

func (s *RedisStore) GetRecentClicks(ctx context.Context, code string, limit int64) ([]ClickEvent, error) {
    data, err := s.client.LRange(ctx, "clicks:"+code, 0, limit-1).Result()
    if err != nil {
        return nil, err
    }

    events := make([]ClickEvent, 0, len(data))
    for _, d := range data {
        var event ClickEvent
        if err := json.Unmarshal([]byte(d), &event); err != nil {
            continue
        }
        events = append(events, event)
    }
    return events, nil
}

The analytics summary:

type ClickSummary struct {
    TotalClicks   int64              `json:"total_clicks"`
    ClicksPerDay  map[string]int     `json:"clicks_per_day"`
    TopReferrers  []ReferrerCount    `json:"top_referrers"`
    TopCountries  []CountryCount     `json:"top_countries"`
}

type ReferrerCount struct {
    Referrer string `json:"referrer"`
    Count    int    `json:"count"`
}

type CountryCount struct {
    Country string `json:"country"`
    Count   int    `json:"count"`
}

func (h *URLHandler) ClickSummary(c *gin.Context) {
    code := c.Param("code")

    clicks, err := h.store.GetRecentClicks(c.Request.Context(), code, 1000)
    if err != nil {
        c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to fetch analytics"})
        return
    }

    stats, err := h.store.GetStats(c.Request.Context(), code)
    if err != nil {
        c.JSON(http.StatusNotFound, gin.H{"error": "Short URL not found"})
        return
    }

    // Clicks per day (last 7 days)
    clicksPerDay := make(map[string]int)
    now := time.Now()
    for i := 0; i < 7; i++ {
        day := now.AddDate(0, 0, -i).Format("2006-01-02")
        clicksPerDay[day] = 0
    }
    for _, click := range clicks {
        day := click.Timestamp.Format("2006-01-02")
        if _, ok := clicksPerDay[day]; ok {
            clicksPerDay[day]++
        }
    }

    // Top referrers
    referrerMap := make(map[string]int)
    for _, click := range clicks {
        ref := click.Referrer
        if ref == "" {
            ref = "Direct"
        }
        referrerMap[ref]++
    }
    topReferrers := sortedCounts(referrerMap, 5)

    // Top countries
    countryMap := make(map[string]int)
    for _, click := range clicks {
        country := click.Country
        if country == "" {
            country = "Unknown"
        }
        countryMap[country]++
    }
    topCountries := sortedCountryCounts(countryMap, 5)

    c.JSON(http.StatusOK, ClickSummary{
        TotalClicks:  stats.Clicks,
        ClicksPerDay: clicksPerDay,
        TopReferrers: topReferrers,
        TopCountries: topCountries,
    })
}

Country resolution from IP:

func resolveCountry(ip string) string {
    // Skip private IPs
    if ip == "127.0.0.1" || ip == "::1" || strings.HasPrefix(ip, "192.168.") ||
        strings.HasPrefix(ip, "10.") {
        return "Local"
    }

    ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
    defer cancel()

    req, _ := http.NewRequestWithContext(ctx, "GET",
        fmt.Sprintf("http://ip-api.com/json/%s?fields=country", ip), nil)

    resp, err := http.DefaultClient.Do(req)
    if err != nil {
        return "Unknown"
    }
    defer resp.Body.Close()

    var result struct {
        Country string `json:"country"`
    }
    if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
        return "Unknown"
    }
    return result.Country
}

The updated redirect handler with background analytics:

func (h *URLHandler) Redirect(c *gin.Context) {
    code := c.Param("code")

    url, err := h.store.GetURL(c.Request.Context(), code)
    if err != nil {
        c.JSON(http.StatusNotFound, gin.H{"error": "Short URL not found"})
        return
    }

    // Record analytics in background
    event := ClickEvent{
        Timestamp: time.Now(),
        Referrer:  c.GetHeader("Referer"),
        UserAgent: c.GetHeader("User-Agent"),
        IP:        c.ClientIP(),
    }

    go func() {
        event.Country = resolveCountry(event.IP)
        ctx := context.Background()
        _ = h.store.RecordClick(ctx, code, event)
        _ = h.store.IncrementClicks(ctx, code)
    }()

    c.Redirect(http.StatusMovedPermanently, url)
}

My review: Claude fixed the context issue I noted earlier — the goroutine now uses context.Background() instead of the request context. The analytics recording was non-blocking, so redirects stayed fast.

The IP geolocation used the free ip-api.com service with a 2-second timeout. Good for a demo, but the free tier has a 45 requests/minute limit. For production, you would use a local GeoIP database.

Time spent: 14 minutes

Prompt 3: Rate Limiting (Minute 29)

Add rate limiting middleware:
1. Use Redis for distributed rate limiting (supports multiple instances)
2. Sliding window algorithm — 10 requests per minute per IP
3. Return 429 Too Many Requests with a Retry-After header
4. Apply to POST /api/shorten only (not to redirects)
5. Include remaining requests in response headers: X-RateLimit-Remaining

What Claude generated:

package middleware

import (
    "fmt"
    "net/http"
    "time"

    "github.com/gin-gonic/gin"
    "github.com/redis/go-redis/v9"
)

func RateLimiter(redisClient *redis.Client, limit int, window time.Duration) gin.HandlerFunc {
    return func(c *gin.Context) {
        ip := c.ClientIP()
        key := fmt.Sprintf("ratelimit:%s", ip)
        ctx := c.Request.Context()

        now := time.Now().UnixMilli()
        windowStart := now - window.Milliseconds()

        pipe := redisClient.Pipeline()

        // Remove old entries outside the window
        pipe.ZRemRangeByScore(ctx, key, "0", fmt.Sprintf("%d", windowStart))

        // Count current entries
        countCmd := pipe.ZCard(ctx, key)

        // Add current request
        pipe.ZAdd(ctx, key, redis.Z{Score: float64(now), Member: now})

        // Set expiry on the key
        pipe.Expire(ctx, key, window)

        _, err := pipe.Exec(ctx)
        if err != nil {
            c.Next()
            return
        }

        count := countCmd.Val()
        remaining := int64(limit) - count - 1

        c.Header("X-RateLimit-Limit", fmt.Sprintf("%d", limit))
        c.Header("X-RateLimit-Remaining", fmt.Sprintf("%d", max(0, remaining)))

        if count >= int64(limit) {
            retryAfter := window.Seconds()
            c.Header("Retry-After", fmt.Sprintf("%.0f", retryAfter))
            c.JSON(http.StatusTooManyRequests, gin.H{
                "error":       "Rate limit exceeded",
                "retry_after": retryAfter,
            })
            c.Abort()
            return
        }

        c.Next()
    }
}

func max(a, b int64) int64 {
    if a > b {
        return a
    }
    return b
}

Applied to the API routes:

api := r.Group("/api")
api.Use(middleware.RateLimiter(store.Client(), 10, time.Minute))
{
    api.POST("/shorten", h.Shorten)
    api.GET("/stats/:code", h.Stats)
    api.GET("/stats/:code/clicks", h.RecentClicks)
    api.GET("/stats/:code/summary", h.ClickSummary)
}

My review: The sliding window algorithm using Redis sorted sets was a good choice. Each request timestamp is added as a score, and old entries are pruned. This gives accurate per-minute counts even with distributed instances.

Problem: Claude defined a custom max function, but Go 1.21+ has a built-in max function. The code compiled but the compiler warned about shadowing the built-in.

I removed the custom max function and used the built-in.

Time spent: 8 minutes

Prompt 4: Web UI (Minute 37)

Create a simple web UI at web/index.html:
1. Clean, centered design with max-width 600px
2. Input field for the URL, optional input for custom alias
3. "Shorten" button
4. After shortening, show the short URL with a "Copy" button
5. When the short URL is displayed, show a "View Stats" link
6. Stats page: show total clicks, clicks per day chart (simple bars with CSS),
   top referrers, top countries
7. Use vanilla HTML, CSS, JS — no frameworks
8. Dark theme, modern look

What Claude generated:

<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>URL Shortener</title>
    <style>
        * { margin: 0; padding: 0; box-sizing: border-box; }

        body {
            font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
            background: #0f0f0f;
            color: #e0e0e0;
            min-height: 100vh;
            display: flex;
            justify-content: center;
            padding: 40px 20px;
        }

        .container {
            max-width: 600px;
            width: 100%;
        }

        h1 {
            font-size: 2rem;
            font-weight: 700;
            margin-bottom: 8px;
            color: #ffffff;
        }

        .subtitle {
            color: #888;
            margin-bottom: 32px;
        }

        .form-group {
            margin-bottom: 16px;
        }

        label {
            display: block;
            font-size: 0.875rem;
            color: #999;
            margin-bottom: 6px;
        }

        input {
            width: 100%;
            padding: 12px 16px;
            background: #1a1a1a;
            border: 1px solid #333;
            border-radius: 8px;
            color: #fff;
            font-size: 1rem;
            transition: border-color 0.2s;
        }

        input:focus {
            outline: none;
            border-color: #4f8ff7;
        }

        button {
            width: 100%;
            padding: 14px;
            background: #4f8ff7;
            color: #fff;
            border: none;
            border-radius: 8px;
            font-size: 1rem;
            font-weight: 600;
            cursor: pointer;
            transition: background 0.2s;
            margin-top: 8px;
        }

        button:hover { background: #3a7be0; }
        button:disabled { opacity: 0.5; cursor: not-allowed; }

        .result {
            margin-top: 24px;
            padding: 20px;
            background: #1a1a1a;
            border: 1px solid #333;
            border-radius: 12px;
            display: none;
        }

        .result.visible { display: block; }

        .short-url {
            display: flex;
            align-items: center;
            gap: 8px;
            background: #0f0f0f;
            padding: 12px 16px;
            border-radius: 8px;
            margin: 12px 0;
        }

        .short-url a {
            flex: 1;
            color: #4f8ff7;
            text-decoration: none;
            font-weight: 500;
            word-break: break-all;
        }

        .copy-btn {
            width: auto;
            padding: 8px 16px;
            font-size: 0.875rem;
            margin: 0;
            background: #333;
        }

        .copy-btn:hover { background: #444; }
        .copy-btn.copied { background: #22c55e; }

        .stats-link {
            display: inline-block;
            margin-top: 8px;
            color: #888;
            text-decoration: none;
            font-size: 0.875rem;
        }

        .stats-link:hover { color: #4f8ff7; }

        .error {
            color: #ef4444;
            font-size: 0.875rem;
            margin-top: 8px;
            display: none;
        }

        .error.visible { display: block; }

        /* Stats section */
        .stats { margin-top: 32px; display: none; }
        .stats.visible { display: block; }

        .stat-card {
            background: #1a1a1a;
            border: 1px solid #333;
            border-radius: 12px;
            padding: 20px;
            margin-bottom: 16px;
        }

        .stat-card h3 {
            font-size: 0.875rem;
            color: #888;
            margin-bottom: 8px;
        }

        .stat-card .value {
            font-size: 2rem;
            font-weight: 700;
            color: #fff;
        }

        .bar-chart {
            display: flex;
            align-items: flex-end;
            gap: 8px;
            height: 120px;
            padding: 12px 0;
        }

        .bar-group {
            flex: 1;
            display: flex;
            flex-direction: column;
            align-items: center;
            gap: 4px;
        }

        .bar {
            width: 100%;
            background: #4f8ff7;
            border-radius: 4px 4px 0 0;
            min-height: 2px;
            transition: height 0.3s;
        }

        .bar-label {
            font-size: 0.7rem;
            color: #666;
        }

        .list-item {
            display: flex;
            justify-content: space-between;
            padding: 8px 0;
            border-bottom: 1px solid #222;
        }

        .list-item:last-child { border-bottom: none; }
    </style>
</head>
<body>
    <div class="container">
        <h1>URL Shortener</h1>
        <p class="subtitle">Shorten your links, track your clicks.</p>

        <div class="form-group">
            <label for="url">URL to shorten</label>
            <input type="url" id="url" placeholder="https://example.com/very/long/url">
        </div>

        <div class="form-group">
            <label for="alias">Custom alias (optional)</label>
            <input type="text" id="alias" placeholder="my-link">
        </div>

        <button id="shorten-btn" onclick="shortenURL()">Shorten URL</button>

        <p class="error" id="error"></p>

        <div class="result" id="result">
            <p style="font-size: 0.875rem; color: #888;">Your short URL:</p>
            <div class="short-url">
                <a id="short-link" href="#" target="_blank"></a>
                <button class="copy-btn" onclick="copyURL()">Copy</button>
            </div>
            <a class="stats-link" id="stats-link" href="#" onclick="loadStats(event)">
                View click statistics
            </a>
        </div>

        <div class="stats" id="stats">
            <h2 style="margin-bottom: 16px;">Analytics</h2>

            <div style="display: grid; grid-template-columns: 1fr 1fr; gap: 12px;">
                <div class="stat-card">
                    <h3>Total Clicks</h3>
                    <div class="value" id="total-clicks">0</div>
                </div>
                <div class="stat-card">
                    <h3>Original URL</h3>
                    <p id="original-url" style="word-break: break-all; font-size: 0.875rem; color: #aaa;"></p>
                </div>
            </div>

            <div class="stat-card">
                <h3>Clicks (Last 7 Days)</h3>
                <div class="bar-chart" id="clicks-chart"></div>
            </div>

            <div class="stat-card">
                <h3>Top Referrers</h3>
                <div id="referrers"></div>
            </div>

            <div class="stat-card">
                <h3>Top Countries</h3>
                <div id="countries"></div>
            </div>
        </div>
    </div>

    <script>
        let currentCode = "";

        async function shortenURL() {
            const url = document.getElementById("url").value.trim();
            const alias = document.getElementById("alias").value.trim();
            const errorEl = document.getElementById("error");
            const resultEl = document.getElementById("result");
            const btn = document.getElementById("shorten-btn");

            errorEl.classList.remove("visible");
            resultEl.classList.remove("visible");

            if (!url) {
                errorEl.textContent = "Please enter a URL";
                errorEl.classList.add("visible");
                return;
            }

            btn.disabled = true;
            btn.textContent = "Shortening...";

            try {
                const body = { url };
                if (alias) body.alias = alias;

                const res = await fetch("/api/shorten", {
                    method: "POST",
                    headers: { "Content-Type": "application/json" },
                    body: JSON.stringify(body),
                });

                const data = await res.json();

                if (!res.ok) {
                    throw new Error(data.error || "Failed to shorten URL");
                }

                currentCode = data.code;
                const link = document.getElementById("short-link");
                link.href = data.short_url;
                link.textContent = data.short_url;

                resultEl.classList.add("visible");
                document.getElementById("stats").classList.remove("visible");
            } catch (err) {
                errorEl.textContent = err.message;
                errorEl.classList.add("visible");
            } finally {
                btn.disabled = false;
                btn.textContent = "Shorten URL";
            }
        }

        function copyURL() {
            const link = document.getElementById("short-link").textContent;
            navigator.clipboard.writeText(link).then(() => {
                const btn = document.querySelector(".copy-btn");
                btn.textContent = "Copied!";
                btn.classList.add("copied");
                setTimeout(() => {
                    btn.textContent = "Copy";
                    btn.classList.remove("copied");
                }, 2000);
            });
        }

        async function loadStats(e) {
            e.preventDefault();
            if (!currentCode) return;

            try {
                const [statsRes, summaryRes] = await Promise.all([
                    fetch(`/api/stats/${currentCode}`),
                    fetch(`/api/stats/${currentCode}/summary`),
                ]);

                const stats = await statsRes.json();
                const summary = await summaryRes.json();

                document.getElementById("total-clicks").textContent = stats.clicks;
                document.getElementById("original-url").textContent = stats.original_url;

                // Bar chart
                const chartEl = document.getElementById("clicks-chart");
                chartEl.innerHTML = "";
                const days = Object.entries(summary.clicks_per_day || {}).sort();
                const maxClicks = Math.max(...days.map(([, v]) => v), 1);

                days.forEach(([date, count]) => {
                    const group = document.createElement("div");
                    group.className = "bar-group";

                    const bar = document.createElement("div");
                    bar.className = "bar";
                    bar.style.height = `${(count / maxClicks) * 100}%`;

                    const label = document.createElement("span");
                    label.className = "bar-label";
                    label.textContent = date.slice(5); // MM-DD

                    group.appendChild(bar);
                    group.appendChild(label);
                    chartEl.appendChild(group);
                });

                // Referrers
                const refEl = document.getElementById("referrers");
                refEl.innerHTML = (summary.top_referrers || [])
                    .map(r => `<div class="list-item"><span>${r.referrer}</span><span>${r.count}</span></div>`)
                    .join("") || "<p style='color: #666; font-size: 0.875rem;'>No data yet</p>";

                // Countries
                const countryEl = document.getElementById("countries");
                countryEl.innerHTML = (summary.top_countries || [])
                    .map(c => `<div class="list-item"><span>${c.country}</span><span>${c.count}</span></div>`)
                    .join("") || "<p style='color: #666; font-size: 0.875rem;'>No data yet</p>";

                document.getElementById("stats").classList.add("visible");
            } catch (err) {
                console.error("Failed to load stats:", err);
            }
        }

        // Enter key to submit
        document.getElementById("url").addEventListener("keydown", (e) => {
            if (e.key === "Enter") shortenURL();
        });
    </script>
</body>
</html>

My review: The UI was clean and functional. Dark theme, well-spaced inputs, and the stats section with CSS bar charts worked without any JavaScript charting library. The copy-to-clipboard used the modern Clipboard API.

No issues. Worked on first try.

Time spent: 8 minutes

Prompt 5: Prometheus Metrics and Health Check (Minute 45)

Add:
1. GET /health — returns {"status": "ok", "redis": "connected"} or
   {"status": "degraded", "redis": "disconnected"}
2. GET /metrics — Prometheus metrics:
   - urls_shortened_total (counter)
   - redirects_total (counter)
   - redirect_duration_seconds (histogram)
   - active_urls (gauge)
3. Use the prometheus/client_golang library

What Claude generated:

package middleware

import (
    "time"

    "github.com/gin-gonic/gin"
    "github.com/prometheus/client_golang/prometheus"
    "github.com/prometheus/client_golang/prometheus/promauto"
)

var (
    urlsShortenedTotal = promauto.NewCounter(prometheus.CounterOpts{
        Name: "urls_shortened_total",
        Help: "Total number of URLs shortened",
    })

    redirectsTotal = promauto.NewCounter(prometheus.CounterOpts{
        Name: "redirects_total",
        Help: "Total number of redirects",
    })

    redirectDuration = promauto.NewHistogram(prometheus.HistogramOpts{
        Name:    "redirect_duration_seconds",
        Help:    "Redirect request duration in seconds",
        Buckets: prometheus.DefBuckets,
    })
)

func MetricsMiddleware() gin.HandlerFunc {
    return func(c *gin.Context) {
        start := time.Now()
        c.Next()
        duration := time.Since(start).Seconds()

        if c.Request.URL.Path != "/metrics" && c.Request.URL.Path != "/health" {
            redirectDuration.Observe(duration)
        }
    }
}

func IncrementShortened() { urlsShortenedTotal.Inc() }
func IncrementRedirect() { redirectsTotal.Inc() }

The health check:

func (h *URLHandler) Health(c *gin.Context) {
    ctx := c.Request.Context()
    err := h.store.Ping(ctx)

    if err != nil {
        c.JSON(http.StatusServiceUnavailable, gin.H{
            "status": "degraded",
            "redis":  "disconnected",
        })
        return
    }

    c.JSON(http.StatusOK, gin.H{
        "status": "ok",
        "redis":  "connected",
    })
}

Time spent: 7 minutes

Prompt 6: Docker Compose (Minute 52)

Create Dockerfile and docker-compose.yml:
1. Multi-stage Go build for small image size
2. Use alpine as the final base image
3. Docker Compose with: Go app + Redis + Prometheus
4. Prometheus config to scrape the /metrics endpoint
5. Redis persistence with appendonly
6. Health checks on all services
7. The app should restart on failure

What Claude generated:

Dockerfile:

FROM golang:1.22-alpine AS builder

WORKDIR /app

COPY go.mod go.sum ./
RUN go mod download

COPY . .
RUN CGO_ENABLED=0 GOOS=linux go build -ldflags="-s -w" -o /server ./cmd/server

FROM alpine:3.20

RUN apk --no-cache add ca-certificates

WORKDIR /app
COPY --from=builder /server .
COPY web/ ./web/

EXPOSE 8080

HEALTHCHECK --interval=30s --timeout=3s --start-period=5s \
    CMD wget -q --spider http://localhost:8080/health || exit 1

CMD ["./server"]

docker-compose.yml:

services:
  app:
    build: .
    ports:
      - "8080:8080"
    environment:
      - REDIS_ADDR=redis:6379
      - PORT=8080
      - BASE_URL=http://localhost:8080
    depends_on:
      redis:
        condition: service_healthy
    restart: unless-stopped

  redis:
    image: redis:7-alpine
    volumes:
      - redis-data:/data
    command: redis-server --appendonly yes
    healthcheck:
      test: ["CMD", "redis-cli", "ping"]
      interval: 10s
      timeout: 3s
      retries: 3
    restart: unless-stopped

  prometheus:
    image: prom/prometheus:latest
    volumes:
      - ./prometheus.yml:/etc/prometheus/prometheus.yml
      - prometheus-data:/prometheus
    ports:
      - "9090:9090"
    depends_on:
      - app
    restart: unless-stopped

volumes:
  redis-data:
  prometheus-data:

prometheus.yml:

global:
  scrape_interval: 15s

scrape_configs:
  - job_name: "url-shortener"
    static_configs:
      - targets: ["app:8080"]
    metrics_path: "/metrics"

I tested the full stack:

docker compose up --build

All three services started. The Go binary was 12MB (thanks to -ldflags="-s -w" and Alpine). Redis had persistence. Prometheus was scraping metrics every 15 seconds.

# Test the full flow
curl -X POST http://localhost:8080/api/shorten \
  -H "Content-Type: application/json" \
  -d '{"url": "https://kemalcodes.com", "alias": "kc"}'

curl -v http://localhost:8080/kc  # 301 redirect

curl http://localhost:8080/api/stats/kc  # Stats

curl http://localhost:8080/health  # Health check

curl http://localhost:8080/metrics  # Prometheus metrics

Everything worked. The Docker image was 18MB total (Go binary + static files + Alpine).

Time spent: 10 minutes

The remaining time (about 1 hour 12 minutes) was spent on:

  • Writing Go tests for the service layer and handlers using httptest
  • Fixing the context bug in the redirect goroutine (already noted earlier)
  • Adding URL expiration — optional expires_in field (24h, 7d, 30d, never)
  • Adding a simple admin endpoint to list all shortened URLs
  • Testing rate limiting — verified it blocked after 10 requests/minute
  • Cleaning up the code and adding comments

Total time: 2 hours 14 minutes.

What Went Right

  • Go code was idiomatic. Proper error handling with %w, context passing, clean package separation. Claude clearly knows Go patterns well.
  • Redis data modeling was good. Separate keys for URL lookup (fast redirect) and metadata (stats). Pipeline usage for atomic operations. Sorted sets for rate limiting.
  • The multi-stage Docker build was efficient. 18MB final image. The -ldflags="-s -w" flag stripped debug info for a smaller binary.
  • The web UI worked with zero issues. Vanilla HTML/CSS/JS was simple enough that there were no bugs.
  • This was the fastest Part 2 project. Go’s simplicity made everything faster — fewer configuration files, simpler dependency management, and a single binary output.

What Went Wrong

1. Context Leak in Redirect Goroutine

The initial goroutine for analytics used the request context, which gets canceled after the redirect response is sent. This caused intermittent failures in analytics recording.

Lesson: When spawning goroutines from HTTP handlers, always use context.Background() or a detached context. Never pass the request context to background work.

2. Custom max Function Shadows Built-in

Claude defined a custom max(a, b int64) function, not knowing that Go 1.21+ has a built-in max function. The compiler warned about shadowing.

Lesson: Claude’s training data may predate recent language additions. Check for built-in functions in newer language versions.

3. IP Geolocation Rate Limiting

The ip-api.com free tier allows 45 requests per minute. Under heavy load, the country resolution would fail silently. For production, you need a local GeoIP database (like MaxMind’s free GeoLite2).

Lesson: Free third-party APIs have limits. For critical features, use local data or paid APIs.

4. No Redis Connection Pooling Configuration

Claude used the default Redis client configuration, which has a pool size of 10. For a production service handling thousands of requests per second, this would be a bottleneck.

Lesson: For production Go services, always configure connection pool sizes: PoolSize, MinIdleConns, MaxRetries.

The Final Result

A complete URL shortener with:

  • Shorten URLs with random codes or custom aliases
  • 301 redirects with background analytics
  • Click tracking: referrer, user agent, country, timestamp
  • Rate limiting with sliding window
  • Simple dark-themed web UI
  • Stats dashboard with bar chart
  • Prometheus metrics and health check
  • Docker Compose deployment (Go + Redis + Prometheus)

To run it yourself:

git clone https://github.com/kemalcodes/vibe-coding-projects.git
cd vibe-coding-projects
git checkout url-shortener

# Run with Docker Compose
docker compose up --build

# Or run locally (requires Redis running)
go run cmd/server/main.go

Open http://localhost:8080 to use the web UI.

Time and Cost

  • Total time: 2 hours 14 minutes
  • Claude prompts: 6 (plus 3 fixes)
  • Estimated token usage: ~50,000 tokens
  • Docker image size: 18MB
  • Hosting cost: $0 (self-hosted with Docker)
  • Dependencies: Gin, go-redis, prometheus/client_golang

Lessons Learned

1. Go is the best language for vibe coding backend services. Simple syntax, single binary, fast compilation, and strong conventions mean Claude generates idiomatic code with minimal iteration. This was the fastest project in Part 2.

2. Redis is perfect for URL shorteners. Key-value lookup for redirects is O(1). Sorted sets for rate limiting. Lists for analytics. Redis handles all three use cases elegantly.

3. Docker multi-stage builds are worth it. The final image was 18MB instead of 1.2GB (full Go image). Claude generated the Dockerfile correctly on the first try.

4. Vanilla HTML/CSS/JS is underrated. The web UI had zero bugs because there was zero framework complexity. For simple UIs, skip the framework.

5. Background goroutines need care. The context leak was a subtle bug that only shows up under load. Always think about goroutine lifecycle in HTTP handlers.

Source Code

Full source code: kemalcodes/vibe-coding-projects (branch: url-shortener)

What’s Next?

That wraps up Part 2: Real Apps. Five projects, five different tech stacks, from full-stack web to mobile to desktop to backend microservice.

Key takeaways from Part 2:

  1. Full-stack apps (3-4 hours) benefit the most from AI. The boilerplate savings are massive — auth setup, database schemas, CRUD endpoints, admin panels. These would take 15-20 hours manually.

  2. KMP is the hardest, Go is the easiest. Build system complexity determines how much time you spend debugging vs. building features. Go’s simplicity gives Claude the cleanest runway.

  3. Auth, charts, and deployment are the three slowest parts. These three areas consistently need the most iteration and debugging, regardless of the project type.

  4. Total time for all 5 Part 2 projects: about 16 hours. Without Claude, these would be 70-100 hours of work. That is a 4-5x speedup, even accounting for debugging time.

In Part 3, we go even bigger: real-time chat with WebSockets, a SaaS dashboard with Stripe payments, and multi-agent development. Stay tuned.