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
aliasfield 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_infield (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)
Related Articles
- Build a Budget Tracker with Claude — Previous article
- Build a Full-Stack Blog with Claude — First Part 2 article
- Build a CLI Todo App with Claude — First article in this series
- Claude Code Mastery — Advanced Claude Code patterns
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:
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.
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.
Auth, charts, and deployment are the three slowest parts. These three areas consistently need the most iteration and debugging, regardless of the project type.
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.