Back to Blog
Tutorial

Build a Real-Time Prediction Market Monitoring Dashboard with React

A step-by-step tutorial for building a real-time market monitoring dashboard using React, Next.js, and the Propheseer WebSocket API to display live prediction market data.

Propheseer Team
10 min read

Introduction

A real-time market monitoring dashboard lets you watch prediction market prices as they move, filter by category or platform, and spot trends before they're obvious. In this tutorial, you'll build one from scratch using React, Next.js, and the Propheseer API.

By the end, you'll have a working dashboard that:

  • Fetches market data from Polymarket, Kalshi, and Gemini
  • Displays markets in a responsive card grid
  • Supports search and category filtering
  • Updates prices in real-time via WebSocket
  • Shows visual indicators for price movements

We'll use Next.js for the framework and the Propheseer API for data. Basic React knowledge is assumed — if you're new to prediction market APIs, start with our quick start guide.

Project Setup

Create the Next.js App

npx create-next-app@latest market-dashboard --typescript --tailwind --app
cd market-dashboard

Environment Variables

Create .env.local in the project root:

NEXT_PUBLIC_PROPHESEER_API_KEY=pk_live_your_key_here
NEXT_PUBLIC_PROPHESEER_WS_URL=wss://api.propheseer.com/ws

Project Structure

We'll create these files:

src/
├── app/
│   └── page.tsx              # Main dashboard page
├── components/
│   ├── MarketCard.tsx        # Individual market card
│   ├── MarketGrid.tsx        # Grid of market cards
│   ├── SearchBar.tsx         # Search and filters
│   └── PriceIndicator.tsx    # Price change indicator
├── hooks/
│   ├── useMarkets.ts         # Market data fetching hook
│   └── useWebSocket.ts       # WebSocket connection hook
└── lib/
    └── api.ts                # API client utilities

Building the API Client

First, create a lightweight API client for server-side and client-side use.

// src/lib/api.ts
const API_KEY = process.env.NEXT_PUBLIC_PROPHESEER_API_KEY;
const BASE_URL = "https://api.propheseer.com/v1";

export interface Market {
  id: string;
  question: string;
  source: "polymarket" | "kalshi" | "gemini";
  category: string;
  status: string;
  outcomes: { name: string; probability: number }[];
  volume: number;
  url: string;
}

export interface MarketsResponse {
  data: Market[];
  meta: {
    total: number;
    limit: number;
    offset: number;
    sources: Record<string, { count: number }>;
  };
}

export async function fetchMarkets(params: {
  q?: string;
  source?: string;
  category?: string;
  status?: string;
  limit?: number;
  offset?: number;
} = {}): Promise<MarketsResponse> {
  const searchParams = new URLSearchParams();
  Object.entries(params).forEach(([key, value]) => {
    if (value !== undefined) searchParams.set(key, String(value));
  });

  const response = await fetch(`${BASE_URL}/markets?${searchParams}`, {
    headers: { "Authorization": `Bearer ${API_KEY}` },
  });

  if (!response.ok) {
    throw new Error(`API error: ${response.status}`);
  }

  return response.json();
}

export async function fetchCategories(): Promise<{ id: string; name: string }[]> {
  const response = await fetch(`${BASE_URL}/categories`, {
    headers: { "Authorization": `Bearer ${API_KEY}` },
  });

  if (!response.ok) {
    throw new Error(`API error: ${response.status}`);
  }

  const data = await response.json();
  return data.data;
}

Market Data Hook

Create a custom hook that manages fetching, filtering, and caching market data.

// src/hooks/useMarkets.ts
"use client";

import { useState, useEffect, useCallback } from "react";
import { fetchMarkets, type Market } from "@/lib/api";

interface UseMarketsOptions {
  initialLimit?: number;
  refreshInterval?: number; // ms
}

export function useMarkets(options: UseMarketsOptions = {}) {
  const { initialLimit = 50, refreshInterval = 60000 } = options;

  const [markets, setMarkets] = useState<Market[]>([]);
  const [loading, setLoading] = useState(true);
  const [error, setError] = useState<string | null>(null);
  const [total, setTotal] = useState(0);

  // Filter state
  const [query, setQuery] = useState("");
  const [source, setSource] = useState<string>("");
  const [category, setCategory] = useState<string>("");

  const loadMarkets = useCallback(async () => {
    try {
      setError(null);
      const params: Record<string, string | number> = {
        status: "open",
        limit: initialLimit,
      };
      if (query) params.q = query;
      if (source) params.source = source;
      if (category) params.category = category;

      const result = await fetchMarkets(params);
      setMarkets(result.data);
      setTotal(result.meta.total);
    } catch (err) {
      setError(err instanceof Error ? err.message : "Failed to fetch markets");
    } finally {
      setLoading(false);
    }
  }, [query, source, category, initialLimit]);

  // Initial load and filter changes
  useEffect(() => {
    setLoading(true);
    loadMarkets();
  }, [loadMarkets]);

  // Periodic refresh
  useEffect(() => {
    if (refreshInterval <= 0) return;
    const interval = setInterval(loadMarkets, refreshInterval);
    return () => clearInterval(interval);
  }, [loadMarkets, refreshInterval]);

  // Update a single market's price (used by WebSocket)
  const updateMarketPrice = useCallback((marketId: string, newProbability: number) => {
    setMarkets(prev =>
      prev.map(m =>
        m.id === marketId
          ? {
              ...m,
              outcomes: m.outcomes.map((o, i) =>
                i === 0
                  ? { ...o, probability: newProbability }
                  : { ...o, probability: 1 - newProbability }
              ),
            }
          : m
      )
    );
  }, []);

  return {
    markets,
    loading,
    error,
    total,
    query, setQuery,
    source, setSource,
    category, setCategory,
    updateMarketPrice,
    refresh: loadMarkets,
  };
}

WebSocket Real-Time Updates

The WebSocket hook connects to the Propheseer streaming API and dispatches price updates to the market list.

// src/hooks/useWebSocket.ts
"use client";

import { useEffect, useRef, useCallback, useState } from "react";

interface WebSocketMessage {
  type: "price_update" | "trade" | "status";
  marketId: string;
  data: {
    probability?: number;
    volume?: number;
    source?: string;
  };
}

interface UseWebSocketOptions {
  url: string;
  onMessage: (message: WebSocketMessage) => void;
  reconnectInterval?: number;
  maxReconnectAttempts?: number;
}

export function useWebSocket({
  url,
  onMessage,
  reconnectInterval = 5000,
  maxReconnectAttempts = 10,
}: UseWebSocketOptions) {
  const wsRef = useRef<WebSocket | null>(null);
  const reconnectCount = useRef(0);
  const [connected, setConnected] = useState(false);

  const connect = useCallback(() => {
    if (wsRef.current?.readyState === WebSocket.OPEN) return;

    try {
      const ws = new WebSocket(url);

      ws.onopen = () => {
        setConnected(true);
        reconnectCount.current = 0;

        // Subscribe to market updates
        ws.send(JSON.stringify({
          type: "subscribe",
          channels: ["markets"],
        }));
      };

      ws.onmessage = (event) => {
        try {
          const message = JSON.parse(event.data) as WebSocketMessage;
          onMessage(message);
        } catch {
          // Ignore malformed messages
        }
      };

      ws.onclose = () => {
        setConnected(false);
        wsRef.current = null;

        // Reconnect with backoff
        if (reconnectCount.current < maxReconnectAttempts) {
          reconnectCount.current += 1;
          const delay = reconnectInterval * reconnectCount.current;
          setTimeout(connect, delay);
        }
      };

      ws.onerror = () => {
        ws.close();
      };

      wsRef.current = ws;
    } catch {
      // Connection failed, will retry via onclose
    }
  }, [url, onMessage, reconnectInterval, maxReconnectAttempts]);

  useEffect(() => {
    connect();
    return () => {
      wsRef.current?.close();
    };
  }, [connect]);

  const subscribe = useCallback((marketIds: string[]) => {
    if (wsRef.current?.readyState === WebSocket.OPEN) {
      wsRef.current.send(JSON.stringify({
        type: "subscribe",
        marketIds,
      }));
    }
  }, []);

  return { connected, subscribe };
}

Market Card Component

Each market gets a card showing the question, probability bar, source badge, and price change indicator.

// src/components/PriceIndicator.tsx
"use client";

interface PriceIndicatorProps {
  probability: number;
  previousProbability?: number;
}

export function PriceIndicator({ probability, previousProbability }: PriceIndicatorProps) {
  const pct = (probability * 100).toFixed(1);

  if (previousProbability === undefined) {
    return <span className="text-2xl font-bold">{pct}%</span>;
  }

  const diff = probability - previousProbability;
  const isUp = diff > 0;
  const isDown = diff < 0;

  return (
    <div className="flex items-center gap-2">
      <span className="text-2xl font-bold">{pct}%</span>
      {diff !== 0 && (
        <span className={`text-sm font-medium ${isUp ? "text-green-500" : "text-red-500"}`}>
          {isUp ? "+" : ""}{(diff * 100).toFixed(1)}%
        </span>
      )}
    </div>
  );
}
// src/components/MarketCard.tsx
"use client";

import { PriceIndicator } from "./PriceIndicator";
import type { Market } from "@/lib/api";

const SOURCE_COLORS: Record<string, string> = {
  polymarket: "bg-purple-100 text-purple-800",
  kalshi: "bg-blue-100 text-blue-800",
  gemini: "bg-cyan-100 text-cyan-800",
};

interface MarketCardProps {
  market: Market;
  previousProbability?: number;
}

export function MarketCard({ market, previousProbability }: MarketCardProps) {
  const yesProbability = market.outcomes[0]?.probability ?? 0.5;
  const sourceColor = SOURCE_COLORS[market.source] ?? "bg-gray-100 text-gray-800";

  return (
    <div className="rounded-xl border border-gray-200 bg-white p-5 shadow-sm hover:shadow-md transition-shadow">
      {/* Header */}
      <div className="flex items-start justify-between gap-3 mb-4">
        <h3 className="text-sm font-semibold text-gray-900 leading-snug line-clamp-2">
          {market.question}
        </h3>
        <span className={`shrink-0 text-xs font-medium px-2 py-1 rounded-full ${sourceColor}`}>
          {market.source}
        </span>
      </div>

      {/* Probability */}
      <div className="mb-3">
        <PriceIndicator probability={yesProbability} previousProbability={previousProbability} />
        <p className="text-xs text-gray-500 mt-1">Yes probability</p>
      </div>

      {/* Probability bar */}
      <div className="w-full bg-gray-100 rounded-full h-2 mb-3">
        <div
          className="bg-gradient-to-r from-purple-500 to-blue-500 h-2 rounded-full transition-all duration-500"
          style={{ width: `${yesProbability * 100}%` }}
        />
      </div>

      {/* Footer */}
      <div className="flex items-center justify-between text-xs text-gray-500">
        <span>{market.category}</span>
        {market.volume > 0 && (
          <span>${(market.volume / 1000).toFixed(0)}K vol</span>
        )}
      </div>
    </div>
  );
}

Search and Filtering

// src/components/SearchBar.tsx
"use client";

import { useState } from "react";

interface SearchBarProps {
  query: string;
  onQueryChange: (q: string) => void;
  source: string;
  onSourceChange: (s: string) => void;
  category: string;
  onCategoryChange: (c: string) => void;
  total: number;
}

const SOURCES = [
  { value: "", label: "All platforms" },
  { value: "polymarket", label: "Polymarket" },
  { value: "kalshi", label: "Kalshi" },
  { value: "gemini", label: "Gemini" },
];

const CATEGORIES = [
  { value: "", label: "All categories" },
  { value: "politics", label: "Politics" },
  { value: "crypto", label: "Crypto" },
  { value: "economics", label: "Economics" },
  { value: "sports", label: "Sports" },
  { value: "science", label: "Science" },
];

export function SearchBar({
  query, onQueryChange,
  source, onSourceChange,
  category, onCategoryChange,
  total,
}: SearchBarProps) {
  const [inputValue, setInputValue] = useState(query);

  const handleSubmit = (e: React.FormEvent) => {
    e.preventDefault();
    onQueryChange(inputValue);
  };

  return (
    <form onSubmit={handleSubmit} className="mb-8 space-y-4">
      {/* Search input */}
      <div className="relative">
        <input
          type="text"
          value={inputValue}
          onChange={(e) => setInputValue(e.target.value)}
          placeholder="Search markets... (e.g., bitcoin, election, fed)"
          className="w-full px-4 py-3 pl-10 rounded-lg border border-gray-300 focus:ring-2 focus:ring-purple-500 focus:border-transparent"
        />
        <svg className="absolute left-3 top-3.5 h-5 w-5 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
          <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z" />
        </svg>
      </div>

      {/* Filters */}
      <div className="flex flex-wrap gap-3 items-center">
        <select
          value={source}
          onChange={(e) => onSourceChange(e.target.value)}
          className="px-3 py-2 rounded-lg border border-gray-300 text-sm"
        >
          {SOURCES.map(s => (
            <option key={s.value} value={s.value}>{s.label}</option>
          ))}
        </select>

        <select
          value={category}
          onChange={(e) => onCategoryChange(e.target.value)}
          className="px-3 py-2 rounded-lg border border-gray-300 text-sm"
        >
          {CATEGORIES.map(c => (
            <option key={c.value} value={c.value}>{c.label}</option>
          ))}
        </select>

        <span className="text-sm text-gray-500 ml-auto">
          {total.toLocaleString()} markets
        </span>
      </div>
    </form>
  );
}

Market Grid

// src/components/MarketGrid.tsx
"use client";

import { MarketCard } from "./MarketCard";
import type { Market } from "@/lib/api";

interface MarketGridProps {
  markets: Market[];
  loading: boolean;
  error: string | null;
  previousPrices: Map<string, number>;
}

export function MarketGrid({ markets, loading, error, previousPrices }: MarketGridProps) {
  if (loading && markets.length === 0) {
    return (
      <div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
        {Array.from({ length: 6 }).map((_, i) => (
          <div key={i} className="rounded-xl border border-gray-200 bg-white p-5 animate-pulse">
            <div className="h-4 bg-gray-200 rounded w-3/4 mb-4" />
            <div className="h-8 bg-gray-200 rounded w-1/3 mb-3" />
            <div className="h-2 bg-gray-200 rounded w-full mb-3" />
            <div className="h-3 bg-gray-200 rounded w-1/4" />
          </div>
        ))}
      </div>
    );
  }

  if (error) {
    return (
      <div className="text-center py-12">
        <p className="text-red-500 text-lg">{error}</p>
        <p className="text-gray-500 mt-2">Check your API key and try again.</p>
      </div>
    );
  }

  if (markets.length === 0) {
    return (
      <div className="text-center py-12">
        <p className="text-gray-500 text-lg">No markets found.</p>
        <p className="text-gray-400 mt-2">Try adjusting your search or filters.</p>
      </div>
    );
  }

  return (
    <div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
      {markets.map(market => (
        <MarketCard
          key={market.id}
          market={market}
          previousProbability={previousPrices.get(market.id)}
        />
      ))}
    </div>
  );
}

Putting It All Together

Now wire everything up in the main page:

// src/app/page.tsx
"use client";

import { useRef, useCallback } from "react";
import { useMarkets } from "@/hooks/useMarkets";
import { useWebSocket } from "@/hooks/useWebSocket";
import { SearchBar } from "@/components/SearchBar";
import { MarketGrid } from "@/components/MarketGrid";

const WS_URL = process.env.NEXT_PUBLIC_PROPHESEER_WS_URL || "wss://api.propheseer.com/ws";

export default function Dashboard() {
  const previousPrices = useRef(new Map<string, number>());

  const {
    markets, loading, error, total,
    query, setQuery,
    source, setSource,
    category, setCategory,
    updateMarketPrice,
  } = useMarkets({ initialLimit: 50, refreshInterval: 60000 });

  // Store previous prices before updates
  const handleMessage = useCallback((message: { type: string; marketId: string; data: { probability?: number } }) => {
    if (message.type === "price_update" && message.data.probability !== undefined) {
      // Store the current price as "previous" before updating
      const currentMarket = markets.find(m => m.id === message.marketId);
      if (currentMarket) {
        previousPrices.current.set(
          message.marketId,
          currentMarket.outcomes[0].probability
        );
      }

      updateMarketPrice(message.marketId, message.data.probability);

      // Clear the "previous" price after animation
      setTimeout(() => {
        previousPrices.current.delete(message.marketId);
      }, 3000);
    }
  }, [markets, updateMarketPrice]);

  const { connected } = useWebSocket({ url: WS_URL, onMessage: handleMessage });

  return (
    <main className="min-h-screen bg-gray-50">
      <div className="max-w-7xl mx-auto px-4 py-8">
        {/* Header */}
        <div className="flex items-center justify-between mb-8">
          <div>
            <h1 className="text-3xl font-bold text-gray-900">Market Monitor</h1>
            <p className="text-gray-500 mt-1">Real-time prediction market data</p>
          </div>
          <div className="flex items-center gap-2">
            <span className={`h-2 w-2 rounded-full ${connected ? "bg-green-500" : "bg-red-500"}`} />
            <span className="text-sm text-gray-500">
              {connected ? "Live" : "Connecting..."}
            </span>
          </div>
        </div>

        {/* Search and filters */}
        <SearchBar
          query={query} onQueryChange={setQuery}
          source={source} onSourceChange={setSource}
          category={category} onCategoryChange={setCategory}
          total={total}
        />

        {/* Market grid */}
        <MarketGrid
          markets={markets}
          loading={loading}
          error={error}
          previousPrices={previousPrices.current}
        />
      </div>
    </main>
  );
}

Running the Dashboard

Start the development server:

npm run dev

Open http://localhost:3000 to see your dashboard. You should see:

  • A grid of market cards with live probabilities
  • Search bar filtering across all platforms
  • Source and category dropdown filters
  • A green "Live" indicator when WebSocket is connected
  • Price change animations when markets update

Styling and Deployment

Adding Dark Mode

Wrap the color scheme in Tailwind's dark mode:

// In page.tsx, update the main element:
<main className="min-h-screen bg-gray-50 dark:bg-gray-900">

Update tailwind.config.ts to enable class-based dark mode:

export default {
  darkMode: "class",
  // ...
}

Deploy to Vercel

The fastest deployment path for a Next.js app:

npm install -g vercel
vercel

Set your environment variables in the Vercel dashboard:

  • NEXT_PUBLIC_PROPHESEER_API_KEY
  • NEXT_PUBLIC_PROPHESEER_WS_URL

Your dashboard will be live at a .vercel.app URL within minutes.

For more on the WebSocket API, check the Propheseer documentation. To learn about the data format powering this dashboard, see our data normalization guide.

Next Steps

Your dashboard is a starting point. Here are some ideas to extend it:

  • Add charts — Use a library like Recharts to plot probability history
  • Arbitrage alerts — Highlight markets where cross-platform spreads exceed a threshold
  • Bookmarks — Let users save markets they want to track
  • Notifications — Browser push notifications on significant price movements
  • Mobile layout — The Tailwind grid already adapts, but you could add swipe gestures

For the backend logic that powers alerting, see our Python trading bot tutorial. And if you're new to the API, start with getting your first response in 5 minutes.


Ready to build your own dashboard? Get your free API key and start fetching live market data from Polymarket, Kalshi, and Gemini in minutes.

Share this post