DEV Community

Danilo Jamaal
Danilo Jamaal

Posted on • Edited on

Build a High-Performance Crypto Rankings Dashboard with LunarCrush API + Inngest in 25 Minutes

Build a High-Performance Crypto Rankings Dashboard with LunarCrush API + Inngest in 25 Minutes

A complete guide to building an enterprise-grade cryptocurrency analytics dashboard with real-time social sentiment data

🌐 Live Demo: https://crypto-rankings.vercel.app/

🔧 API Backend: https://crypto-rankings.onrender.com

📱 GitHub Repo: https://github.com/danilobatson/crypto-rankings


Why Real-Time Crypto Social Sentiment Matters

In today's volatile cryptocurrency market, social sentiment drives price movements more than traditional financial metrics. A single tweet from an influencer can cause 20% price swings in minutes. Professional traders and investors need real-time social intelligence to make informed decisions.

That's where LunarCrush comes in. Their API provides social sentiment analysis across 6,700+ cryptocurrencies, tracking millions of social media posts, news articles, and community discussions in real-time.

Dashboard

What You'll Build

In this tutorial, you'll create a production-ready Crypto Rankings Dashboard that showcases modern development practices perfect for job interviews. The system demonstrates:

  • High-Performance Go API with sub-second response times
  • Background Job Processing with Inngest workflows
  • Redis Cloud Caching for 15-minute data persistence
  • Professional React Dashboard with TypeScript and Tailwind CSS
  • Real-Time Updates every 5 minutes via automated jobs
  • 11 Cryptocurrency Metrics including social sentiment analysis
  • Full Cloud Deployment on Render and Vercel

Time Investment: 25 minutes

Skill Level: Intermediate

What You'll Learn: Go backend development, background job processing, Redis caching, React state management, cloud deployment

💡 Pro Tip: This project is perfect for showcasing full-stack expertise in technical interviews. The architecture demonstrates understanding of scalable systems, real-time data processing, and modern cloud practices.

Live Example: View the deployed dashboard →

Table

Prerequisites & Account Setup

You'll Need:

  • Go 1.24+ installed
  • Node.js 18+ for the frontend
  • Basic knowledge of Go and React
  • 4 API keys from different services (we'll walk through signup below)

Two Ways to Experience This Tutorial:

  1. 👨‍💻 Build It Yourself - Follow along step-by-step with your own API keys
  2. 🚀 Try the Live Demo - View the deployed version and clone the repo to explore

Sign Up For LunarCrush API

LunarCrush provides the social sentiment data that powers our analytics dashboard.

Use my discount referral code JAMAALBUILDS to receive 15% off your plan.

  1. Visit LunarCrush Signup
  2. Enter your email address and click "Continue"
  3. Check your email for verification code and enter it
  4. Complete the onboarding steps:
    • Select your favorite categories (or keep defaults)
    • Create your profile (add photo and nickname if desired)
    • Important: Select a subscription plan (you'll need it to generate an API key)

Generate Your API Key

Once you've subscribed, navigate to the API authentication page and generate an API key.

Save this API key - you'll add it to your environment variables later.

Set Up Redis Cloud

Redis Cloud provides managed Redis hosting with a generous free tier:

  1. Sign up: Visit Redis Cloud and create an account
  2. Create Database: Choose the free 30MB tier
  3. Get Connection Details: In your dashboard, copy:
    • Redis endpoint URL
    • Port number
    • Password
  4. Format Connection String: Combine into: redis://default:password@host:port

Set Up Inngest

Inngest handles our background job processing:

  1. Sign up: Visit Inngest and create an account
  2. Create App: Name it "crypto-rankings"
  3. Get Your Keys: In the settings, copy your:
    • Event Key (starts with prod_)
    • Signing Key (starts with signkey_)

Set Up Render (Backend Hosting)

  1. Sign up: Visit Render and connect your GitHub account
  2. Note: We'll configure deployment later in the tutorial

Set Up Vercel (Frontend Hosting)

  1. Sign up: Visit Vercel and connect your GitHub account
  2. Note: We'll configure deployment later in the tutorial

Environment Setup

Create .env.example for reference:

# Redis Cloud
REDIS_URL=redis://default:your_password@your-host:port

# LunarCrush API
LUNARCRUSH_API_KEY=lc_your_key_here

# Inngest
INNGEST_EVENT_KEY=your_event_key
INNGEST_SIGNING_KEY=your_signing_key
INNGEST_DEV_URL=http://localhost:8080/api/inngest

# Application
PORT=8080
Enter fullscreen mode Exit fullscreen mode

Project Setup

Now let's build our crypto rankings dashboard step by step.

Create Project Structure

# Create main project directory
mkdir crypto-rankings
cd crypto-rankings

# Create Go backend directory
mkdir server
cd server

# Initialize Go module
go mod init crypto-rankings

# Install Go dependencies
go get github.com/gin-gonic/gin
go get github.com/gin-contrib/cors
go get github.com/redis/go-redis/v9
go get github.com/inngest/inngestgo
go get github.com/joho/godotenv

# Create main.go file
touch main.go

# Create environment file
touch .env
Enter fullscreen mode Exit fullscreen mode

Create React Frontend

# Navigate back to project root
cd ..

# Create Next.js frontend
npx create-next-app@latest frontend --typescript --tailwind --eslint --app

# Navigate to frontend
cd frontend

# Install additional dependencies
npm install @radix-ui/react-select @radix-ui/react-icons @radix-ui/react-toast
npm install @tanstack/react-query @tanstack/react-query-devtools
Enter fullscreen mode Exit fullscreen mode

Set Up Environment Variables

Create .env in the server directory:

# .env (server directory)
REDIS_URL=redis://default:your_password@your-host:port
LUNARCRUSH_API_KEY=lc_your_key_here
INNGEST_EVENT_KEY=your_event_key
INNGEST_SIGNING_KEY=your_signing_key
INNGEST_DEV_URL=http://localhost:8080/api/inngest
PORT=8080
Enter fullscreen mode Exit fullscreen mode

Create .env.local in the frontend directory:

# .env.local (frontend directory)
NEXT_PUBLIC_API_URL=http://localhost:8080
Enter fullscreen mode Exit fullscreen mode

Go Backend Implementation

Complete API Server

Create the complete Go backend in server/main.go:

package main

import (
    "context"
    "encoding/json"
    "fmt"
    "io"
    "log"
    "net/http"
    "os"
    "sync"
    "time"

    "github.com/gin-contrib/cors"
    "github.com/gin-gonic/gin"
    "github.com/inngest/inngestgo"
    "github.com/inngest/inngestgo/step"
    "github.com/joho/godotenv"
    "github.com/redis/go-redis/v9"
)

// Data structures for crypto analytics
type CryptoData struct {
    Name   string `json:"name"`
    Symbol string `json:"symbol"`
    Value  string `json:"value"`
    Sort   string `json:"sort"`
}

type CryptoDataResponse struct {
    Timestamp    time.Time                    `json:"timestamp"`
    TotalMetrics int                          `json:"total_metrics"`
    AllMetrics   map[string]MetricData        `json:"all_metrics"`
    FetchStats   FetchStats                   `json:"fetch_stats"`
}

type MetricData struct {
    Name         string       `json:"name"`
    Priority     string       `json:"priority"`
    Description  string       `json:"description"`
    Success      bool         `json:"success"`
    DataCount    int          `json:"data_count"`
    AllData      []CryptoData `json:"all_data"`
    Top3Preview  []CryptoData `json:"top_3_preview"`
    FetchTimeMs  int64        `json:"fetch_time_ms"`
    Error        string       `json:"error,omitempty"`
}

type FetchStats struct {
    TotalDurationMs   int64  `json:"total_duration_ms"`
    SuccessfulFetches int    `json:"successful_fetches"`
    FailedFetches     int    `json:"failed_fetches"`
    LastUpdate        string `json:"last_update"`
}

// LunarCrush API response structure
type LunarCrushResponse struct {
    Data []LunarCrushCoin `json:"data"`
}

type LunarCrushCoin struct {
    ID               int     `json:"id"`
    Symbol           string  `json:"symbol"`
    Name             string  `json:"name"`
    Price            float64 `json:"price"`
    MarketCap        float64 `json:"market_cap"`
    Volume24h        float64 `json:"volume_24h"`
    PercentChange1h  float64 `json:"percent_change_1h"`
    PercentChange24h float64 `json:"percent_change_24h"`
    PercentChange7d  float64 `json:"percent_change_7d"`
    AltRank          int     `json:"alt_rank"`
    Interactions24h  *float64 `json:"interactions_24h,omitempty"`
    SocialDominance  *float64 `json:"social_dominance,omitempty"`
    CirculatingSupply *float64 `json:"circulating_supply,omitempty"`
    MarketDominance  *float64 `json:"market_dominance,omitempty"`
}

// Global Redis client
var rdb *redis.Client

// Metric configurations
var AllSortableMetrics = map[string]struct{
    Name        string
    Priority    string
    Description string
}{
    "market_cap":         {"Market Cap", "high", "Total Market Value"},
    "alt_rank":           {"AltRank™", "high", "LunarCrush Performance Ranking"},
    "price":              {"Price", "high", "Current USD Price"},
    "volume_24h":         {"24h Volume", "high", "Trading Volume"},
    "interactions":       {"Social Interactions", "high", "Social Engagements"},
    "percent_change_1h":  {"1h Change", "high", "1 Hour Price Change"},
    "percent_change_24h": {"24h Change", "high", "24 Hour Price Change"},
    "percent_change_7d":  {"7d Change", "high", "7 Day Price Change"},
    "social_dominance":   {"Social Dominance", "medium", "Social Volume Percentage"},
    "circulating_supply": {"Circulating Supply", "medium", "Available Token Supply"},
    "market_dominance":   {"Market Dominance", "medium", "Market Cap Percentage"},
}

// Initialize Redis connection
func initRedis() {
    redisURL := os.Getenv("REDIS_URL")
    if redisURL == "" {
        redisURL = "redis://localhost:6379"
    }

    opt, err := redis.ParseURL(redisURL)
    if err != nil {
        log.Printf("⚠️  Invalid Redis URL: %v", err)
        rdb = redis.NewClient(&redis.Options{Addr: "localhost:6379"})
    } else {
        rdb = redis.NewClient(opt)
    }

    ctx := context.Background()
    _, err = rdb.Ping(ctx).Result()
    if err != nil {
        log.Printf("⚠️  Redis connection failed: %v", err)
        rdb = nil
    } else {
        log.Printf("✅ Redis connected successfully")
    }
}

// Helper function to format large numbers
func formatLargeNumber(n int64) string {
    if n >= 1000000000000 {
        return fmt.Sprintf("%.1fT", float64(n)/1000000000000)
    } else if n >= 1000000000 {
        return fmt.Sprintf("%.1fB", float64(n)/1000000000)
    } else if n >= 1000000 {
        return fmt.Sprintf("%.1fM", float64(n)/1000000)
    } else if n >= 1000 {
        return fmt.Sprintf("%.1fK", float64(n)/1000)
    }
    return fmt.Sprintf("%d", n)
}

// Format values based on metric type
func formatValueForMetric(coin LunarCrushCoin, sortType string) string {
    switch sortType {
    case "market_cap":
        return fmt.Sprintf("$%s", formatLargeNumber(int64(coin.MarketCap)))
    case "price":
        return fmt.Sprintf("$%.2f", coin.Price)
    case "volume_24h":
        return fmt.Sprintf("$%s", formatLargeNumber(int64(coin.Volume24h)))
    case "percent_change_1h", "percent_change_24h", "percent_change_7d":
        value := coin.PercentChange24h
        if sortType == "percent_change_1h" {
            value = coin.PercentChange1h
        } else if sortType == "percent_change_7d" {
            value = coin.PercentChange7d
        }
        return fmt.Sprintf("%.2f%%", value)
    case "alt_rank":
        return fmt.Sprintf("%d", coin.AltRank)
    case "interactions":
        if coin.Interactions24h != nil {
            return formatLargeNumber(int64(*coin.Interactions24h))
        }
        return "0"
    case "social_dominance", "market_dominance":
        var value *float64
        if sortType == "social_dominance" {
            value = coin.SocialDominance
        } else {
            value = coin.MarketDominance
        }
        if value != nil {
            return fmt.Sprintf("%.2f%%", *value)
        }
        return "0%"
    case "circulating_supply":
        if coin.CirculatingSupply != nil {
            return formatLargeNumber(int64(*coin.CirculatingSupply))
        }
        return "0"
    default:
        return fmt.Sprintf("$%.2f", coin.Price)
    }
}

// Fetch single metric from LunarCrush API
func fetchSingleMetric(apiKey, sortType string, limit int) MetricData {
    startTime := time.Now()

    config, exists := AllSortableMetrics[sortType]
    if !exists {
        return MetricData{
            Name:    sortType,
            Success: false,
            Error:   fmt.Sprintf("Unknown sort type: %s", sortType),
        }
    }

    result := MetricData{
        Name:        config.Name,
        Priority:    config.Priority,
        Description: config.Description,
        Success:     false,
        AllData:     []CryptoData{},
        Top3Preview: []CryptoData{},
    }

    url := fmt.Sprintf("https://lunarcrush.com/api4/public/coins/list/v2?sort=%s&limit=%d", 
        sortType, limit)

    client := &http.Client{Timeout: 30 * time.Second}
    req, err := http.NewRequest("GET", url, nil)
    if err != nil {
        result.Error = fmt.Sprintf("Failed to create request: %v", err)
        result.FetchTimeMs = time.Since(startTime).Milliseconds()
        return result
    }

    req.Header.Set("Authorization", fmt.Sprintf("Bearer %s", apiKey))
    req.Header.Set("Content-Type", "application/json")

    resp, err := client.Do(req)
    if err != nil {
        result.Error = fmt.Sprintf("Failed to fetch data: %v", err)
        result.FetchTimeMs = time.Since(startTime).Milliseconds()
        return result
    }
    defer resp.Body.Close()

    body, err := io.ReadAll(resp.Body)
    if err != nil {
        result.Error = fmt.Sprintf("Failed to read response: %v", err)
        result.FetchTimeMs = time.Since(startTime).Milliseconds()
        return result
    }

    result.FetchTimeMs = time.Since(startTime).Milliseconds()

    if resp.StatusCode != 200 {
        result.Error = fmt.Sprintf("API returned status %d", resp.StatusCode)
        return result
    }

    var response LunarCrushResponse
    err = json.Unmarshal(body, &response)
    if err != nil {
        result.Error = fmt.Sprintf("Failed to parse JSON: %v", err)
        return result
    }

    if len(response.Data) == 0 {
        result.Error = "No data returned from API"
        return result
    }

    // Process data
    var allCryptoData []CryptoData
    var top3Preview []CryptoData

    for i, coin := range response.Data {
        value := formatValueForMetric(coin, sortType)

        crypto := CryptoData{
            Name:   coin.Name,
            Symbol: coin.Symbol,
            Value:  value,
            Sort:   sortType,
        }

        allCryptoData = append(allCryptoData, crypto)
        if i < 3 {
            top3Preview = append(top3Preview, crypto)
        }
    }

    result.Success = true
    result.DataCount = len(response.Data)
    result.AllData = allCryptoData
    result.Top3Preview = top3Preview

    return result
}

// Store data in Redis
func storeLatestDataInRedis(data CryptoDataResponse) {
    if rdb == nil {
        return
    }

    ctx := context.Background()
    jsonData, err := json.Marshal(data)
    if err != nil {
        log.Printf("❌ Failed to marshal result: %v", err)
        return
    }

    key := "crypto:latest"
    err = rdb.Set(ctx, key, jsonData, 15*time.Minute).Err()
    if err != nil {
        log.Printf("❌ Failed to store in Redis: %v", err)
    } else {
        log.Printf("✅ Stored latest crypto data")
    }
}

// Get data from Redis
func getLatestDataFromRedis() (CryptoDataResponse, bool) {
    if rdb == nil {
        return CryptoDataResponse{}, false
    }

    ctx := context.Background()
    key := "crypto:latest"
    data, err := rdb.Get(ctx, key).Result()
    if err != nil {
        return CryptoDataResponse{}, false
    }

    var result CryptoDataResponse
    err = json.Unmarshal([]byte(data), &result)
    if err != nil {
        return CryptoDataResponse{}, false
    }

    return result, true
}

// Create Inngest function for background processing
func createCryptoFunction(client inngestgo.Client, apiKey string) (inngestgo.ServableFunction, error) {
    return inngestgo.CreateFunction(
        client,
        inngestgo.FunctionOpts{ID: "fetch-crypto-metrics"},
        inngestgo.CronTrigger("*/5 * * * *"), // Every 5 minutes
        func(ctx context.Context, input inngestgo.Input[map[string]interface{}]) (any, error) {
            log.Printf("🚀 Fetching crypto metrics")
            startTime := time.Now()

            // Get all metric names
            allMetrics := []string{}
            for sortType := range AllSortableMetrics {
                allMetrics = append(allMetrics, sortType)
            }

            // Fetch all metrics in parallel
            allResults, err := step.Run(ctx, "fetch-all-metrics", 
                func(ctx context.Context) (CryptoDataResponse, error) {
                var wg sync.WaitGroup
                results := make(map[string]MetricData)
                resultsMutex := &sync.Mutex{}

                // Process in batches to avoid rate limits
                batchSize := 5
                for i := 0; i < len(allMetrics); i += batchSize {
                    end := i + batchSize
                    if end > len(allMetrics) {
                        end = len(allMetrics)
                    }

                    batch := allMetrics[i:end]
                    for _, sortType := range batch {
                        wg.Add(1)
                        go func(st string) {
                            defer wg.Done()
                            result := fetchSingleMetric(apiKey, st, 10)
                            resultsMutex.Lock()
                            results[st] = result
                            resultsMutex.Unlock()
                        }(sortType)
                    }
                    wg.Wait()

                    if end < len(allMetrics) {
                        time.Sleep(1 * time.Second)
                    }
                }

                // Count successes and failures
                successful := 0
                failed := 0
                for _, result := range results {
                    if result.Success {
                        successful++
                    } else {
                        failed++
                    }
                }

                return CryptoDataResponse{
                    Timestamp:    time.Now(),
                    TotalMetrics: len(AllSortableMetrics),
                    AllMetrics:   results,
                    FetchStats: FetchStats{
                        TotalDurationMs:   time.Since(startTime).Milliseconds(),
                        SuccessfulFetches: successful,
                        FailedFetches:     failed,
                        LastUpdate:       time.Now().Format("2006-01-02 15:04:05"),
                    },
                }, nil
            })

            if err != nil {
                return nil, err
            }

            // Store in Redis
            _, err = step.Run(ctx, "store-latest", func(ctx context.Context) (string, error) {
                storeLatestDataInRedis(allResults)
                return "stored", nil
            })

            return map[string]interface{}{
                "total_metrics":      len(allMetrics),
                "successful_fetches": allResults.FetchStats.SuccessfulFetches,
                "failed_fetches":     allResults.FetchStats.FailedFetches,
                "status":            "completed",
            }, nil
        },
    )
}

func main() {
    // Load environment variables
    if err := godotenv.Load(); err != nil {
        log.Printf("Warning: No .env file found")
    }

    apiKey := os.Getenv("LUNARCRUSH_API_KEY")
    if apiKey == "" {
        log.Fatal("LUNARCRUSH_API_KEY environment variable is required")
    }

    // Initialize Redis
    initRedis()

    // Create Inngest client
    inngestClient, err := inngestgo.NewClient(inngestgo.ClientOpts{
        AppID: "crypto-rankings",
    })
    if err != nil {
        log.Fatal("Failed to create Inngest client:", err)
    }

    // Create background function
    cryptoFunction, err := createCryptoFunction(inngestClient, apiKey)
    if err != nil {
        log.Fatal("Failed to create crypto function:", err)
    }

    log.Printf("✅ Inngest function created: %s", cryptoFunction.Name())

    // Initialize Gin router
    r := gin.Default()

    // CORS configuration
    r.Use(cors.New(cors.Config{
        AllowOrigins: []string{
            "http://localhost:3000",
            "http://localhost:3001",
            "https://crypto-rankings.vercel.app",
            "https://crypto-rankings-*.vercel.app",
        },
        AllowMethods:     []string{"GET", "POST", "PUT", "DELETE", "OPTIONS"},
        AllowHeaders:     []string{"Origin", "Content-Type", "Authorization"},
        AllowCredentials: true,
        MaxAge:           12 * time.Hour,
    }))

    // API routes
    r.GET("/health", func(c *gin.Context) {
        c.JSON(200, gin.H{
            "status":  "healthy",
            "service": "crypto-rankings-api",
            "redis":   rdb != nil,
        })
    })

    r.GET("/api/crypto/data", func(c *gin.Context) {
        data, exists := getLatestDataFromRedis()
        if !exists {
            c.JSON(404, gin.H{
                "error":   "No crypto data available yet",
                "message": "Data is updated every 5 minutes",
            })
            return
        }
        c.JSON(200, data)
    })

    r.GET("/api/crypto/info", func(c *gin.Context) {
        c.JSON(200, gin.H{
            "metrics":         AllSortableMetrics,
            "total":           len(AllSortableMetrics),
            "update_schedule": "Every 5 minutes",
        })
    })

    // Development endpoint for manual triggers
    r.POST("/dev/trigger", func(c *gin.Context) {
        _, err := inngestClient.Send(context.Background(), map[string]interface{}{
            "name": "crypto/manual",
            "data": map[string]interface{}{"manual": true},
        })

        if err != nil {
            c.JSON(500, gin.H{"error": "Failed to trigger function"})
            return
        }

        c.JSON(200, gin.H{
            "message": "Manual crypto fetch triggered",
            "status":  "processing",
        })
    })

    // Inngest webhook
    r.Any("/api/inngest", gin.WrapH(inngestClient.Serve()))

    // Start server
    port := os.Getenv("PORT")
    if port == "" {
        port = "8080"
    }

    log.Printf("🚀 Server starting on port %s", port)
    r.Run(":" + port)
}
Enter fullscreen mode Exit fullscreen mode

This Go backend provides:

  • Redis Cloud integration for high-performance caching
  • LunarCrush API client with proper error handling
  • Inngest background jobs for automated data fetching
  • RESTful API endpoints for frontend consumption
  • CORS configuration for cross-origin requests

React Frontend Implementation

Main Dashboard Component

Create the complete frontend in frontend/src/app/page.tsx:

'use client';

import { useState } from 'react';
import {
  QueryClient,
  QueryClientProvider,
  useQuery,
} from '@tanstack/react-query';
import * as Select from '@radix-ui/react-select';
import * as Toast from '@radix-ui/react-toast';
import {
  ChevronDownIcon,
  ReloadIcon,
  CheckCircledIcon,
  ExclamationTriangleIcon,
} from '@radix-ui/react-icons';

const queryClient = new QueryClient({
  defaultOptions: {
    queries: {
      staleTime: 5 * 60 * 1000, // 5 minutes
      refetchInterval: 5 * 60 * 1000, // Auto-refresh
      retry: 3,
    },
  },
});

interface CryptoItem {
  name: string;
  symbol: string;
  value: string;
  sort: string;
}

interface MetricData {
  name: string;
  priority: string;
  description: string;
  success: boolean;
  data_count: number;
  all_data: CryptoItem[];
  top_3_preview: CryptoItem[];
  fetch_time_ms: number;
  error?: string;
}

interface ApiResponse {
  timestamp: string;
  total_metrics: number;
  all_metrics: { [key: string]: MetricData };
  fetch_stats: {
    total_duration_ms: number;
    successful_fetches: number;
    failed_fetches: number;
    last_update: string;
  };
}

const METRIC_OPTIONS = [
  {
    value: 'market_cap',
    label: 'Market Cap',
    description: 'Total dollar value of all coins in circulation',
    icon: '💰',
  },
  {
    value: 'price',
    label: 'Price',
    description: 'Current USD price per coin',
    icon: '💵',
  },
  {
    value: 'volume_24h',
    label: '24h Volume',
    description: 'Total trading volume in last 24 hours',
    icon: '📊',
  },
  {
    value: 'alt_rank',
    label: 'AltRank™',
    description: 'LunarCrush performance ranking',
    icon: '🏆',
  },
  {
    value: 'interactions',
    label: 'Social Interactions',
    description: 'Total social media engagements',
    icon: '💬',
  },
  {
    value: 'percent_change_24h',
    label: '24h Change',
    description: 'Price change in last 24 hours',
    icon: '📈',
  },
  {
    value: 'social_dominance',
    label: 'Social Dominance',
    description: 'Share of total crypto social volume',
    icon: '🌐',
  },
];

const useCryptoData = () => {
  return useQuery({
    queryKey: ['cryptoData'],
    queryFn: async (): Promise<ApiResponse> => {
      const API_BASE_URL = process.env.NEXT_PUBLIC_API_URL || 'http://localhost:8080';
      const response = await fetch(`${API_BASE_URL}/api/crypto/data`);
      if (!response.ok) {
        throw new Error(`API Error: ${response.status}`);
      }
      return response.json();
    },
  });
};

function CryptoDashboard() {
  const [selectedMetric, setSelectedMetric] = useState('market_cap');
  const [toastOpen, setToastOpen] = useState(false);
  const [toastMessage, setToastMessage] = useState({
    title: '',
    description: '',
    type: 'success' as 'success' | 'error',
  });

  const { data, isLoading, error, refetch, isRefetching } = useCryptoData();

  const triggerManualFetch = async () => {
    try {
      const API_BASE_URL = process.env.NEXT_PUBLIC_API_URL || 'http://localhost:8080';
      const response = await fetch(`${API_BASE_URL}/dev/trigger`, {
        method: 'POST',
        headers: { 'Content-Type': 'application/json' },
      });

      if (response.ok) {
        setToastMessage({
          title: 'Data fetch triggered',
          description: 'Fresh data will be available in ~30 seconds',
          type: 'success',
        });
        setToastOpen(true);
        setTimeout(() => refetch(), 2000);
      }
    } catch (err) {
      setToastMessage({
        title: 'Trigger failed',
        description: 'Unable to trigger manual data fetch',
        type: 'error',
      });
      setToastOpen(true);
    }
  };

  const selectedMetricOption = METRIC_OPTIONS.find(opt => opt.value === selectedMetric) || METRIC_OPTIONS[0];
  const currentMetricData = data?.all_metrics?.[selectedMetric];

  if (isLoading) {
    return (
      <div className="min-h-screen flex items-center justify-center bg-gradient-to-br from-blue-50 to-purple-50">
        <div className="text-center">
          <div className="animate-spin rounded-full h-12 w-12 border-b-2 border-blue-600 mx-auto mb-4"></div>
          <h2 className="text-xl font-semibold text-gray-900">Loading Crypto Analytics...</h2>
          <p className="text-gray-600">Fetching real-time social sentiment data</p>
        </div>
      </div>
    );
  }

  return (
    <Toast.Provider swipeDirection="right">
      <div className="min-h-screen bg-gradient-to-br from-blue-50 to-purple-50">
        {/* Header */}
        <div className="bg-gradient-to-r from-blue-600 to-purple-600 text-white py-12">
          <div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 text-center">
            <h1 className="text-4xl font-bold mb-4">Crypto Rankings Dashboard</h1>
            <p className="text-xl opacity-90 mb-6">
              Real-time cryptocurrency social sentiment and market data
            </p>
            <div className="flex justify-center gap-4 flex-wrap">
              <span className="bg-white/20 px-3 py-1 rounded-full text-sm">Go + Gin</span>
              <span className="bg-white/20 px-3 py-1 rounded-full text-sm">Inngest Jobs</span>
              <span className="bg-white/20 px-3 py-1 rounded-full text-sm">Redis Cloud</span>
              <span className="bg-white/20 px-3 py-1 rounded-full text-sm">React + TypeScript</span>
            </div>
          </div>
        </div>

        {/* Main Content */}
        <div className="max-w-7xl mx-auto px-4 py-8">
          {error ? (
            <div className="text-center py-12">
              <ExclamationTriangleIcon className="h-12 w-12 text-red-500 mx-auto mb-4" />
              <h3 className="text-lg font-semibold text-gray-900 mb-2">Unable to load data</h3>
              <p className="text-gray-600 mb-4">{(error as Error).message}</p>
              <button
                onClick={() => refetch()}
                className="bg-blue-600 text-white px-6 py-2 rounded-lg hover:bg-blue-700"
              >
                Try again
              </button>
            </div>
          ) : (
            <div className="grid grid-cols-1 lg:grid-cols-4 gap-8">
              {/* Control Panel */}
              <div className="lg:col-span-1">
                <div className="bg-white rounded-xl shadow-lg p-6 sticky top-6">
                  <h3 className="text-lg font-semibold text-gray-900 mb-4">Metric Selector</h3>

                  <div className="mb-6">
                    <label className="block text-sm font-medium text-gray-700 mb-2">
                      Choose Analysis Type
                    </label>

                    <Select.Root value={selectedMetric} onValueChange={setSelectedMetric}>
                      <Select.Trigger className="w-full p-3 border border-gray-300 rounded-lg bg-white hover:border-blue-500 focus:border-blue-500 focus:outline-none">
                        <div className="flex items-center gap-3">
                          <span className="text-lg">{selectedMetricOption.icon}</span>
                          <div className="text-left">
                            <div className="font-medium text-gray-900">
                              {selectedMetricOption.label}
                            </div>
                            <div className="text-sm text-gray-500">
                              {selectedMetricOption.description}
                            </div>
                          </div>
                        </div>
                        <Select.Icon>
                          <ChevronDownIcon className="w-4 h-4" />
                        </Select.Icon>
                      </Select.Trigger>

                      <Select.Portal>
                        <Select.Content className="bg-white border border-gray-200 rounded-lg shadow-xl p-2 max-h-80 overflow-y-auto z-50">
                          <Select.Viewport>
                            {METRIC_OPTIONS.map((option) => (
                              <Select.Item
                                key={option.value}
                                value={option.value}
                                className="p-3 rounded-lg cursor-pointer hover:bg-blue-50 flex items-center gap-3"
                              >
                                <span className="text-lg">{option.icon}</span>
                                <div>
                                  <Select.ItemText>
                                    <div className="font-medium">{option.label}</div>
                                    <div className="text-sm text-gray-500">{option.description}</div>
                                  </Select.ItemText>
                                </div>
                                <Select.ItemIndicator className="ml-auto">
                                  <CheckCircledIcon className="w-4 h-4 text-blue-600" />
                                </Select.ItemIndicator>
                              </Select.Item>
                            ))}
                          </Select.Viewport>
                        </Select.Content>
                      </Select.Portal>
                    </Select.Root>
                  </div>

                  <div className="space-y-3">
                    <button
                      onClick={() => refetch()}
                      disabled={isRefetching}
                      className="w-full bg-gray-100 text-gray-700 px-4 py-2 rounded-lg hover:bg-gray-200 disabled:opacity-50 flex items-center justify-center gap-2"
                    >
                      <ReloadIcon className={`w-4 h-4 ${isRefetching ? 'animate-spin' : ''}`} />
                      Refresh
                    </button>

                    <button
                      onClick={triggerManualFetch}
                      className="w-full bg-blue-600 text-white px-4 py-2 rounded-lg hover:bg-blue-700 flex items-center justify-center gap-2"
                    >
                      Trigger Update
                    </button>
                  </div>

                  {data && (
                    <div className="mt-6 p-4 bg-gray-50 rounded-lg">
                      <h4 className="text-sm font-medium text-gray-700 mb-2">System Status</h4>
                      <div className="grid grid-cols-2 gap-2 text-sm">
                        <div>
                          <div className="font-medium text-green-600">
                            {data.fetch_stats.successful_fetches}
                          </div>
                          <div className="text-gray-500">Successful</div>
                        </div>
                        <div>
                          <div className="font-medium text-red-600">
                            {data.fetch_stats.failed_fetches}
                          </div>
                          <div className="text-gray-500">Failed</div>
                        </div>
                      </div>
                    </div>
                  )}
                </div>
              </div>

              {/* Results Panel */}
              <div className="lg:col-span-3">
                <div className="bg-white rounded-xl shadow-lg overflow-hidden">
                  <div className="bg-gray-50 px-6 py-4 border-b">
                    <div className="flex items-center justify-between">
                      <div className="flex items-center gap-3">
                        <span className="text-2xl">{selectedMetricOption.icon}</span>
                        <div>
                          <h2 className="text-lg font-semibold text-gray-900">
                            Top 10 by {selectedMetricOption.label}
                          </h2>
                          <p className="text-sm text-gray-600">
                            {selectedMetricOption.description}
                          </p>
                        </div>
                      </div>
                      {currentMetricData && (
                        <div className="text-right text-sm text-gray-500">
                          <div>{currentMetricData.data_count} items</div>
                          <div>{currentMetricData.fetch_time_ms}ms</div>
                        </div>
                      )}
                    </div>
                  </div>

                  <div className="p-6">
                    {currentMetricData && currentMetricData.success ? (
                      <div className="space-y-3">
                        {currentMetricData.all_data.slice(0, 10).map((item, index) => (
                          <div
                            key={`${item.symbol}-${index}`}
                            className="flex items-center justify-between p-4 bg-gray-50 rounded-lg hover:bg-gray-100 transition-colors"
                          >
                            <div className="flex items-center gap-4">
                              <div className="w-8 h-8 bg-blue-600 text-white rounded-full flex items-center justify-center font-semibold text-sm">
                                {index + 1}
                              </div>
                              <div>
                                <div className="font-medium text-gray-900">{item.name}</div>
                                <div className="text-sm text-gray-500">{item.symbol?.toUpperCase()}</div>
                              </div>
                            </div>
                            <div className="text-right">
                              <div className="font-semibold text-gray-900">{item.value}</div>
                            </div>
                          </div>
                        ))}
                      </div>
                    ) : (
                      <div className="text-center py-12">
                        <div className="animate-spin rounded-full h-8 w-8 border-b-2 border-blue-600 mx-auto mb-4"></div>
                        <div className="text-gray-600">
                          Processing {selectedMetricOption.label} data...
                        </div>
                      </div>
                    )}
                  </div>
                </div>
              </div>
            </div>
          )}
        </div>
      </div>

      {/* Toast Viewport */}
      <Toast.Viewport className="fixed bottom-0 right-0 flex flex-col p-6 gap-2 w-96 max-w-full m-0 list-none z-50 outline-none" />

      <Toast.Root 
        open={toastOpen} 
        onOpenChange={setToastOpen}
        className="bg-white border border-gray-200 rounded-lg shadow-lg p-4"
      >
        <Toast.Title className="font-medium text-gray-900">{toastMessage.title}</Toast.Title>
        <Toast.Description className="text-sm text-gray-600 mt-1">
          {toastMessage.description}
        </Toast.Description>
        <Toast.Close className="absolute top-2 right-2 text-gray-400 hover:text-gray-600">
          ×
        </Toast.Close>
      </Toast.Root>
    </Toast.Provider>
  );
}

export default function App() {
  return (
    <QueryClientProvider client={queryClient}>
      <CryptoDashboard />
    </QueryClientProvider>
  );
}
Enter fullscreen mode Exit fullscreen mode

Enhanced Styling

Update frontend/src/app/globals.css:

@tailwind base;
@tailwind components;
@tailwind utilities;

@import url('https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700&display=swap');

* {
  box-sizing: border-box;
  margin: 0;
  padding: 0;
}

html {
  font-family: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
  -webkit-font-smoothing: antialiased;
  -moz-osx-font-smoothing: grayscale;
}

body {
  line-height: 1.6;
  overflow-x: hidden;
}

/* Custom animations */
@keyframes fadeIn {
  from { opacity: 0; transform: translateY(20px); }
  to { opacity: 1; transform: translateY(0); }
}

.animate-fade-in {
  animation: fadeIn 0.6s ease-out;
}

/* Custom scrollbar */
::-webkit-scrollbar {
  width: 6px;
}

::-webkit-scrollbar-track {
  background: #f1f5f9;
}

::-webkit-scrollbar-thumb {
  background: #cbd5e1;
  border-radius: 3px;
}

::-webkit-scrollbar-thumb:hover {
  background: #94a3b8;
}

/* Loading states */
.loading-spinner {
  animation: spin 1s linear infinite;
}

@keyframes spin {
  from { transform: rotate(0deg); }
  to { transform: rotate(360deg); }
}

/* Select component styling */
[data-radix-select-content] {
  z-index: 100;
}

[data-radix-select-viewport] {
  padding: 4px;
}

/* Toast styling */
[data-radix-toast-viewport] {
  z-index: 2147483647;
}
Enter fullscreen mode Exit fullscreen mode

Deployment Guide

Backend Deployment (Render)

  1. Push to GitHub:
cd server
git init
git add .
git commit -m "Initial Go backend"
git branch -M main
git remote add origin https://github.com/yourusername/crypto-rankings.git
git push -u origin main
Enter fullscreen mode Exit fullscreen mode
  1. Deploy on Render:

    • Visit Render Dashboard
    • Click "New +" → "Web Service"
    • Connect your GitHub repository
    • Configure settings:
      • Name: crypto-rankings
      • Root Directory: server
      • Build Command: go build -o main ./main.go
      • Start Command: ./main
  2. Add Environment Variables:

REDIS_URL=redis://default:password@your-redis-host:port
LUNARCRUSH_API_KEY=lc_your_api_key_here
INNGEST_EVENT_KEY=your_inngest_event_key
INNGEST_SIGNING_KEY=your_inngest_signing_key
INNGEST_DEV_URL=https://your-app.onrender.com/api/inngest
PORT=10000
Enter fullscreen mode Exit fullscreen mode

Frontend Deployment (Vercel)

  1. Configure Environment:
cd frontend
echo "NEXT_PUBLIC_API_URL=https://your-app.onrender.com" > .env.local
Enter fullscreen mode Exit fullscreen mode
  1. Deploy on Vercel:

    • Visit Vercel Dashboard
    • Import your GitHub repository
    • Configure settings:
      • Framework: Next.js
      • Root Directory: frontend
      • Build Command: npm run build
  2. Add Environment Variables:

NEXT_PUBLIC_API_URL=https://your-app.onrender.com
Enter fullscreen mode Exit fullscreen mode

Configure Inngest Production

  1. Update Inngest Environment:
    • In Render, update INNGEST_DEV_URL to your production URL
    • Register your production webhook in Inngest dashboard

Testing & Verification

Local Testing

# Terminal 1: Start backend
cd server
go run main.go

# Terminal 2: Start frontend  
cd frontend
npm run dev
Enter fullscreen mode Exit fullscreen mode

Verify Everything Works:

  1. 🔍 Dashboard loads: Visit http://localhost:3001
  2. 🚀 Metrics display: Select different metrics from dropdown
  3. 📊 Data fetches: Manual trigger button works
  4. 🔄 Auto-refresh: Data updates every 5 minutes
  5. 💾 Redis caches: Fast subsequent requests

Production Testing

# Health check
curl https://your-app.onrender.com/health

# Data endpoint (may take 30-60s first time)
curl https://your-app.onrender.com/api/crypto/data

# Manual trigger
curl -X POST https://your-app.onrender.com/dev/trigger
Enter fullscreen mode Exit fullscreen mode

Common Issues & Solutions

Issue Solution
Redis Connection Failed Verify Redis Cloud URL format and credentials
LunarCrush 401 Error Check API key validity and subscription status
Inngest Function Not Triggering Verify webhook URL and signing key
Frontend CORS Error Update backend CORS configuration with frontend URL
Render Cold Start First request takes 30-60s on free tier

Next Steps & Extensions

Immediate Improvements

Performance Optimizations:

  • Implement request caching with SWR strategy
  • Add pagination for large datasets
  • Optimize bundle size with dynamic imports

Enhanced Features:

  • Real-time WebSocket updates
  • Advanced filtering and search
  • Data export functionality
  • Historical trend charts

Production Upgrades:

  • Upgrade to Render paid plan for always-on service
  • Implement proper logging and monitoring
  • Add comprehensive error tracking
  • Set up automated testing pipeline

AI Integration Opportunities

Consider these extensions:

LunarCrush API Integration:

  • Connect to AI assistants via Model Context Protocol
  • Enable natural language queries for crypto data
  • Build AI-powered trading signal generation

Machine Learning Features:

  • Sentiment analysis trend prediction
  • Anomaly detection in social metrics
  • Automated trading recommendations

Resources & Documentation

Key Technologies

Cloud Platforms

API References


Conclusion

Congratulations! You've built a production-ready crypto rankings dashboard that demonstrates modern full-stack development practices. This system showcases:

  • High-Performance Backend: Go API with Redis caching
  • Background Processing: Inngest workflow orchestration
  • Professional Frontend: React with TypeScript and modern UI
  • Cloud Deployment: Multi-platform production setup
  • Real-Time Data: Automated social sentiment analysis

What You've Accomplished

Technical Excellence:

  • Built a scalable API architecture with proper error handling
  • Implemented background job processing with error recovery
  • Created a responsive, accessible user interface
  • Deployed to production with proper environment management

Business Value:

  • Demonstrated understanding of cryptocurrency market dynamics
  • Integrated real-time social sentiment analysis
  • Created a tool valuable for traders and investors
  • Showcased ability to work with external APIs and data sources

Interview Readiness:

  • Perfect portfolio project for Go and React positions
  • Demonstrates understanding of distributed systems
  • Shows experience with modern cloud platforms
  • Highlights ability to build complete, production-ready applications

Take Action Now

Get Started Building:

Use my discount referral code JAMAALBUILDS to receive 15% off your plan.

Extend Your Dashboard:

  • Add historical trend analysis
  • Implement real-time WebSocket updates
  • Create AI-powered trading signals
  • Build mobile app with React Native

Share Your Success:

  • Star the repository if you found it helpful
  • Share your deployed version on social media
  • Contribute improvements back to the project
  • Use it in your portfolio and job interviews

Built with ❤️ using LunarCrushGoInngestRedis CloudReactVercel

Questions? Drop them below! I respond to every comment and love helping fellow developers build amazing applications with real-time social sentiment analysis. 🚀

Top comments (0)