Introduction
In this tutorial, we are going to build a small crypto dashboard that shows live coin prices in a compact table and displays a candlestick chart for the selected coin.
The idea is simple:
- use CCXT as the main market data source;
- use CoinGecko as a fallback when the exchange does not have a required pair;
- use
fetchOHLCVto load candlestick data; - use React Query to refresh prices automatically;
- render candles with Lightweight Charts;
- make the UI clean, compact and monochrome.
By the end, we will have a small dashboard where the table sits on the left, the chart sits on the right, and clicking a coin updates the active chart.
The complete source code for this project is available on GitHub.
Step 1: Create the Project
Start with a React + TypeScript Vite project:
npm create vite@latest crypto-dashboard -- --template react-ts
cd crypto-dashboardInstall the packages:
npm install ccxt express cors @tanstack/react-query lightweight-charts concurrently tsx
npm install -D @types/express @types/corsWe need:
-
ccxtfor exchange market data; -
expressfor the local API; -
corsso the React app can call the API; -
@tanstack/react-queryfor polling and caching; -
lightweight-chartsfor the candlestick chart; -
concurrentlyto run frontend and backend together; -
tsxto run the TypeScript server directly.
Step 2: Add Scripts
Update package.json:
{
"scripts": {
"dev": "concurrently \"npm run dev:client\" \"npm run dev:server\"",
"dev:client": "vite",
"dev:server": "tsx watch server/index.ts",
"build": "tsc -b && vite build",
"preview": "vite preview"
}
}Now one command starts both the frontend and the backend:
npm run devStep 3: Create the Backend API
Create a new file:
server/index.tsAdd the base Express server:
import express from "express";
import cors from "cors";
import * as ccxt from "ccxt";
const app = express();
app.use(cors());
const exchange = new ccxt.bitget({
enableRateLimit: true,
});
app.listen(4000, () => {
console.log("API running on http://localhost:4000");
});We are using Bitget here because it works well for public market data and does not require an API key for tickers or candles.
Step 4: Define the Watchlist
Add a small watchlist:
type WatchAsset = {
symbol: string;
pair: string;
geckoId: string;
};
const watchlist: WatchAsset[] = [
{ symbol: "BTC", pair: "BTC/USDT", geckoId: "bitcoin" },
{ symbol: "ETH", pair: "ETH/USDT", geckoId: "ethereum" },
{ symbol: "SOL", pair: "SOL/USDT", geckoId: "solana" },
{ symbol: "DOGE", pair: "DOGE/USDT", geckoId: "dogecoin" },
{ symbol: "TAO", pair: "TAO/USDT", geckoId: "bittensor" }
];Each coin has three fields:
-
symbolis what we show in the UI; -
pairis what CCXT uses; -
geckoIdis what CoinGecko uses.
This small mapping is important because exchanges and CoinGecko do not use the same identifiers.
Step 5: Add CoinGecko Fallback
Sometimes a coin exists on CoinGecko but does not have a liquid pair on the exchange we use. Instead of failing, we can fallback to CoinGecko.
Add this helper:
async function getCoinGeckoPrices(ids: string[]) {
if (ids.length === 0) return {};
const url = new URL("https://api.coingecko.com/api/v3/simple/price");
url.searchParams.set("ids", [...ids, "tether"].join(","));
url.searchParams.set("vs_currencies", "usd");
url.searchParams.set("include_24hr_change", "true");
url.searchParams.set("include_24hr_vol", "true");
const response = await fetch(url);
if (!response.ok) {
throw new Error("CoinGecko request failed");
}
const data = await response.json();
const tetherUsd = data.tether?.usd ?? 1;
return Object.fromEntries(
Object.entries(data).map(([id, value]: any) => [
id,
{
price: value.usd / tetherUsd,
change24h: value.usd_24h_change ?? null,
volume: value.usd_24h_vol ?? null
}
])
);
}CoinGecko returns prices in USD. Exchange pairs usually come in USDT. For a small dashboard, USD and USDT are often close enough, but we can still normalize CoinGecko values by dividing them by the current Tether price.
Step 6: Create the Prices Endpoint
Now add /api/prices:
app.get("/api/prices", async (_req, res) => {
try {
await exchange.loadMarkets();
const pairs = watchlist.map((asset) => asset.pair);
const tickers = await exchange.fetchTickers(pairs);
const missingGeckoIds: string[] = [];
for (const asset of watchlist) {
if (!tickers[asset.pair]?.last) {
missingGeckoIds.push(asset.geckoId);
}
}
const geckoPrices = await getCoinGeckoPrices(missingGeckoIds);
const prices = watchlist.map((asset) => {
const ticker = tickers[asset.pair];
const gecko = geckoPrices[asset.geckoId] as any;
return {
symbol: asset.symbol,
pair: asset.pair,
source: ticker?.last ? "ccxt" : "coingecko",
price: ticker?.last ?? gecko?.price ?? null,
high24h: ticker?.high ?? null,
low24h: ticker?.low ?? null,
change24h: ticker?.percentage ?? gecko?.change24h ?? null,
volume: ticker?.quoteVolume ?? gecko?.volume ?? null
};
});
res.json(prices);
} catch (error) {
console.error(error);
res.status(500).json({ error: "Failed to load prices" });
}
});The flow is:
- Load exchange markets.
- Request tickers from CCXT.
- Find missing pairs.
- Request missing prices from CoinGecko.
- Merge both sources into one response.
The frontend does not need to know how complicated the data fetching is. It receives one clean array.
Step 7: Add Candlestick Data with fetchOHLCV
For the chart, add another endpoint:
app.get("/api/candles/:symbol", async (req, res) => {
try {
await exchange.loadMarkets();
const coin = req.params.symbol.toUpperCase();
const pair = `${coin}/USDT`;
if (!exchange.markets[pair]) {
return res.status(404).json({
error: `No ${pair} market on Bitget`
});
}
const timeframe = String(req.query.timeframe ?? "1m");
const limit = Number(req.query.limit ?? 120);
const candles = await exchange.fetchOHLCV(
pair,
timeframe,
undefined,
limit
);
const result = candles.map(([time, open, high, low, close, volume]) => ({
time: Math.floor(time / 1000),
open,
high,
low,
close,
volume
}));
res.json(result);
} catch (error) {
console.error(error);
res.status(500).json({ error: "Failed to load candles" });
}
});fetchOHLCV returns arrays in this format:
[
timestamp,
open,
high,
low,
close,
volume
]Lightweight Charts expects candle data as objects, so we convert the array into a cleaner structure.
Step 8: Create Frontend API Helpers
Create:
src/api/prices.tsexport type CoinPrice = {
symbol: string;
pair: string;
source: "ccxt" | "coingecko";
price: number | null;
high24h: number | null;
low24h: number | null;
change24h: number | null;
volume: number | null;
};
export async function fetchPrices(): Promise<CoinPrice[]> {
const response = await fetch("http://localhost:4000/api/prices");
if (!response.ok) {
throw new Error("Failed to fetch prices");
}
return response.json();
}Then create:
src/api/candles.tsexport type Candle = {
time: number;
open: number;
high: number;
low: number;
close: number;
volume: number;
};
export async function fetchCandles(symbol: string): Promise<Candle[]> {
const response = await fetch(
`http://localhost:4000/api/candles/${symbol}?timeframe=1m&limit=120`
);
if (!response.ok) {
throw new Error("Failed to fetch candles");
}
return response.json();
}Step 9: Add React Query Hook
Create:
src/hooks/usePrices.tsimport { useQuery } from "@tanstack/react-query";
import { fetchPrices } from "../api/prices";
export function usePrices() {
return useQuery({
queryKey: ["prices"],
queryFn: fetchPrices,
refetchInterval: 10_000
});
}This refreshes prices every 10 seconds.
For a local dashboard, this is enough. In a production app, we could add caching, request deduplication and rate-limit protection on the backend.
Step 10: Build the Candlestick Chart
Create:
src/components/CandleChart.tsximport { useEffect, useRef } from "react";
import {
createChart,
CandlestickSeries,
type IChartApi,
type ISeriesApi,
type CandlestickData
} from "lightweight-charts";
import { useQuery } from "@tanstack/react-query";
import { fetchCandles } from "../api/candles";
type CandleChartProps = {
symbol: string;
};
export function CandleChart({ symbol }: CandleChartProps) {
const containerRef = useRef<HTMLDivElement | null>(null);
const chartRef = useRef<IChartApi | null>(null);
const seriesRef = useRef<ISeriesApi<"Candlestick"> | null>(null);
const { data } = useQuery({
queryKey: ["candles", symbol],
queryFn: () => fetchCandles(symbol),
refetchInterval: 15_000
});
useEffect(() => {
if (!containerRef.current) return;
const chart = createChart(containerRef.current, {
height: 360,
layout: {
background: { color: "#18181b" },
textColor: "#d4d4d8"
},
grid: {
vertLines: { color: "#27272a" },
horzLines: { color: "#27272a" }
}
});
const series = chart.addSeries(CandlestickSeries);
chartRef.current = chart;
seriesRef.current = series;
const resize = () => {
chart.applyOptions({
width: containerRef.current?.clientWidth ?? 800
});
};
resize();
window.addEventListener("resize", resize);
return () => {
window.removeEventListener("resize", resize);
chart.remove();
};
}, []);
useEffect(() => {
if (!seriesRef.current || !data) return;
seriesRef.current.setData(data as CandlestickData[]);
chartRef.current?.timeScale().fitContent();
}, [data]);
return (
<section className="chartCard">
<div className="chartHeader">
<h2>{symbol}/USDT Chart</h2>
<span>1m candles</span>
</div>
<div ref={containerRef} />
</section>
);
}The chart updates whenever the selected symbol changes.
Step 11: Build the Dashboard Layout
Now update src/App.tsx:
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { useMemo, useState } from "react";
import { usePrices } from "./hooks/usePrices";
import { CandleChart } from "./components/CandleChart";
import "./App.css";
const queryClient = new QueryClient();
function formatUsd(value: number | null) {
if (value == null) return "n/a";
return new Intl.NumberFormat("en-US", {
style: "currency",
currency: "USD",
maximumFractionDigits: value > 10 ? 2 : 6
}).format(value);
}
function formatNumber(value: number | null) {
if (value == null) return "n/a";
return new Intl.NumberFormat("en-US", {
notation: "compact",
maximumFractionDigits: 2
}).format(value);
}
function formatPercent(value: number | null) {
if (value == null) return "n/a";
return `${value >= 0 ? "+" : ""}${value.toFixed(2)}%`;
}
function PriceDashboard() {
const { data, isLoading, error } = usePrices();
const [selectedSymbol, setSelectedSymbol] = useState("BTC");
const selectedCoin = useMemo(() => {
return data?.find((coin) => coin.symbol === selectedSymbol);
}, [data, selectedSymbol]);
if (isLoading) {
return <main className="dashboard">Loading prices...</main>;
}
if (error) {
return <main className="dashboard">Failed to load prices.</main>;
}
return (
<main className="dashboard">
<header className="pageHeader">
<div>
<h1>Crypto Dashboard</h1>
<p>Live prices with CCXT, CoinGecko fallback and OHLCV candles.</p>
</div>
{selectedCoin && (
<div className="selectedBadge">
Selected: <strong>{selectedCoin.symbol}/USDT</strong>
</div>
)}
</header>
<div className="dashboardGrid">
<section className="tableCard">
<div className="tableHeader">
<h2>Watchlist</h2>
<span>Click a coin</span>
</div>
<div className="tableWrap">
<table className="coinTable">
<thead>
<tr>
<th>Coin</th>
<th>Source</th>
<th>Price</th>
<th>24h</th>
<th>Volume</th>
</tr>
</thead>
<tbody>
{data?.map((coin) => {
const isSelected = coin.symbol === selectedSymbol;
return (
<tr
key={coin.symbol}
className={isSelected ? "selectedRow" : ""}
onClick={() => setSelectedSymbol(coin.symbol)}
>
<td>
<div className="coinCell">
<span className="coinIcon">{coin.symbol[0]}</span>
<div>
<strong>{coin.symbol}</strong>
<small>{coin.pair}</small>
</div>
</div>
</td>
<td>
<span className={`sourcePill ${coin.source}`}>
{coin.source}
</span>
</td>
<td>{formatUsd(coin.price)}</td>
<td>
<span
className={
coin.change24h != null && coin.change24h >= 0
? "up"
: "down"
}
>
{formatPercent(coin.change24h)}
</span>
</td>
<td>{formatNumber(coin.volume)}</td>
</tr>
);
})}
</tbody>
</table>
</div>
</section>
<CandleChart symbol={selectedSymbol} />
</div>
</main>
);
}
export default function App() {
return (
<QueryClientProvider client={queryClient}>
<PriceDashboard />
</QueryClientProvider>
);
}The important part is this state:
const [selectedSymbol, setSelectedSymbol] = useState("BTC");When the user clicks a table row, we call:
setSelectedSymbol(coin.symbol)Then the chart receives the new symbol:
<CandleChart symbol={selectedSymbol} />This keeps the UI simple. The table controls the chart, and the selected row is highlighted with CSS.
Step 12: Add the Final Monochrome UI
Replace src/App.css:
body {
margin: 0;
background: #0f0f10;
color: #f4f4f5;
font-family: Inter, system-ui, sans-serif;
}
.dashboard {
max-width: 1440px;
margin: 0 auto;
padding: 40px 24px;
}
.pageHeader {
display: flex;
justify-content: space-between;
gap: 24px;
align-items: flex-start;
margin-bottom: 28px;
}
h1 {
margin: 0 0 12px;
font-size: 36px;
letter-spacing: -0.04em;
}
.pageHeader p {
margin: 0;
color: #a1a1aa;
line-height: 1.6;
}
.selectedBadge {
border: 1px solid #27272a;
border-radius: 999px;
padding: 8px 14px;
background: #18181b;
color: #a1a1aa;
white-space: nowrap;
}
.selectedBadge strong {
color: #f4f4f5;
}
.dashboardGrid {
display: grid;
grid-template-columns: 420px minmax(0, 1fr);
gap: 20px;
align-items: start;
}
.tableCard,
.chartCard {
border: 1px solid #27272a;
border-radius: 16px;
background: #18181b;
overflow: hidden;
}
.tableHeader,
.chartHeader {
display: flex;
justify-content: space-between;
align-items: center;
gap: 12px;
padding: 14px 16px;
border-bottom: 1px solid #27272a;
}
.tableHeader h2,
.chartHeader h2 {
margin: 0;
font-size: 15px;
}
.tableHeader span,
.chartHeader span {
color: #a1a1aa;
font-size: 12px;
}
.tableWrap {
overflow-x: auto;
}
.coinTable {
width: 100%;
border-collapse: collapse;
min-width: 420px;
}
.coinTable th {
color: #a1a1aa;
font-size: 10px;
font-weight: 600;
text-align: left;
text-transform: uppercase;
letter-spacing: 0.04em;
padding: 9px 12px;
border-bottom: 1px solid #27272a;
}
.coinTable td {
padding: 10px 12px;
border-bottom: 1px solid #27272a;
font-size: 13px;
}
.coinTable tbody tr {
cursor: pointer;
transition: background 0.15s ease;
}
.coinTable tbody tr:hover {
background: #202023;
}
.coinTable tbody tr.selectedRow {
background: #27272a;
box-shadow: inset 3px 0 0 #f4f4f5;
}
.coinCell {
display: flex;
align-items: center;
gap: 9px;
}
.coinCell strong {
display: block;
font-size: 13px;
}
.coinCell small {
display: block;
margin-top: 1px;
color: #a1a1aa;
font-size: 11px;
}
.coinIcon {
display: grid;
width: 26px;
height: 26px;
place-items: center;
border-radius: 50%;
background: #27272a;
color: #f4f4f5;
font-size: 12px;
font-weight: 700;
}
.sourcePill {
display: inline-flex;
border-radius: 999px;
border: 1px solid #3f3f46;
padding: 3px 8px;
color: #d4d4d8;
background: #18181b;
font-size: 10px;
font-weight: 700;
text-transform: uppercase;
}
.sourcePill.ccxt,
.sourcePill.coingecko {
background: #18181b;
color: #d4d4d8;
}
.up,
.down {
color: #f4f4f5;
}
.chartCard {
min-height: 460px;
}
.chartCard > div:last-child {
padding: 0 16px 16px;
}
@media (max-width: 980px) {
.dashboardGrid {
grid-template-columns: 1fr;
}
}The UI is intentionally monochrome. No green/red candles in the table, no colorful badges, no noisy cards. This makes the dashboard feel more like a small internal tool than a crypto casino interface.
Step 13: Run the App
Start everything with:
npm run devOpen the frontend URL from Vite, usually:
http://localhost:5173The API runs on:
http://localhost:4000You should now see:
- a compact coin table on the left;
- selected row highlighting;
- a candlestick chart on the right;
- live price updates;
- CoinGecko fallback for missing exchange pairs;
- OHLCV candles loaded from CCXT.
What We Built
This small dashboard is not a trading platform. It is a practical market data UI.
The backend hides the messy part:
- exchange symbols;
- missing pairs;
- fallback providers;
- ticker normalization;
- OHLCV conversion.
The frontend stays simple:
- React Query loads prices;
- table rows control selected state;
- the chart receives one symbol prop;
- CSS handles the selected row and layout.
This is a good base for a larger local crypto tracker. From here, we can add saved watchlists, portfolio positions, local storage, SQLite, historical snapshots, alerts, or even Electron packaging.
The most important part is the architecture: CCXT is the primary source, CoinGecko is the fallback, and the UI consumes one clean internal API.