How to Build a Multi-Location Restaurant Health Inspection Dashboard

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:

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);
Cost Control

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.

Important

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:

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.

Ready to Add Health Scores to Your Platform?

Join the FoodSafe Score API waitlist and get early access to normalized inspection data across 10+ major US jurisdictions.