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.
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_KEYNEXT_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.