If you operate or oversee dozens - or hundreds - of restaurant locations, manually checking health inspection scores one by one is not a workflow, it's a liability. A centralized dashboard that pulls normalized inspection data for all your locations in a single view gives operations and QA teams the situational awareness they need to catch problems before they become headlines.
This guide walks through building a multi-location restaurant health inspection monitoring dashboard from the API call to the React UI. We'll cover fetching bulk data by ZIP code, storing it in a database, building a sortable table with color-coded grade badges, adding location filtering, and exporting reports to CSV. All code samples are production-ready and designed for minimal dependencies.
Architecture Overview
The stack for this dashboard is straightforward:
- Data layer - PostgreSQL (or SQLite for smaller deployments) storing cached inspection records and your location registry
- Sync layer - A Node.js service that calls the FoodSafe Score API's bulk endpoint and upserts records nightly
- API layer - A simple Express endpoint that the frontend queries
- UI layer - A React component with sorting, filtering, and CSV export
The key design principle is to cache inspection data in your own database rather than calling the API on every page load. Inspection records update monthly at most, so a nightly sync is more than sufficient and keeps your API costs predictable.
Step 1 - Database Schema
Start by creating two tables: one for your monitored locations and one for the cached inspection records.
-- Your registered franchise/chain locations
CREATE TABLE locations (
id SERIAL PRIMARY KEY,
name VARCHAR(255) NOT NULL,
address VARCHAR(255) NOT NULL,
city VARCHAR(100) NOT NULL,
state CHAR(2) NOT NULL,
zip CHAR(10) NOT NULL,
region VARCHAR(100), -- Optional grouping (e.g. "Northeast")
created_at TIMESTAMPTZ DEFAULT NOW()
);
-- Cached inspection scores from the API
CREATE TABLE inspection_scores (
id SERIAL PRIMARY KEY,
location_id INTEGER REFERENCES locations(id) ON DELETE CASCADE,
score SMALLINT,
grade CHAR(1),
last_inspection_date DATE,
violation_count SMALLINT,
critical_violation_count SMALLINT,
non_critical_violation_count SMALLINT,
corrected_on_site_count SMALLINT,
jurisdiction VARCHAR(255),
source_url TEXT,
match_status VARCHAR(20),
fetched_at TIMESTAMPTZ DEFAULT NOW(),
UNIQUE (location_id) -- One current record per location; use history table for trends
);
-- Optional: score history for trend analysis
CREATE TABLE inspection_score_history (
id SERIAL PRIMARY KEY,
location_id INTEGER REFERENCES locations(id) ON DELETE CASCADE,
score SMALLINT,
grade CHAR(1),
inspection_date DATE,
recorded_at TIMESTAMPTZ DEFAULT NOW()
);
The inspection_scores table uses an upsert pattern - one current record per location, replaced on each sync. The inspection_score_history table builds up a time series, which is useful for trend charts and the alerting patterns covered in our post on setting up restaurant inspection score alerts.
Step 2 - Fetching Bulk Data by ZIP Code
Rather than calling the individual lookup endpoint for each location (which would be slow and expensive for a 100-location chain), use the bulk-by-zip endpoint to pull all inspection records for a given ZIP code in one call. Then match those results against your registered locations client-side.
const FOODSAFE_API_KEY = process.env.FOODSAFE_API_KEY;
const BASE_URL = 'https://api.foodsafescoreapi.com/v1';
async function fetchInspectionsByZip(zip) {
const res = await fetch(`${BASE_URL}/bulk?zip=${zip}`, {
headers: {
'Authorization': `Bearer ${FOODSAFE_API_KEY}`,
'Accept': 'application/json'
}
});
if (!res.ok) {
const err = await res.json().catch(() => ({}));
throw new Error(err.message || `Bulk fetch failed for ZIP ${zip} (${res.status})`);
}
const data = await res.json();
return data.results || [];
}
// Normalize API result to our DB schema
function normalizeRecord(apiRecord, locationId) {
return {
location_id: locationId,
score: apiRecord.score,
grade: apiRecord.grade,
last_inspection_date: apiRecord.last_inspection_date,
violation_count: apiRecord.violation_count,
critical_violation_count: apiRecord.critical_violation_count,
non_critical_violation_count: apiRecord.non_critical_violation_count,
corrected_on_site_count: apiRecord.corrected_on_site_count,
jurisdiction: apiRecord.jurisdiction,
source_url: apiRecord.source_url,
match_status: apiRecord.match_status
};
}
Matching API results to your location registry
The bulk endpoint returns all inspection records in a ZIP - you need to match those back to your specific locations. A simple name-similarity match works well for most cases:
function matchResultToLocation(apiResults, location) {
const normalizedTarget = location.name.toLowerCase()
.replace(/[^a-z0-9\s]/g, '')
.trim();
// Sort by name similarity, return best match above threshold
const scored = apiResults.map(r => {
const normalized = r.name.toLowerCase()
.replace(/[^a-z0-9\s]/g, '')
.trim();
// Simple word overlap score
const targetWords = new Set(normalizedTarget.split(/\s+/));
const resultWords = normalized.split(/\s+/);
const overlap = resultWords.filter(w => targetWords.has(w)).length;
const score = overlap / Math.max(targetWords.size, resultWords.length);
return { result: r, score };
});
scored.sort((a, b) => b.score - a.score);
// Require at least 50% word overlap to count as a match
return scored[0]?.score >= 0.5 ? scored[0].result : null;
}
Step 3 - The Nightly Sync Service
Wire up the bulk fetch and matching logic into a sync function that runs on a schedule. This groups your locations by ZIP, fetches once per ZIP, then upserts results into your database.
const { Pool } = require('pg');
const pool = new Pool({ connectionString: process.env.DATABASE_URL });
async function syncAllLocations() {
const { rows: locations } = await pool.query('SELECT * FROM locations ORDER BY zip');
// Group by ZIP to minimize API calls
const byZip = locations.reduce((acc, loc) => {
if (!acc[loc.zip]) acc[loc.zip] = [];
acc[loc.zip].push(loc);
return acc;
}, {});
let synced = 0;
let notFound = 0;
for (const [zip, zipLocations] of Object.entries(byZip)) {
let apiResults;
try {
apiResults = await fetchInspectionsByZip(zip);
} catch (err) {
console.error(`Failed to fetch ZIP ${zip}:`, err.message);
continue;
}
for (const location of zipLocations) {
const match = matchResultToLocation(apiResults, location);
if (!match) {
console.warn(`No match found for location ${location.id} (${location.name}, ${zip})`);
notFound++;
continue;
}
const record = normalizeRecord(match, location.id);
await pool.query(`
INSERT INTO inspection_scores
(location_id, score, grade, last_inspection_date, violation_count,
critical_violation_count, non_critical_violation_count,
corrected_on_site_count, jurisdiction, source_url, match_status, fetched_at)
VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,NOW())
ON CONFLICT (location_id) DO UPDATE SET
score = EXCLUDED.score,
grade = EXCLUDED.grade,
last_inspection_date = EXCLUDED.last_inspection_date,
violation_count = EXCLUDED.violation_count,
critical_violation_count = EXCLUDED.critical_violation_count,
non_critical_violation_count = EXCLUDED.non_critical_violation_count,
corrected_on_site_count = EXCLUDED.corrected_on_site_count,
jurisdiction = EXCLUDED.jurisdiction,
source_url = EXCLUDED.source_url,
match_status = EXCLUDED.match_status,
fetched_at = NOW()
`, [
record.location_id, record.score, record.grade, record.last_inspection_date,
record.violation_count, record.critical_violation_count,
record.non_critical_violation_count, record.corrected_on_site_count,
record.jurisdiction, record.source_url, record.match_status
]);
synced++;
}
// Brief pause between ZIP batches to be a good API citizen
await new Promise(r => setTimeout(r, 300));
}
console.log(`Sync complete: ${synced} updated, ${notFound} not found`);
}
// Run on a schedule (e.g. via node-cron or a cron job)
syncAllLocations().catch(console.error);
The bulk-by-zip endpoint is billed per unique ZIP code fetched, not per record returned. If your 80 locations span 30 ZIP codes, you pay for 30 bulk calls per sync cycle - not 80 individual lookups. For large chains, this can reduce data costs by 50-70% versus per-location lookups.
Step 4 - Building the React Dashboard
With data flowing into your database, the UI is a sortable table with filtering. Here's the complete grade badge component and the dashboard table component.
GradeBadge component
function GradeBadge({ grade, score }) {
const config = {
A: { bg: '#16a34a', label: 'Excellent' },
B: { bg: '#d97706', label: 'Good' },
C: { bg: '#ea580c', label: 'Needs Improvement' },
F: { bg: '#dc2626', label: 'Critical' }
};
const { bg, label } = config[grade] ?? config.F;
return (
<div
title={label}
style={{
display: 'inline-flex',
flexDirection: 'column',
alignItems: 'center',
background: bg,
color: '#fff',
borderRadius: '8px',
padding: '4px 10px',
fontWeight: 800,
minWidth: '52px',
cursor: 'default'
}}
>
<span style={{ fontSize: '1.5rem', lineHeight: 1 }}>{grade}</span>
<span style={{ fontSize: '0.65rem', opacity: 0.85 }}>{score}/100</span>
</div>
);
}
LocationTable component with sorting and filtering
import { useState, useMemo } from 'react';
function LocationTable({ locations }) {
const [sortKey, setSortKey] = useState('score');
const [sortDir, setSortDir] = useState('asc'); // asc = worst first (most urgent)
const [filterGrade, setFilterGrade] = useState('all');
const [filterRegion, setFilterRegion] = useState('all');
const [search, setSearch] = useState('');
const regions = useMemo(() =>
[...new Set(locations.map(l => l.region).filter(Boolean))].sort(),
[locations]
);
const sorted = useMemo(() => {
let list = [...locations];
// Filter
if (filterGrade !== 'all') list = list.filter(l => l.grade === filterGrade);
if (filterRegion !== 'all') list = list.filter(l => l.region === filterRegion);
if (search) {
const q = search.toLowerCase();
list = list.filter(l =>
l.name.toLowerCase().includes(q) ||
l.city.toLowerCase().includes(q) ||
l.address.toLowerCase().includes(q)
);
}
// Sort
list.sort((a, b) => {
let av = a[sortKey], bv = b[sortKey];
if (typeof av === 'string') av = av.toLowerCase();
if (typeof bv === 'string') bv = bv.toLowerCase();
if (av < bv) return sortDir === 'asc' ? -1 : 1;
if (av > bv) return sortDir === 'asc' ? 1 : -1;
return 0;
});
return list;
}, [locations, sortKey, sortDir, filterGrade, filterRegion, search]);
function handleSort(key) {
if (sortKey === key) {
setSortDir(d => d === 'asc' ? 'desc' : 'asc');
} else {
setSortKey(key);
setSortDir('asc');
}
}
function exportCsv() {
const header = ['Name','Address','City','State','Grade','Score',
'Last Inspection','Critical Violations','Non-Critical Violations','Region'];
const rows = sorted.map(l => [
l.name, l.address, l.city, l.state, l.grade, l.score,
l.last_inspection_date, l.critical_violation_count,
l.non_critical_violation_count, l.region ?? ''
]);
const csv = [header, ...rows]
.map(r => r.map(v => `"${String(v ?? '').replace(/"/g, '""')}"`).join(','))
.join('\n');
const blob = new Blob([csv], { type: 'text/csv' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = `health-scores-${new Date().toISOString().slice(0, 10)}.csv`;
a.click();
URL.revokeObjectURL(url);
}
const SortIcon = ({ col }) => sortKey === col
? (sortDir === 'asc' ? ' ^' : ' v')
: ' -';
return (
<div>
<div style={{ display: 'flex', gap: '0.75rem', flexWrap: 'wrap', marginBottom: '1rem' }}>
<input
type="text"
placeholder="Search locations..."
value={search}
onChange={e => setSearch(e.target.value)}
style={{ padding: '0.5rem 0.75rem', borderRadius: '6px', border: '1px solid #1e2d45',
background: '#1a2235', color: '#f1f5f9', minWidth: '220px' }}
/>
<select value={filterGrade} onChange={e => setFilterGrade(e.target.value)}
style={{ padding: '0.5rem 0.75rem', borderRadius: '6px', border: '1px solid #1e2d45',
background: '#1a2235', color: '#f1f5f9' }}>
<option value="all">All Grades</option>
{['A','B','C','F'].map(g => <option key={g} value={g}>Grade {g}</option>)}
</select>
{regions.length > 0 && (
<select value={filterRegion} onChange={e => setFilterRegion(e.target.value)}
style={{ padding: '0.5rem 0.75rem', borderRadius: '6px', border: '1px solid #1e2d45',
background: '#1a2235', color: '#f1f5f9' }}>
<option value="all">All Regions</option>
{regions.map(r => <option key={r} value={r}>{r}</option>)}
</select>
)}
<button onClick={exportCsv}
style={{ marginLeft: 'auto', padding: '0.5rem 1rem', borderRadius: '6px',
background: '#3b82f6', color: '#fff', border: 'none', cursor: 'pointer',
fontWeight: 600 }}>
Export CSV
</button>
</div>
<div style={{ overflowX: 'auto' }}>
<table style={{ width: '100%', borderCollapse: 'collapse', fontSize: '0.875rem' }}>
<thead>
<tr style={{ borderBottom: '2px solid #1e2d45' }}>
{[['name','Location'],['grade','Grade'],['score','Score'],
['last_inspection_date','Last Inspected'],['critical_violation_count','Critical'],
['city','City'],['region','Region']].map(([key, label]) => (
<th key={key}
onClick={() => handleSort(key)}
style={{ padding: '0.75rem 1rem', textAlign: 'left', cursor: 'pointer',
color: sortKey === key ? '#60a5fa' : '#a8b8cc', fontWeight: 600,
whiteSpace: 'nowrap' }}>
{label}<SortIcon col={key} />
</th>
))}
</tr>
</thead>
<tbody>
{sorted.map(loc => (
<tr key={loc.id}
style={{ borderBottom: '1px solid #1e2d45', transition: 'background 0.15s' }}
onMouseEnter={e => e.currentTarget.style.background = '#1f2a40'}
onMouseLeave={e => e.currentTarget.style.background = ''}>
<td style={{ padding: '0.75rem 1rem', color: '#f1f5f9', fontWeight: 500 }}>
{loc.name}
<div style={{ fontSize: '0.75rem', color: '#8494a7' }}>{loc.address}</div>
</td>
<td style={{ padding: '0.75rem 1rem' }}>
<GradeBadge grade={loc.grade} score={loc.score} />
</td>
<td style={{ padding: '0.75rem 1rem', color: '#a8b8cc' }}>{loc.score}</td>
<td style={{ padding: '0.75rem 1rem', color: '#a8b8cc', whiteSpace: 'nowrap' }}>
{loc.last_inspection_date}
</td>
<td style={{ padding: '0.75rem 1rem',
color: loc.critical_violation_count > 0 ? '#ef4444' : '#22c55e',
fontWeight: loc.critical_violation_count > 0 ? 700 : 400 }}>
{loc.critical_violation_count}
</td>
<td style={{ padding: '0.75rem 1rem', color: '#a8b8cc' }}>{loc.city}</td>
<td style={{ padding: '0.75rem 1rem', color: '#8494a7' }}>{loc.region ?? '-'}</td>
</tr>
))}
</tbody>
</table>
{sorted.length === 0 && (
<p style={{ textAlign: 'center', padding: '2rem', color: '#8494a7' }}>
No locations match the current filters.
</p>
)}
</div>
<p style={{ marginTop: '0.75rem', color: '#8494a7', fontSize: '0.8rem' }}>
Showing {sorted.length} of {locations.length} locations
</p>
</div>
);
}
Step 5 - Summary Statistics Bar
Above the table, a summary row showing grade distribution at a glance helps operations managers spot patterns without scrolling the table. This is especially useful when presenting to non-technical stakeholders.
function SummaryBar({ locations }) {
const counts = locations.reduce((acc, l) => {
acc[l.grade] = (acc[l.grade] || 0) + 1;
return acc;
}, {});
const total = locations.length;
return (
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(4, 1fr)', gap: '1rem',
marginBottom: '2rem' }}>
{[
{ grade: 'A', label: 'Excellent', color: '#16a34a' },
{ grade: 'B', label: 'Good', color: '#d97706' },
{ grade: 'C', label: 'Needs Improvement', color: '#ea580c' },
{ grade: 'F', label: 'Critical', color: '#dc2626' }
].map(({ grade, label, color }) => (
<div key={grade} style={{ background: '#1a2235', border: '1px solid #1e2d45',
borderRadius: '10px', padding: '1.25rem', borderTop: `3px solid ${color}` }}>
<div style={{ fontSize: '2rem', fontWeight: 800, color }}>
{counts[grade] || 0}
</div>
<div style={{ fontSize: '0.8rem', color: '#a8b8cc', marginTop: '0.25rem' }}>
Grade {grade} - {label}
</div>
<div style={{ fontSize: '0.75rem', color: '#8494a7' }}>
{total ? Math.round(((counts[grade] || 0) / total) * 100) : 0}% of locations
</div>
</div>
))}
</div>
);
}
Step 6 - Wiring It All Together
The main dashboard component fetches from your Express endpoint and renders the summary bar and table:
import { useState, useEffect } from 'react';
function HealthDashboard() {
const [locations, setLocations] = useState([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState(null);
useEffect(() => {
fetch('/api/health-scores')
.then(res => {
if (!res.ok) throw new Error(`Server error (${res.status})`);
return res.json();
})
.then(data => {
setLocations(data);
setLoading(false);
})
.catch(err => {
setError(err.message);
setLoading(false);
});
}, []);
if (loading) return <div style={{ padding: '2rem', color: '#a8b8cc' }}>Loading...</div>;
if (error) return <div style={{ padding: '2rem', color: '#ef4444' }}>Error: {error}</div>;
return (
<div style={{ padding: '2rem', maxWidth: '1200px', margin: '0 auto' }}>
<h1 style={{ color: '#f1f5f9', marginBottom: '0.5rem' }}>Health Inspection Dashboard</h1>
<p style={{ color: '#8494a7', marginBottom: '2rem' }}>
{locations.length} monitored locations - Last synced: {new Date().toLocaleDateString()}
</p>
<SummaryBar locations={locations} />
<LocationTable locations={locations} />
</div>
);
}
Data Normalization Considerations
One subtle challenge with multi-jurisdiction data is that a score of 80 in New York City is calculated differently than a score of 80 in Houston - the underlying violation categories and raw scoring systems differ significantly by jurisdiction. That's the core problem the FoodSafe Score API solves: its 0-100 score is normalized across all covered jurisdictions so a score comparison between a Miami and a Chicago location is actually meaningful.
For a detailed look at how this normalization works under the hood, see our post on how to normalize food safety scores across US jurisdictions.
Always display the jurisdiction field alongside scores in your dashboard. This provides traceability back to the source authority and helps users understand why two nearby locations with similar raw violations might have different normalized scores.
Adding Score-Change Alerts
A static dashboard is valuable, but proactive alerting when a location's grade drops is what turns the tool into an operational system. Once this dashboard is live, the natural next step is configuring automated notifications for score changes. Our guide on setting up restaurant inspection score alerts covers exactly that - including Slack/email notification routing and how to avoid alert fatigue for chains with high inspection turnover.
Performance Tips for Large Chains
For chains with 500+ locations, a few additional patterns keep the dashboard fast:
- Paginate the API endpoint - Don't return all records at once; paginate at 100 per page and use a virtual list in React
- Index the database - Add an index on
inspection_scores.gradeandlocations.regionfor fast filter queries - Pre-compute summary stats - Store grade distribution counts in a separate summary table updated by the sync job, so the summary bar query is instant
- Parallelize ZIP fetches in the sync job - Use
Promise.allSettledwith a concurrency limit of 5 to speed up nightly syncs without overwhelming the API
For more on the franchise monitoring use case specifically, including how to structure QA workflows around inspection data, see our guide on how to use health inspection data in your franchise QA program.