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.
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 →
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:
- 👨💻 Build It Yourself - Follow along step-by-step with your own API keys
- 🚀 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.
- Visit LunarCrush Signup
- Enter your email address and click "Continue"
- Check your email for verification code and enter it
- 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:
- Sign up: Visit Redis Cloud and create an account
- Create Database: Choose the free 30MB tier
-
Get Connection Details: In your dashboard, copy:
- Redis endpoint URL
- Port number
- Password
-
Format Connection String: Combine into:
redis://default:password@host:port
Set Up Inngest
Inngest handles our background job processing:
- Sign up: Visit Inngest and create an account
- Create App: Name it "crypto-rankings"
-
Get Your Keys: In the settings, copy your:
-
Event Key (starts with
prod_
) -
Signing Key (starts with
signkey_
)
-
Event Key (starts with
Set Up Render (Backend Hosting)
- Sign up: Visit Render and connect your GitHub account
- Note: We'll configure deployment later in the tutorial
Set Up Vercel (Frontend Hosting)
- Sign up: Visit Vercel and connect your GitHub account
- 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
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
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
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
Create .env.local
in the frontend directory:
# .env.local (frontend directory)
NEXT_PUBLIC_API_URL=http://localhost:8080
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)
}
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>
);
}
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;
}
Deployment Guide
Backend Deployment (Render)
- 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
-
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
-
Name:
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
Frontend Deployment (Vercel)
- Configure Environment:
cd frontend
echo "NEXT_PUBLIC_API_URL=https://your-app.onrender.com" > .env.local
-
Deploy on Vercel:
- Visit Vercel Dashboard
- Import your GitHub repository
- Configure settings:
- Framework: Next.js
-
Root Directory:
frontend
-
Build Command:
npm run build
Add Environment Variables:
NEXT_PUBLIC_API_URL=https://your-app.onrender.com
Configure Inngest Production
-
Update Inngest Environment:
- In Render, update
INNGEST_DEV_URL
to your production URL - Register your production webhook in Inngest dashboard
- In Render, update
Testing & Verification
Local Testing
# Terminal 1: Start backend
cd server
go run main.go
# Terminal 2: Start frontend
cd frontend
npm run dev
Verify Everything Works:
- 🔍 Dashboard loads: Visit
http://localhost:3001
- 🚀 Metrics display: Select different metrics from dropdown
- 📊 Data fetches: Manual trigger button works
- 🔄 Auto-refresh: Data updates every 5 minutes
- 💾 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
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
- Go Documentation: https://golang.org/doc/
- Inngest Docs: https://inngest.com/docs
- Redis Documentation: https://redis.io/documentation
- Next.js Guide: https://nextjs.org/docs
Cloud Platforms
- Render Documentation: https://render.com/docs
- Vercel Documentation: https://vercel.com/docs
- Redis Cloud: https://redis.com/redis-enterprise-cloud/
API References
- LunarCrush API: https://lunarcrush.com/developers/api/endpoints
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:
- Clone the Repository - Start with the complete codebase
- Deploy Your Version - Showcase your implementation
- Subscribe to LunarCrush API - Access unique social sentiment data
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 LunarCrush • Go • Inngest • Redis Cloud • React • Vercel
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)