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
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)])
}
Run migrations:
echo 'DATABASE_URL="postgresql://user:pass@localhost:5432/status"' > .env
pnpx prisma migrate dev --name init
pnpx prisma generate
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;
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;
}
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 });
}
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;
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';
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]}`} />;
}
Configure Vercel Cron
Create vercel.json:
{
"crons": [{
"path": "/api/cron/health-check",
"schedule": "*/5 * * * *"
}]
}
Deploy to Vercel
# Set environment variables
vercel env add DATABASE_URL
vercel env add REDIS_URL
vercel env add CRON_SECRET
# Deploy
vercel --prod
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
- Add Alerts: Integrate email/Slack notifications
- Multi-Region Checks: Test from different geographic locations
- Advanced Metrics: Track error rates, response times, uptime %
- Incident Management: Auto-create incidents when services go down
- 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)