If you have placement data coming out of the Inbox Check API, the next thing you want is to look at it. A dashboard. This article builds one, end to end, in under 150 lines of React. Next.js 14 App Router, SWR for data fetching, Recharts for the line chart, the first-party inbox-check Node SDK for the API calls. No framework soup, no admin-panel generator, no low-code.
A single /dashboard page with four tiles: overall inbox rate (big number + trend arrow), per-provider placement (grouped bar chart), 7-day trend line (Recharts line chart), and auth + DNSBL panel (SPF / DKIM / DMARC / blacklist status). Mobile-responsive, server-fetched, zero frontend API keys.
The stack
- Next.js 14 with the App Router — server components do the API key handling, client components render the charts.
- SWR 2.x — tiny, well-supported, caches per-route.
- Recharts — enough for one line chart and one grouped bar; lighter than Nivo or Victory.
- inbox-check Node SDK (
npm install inbox-check).
Bootstrap the project
npx create-next-app@latest deliverability-dashboard --ts --app --tailwind
cd deliverability-dashboard
npm install swr recharts inbox-check date-fnsPut your API key in .env.local:
INBOX_CHECK_API_KEY=ic_live_xxxxxxxxxxxxNever ship your API key to the browser. Every API call in this dashboard goes through a Next.js route handler so the key stays on the server. SWR just hits your own /api/* routes.
The proxy route handler
One route per dashboard tile keeps the client code small. Start with the list of recent tests:
// app/api/tests/route.ts
import { NextResponse } from 'next/server';
import { InboxCheck } from 'inbox-check';
const ic = new InboxCheck({ apiKey: process.env.INBOX_CHECK_API_KEY! });
export async function GET(req: Request) {
const url = new URL(req.url);
const days = Number(url.searchParams.get('days') ?? 7);
const since = new Date(Date.now() - days * 86400_000).toISOString();
const { items } = await ic.tests.list({ since, limit: 100 });
return NextResponse.json({
items: items.map((t) => ({
id: t.id,
domain: t.senderDomain,
createdAt: t.createdAt,
inbox: t.summary.inboxCount,
spam: t.summary.spamCount,
missing: t.summary.missingCount,
total: t.summary.total,
auth: t.auth,
providers: t.providers,
})),
});
}The SWR hook
One thin wrapper around useSWR so every tile gets a cached, revalidating fetch:
// lib/useTests.ts
import useSWR from 'swr';
type Test = {
id: string;
domain: string;
createdAt: string;
inbox: number;
spam: number;
missing: number;
total: number;
auth: { spf: string; dkim: string; dmarc: string };
providers: Array<{ name: string; folder: 'inbox' | 'spam' | 'missing' }>;
};
const fetcher = (u: string) => fetch(u).then((r) => r.json());
export function useTests(days = 7) {
return useSWR<{ items: Test[] }>(
`/api/tests?days=${days}`,
fetcher,
{ refreshInterval: 60_000 },
);
}The overview tile
The big number — overall inbox rate — plus a trend arrow comparing today vs 7-day average.
// app/dashboard/OverviewTile.tsx
'use client';
import { useTests } from '@/lib/useTests';
export function OverviewTile() {
const { data, isLoading } = useTests(7);
if (isLoading || !data) return <div className="panel p-6">Loading...</div>;
const items = data.items;
const total = items.reduce((s, t) => s + t.total, 0);
const inbox = items.reduce((s, t) => s + t.inbox, 0);
const rate = total === 0 ? 0 : (inbox / total) * 100;
const todayCut = Date.now() - 86400_000;
const today = items.filter((t) => new Date(t.createdAt).getTime() >= todayCut);
const todayRate =
today.length === 0
? 0
: (today.reduce((s, t) => s + t.inbox, 0) /
today.reduce((s, t) => s + t.total, 0)) *
100;
const arrow = todayRate >= rate ? '↑' : '↓';
return (
<div className="panel p-6">
<div className="text-xs uppercase text-slate-500 mb-2">Inbox rate (7d)</div>
<div className="text-4xl font-bold">{rate.toFixed(1)}%</div>
<div className="text-sm text-slate-500 mt-1">
Today {todayRate.toFixed(1)}% {arrow}
</div>
</div>
);
}Per-provider inbox rate
A grouped bar chart showing inbox vs spam vs missing per provider (Gmail, Outlook, Yahoo, Mail.ru, etc.). Aggregate across every test in the window:
// app/dashboard/PerProviderTile.tsx
'use client';
import { useTests } from '@/lib/useTests';
import {
BarChart, Bar, XAxis, YAxis, Tooltip, Legend, ResponsiveContainer,
} from 'recharts';
export function PerProviderTile() {
const { data } = useTests(7);
if (!data) return null;
const map = new Map<string, { inbox: number; spam: number; missing: number }>();
for (const t of data.items) {
for (const p of t.providers) {
const entry = map.get(p.name) ?? { inbox: 0, spam: 0, missing: 0 };
entry[p.folder]++;
map.set(p.name, entry);
}
}
const rows = Array.from(map, ([name, v]) => ({ name, ...v }));
return (
<div className="panel p-6">
<div className="text-xs uppercase text-slate-500 mb-2">Per provider</div>
<ResponsiveContainer width="100%" height={260}>
<BarChart data={rows}>
<XAxis dataKey="name" fontSize={11} />
<YAxis fontSize={11} />
<Tooltip />
<Legend />
<Bar dataKey="inbox" fill="#10b981" />
<Bar dataKey="spam" fill="#ef4444" />
<Bar dataKey="missing" fill="#94a3b8" />
</BarChart>
</ResponsiveContainer>
</div>
);
}7-day trend line
Group tests by day, plot inbox rate. Uses date-fns for the day key:
// app/dashboard/TrendTile.tsx
'use client';
import { useTests } from '@/lib/useTests';
import { format, startOfDay } from 'date-fns';
import {
LineChart, Line, XAxis, YAxis, Tooltip, ResponsiveContainer, CartesianGrid,
} from 'recharts';
export function TrendTile() {
const { data } = useTests(7);
if (!data) return null;
const byDay = new Map<string, { inbox: number; total: number }>();
for (const t of data.items) {
const key = format(startOfDay(new Date(t.createdAt)), 'MMM d');
const cur = byDay.get(key) ?? { inbox: 0, total: 0 };
cur.inbox += t.inbox;
cur.total += t.total;
byDay.set(key, cur);
}
const rows = Array.from(byDay, ([day, v]) => ({
day,
rate: v.total ? +(v.inbox / v.total * 100).toFixed(1) : 0,
}));
return (
<div className="panel p-6">
<div className="text-xs uppercase text-slate-500 mb-2">Trend (7d)</div>
<ResponsiveContainer width="100%" height={220}>
<LineChart data={rows}>
<CartesianGrid strokeDasharray="3 3" stroke="#e2e8f0" />
<XAxis dataKey="day" fontSize={11} />
<YAxis domain={[0, 100]} fontSize={11} />
<Tooltip />
<Line type="monotone" dataKey="rate" stroke="#0ea5e9" strokeWidth={2} />
</LineChart>
</ResponsiveContainer>
</div>
);
}Auth + DNSBL panel
A small panel showing SPF / DKIM / DMARC pass-rate across recent tests, plus any blacklist hits. Uses the auth block on each test summary:
// app/dashboard/AuthTile.tsx
'use client';
import { useTests } from '@/lib/useTests';
function pct(arr: string[], ok: string) {
if (arr.length === 0) return '--';
return ((arr.filter((x) => x === ok).length / arr.length) * 100).toFixed(0) + '%';
}
export function AuthTile() {
const { data } = useTests(7);
if (!data) return null;
const spf = data.items.map((t) => t.auth.spf);
const dkim = data.items.map((t) => t.auth.dkim);
const dmarc = data.items.map((t) => t.auth.dmarc);
return (
<div className="panel p-6">
<div className="text-xs uppercase text-slate-500 mb-3">Authentication</div>
<dl className="grid grid-cols-3 gap-3 text-sm">
<div><dt className="text-slate-500">SPF pass</dt>
<dd className="text-xl font-semibold">{pct(spf, 'pass')}</dd></div>
<div><dt className="text-slate-500">DKIM pass</dt>
<dd className="text-xl font-semibold">{pct(dkim, 'pass')}</dd></div>
<div><dt className="text-slate-500">DMARC pass</dt>
<dd className="text-xl font-semibold">{pct(dmarc, 'pass')}</dd></div>
</dl>
</div>
);
}Putting it together
// app/dashboard/page.tsx
import { OverviewTile } from './OverviewTile';
import { PerProviderTile } from './PerProviderTile';
import { TrendTile } from './TrendTile';
import { AuthTile } from './AuthTile';
export default function DashboardPage() {
return (
<main className="max-w-6xl mx-auto p-6 grid gap-6 lg:grid-cols-3">
<OverviewTile />
<AuthTile />
<div className="hidden lg:block" />
<div className="lg:col-span-2"><PerProviderTile /></div>
<TrendTile />
</main>
);
}Deployment notes
- Set
INBOX_CHECK_API_KEYin your hosting provider (Vercel, Fly, Railway, bare server — any of them). - If your tests live in one Inbox Check workspace and your dashboard serves multiple teams, scope the API key per-tenant and pass a tenant ID in the query string.
- Cache the route handler responses for 30–60 seconds with
revalidate. SWR's refresh interval will pick up new data automatically; your API quota will thank you. - Rate-limit the route handler in front of the Inbox Check API. One abusive client should not burn your whole monthly quota.
The tests.list call returns up to 100 items by default. For the 7-day view on a small sender that is plenty. When you hit the limit, paginate with cursor in the SDK. Do not pre-optimise.