DEV Community

Shib™ 🚀
Shib™ 🚀

Posted on • Originally published at apistatuscheck.com

Build an API Status Dashboard in 30 Minutes (Next.js + Redis)

Want to know when your APIs go down before your users do? Let's build a real-time API status dashboard.

What We're Building

A production-ready dashboard that:

  • Checks API health every 5 minutes
  • Stores results in Redis (current status) and PostgreSQL (history)
  • Shows real-time updates via Server-Sent Events
  • Sends alerts when services go down
  • Displays uptime percentages

Tech Stack: Next.js, TypeScript, Redis, PostgreSQL, Prisma

Quick Setup

# Create Next.js app
pnpm create next-app@latest api-status --typescript --tailwind --app
cd api-status

# Install dependencies
pnpm add ioredis pg date-fns recharts
pnpm add -D @types/pg prisma

# Initialize Prisma
pnpx prisma init
Enter fullscreen mode Exit fullscreen mode

Database Schema

// prisma/schema.prisma
generator client {
  provider = "prisma-client-js"
}

datasource db {
  provider = "postgresql"
  url      = env("DATABASE_URL")
}

model Service {
  id            String   @id @default(cuid())
  name          String
  url           String
  checkInterval Int      @default(300000) // 5 minutes
  createdAt     DateTime @default(now())
  checks        Check[]

  @@index([name])
}

model Check {
  id           String   @id @default(cuid())
  serviceId    String
  status       String   // 'up', 'down', 'degraded'
  latency      Int      // milliseconds
  statusCode   Int?
  errorMessage String?
  checkedAt    DateTime @default(now())
  service      Service  @relation(fields: [serviceId], references: [id])

  @@index([serviceId, checkedAt(sort: Desc)])
}
Enter fullscreen mode Exit fullscreen mode

Run migrations:

echo 'DATABASE_URL="postgresql://user:pass@localhost:5432/status"' > .env
pnpx prisma migrate dev --name init
pnpx prisma generate
Enter fullscreen mode Exit fullscreen mode

Redis Client

// src/lib/redis.ts
import Redis from 'ioredis';

const redis = new Redis(process.env.REDIS_URL || 'redis://localhost:6379');

export async function cacheServiceStatus(serviceId: string, data: any) {
  await redis.setex(
    `service:${serviceId}:status`,
    300, // 5 minutes TTL
    JSON.stringify(data)
  );
}

export async function getCachedServiceStatus(serviceId: string) {
  const cached = await redis.get(`service:${serviceId}:status`);
  return cached ? JSON.parse(cached) : null;
}

export async function publishStatusUpdate(serviceId: string, data: any) {
  await redis.publish('status:updates', JSON.stringify({ serviceId, ...data }));
}

export default redis;
Enter fullscreen mode Exit fullscreen mode

Health Checker

// src/lib/health-checker.ts
import { PrismaClient } from '@prisma/client';
import { cacheServiceStatus, publishStatusUpdate } from './redis';

const prisma = new PrismaClient();

export async function checkEndpoint(url: string) {
  const startTime = Date.now();

  try {
    const controller = new AbortController();
    const timeoutId = setTimeout(() => controller.abort(), 10000);

    const response = await fetch(url, {
      method: 'GET',
      signal: controller.signal,
      headers: { 'User-Agent': 'StatusDashboard/1.0' },
    });

    clearTimeout(timeoutId);
    const latency = Date.now() - startTime;

    const status = response.ok 
      ? (latency > 1000 ? 'degraded' : 'up')
      : 'down';

    return {
      status,
      latency,
      statusCode: response.status,
    };
  } catch (error: any) {
    return {
      status: 'down',
      latency: Date.now() - startTime,
      errorMessage: error.message,
    };
  }
}

export async function checkService(serviceId: string) {
  const service = await prisma.service.findUnique({
    where: { id: serviceId },
  });

  if (!service) throw new Error(`Service ${serviceId} not found`);

  const result = await checkEndpoint(service.url);

  // Save to database
  const check = await prisma.check.create({
    data: {
      serviceId: service.id,
      status: result.status,
      latency: result.latency,
      statusCode: result.statusCode,
      errorMessage: result.errorMessage,
    },
  });

  // Cache current status
  await cacheServiceStatus(serviceId, {
    status: result.status,
    latency: result.latency,
    lastChecked: check.checkedAt,
  });

  // Publish real-time update
  await publishStatusUpdate(serviceId, {
    status: result.status,
    latency: result.latency,
    timestamp: check.checkedAt,
  });

  return check;
}

export async function checkAllServices() {
  const services = await prisma.service.findMany();
  const results = await Promise.allSettled(
    services.map(service => checkService(service.id))
  );
  return results;
}
Enter fullscreen mode Exit fullscreen mode

API Routes

Get All Services

// src/app/api/services/route.ts
import { NextResponse } from 'next/server';
import { PrismaClient } from '@prisma/client';
import { getCachedServiceStatus } from '@/lib/redis';

const prisma = new PrismaClient();

export async function GET() {
  const services = await prisma.service.findMany({
    orderBy: { name: 'asc' },
  });

  // Enrich with cached status
  const enriched = await Promise.all(
    services.map(async (service) => {
      const cachedStatus = await getCachedServiceStatus(service.id);
      return { ...service, currentStatus: cachedStatus };
    })
  );

  return NextResponse.json(enriched);
}

export async function POST(request: Request) {
  const { name, url } = await request.json();

  const service = await prisma.service.create({
    data: { name, url },
  });

  return NextResponse.json(service, { status: 201 });
}
Enter fullscreen mode Exit fullscreen mode

Cron Job for Health Checks

// src/app/api/cron/health-check/route.ts
import { NextResponse } from 'next/server';
import { checkAllServices } from '@/lib/health-checker';

export async function GET(request: Request) {
  // Verify cron secret
  const authHeader = request.headers.get('authorization');
  if (authHeader !== `Bearer ${process.env.CRON_SECRET}`) {
    return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
  }

  const results = await checkAllServices();

  return NextResponse.json({
    success: true,
    checked: results.length,
    timestamp: new Date().toISOString(),
  });
}

export const runtime = 'nodejs';
export const maxDuration = 300;
Enter fullscreen mode Exit fullscreen mode

Server-Sent Events for Real-Time Updates

// src/app/api/sse/route.ts
import redis from '@/lib/redis';

export async function GET() {
  const encoder = new TextEncoder();

  const stream = new ReadableStream({
    async start(controller) {
      const subscriber = redis.duplicate();
      await subscriber.subscribe('status:updates');

      subscriber.on('message', (channel, message) => {
        const data = `data: ${message}\n\n`;
        controller.enqueue(encoder.encode(data));
      });

      // Heartbeat every 30s
      const interval = setInterval(() => {
        controller.enqueue(encoder.encode(': heartbeat\n\n'));
      }, 30000);

      return () => {
        clearInterval(interval);
        subscriber.unsubscribe();
        subscriber.quit();
      };
    },
  });

  return new Response(stream, {
    headers: {
      'Content-Type': 'text/event-stream',
      'Cache-Control': 'no-cache',
      'Connection': 'keep-alive',
    },
  });
}

export const runtime = 'nodejs';
Enter fullscreen mode Exit fullscreen mode

Frontend Dashboard

// src/app/page.tsx
'use client';

import { useEffect, useState } from 'react';
import { formatDistanceToNow } from 'date-fns';

interface Service {
  id: string;
  name: string;
  url: string;
  currentStatus: {
    status: 'up' | 'down' | 'degraded';
    latency: number;
    lastChecked: string;
  } | null;
}

export default function Dashboard() {
  const [services, setServices] = useState<Service[]>([]);

  useEffect(() => {
    fetchServices();

    // Set up SSE connection
    const eventSource = new EventSource('/api/sse');

    eventSource.onmessage = (event) => {
      const update = JSON.parse(event.data);
      updateServiceStatus(update.serviceId, update);
    };

    return () => eventSource.close();
  }, []);

  async function fetchServices() {
    const res = await fetch('/api/services');
    const data = await res.json();
    setServices(data);
  }

  function updateServiceStatus(serviceId: string, statusData: any) {
    setServices(prev =>
      prev.map(service =>
        service.id === serviceId
          ? {
              ...service,
              currentStatus: {
                status: statusData.status,
                latency: statusData.latency,
                lastChecked: statusData.timestamp,
              },
            }
          : service
      )
    );
  }

  const allUp = services.every(s => s.currentStatus?.status === 'up');
  const overallStatus = allUp ? 'All Systems Operational' : 'Partial Outage';

  return (
    <div className="min-h-screen bg-gray-50 p-8">
      <div className="max-w-6xl mx-auto">
        <h1 className="text-4xl font-bold mb-4">API Status Dashboard</h1>

        <div className={`px-4 py-2 rounded-full inline-block mb-8 ${
          allUp ? 'bg-green-100 text-green-800' : 'bg-red-100 text-red-800'
        }`}>
          {overallStatus}
        </div>

        <div className="space-y-4">
          {services.map(service => (
            <ServiceCard key={service.id} service={service} />
          ))}
        </div>
      </div>
    </div>
  );
}

function ServiceCard({ service }: { service: Service }) {
  const status = service.currentStatus;

  return (
    <div className="bg-white rounded-lg shadow p-6">
      <div className="flex items-center justify-between">
        <div className="flex items-center gap-3">
          <StatusDot status={status?.status || 'unknown'} />
          <h3 className="text-xl font-semibold">{service.name}</h3>
        </div>
        {status && (
          <div className="text-sm text-gray-500">
            {status.latency}ms  {formatDistanceToNow(new Date(status.lastChecked))} ago
          </div>
        )}
      </div>
      <div className="text-sm text-gray-600 mt-2">{service.url}</div>
    </div>
  );
}

function StatusDot({ status }: { status: string }) {
  const colors = {
    up: 'bg-green-500',
    down: 'bg-red-500',
    degraded: 'bg-yellow-500',
    unknown: 'bg-gray-400',
  };

  return <div className={`w-3 h-3 rounded-full ${colors[status as keyof typeof colors]}`} />;
}
Enter fullscreen mode Exit fullscreen mode

Configure Vercel Cron

Create vercel.json:

{
  "crons": [{
    "path": "/api/cron/health-check",
    "schedule": "*/5 * * * *"
  }]
}
Enter fullscreen mode Exit fullscreen mode

Deploy to Vercel

# Set environment variables
vercel env add DATABASE_URL
vercel env add REDIS_URL
vercel env add CRON_SECRET

# Deploy
vercel --prod
Enter fullscreen mode Exit fullscreen mode

Managed Services:

What You Built

✅ Automated health checks every 5 minutes

✅ Real-time status updates with SSE

✅ Historical data storage

✅ Uptime tracking

✅ Clean, responsive UI

✅ Production-ready architecture

Next Steps

  1. Add Alerts: Integrate email/Slack notifications
  2. Multi-Region Checks: Test from different geographic locations
  3. Advanced Metrics: Track error rates, response times, uptime %
  4. Incident Management: Auto-create incidents when services go down
  5. Public Status Page: Show customers what's working

Or Use a Ready-Made Solution

Building is fun, but maintaining a production monitoring system requires:

  • Infrastructure costs ($50-200/month)
  • Ongoing maintenance (5-10 hours/month)
  • Global monitoring infrastructure
  • Alert management and fine-tuning

If you're monitoring third-party APIs (Stripe, OpenAI, AWS, etc.), check out API Status Check — we've already built this for you.


Questions? Drop them in the comments!

Full tutorial with more features: apistatuscheck.com/blog/build-api-status-dashboard

Top comments (0)