React10 min read

Build a deliverability dashboard in React

SWR, Recharts, the Inbox Check Node.js SDK, and about 150 lines of React. A real dashboard: inbox rate per provider, trend lines, DMARC alignment, DNSBL status.

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.

What you will build

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-fns

Put your API key in .env.local:

INBOX_CHECK_API_KEY=ic_live_xxxxxxxxxxxx
Server-side only

Never 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

  1. Set INBOX_CHECK_API_KEY in your hosting provider (Vercel, Fly, Railway, bare server — any of them).
  2. 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.
  3. 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.
  4. Rate-limit the route handler in front of the Inbox Check API. One abusive client should not burn your whole monthly quota.
Add pagination later, not now

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.

Frequently asked questions

Can I use this dashboard with a framework other than Next.js?

Yes. Replace the route handlers with any small Express / Fastify / Hono server, keep the client-side React code identical. SWR works anywhere. The only Next-specific piece is the App Router layout.

How do I add a per-sender filter?

Pass a senderDomain query param through the route handler and into the SDK's tests.list({ senderDomain }) call. Swap SWR's key to include the filter so caching stays correct. ~15 lines total.

What about real-time updates when a new test finishes?

Subscribe to the /api/tests/stream SSE endpoint from the browser (via EventSource) and mutate the SWR cache on each complete event. Or keep the 60-second refresh — for deliverability data it is plenty.

Do I need the Recharts dependency or can I use charting-free?

Recharts is convenient but not required. Replace each chart tile with a CSS-only bar (a div with a width prop), and you can drop the dependency. The overview and auth tiles do not need a chart library at all.
Related reading

Check your deliverability across 20+ providers

Gmail, Outlook, Yahoo, Mail.ru, Yandex, GMX, ProtonMail and more. Real inbox screenshots, SPF/DKIM/DMARC, spam engine verdicts. Free, no signup.

Run Free Test →

Unlimited tests · 20+ seed mailboxes · Live results · No account required