Most crypto APIs are either free and broken, or reliable and expensive.
If you've tried to build something real with the usual suspects:
- CoinGecko rate-limits you at 10–30 req/min before you even have a key,
- CoinMarketCap puts historical data behind a $29+/month paywall,
- Binance API only knows assets that trade on Binance.
You hit the wall before the first component renders.
I built a real-time crypto dashboard using CoinStats API — deployed, fully functional, four data sections running in parallel. This article covers the actual implementation: the endpoints I used, the decisions I made, and the bugs I hit that the documentation doesn't mention.
The full source is on GitHub: github.com/Kevinelectronics/cryptodashboard
Why the Usual Options Don't Work
Before the solution, the problem. Because the tradeoffs matter when you're architecting a dashboard with multiple data types running simultaneously.
| API | Free Rate Limit | Historical Charts | News Feed | Global Stats |
|---|---|---|---|---|
| CoinGecko | ~10–30 req/min, no key | ✅ limited | ❌ | ✅ /global
|
| CoinMarketCap | Latest quote only | ❌ paid | ❌ free tier | ✅ |
| Binance | Exchange assets only | ✅ OHLCV | ❌ | ❌ |
| CoinStats | 100 req/min with key | ✅ /coins/{id}/charts |
✅ /news |
✅ /markets |
The news endpoint alone eliminates a second API integration. For a dashboard that needs coins + charts + market context + news from a single base URL, CoinStats is the only free-tier option that doesn't break the architecture.
👉 Get your free CoinStats API key here — no credit card required.
What I Built
A single-page React app with four vertical sections:
- Global Market Stats — 4 cards: Total Market Cap, 24h Volume, BTC Dominance, Alt Dominance
- Coin Table — Top 50 coins, sortable by any column, with real-time search by name or symbol
- Price Chart — Appears inline when you click a coin. Color and gradient change based on price direction. Period selector: 1D, 1W, 1M, 3M, 6M, 1Y
- News Feed — 6 recent crypto news items with image, source, and timestamp
Stack: Vite 5 + React 19, JavaScript (no TypeScript), Tailwind CSS v4, Recharts. Dark mode only — GitHub-style #0d1117 background.
[Screenshot: full dashboard view — global stats cards at top, coin table below, news grid at bottom]
What the API Actually Returns
Real JSON shapes before any component code. Technical readers need to see the structure before trusting an API.
GET /coins?limit=50
{
"result": [
{
"id": "bitcoin",
"symbol": "BTC",
"name": "Bitcoin",
"icon": "https://static.coinstats.app/coins/1650455588819.png",
"rank": 1,
"price": 67420.34,
"priceChange1h": 0.21,
"priceChange1d": 2.14,
"priceChange1w": -3.45,
"volume": 28940000000,
"marketCap": 1324000000000,
"availableSupply": 19700000,
"totalSupply": 21000000
}
],
"meta": { "itemCount": 50, "totalCount": 14000 }
}
Use priceChange1d and priceChange1w directly — they're already percentages. Don't compute (price - price24hAgo) / price24hAgo * 100 manually. The API does that for you.
GET /markets
{
"totalMarketCap": 2340000000000,
"totalVolume": 98700000000,
"btcDominance": 52.4,
"altDominance": 47.6,
"marketCapChange": 1.8
}
⚠️ Gotcha — the endpoint is
/markets, not/global. The docs reference a/globalendpoint in older versions. It returns a 404. If you spend 20 minutes wondering why your global stats call keeps failing, this is why. The correct endpoint is/markets.
GET /coins/{id}/charts?period=1w
{
"result": [
[1714000000000, 62100.5, 0.00092, 31200000000],
[1714086400000, 63450.2, 0.00094, 29800000000]
]
}
Each item is [timestamp, price, priceBtc, volume] — four values, not two. Most tutorials show data.map(([time, price]) => ...) and silently discard priceBtc and volume. That's fine if you only need price, but you should destructure explicitly so it's clear:
result.map(([time, price, priceBtc, volume]) => ({
time: new Date(time).toLocaleDateString('en-US', { month: 'short', day: 'numeric' }),
price,
volume
}))
GET /news?limit=6
{
"news": [
{
"id": "abc123",
"title": "Bitcoin hits new monthly high amid ETF inflows",
"description": "Spot ETFs saw $400M in net inflows...",
"feedName": "CoinDesk",
"imgUrl": "https://...",
"link": "https://...",
"shareURL": "https://...",
"reactionsCount": 42,
"publishedAt": "2024-04-26T08:00:00Z"
}
]
}
imgUrl is ready to drop into an <img> tag. publishedAt is ISO 8601 — pass it to new Date() and format from there.
Project Setup
npm create vite@latest crypto-dashboard -- --template react
cd crypto-dashboard
npm install recharts
npm install -D tailwindcss @tailwindcss/vite
Tailwind v4 configuration in vite.config.js:
import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'
import tailwindcss from '@tailwindcss/vite'
export default defineConfig({
plugins: [react(), tailwindcss()]
})
In src/index.css:
@import "tailwindcss";
Create .env in the root:
VITE_COINSTATS_KEY=your_api_key_here
Access it everywhere as import.meta.env.VITE_COINSTATS_KEY.
The Data Hooks
Four endpoints, four hooks. Each follows the same pattern: loading state, error state, cleanup flag on unmount.
useCoinList — The main table data
// hooks/useCoinList.js
import { useState, useEffect } from 'react';
const BASE = 'https://openapiv1.coinstats.app';
const HEADERS = { 'X-API-KEY': import.meta.env.VITE_COINSTATS_KEY };
export function useCoinList(limit = 50) {
const [coins, setCoins] = useState([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState(null);
useEffect(() => {
let cancelled = false;
async function fetchCoins() {
try {
const res = await fetch(`${BASE}/coins?limit=${limit}`, { headers: HEADERS });
if (!res.ok) throw new Error(`HTTP ${res.status}`);
const { result } = await res.json();
if (!cancelled) setCoins(result);
} catch (err) {
if (!cancelled) setError(err.message);
} finally {
if (!cancelled) setLoading(false);
}
}
fetchCoins();
return () => { cancelled = true; };
}, [limit]);
return { coins, loading, error };
}
The cancelled = true pattern is not optional. If the user navigates away or a parent re-renders before the fetch resolves, React throws a warning about setting state on an unmounted component. The cleanup function silences it by checking the flag before every setState call.
useMarketStats — Global market cards
// hooks/useMarketStats.js
import { useState, useEffect } from 'react';
export function useMarketStats() {
const [stats, setStats] = useState(null);
const [loading, setLoading] = useState(true);
useEffect(() => {
let cancelled = false;
fetch('https://openapiv1.coinstats.app/markets', {
headers: { 'X-API-KEY': import.meta.env.VITE_COINSTATS_KEY }
})
.then(res => res.json())
.then(data => { if (!cancelled) { setStats(data); setLoading(false); } });
return () => { cancelled = true; };
}, []);
return { stats, loading };
}
useChartData — Price history per coin
// hooks/useChartData.js
import { useState, useEffect, useCallback } from 'react';
const BASE = 'https://openapiv1.coinstats.app';
const HEADERS = { 'X-API-KEY': import.meta.env.VITE_COINSTATS_KEY };
export function useChartData(coinId, period = '1w') {
const [chartData, setChartData] = useState([]);
const [loading, setLoading] = useState(false);
const fetchChart = useCallback(async () => {
if (!coinId) return;
setLoading(true);
let cancelled = false;
try {
const res = await fetch(
`${BASE}/coins/${coinId}/charts?period=${period}`,
{ headers: HEADERS }
);
const { result } = await res.json();
const formatted = result.map(([time, price]) => ({
time: new Date(time).toLocaleDateString('en-US', { month: 'short', day: 'numeric' }),
price
}));
if (!cancelled) setChartData(formatted);
} finally {
if (!cancelled) setLoading(false);
}
return () => { cancelled = true; };
}, [coinId, period]);
useEffect(() => { fetchChart(); }, [fetchChart]);
return { chartData, loading };
}
useCallback wraps the fetch function so it only recreates when coinId or period changes. Without it, every parent re-render generates a new function reference → triggers useEffect → fires another fetch → infinite loop. With the wrong deps array, your chart refetches on every keystroke in the search box.
useNews — News feed
// hooks/useNews.js
import { useState, useEffect } from 'react';
export function useNews(limit = 6) {
const [news, setNews] = useState([]);
const [loading, setLoading] = useState(true);
useEffect(() => {
let cancelled = false;
fetch(`https://openapiv1.coinstats.app/news?limit=${limit}`, {
headers: { 'X-API-KEY': import.meta.env.VITE_COINSTATS_KEY }
})
.then(res => res.json())
.then(data => { if (!cancelled) { setNews(data.news); setLoading(false); } });
return () => { cancelled = true; };
}, [limit]);
return { news, loading };
}
Section 1: Global Market Stats
[Screenshot: 4 stat cards — Total Market Cap, 24h Volume, BTC Dominance, Alt Dominance — on dark background]
// components/MarketStats.jsx
import { useMarketStats } from '../hooks/useMarketStats';
function StatCard({ label, value, change }) {
return (
<div className="bg-[#161b22] rounded-xl p-5 border border-[#30363d]">
<p className="text-[#8b949e] text-sm mb-1">{label}</p>
<p className="text-white text-2xl font-semibold">{value}</p>
{change !== undefined && (
<p className={`text-sm mt-1 ${change >= 0 ? 'text-green-400' : 'text-red-400'}`}>
{change >= 0 ? '+' : ''}{change?.toFixed(2)}%
</p>
)}
</div>
);
}
export function MarketStats() {
const { stats, loading } = useMarketStats();
if (loading) return (
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
{[...Array(4)].map((_, i) => (
<div key={i} className="bg-[#161b22] rounded-xl p-5 h-24 animate-pulse border border-[#30363d]" />
))}
</div>
);
return (
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
<StatCard
label="Total Market Cap"
value={`$${(stats.totalMarketCap / 1e12).toFixed(2)}T`}
change={stats.marketCapChange}
/>
<StatCard
label="24h Volume"
value={`$${(stats.totalVolume / 1e9).toFixed(0)}B`}
/>
<StatCard label="BTC Dominance" value={`${stats.btcDominance?.toFixed(1)}%`} />
<StatCard label="Alt Dominance" value={`${stats.altDominance?.toFixed(1)}%`} />
</div>
);
}
The skeleton uses animate-pulse from Tailwind — same dimensions as the real card, no layout shift when data arrives. No spinners anywhere in the app. Spinners are disorienting because the user can't tell how much is loading. Skeletons show exactly what's coming.
Section 2: Coin Table with Sort and Search
[Screenshot: coin table with sortable headers, BTC row highlighted, search bar active]
// components/CoinTable.jsx
import { useState, useMemo } from 'react';
import { useCoinList } from '../hooks/useCoinList';
const COLUMNS = [
{ key: 'rank', label: '#' },
{ key: 'name', label: 'Name' },
{ key: 'price', label: 'Price' },
{ key: 'priceChange1h', label: '1h %' },
{ key: 'priceChange1d', label: '24h %' },
{ key: 'priceChange1w', label: '7d %' },
{ key: 'marketCap', label: 'Market Cap' },
];
export function CoinTable({ onSelectCoin }) {
const { coins, loading } = useCoinList(50);
const [query, setQuery] = useState('');
const [sortKey, setSortKey] = useState('rank');
const [sortDir, setSortDir] = useState(1); // 1 = asc, -1 = desc
function handleSort(key) {
if (key === sortKey) {
setSortDir(sortDir * -1); // toggle direction
} else {
setSortKey(key);
setSortDir(1);
}
}
const filtered = useMemo(() => {
const q = query.toLowerCase();
return coins.filter(c =>
c.name.toLowerCase().includes(q) || c.symbol.toLowerCase().includes(q)
);
}, [coins, query]);
const sorted = useMemo(() => {
return [...filtered].sort((a, b) => {
const aVal = a[sortKey] ?? 0;
const bVal = b[sortKey] ?? 0;
if (typeof aVal === 'string') return sortDir * aVal.localeCompare(bVal);
return sortDir * (aVal - bVal);
});
}, [filtered, sortKey, sortDir]);
if (loading) return (
<div className="space-y-2">
{[...Array(10)].map((_, i) => (
<div key={i} className="h-12 bg-[#161b22] rounded animate-pulse border border-[#30363d]" />
))}
</div>
);
return (
<div>
<input
type="text"
placeholder="Search coin or symbol..."
value={query}
onChange={e => setQuery(e.target.value)}
className="mb-4 w-full bg-[#161b22] border border-[#30363d] rounded-lg px-4 py-2 text-white placeholder-[#8b949e] focus:outline-none focus:border-blue-500"
/>
<div className="overflow-x-auto">
<table className="w-full text-sm">
<thead>
<tr className="text-[#8b949e] border-b border-[#30363d]">
{COLUMNS.map(col => (
<th
key={col.key}
onClick={() => handleSort(col.key)}
className="py-3 px-4 text-left cursor-pointer select-none hover:text-white transition-colors"
>
{col.label}
{sortKey === col.key && (sortDir === 1 ? ' ↑' : ' ↓')}
</th>
))}
</tr>
</thead>
<tbody>
{sorted.map(coin => (
<tr
key={coin.id}
onClick={() => onSelectCoin(coin)}
className="border-b border-[#21262d] hover:bg-[#161b22] cursor-pointer transition-colors"
>
<td className="py-3 px-4 text-[#8b949e]">{coin.rank}</td>
<td className="py-3 px-4">
<div className="flex items-center gap-2">
<img src={coin.icon} alt={coin.name} className="w-6 h-6 rounded-full" />
<span className="text-white font-medium">{coin.name}</span>
<span className="text-[#8b949e]">{coin.symbol}</span>
</div>
</td>
<td className="py-3 px-4 text-white font-mono">
${coin.price >= 1 ? coin.price.toLocaleString() : coin.price.toFixed(6)}
</td>
{['priceChange1h', 'priceChange1d', 'priceChange1w'].map(k => (
<td key={k} className={`py-3 px-4 font-mono ${coin[k] >= 0 ? 'text-green-400' : 'text-red-400'}`}>
{coin[k] >= 0 ? '+' : ''}{coin[k]?.toFixed(2)}%
</td>
))}
<td className="py-3 px-4 text-[#8b949e]">
${(coin.marketCap / 1e9).toFixed(1)}B
</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
);
}
sortDir * -1 toggles between ascending and descending with one multiplication. No switch statement, no ternary chain. useMemo on both filtered and sorted means the search and sort operations don't re-run on unrelated renders — only when coins, query, sortKey, or sortDir actually change.
Section 3: The Inline Price Chart
[Screenshot: price chart expanded below a coin row — green gradient, period selector 1W active]
The chart appears inline when the user clicks a coin row. It doesn't navigate anywhere. onSelectCoin in the parent sets a state variable; the chart renders conditionally below the table.
// components/PriceChart.jsx
import { useState, useMemo } from 'react';
import {
AreaChart, Area, XAxis, YAxis, Tooltip,
ResponsiveContainer, defs, linearGradient, stop
} from 'recharts';
import { useChartData } from '../hooks/useChartData';
const PERIODS = ['1d', '1w', '1m', '3m', '6m', '1y'];
export function PriceChart({ coin }) {
const [period, setPeriod] = useState('1w');
const { chartData, loading } = useChartData(coin.id, period);
// Determine price direction for the selected period
const isUp = useMemo(() => {
if (chartData.length < 2) return true;
return chartData[chartData.length - 1].price >= chartData[0].price;
}, [chartData]);
const color = isUp ? '#22c55e' : '#ef4444'; // green-500 / red-500
return (
<div className="bg-[#161b22] rounded-xl p-5 border border-[#30363d] mt-2">
<div className="flex items-center justify-between mb-4">
<div>
<span className="text-white font-semibold text-lg">{coin.name}</span>
<span className="text-[#8b949e] ml-2">{coin.symbol}</span>
</div>
<div className="flex gap-1">
{PERIODS.map(p => (
<button
key={p}
onClick={() => setPeriod(p)}
className={`px-3 py-1 rounded text-sm uppercase transition-colors ${
period === p
? 'bg-blue-600 text-white'
: 'text-[#8b949e] hover:text-white'
}`}
>
{p}
</button>
))}
</div>
</div>
{loading ? (
<div className="h-64 animate-pulse bg-[#0d1117] rounded" />
) : (
<ResponsiveContainer width="100%" height={260}>
<AreaChart data={chartData}>
<defs>
<linearGradient id="chartGradient" x1="0" y1="0" x2="0" y2="1">
<stop offset="5%" stopColor={color} stopOpacity={0.3} />
<stop offset="95%" stopColor={color} stopOpacity={0} />
</linearGradient>
</defs>
<XAxis
dataKey="time"
tick={{ fill: '#8b949e', fontSize: 11 }}
axisLine={false}
tickLine={false}
interval="preserveStartEnd"
/>
<YAxis
domain={['auto', 'auto']}
tick={{ fill: '#8b949e', fontSize: 11 }}
axisLine={false}
tickLine={false}
tickFormatter={v => `$${v >= 1000 ? (v / 1000).toFixed(1) + 'k' : v.toFixed(2)}`}
/>
<Tooltip
contentStyle={{ background: '#161b22', border: '1px solid #30363d', borderRadius: 8 }}
labelStyle={{ color: '#8b949e' }}
formatter={v => [`$${v.toLocaleString()}`, 'Price']}
/>
<Area
type="monotone"
dataKey="price"
stroke={color}
strokeWidth={2}
fill="url(#chartGradient)"
dot={false}
/>
</AreaChart>
</ResponsiveContainer>
)}
</div>
);
}
The gradient ID chartGradient is static — if you render multiple charts at once, give each a unique ID. In this dashboard there's only one chart visible at a time, so it's not an issue.
isUp compares chartData[0].price against chartData[chartData.length - 1].price. When the user switches from 1W to 1M, the hook refetches, chartData updates, isUp recalculates, and the color flips automatically. No extra state needed.
Section 4: News Feed
[Screenshot: 2x3 news grid — dark cards, article images, source badge and timestamp]
// components/NewsFeed.jsx
import { useNews } from '../hooks/useNews';
function NewsCard({ item }) {
const date = new Date(item.publishedAt).toLocaleDateString('en-US', {
month: 'short', day: 'numeric', year: 'numeric'
});
return (
<a
href={item.link}
target="_blank"
rel="noopener noreferrer"
className="bg-[#161b22] rounded-xl overflow-hidden border border-[#30363d] hover:border-blue-500 transition-colors flex flex-col"
>
{item.imgUrl && (
<img
src={item.imgUrl}
alt={item.title}
className="w-full h-40 object-cover"
onError={e => { e.target.style.display = 'none'; }}
/>
)}
<div className="p-4 flex flex-col flex-1">
<p className="text-white font-medium text-sm leading-snug line-clamp-2 mb-2">
{item.title}
</p>
<div className="mt-auto flex items-center justify-between text-xs text-[#8b949e]">
<span>{item.feedName}</span>
<span>{date}</span>
</div>
</div>
</a>
);
}
export function NewsFeed() {
const { news, loading } = useNews(6);
if (loading) return (
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
{[...Array(6)].map((_, i) => (
<div key={i} className="h-64 bg-[#161b22] rounded-xl animate-pulse border border-[#30363d]" />
))}
</div>
);
return (
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
{news.map(item => <NewsCard key={item.id} item={item} />)}
</div>
);
}
onError on the image handles broken CDN URLs gracefully — hides the image element instead of showing a broken icon. Small detail, but news images break often enough that it matters in production.
Composing the App
// App.jsx
import { useState } from 'react';
import { MarketStats } from './components/MarketStats';
import { CoinTable } from './components/CoinTable';
import { PriceChart } from './components/PriceChart';
import { NewsFeed } from './components/NewsFeed';
export default function App() {
const [selectedCoin, setSelectedCoin] = useState(null);
return (
<div className="min-h-screen bg-[#0d1117] text-white">
<div className="max-w-7xl mx-auto px-4 py-8 space-y-10">
<header>
<h1 className="text-2xl font-bold">Crypto Dashboard</h1>
<p className="text-[#8b949e] text-sm mt-1">Powered by CoinStats API</p>
</header>
<section>
<h2 className="text-lg font-semibold mb-4">Market Overview</h2>
<MarketStats />
</section>
<section>
<h2 className="text-lg font-semibold mb-4">Top 50 Coins</h2>
<CoinTable onSelectCoin={setSelectedCoin} />
{selectedCoin && <PriceChart coin={selectedCoin} />}
</section>
<section>
<h2 className="text-lg font-semibold mb-4">Latest News</h2>
<NewsFeed />
</section>
</div>
</div>
);
}
No router. No context. No global state manager. One useState at the top level passes the selected coin down to PriceChart. For a single-page dashboard, this is exactly as complex as it needs to be.
How Many API Calls Does the Dashboard Make?
At cold load, before any user interaction:
| Call | Endpoint | Fires |
|---|---|---|
| Market stats | GET /markets |
On mount |
| Coin list | GET /coins?limit=50 |
On mount |
| News | GET /news?limit=6 |
On mount |
| Price chart | GET /coins/{id}/charts?period=1w |
On coin click |
3 calls on load. 1 additional on first coin click. 1 per period change.
CoinStats free tier allows 100 req/min. The dashboard at idle uses 3 calls total. Even with aggressive period switching (6 periods × 5 coins), you'd use ~33 calls — well within limits.
API Key Security
In a Vite app, VITE_ prefixed variables are bundled into the JavaScript. Anyone who opens DevTools → Sources can read your key.
For this dashboard as a demo project, that's an acceptable tradeoff.
For a public production deployment:
Option 1 — Cloudflare Worker proxy: Your frontend calls https://your-worker.workers.dev/coins, the worker injects X-API-KEY server-side and forwards to CoinStats. The key never appears in the browser bundle. Free tier is 100,000 requests/day.
Option 2 — Express backend: Same logic, more control, more infrastructure. Only worth it if you're already running a server.
The architecture change is minimal — replace the base URL constant in your hooks with your proxy URL. Everything else stays identical.
FAQ
Do I need a backend to use CoinStats API?
No. All four endpoints used in this dashboard work directly from the browser. A backend is only necessary if you want to keep your API key out of the JS bundle.
How do I protect the API key in production?
Use a Cloudflare Worker as a transparent proxy. It injects the key server-side before forwarding requests to CoinStats. Your frontend never sees the key. Setup takes about 15 minutes.
Why Recharts instead of Chart.js or D3?
Recharts is declarative and built for React. Chart.js requires an imperative configuration model that fights React's rendering cycle. D3 is powerful for custom visualizations but overkill for a standard price area chart. Recharts ships ResponsiveContainer which handles resize automatically — no ResizeObserver wiring needed.
Why no TypeScript?
Deliberate choice for this project. The API responses are well-structured and the codebase is small enough that the overhead of maintaining types doesn't pay off. In a larger app or a team project, TypeScript makes sense.
Does this work with React 19?
Yes. All hooks used — useState, useEffect, useMemo, useCallback — are stable across React 16+. The cancelled flag cleanup pattern works identically in React 19.
Can I add portfolio tracking without a backend?
Yes, for local tracking. Store holdings as { coinId, amount } in localStorage, fetch current prices from /coins, and compute portfolio value client-side with useMemo. For cross-device sync, you need a backend.
Wrapping Up
Four data sections. Four hooks. One API.
The architecture decisions here aren't clever — they're the minimum viable structure for a dashboard that doesn't fight React: custom hooks that own their fetch lifecycle, cleanup flags that prevent stale state updates, useMemo for derived data, and a single state variable to coordinate the chart.
CoinStats API handles the rest: coins, charts, market context, and news from one base URL, on a free tier that doesn't require a credit card.
The full source is available on GitHub — clone it, break it, and build something better: github.com/Kevinelectronics/cryptodashboard
Looking for technical content for your company? I can help — LinkedIn · kevinmenesesgonzalez@gmail.com
Top comments (0)