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 payment required, 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/pricing 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);
}