Generic explorers like Birdeye and DexScreener show you everyone's trades. A custom dashboard shows you only the wallets you actually care about, with the filters and context you choose. If you are tracking a specific set of KOLs and want to see their trades with deployer tier context in one place — without paying for a copy-trade product that may not match your strategy — building your own is the right move.
This guide builds a two-panel Next.js dashboard: a cached leaderboard sidebar that updates every 5 minutes, and a live feed table that polls for new trades every 30 seconds. The BASIC tier API key (free, no credit card, 200 calls/day) is enough to build and run this.
What We Are Building
- Leaderboard panel: KOL rankings by 7-day PnL and win rate, rendered as a React Server Component with a 5-minute cache
- Live feed table: Recent KOL trades with token, action, SOL amount, time ago, and deployer tier badge
- Strategy filter: Toggle between
scalper, early_buyer, and mid_term KOL profiles
- Deployer tier badge: Highlights trades on tokens launched by elite deployers
Prerequisites
- Next.js 14 or 15 (App Router required — we use Server Components and
fetch caching)
- A MadeOnSol API key — BASIC tier is sufficient for this dashboard. Get one free at madeonsol.com/developer with 200 calls/day.
- Basic familiarity with React Server Components and the App Router
Step 1: Install the SDK
npm install madeonsol
The TypeScript SDK wraps all MadeOnSol API endpoints with typed responses and handles authentication headers for you.
Step 2: Initialize the Client
Create a shared client instance. Keep the API key server-side only — never expose it in client components.
// lib/madeonsol.ts
import { MadeOnSolClient } from 'madeonsol';
if (!process.env.MADEONSOL_API_KEY) {
throw new Error('MADEONSOL_API_KEY is not set');
}
export const mosClient = new MadeOnSolClient({
apiKey: process.env.MADEONSOL_API_KEY,
});
Add the key to .env.local:
MADEONSOL_API_KEY=msk_your_key_here
This file is gitignored by Next.js by default. Do not commit it.
Step 3: Leaderboard Server Component
The leaderboard does not need to update in real time. Ranking changes slowly — a 5-minute cache is appropriate and saves API quota.
// components/KolLeaderboard.tsx
import { mosClient } from '@/lib/madeonsol';
interface KolEntry {
rank: number;
wallet_address: string;
display_name: string | null;
pnl_7d_usd: number;
win_rate: number;
trade_volume_7d_sol: number;
strategy_tags: string[];
}
interface LeaderboardProps {
strategyFilter?: string;
}
export default async function KolLeaderboard({ strategyFilter }: LeaderboardProps) {
const data = await fetch(
`https://madeonsol.com/api/v1/kol/leaderboard?period=7d${strategyFilter ? `&strategy=${strategyFilter}` : ''}`,
{
headers: { Authorization: `Bearer ${process.env.MADEONSOL_API_KEY}` },
next: { revalidate: 300 }, // Cache for 5 minutes
}
).then((r) => r.json());
const kols: KolEntry[] = data.kols ?? [];
return (
<aside className="w-72 shrink-0 border border-border rounded-lg overflow-hidden">
<div className="px-4 py-3 border-b border-border bg-surface">
<h2 className="text-sm font-semibold text-foreground">7-Day Leaderboard</h2>
<p className="text-xs text-muted mt-0.5">Updates every 5 min</p>
</div>
<ul className="divide-y divide-border">
{kols.slice(0, 20).map((kol) => (
<li key={kol.wallet_address} className="px-4 py-3 flex items-center gap-3">
<span className="text-xs text-muted w-5 text-right">{kol.rank}</span>
<div className="flex-1 min-w-0">
<p className="text-sm font-medium text-foreground truncate">
{kol.display_name ?? truncateAddress(kol.wallet_address)}
</p>
<div className="flex items-center gap-2 mt-0.5">
<span className="text-xs text-muted">
Win rate: {(kol.win_rate * 100).toFixed(0)}%
</span>
{kol.strategy_tags.slice(0, 1).map((tag) => (
<StrategyBadge key={tag} tag={tag} />
))}
</div>
</div>
<div className="text-right">
<p
className={`text-sm font-semibold tabular-nums ${
kol.pnl_7d_usd >= 0 ? 'text-green-600' : 'text-red-500'
}`}
>
{kol.pnl_7d_usd >= 0 ? '+' : ''}
{formatUsd(kol.pnl_7d_usd)}
</p>
<p className="text-xs text-muted">
{kol.trade_volume_7d_sol.toFixed(1)} SOL vol
</p>
</div>
</li>
))}
</ul>
</aside>
);
}
function StrategyBadge({ tag }: { tag: string }) {
const labels: Record<string, string> = {
scalper: 'Scalper',
early_buyer: 'Early',
mid_term: 'Mid',
};
return (
<span className="text-xs px-1.5 py-0.5 rounded bg-surface border border-border text-muted">
{labels[tag] ?? tag}
</span>
);
}
function truncateAddress(addr: string): string {
return `${addr.slice(0, 4)}…${addr.slice(-4)}`;
}
function formatUsd(value: number): string {
const abs = Math.abs(value);
if (abs >= 1000) return `$${(abs / 1000).toFixed(1)}k`;
return `$${abs.toFixed(0)}`;
}
This is a pure Server Component — no 'use client' directive. The fetch call runs at request time on the server, and Next.js caches the response for 300 seconds (revalidate: 300). The component receives zero JavaScript in the browser bundle.
Step 4: Live Feed Client Component
The feed polls for new trades on an interval. This requires a Client Component.
// components/KolFeed.tsx
'use client';
import { useEffect, useState, useCallback } from 'react';
interface KolTrade {
id: string;
kol_display_name: string | null;
kol_wallet_address: string;
token_symbol: string;
token_address: string;
action: 'buy' | 'sell';
sol_amount: number;
usd_value: number;
timestamp: string;
deployer: {
tier: string | null;
} | null;
}
interface KolFeedProps {
strategyFilter?: string;
}
const POLL_INTERVAL = 30_000; // 30 seconds
export default function KolFeed({ strategyFilter }: KolFeedProps) {
const [trades, setTrades] = useState<KolTrade[]>([]);
const [loading, setLoading] = useState(true);
const [lastUpdated, setLastUpdated] = useState<Date | null>(null);
const fetchFeed = useCallback(async () => {
const params = new URLSearchParams({ limit: '20' });
if (strategyFilter) params.set('strategy', strategyFilter);
const res = await fetch(`/api/kol-feed?${params.toString()}`);
if (!res.ok) return;
const data = await res.json();
setTrades(data.trades ?? []);
setLastUpdated(new Date());
setLoading(false);
}, [strategyFilter]);
useEffect(() => {
fetchFeed();
const interval = setInterval(fetchFeed, POLL_INTERVAL);
return () => clearInterval(interval);
}, [fetchFeed]);
if (loading) {
return <div className="text-sm text-muted px-4 py-8 text-center">Loading feed...</div>;
}
return (
<div className="flex-1 min-w-0">
<div className="flex items-center justify-between px-4 py-3 border-b border-border bg-surface">
<h2 className="text-sm font-semibold text-foreground">Live Trade Feed</h2>
{lastUpdated && (
<span className="text-xs text-muted">
Updated {timeAgo(lastUpdated.toISOString())}
</span>
)}
</div>
<div className="overflow-x-auto">
<table className="w-full text-sm">
<thead>
<tr className="border-b border-border bg-surface">
<th className="px-4 py-2 text-left text-xs font-medium text-muted">KOL</th>
<th className="px-4 py-2 text-left text-xs font-medium text-muted">Token</th>
<th className="px-4 py-2 text-left text-xs font-medium text-muted">Action</th>
<th className="px-4 py-2 text-right text-xs font-medium text-muted">SOL</th>
<th className="px-4 py-2 text-left text-xs font-medium text-muted">Deployer</th>
<th className="px-4 py-2 text-right text-xs font-medium text-muted">Time</th>
</tr>
</thead>
<tbody className="divide-y divide-border">
{trades.map((trade) => (
<tr key={trade.id} className="hover:bg-surface/50 transition-colors">
<td className="px-4 py-2.5 font-medium text-foreground">
{trade.kol_display_name ?? truncateAddress(trade.kol_wallet_address)}
</td>
<td className="px-4 py-2.5">
<a
href={`https://dexscreener.com/solana/${trade.token_address}`}
target="_blank"
rel="noopener noreferrer"
className="text-foreground hover:underline"
>
{trade.token_symbol}
</a>
</td>
<td className="px-4 py-2.5">
<span
className={`inline-flex items-center px-2 py-0.5 rounded text-xs font-medium ${
trade.action === 'buy'
? 'bg-green-50 text-green-700 border border-green-200'
: 'bg-red-50 text-red-700 border border-red-200'
}`}
>
{trade.action === 'buy' ? 'Buy' : 'Sell'}
</span>
</td>
<td className="px-4 py-2.5 text-right tabular-nums text-foreground">
{trade.sol_amount.toFixed(2)}
</td>
<td className="px-4 py-2.5">
{trade.deployer?.tier === 'elite' && (
<span className="inline-flex items-center px-2 py-0.5 rounded text-xs font-medium bg-yellow-50 text-yellow-800 border border-yellow-200">
Elite
</span>
)}
</td>
<td className="px-4 py-2.5 text-right text-muted tabular-nums">
{timeAgo(trade.timestamp)}
</td>
</tr>
))}
</tbody>
</table>
{trades.length === 0 && (
<p className="text-sm text-muted px-4 py-8 text-center">No trades yet.</p>
)}
</div>
</div>
);
}
function truncateAddress(addr: string): string {
return `${addr.slice(0, 4)}…${addr.slice(-4)}`;
}
function timeAgo(iso: string): string {
const diff = Date.now() - new Date(iso).getTime();
const s = Math.floor(diff / 1000);
if (s < 60) return `${s}s ago`;
const m = Math.floor(s / 60);
if (m < 60) return `${m}m ago`;
const h = Math.floor(m / 60);
return `${h}h ago`;
}
The feed component calls /api/kol-feed, a Next.js route handler that proxies to MadeOnSol. Keeping the API key in route handlers (server-side) means it never reaches the browser.
// app/api/kol-feed/route.ts
import { NextRequest, NextResponse } from 'next/server';
export async function GET(req: NextRequest) {
const { searchParams } = req.nextUrl;
const limit = searchParams.get('limit') ?? '20';
const strategy = searchParams.get('strategy');
const url = new URL('https://madeonsol.com/api/v1/kol/feed');
url.searchParams.set('limit', limit);
if (strategy) url.searchParams.set('strategy', strategy);
const res = await fetch(url.toString(), {
headers: { Authorization: `Bearer ${process.env.MADEONSOL_API_KEY}` },
// Do not cache this — it needs fresh data on every request
cache: 'no-store',
});
if (!res.ok) {
return NextResponse.json({ error: 'Upstream error' }, { status: res.status });
}
const data = await res.json();
return NextResponse.json(data);
}
Step 5: Strategy Tag Filter
Add a client-side filter that passes a strategy parameter to both the leaderboard and feed. The filter state lives in the page component.
// app/dashboard/page.tsx
'use client';
import { useState, Suspense } from 'react';
import KolLeaderboard from '@/components/KolLeaderboard';
import KolFeed from '@/components/KolFeed';
const STRATEGIES = [
{ value: '', label: 'All' },
{ value: 'scalper', label: 'Scalpers' },
{ value: 'early_buyer', label: 'Early Buyers' },
{ value: 'mid_term', label: 'Mid Term' },
];
export default function DashboardPage() {
const [strategy, setStrategy] = useState('');
return (
<div className="max-w-7xl mx-auto px-4 py-8">
<div className="flex items-center justify-between mb-6">
<h1 className="text-xl font-bold text-foreground">KOL Dashboard</h1>
<div className="flex items-center gap-1 p-1 bg-surface border border-border rounded-lg">
{STRATEGIES.map((s) => (
<button
key={s.value}
onClick={() => setStrategy(s.value)}
className={`px-3 py-1.5 text-sm rounded-md transition-colors ${
strategy === s.value
? 'bg-background border border-border text-foreground font-medium shadow-sm'
: 'text-muted hover:text-foreground'
}`}
>
{s.label}
</button>
))}
</div>
</div>
<div className="flex gap-6">
<Suspense fallback={<LeaderboardSkeleton />}>
<KolLeaderboard strategyFilter={strategy} />
</Suspense>
<KolFeed strategyFilter={strategy} />
</div>
</div>
);
}
function LeaderboardSkeleton() {
return (
<aside className="w-72 shrink-0 border border-border rounded-lg">
<div className="px-4 py-3 border-b border-border bg-surface">
<div className="h-4 w-32 bg-border rounded animate-pulse" />
</div>
<div className="divide-y divide-border">
{Array.from({ length: 10 }).map((_, i) => (
<div key={i} className="px-4 py-3 flex gap-3">
<div className="h-4 w-4 bg-border rounded animate-pulse" />
<div className="flex-1 space-y-1.5">
<div className="h-3.5 w-24 bg-border rounded animate-pulse" />
<div className="h-3 w-16 bg-border rounded animate-pulse" />
</div>
</div>
))}
</div>
</aside>
);
}
Note: the KolLeaderboard component is a Server Component, but wrapping it in Suspense inside a Client Component works because Next.js handles the server/client boundary through the Suspense boundary. If you hit rendering issues, move the strategy prop via a URL search param and read it with useSearchParams in the Server Component.
Step 6: Deployer Tier Badge Logic
The feed table already renders an "Elite" badge when trade.deployer?.tier === 'elite'. You can extend this to all tiers with color coding:
const TIER_STYLES: Record<string, string> = {
elite: 'bg-yellow-50 text-yellow-800 border-yellow-200',
good: 'bg-green-50 text-green-700 border-green-200',
moderate: 'bg-orange-50 text-orange-700 border-orange-200',
rising: 'bg-blue-50 text-blue-700 border-blue-200',
};
function DeployerTierBadge({ tier }: { tier: string | null }) {
if (!tier || !TIER_STYLES[tier]) return null;
const label = tier.charAt(0).toUpperCase() + tier.slice(1);
return (
<span className={`inline-flex items-center px-2 py-0.5 rounded text-xs font-medium border ${TIER_STYLES[tier]}`}>
{label}
</span>
);
}
Replace the inline badge in the feed table with <DeployerTierBadge tier={trade.deployer?.tier ?? null} />.
BASIC Tier Limitations
On the free BASIC tier (200 calls/day), the KOL feed returns truncated wallet addresses. Display names still appear for KOLs that have public profiles, but wallet addresses you would use for copy trade execution are shortened (e.g., AbcD…wxYZ).
To build actual copy trade execution — subscribing to a wallet and mirroring its trades — you need full wallet addresses. That requires upgrading to PRO ($49/month), which also gives you 10,000 calls/day, 3 registered webhooks, and 1 WebSocket connection.
The dashboard in this guide is fully functional on BASIC. It shows all the signal information you need to manually follow trades. Full automation requires PRO.
Upgrading to WebSocket for Real-Time Updates
Polling every 30 seconds is simple and works well for a monitoring dashboard. If you want sub-second trade delivery, the PRO and ULTRA tiers include WebSocket streaming. Connect to the MadeOnSol WS endpoint and receive kol:trade events as they happen:
// Example — requires PRO/ULTRA key
const ws = new WebSocket('wss://madeonsol.com/api/v1/stream', {
headers: { Authorization: `Bearer ${process.env.MADEONSOL_API_KEY}` },
});
ws.on('message', (raw) => {
const event = JSON.parse(raw.toString());
if (event.type === 'kol:trade') {
// Push to your feed state
}
});
Replace the setInterval polling in KolFeed with a WebSocket connection managed in a useEffect. ULTRA tier ($199/month) supports 3 simultaneous WS connections if you need to stream multiple event types in parallel.
API Call Budget
For reference, this dashboard's call patterns on a 24-hour cycle:
| Component | Frequency | Calls/day |
|---|
| Leaderboard (revalidate 300s) | Every 5 min | ~288 |
| Feed (poll 30s) | Every 30 sec | ~2,880 |
The feed alone exceeds the BASIC tier limit (200 calls/day) if running continuously. For development and testing, BASIC is fine. For a dashboard running all day, PRO is the right tier — 10,000 calls/day covers both components comfortably.
Next Steps
This dashboard shows you what the KOLs are trading. The natural extension is acting on those trades automatically. The wiring is:
- Receive a
kol:trade event (webhook on PRO, or WebSocket stream)
- Extract the token address and action
- Pass it to Jupiter's swap API to execute the same trade
That execution layer is covered in How to Build a Solana Trading Bot. The MadeOnSol feed provides the signal; Jupiter handles the execution.
For the full API reference, endpoint schemas, and SDK documentation, see madeonsol.com/developer.