Health Inspection API React Native Mobile App Integration

Restaurant health inspection scores are among the most sought-after data points for food-discovery and delivery apps. Users want to know whether a restaurant's kitchen passed its last inspection before they order - not after. This tutorial walks you through a complete React Native integration of the FoodSafe Score API, from the service module all the way to a geo-search screen that uses the device's GPS to surface nearby restaurants sorted by health score.

By the end you will have: a typed HealthScoreService module, a reusable GradeBadge component, a name-based search screen, a location-based nearby screen powered by expo-location, proper loading and error states throughout, and a lightweight AsyncStorage caching layer so repeat lookups are instant.

Prerequisites

This tutorial assumes a working Expo (SDK 50+) or bare React Native (0.73+) project with TypeScript configured. The only third-party dependency added here is expo-location for GPS access. The Fetch API is built into React Native's JavaScript runtime - no additional HTTP library is needed.

Project Structure

We will add the following files to an existing project:

src/
  services/
    HealthScoreService.ts     # API wrapper + cache
  components/
    GradeBadge.tsx            # Color-coded score badge
  screens/
    SearchScreen.tsx          # Name + city lookup
    NearbyScreen.tsx          # GPS-based geo search
  types/
    healthScore.ts            # Shared TypeScript types

Step 1 - Install expo-location

If you are on a managed Expo project, add the location module and rebuild:

npx expo install expo-location

For bare React Native, follow the expo-location native linking instructions. You will also need to add the NSLocationWhenInUseUsageDescription key to Info.plist on iOS and ACCESS_FINE_LOCATION to AndroidManifest.xml.

Step 2 - Define TypeScript Types

Create src/types/healthScore.ts so every module shares the same shape:

// src/types/healthScore.ts

export type RiskGrade = 'A' | 'B' | 'C' | 'F' | 'N/A';

export interface Violation {
  type: 'critical' | 'non_critical' | 'corrected';
  description: string;
  points_deducted: number;
}

export interface InspectionRecord {
  date: string;            // ISO 8601
  score: number;
  grade: RiskGrade;
  violations: Violation[];
}

export interface HealthScoreResult {
  restaurant_id: string;
  name: string;
  address: string;
  city: string;
  state: string;
  zip: string;
  latitude: number;
  longitude: number;
  score: number;           // 0-100
  grade: RiskGrade;
  last_inspected: string;  // ISO 8601
  inspection_history: InspectionRecord[];
  trend: 'improving' | 'declining' | 'stable';
}

export interface GeoSearchResult {
  results: HealthScoreResult[];
  total: number;
}

export interface LookupResult {
  result: HealthScoreResult | null;
  error?: string;
}

Step 3 - Build the HealthScoreService Module

This module centralizes all API calls and AsyncStorage caching. Cache entries expire after 24 hours - health scores don't change minute-to-minute, so aggressive caching is appropriate.

// src/services/HealthScoreService.ts

import AsyncStorage from '@react-native-async-storage/async-storage';
import type {
  HealthScoreResult,
  GeoSearchResult,
  LookupResult,
} from '../types/healthScore';

const BASE_URL = 'https://api.foodsafescore.com/v1';
const CACHE_TTL_MS = 24 * 60 * 60 * 1000; // 24 hours

// Store your API key in a .env file and access via expo-constants
// or react-native-config. Never hard-code it.
const API_KEY = process.env.EXPO_PUBLIC_FOODSAFE_API_KEY ?? '';

interface CacheEntry<T> {
  data: T;
  cachedAt: number;
}

async function readCache<T>(key: string): Promise<T | null> {
  try {
    const raw = await AsyncStorage.getItem(key);
    if (!raw) return null;
    const entry: CacheEntry<T> = JSON.parse(raw);
    if (Date.now() - entry.cachedAt > CACHE_TTL_MS) {
      await AsyncStorage.removeItem(key);
      return null;
    }
    return entry.data;
  } catch {
    return null;
  }
}

async function writeCache<T>(key: string, data: T): Promise<void> {
  try {
    const entry: CacheEntry<T> = { data, cachedAt: Date.now() };
    await AsyncStorage.setItem(key, JSON.stringify(entry));
  } catch {
    // Cache write failure is non-fatal
  }
}

async function apiFetch<T>(path: string): Promise<T> {
  const url = `${BASE_URL}${path}`;
  const res = await fetch(url, {
    headers: {
      'X-Api-Key': API_KEY,
      'Accept': 'application/json',
    },
  });
  if (!res.ok) {
    const err = await res.json().catch(() => ({})) as { message?: string };
    throw new Error(err.message ?? `Request failed (${res.status})`);
  }
  return res.json() as Promise<T>;
}

export async function lookupByName(
  name: string,
  city: string,
  state: string,
): Promise<LookupResult> {
  const cacheKey = `lookup:${name}:${city}:${state}`.toLowerCase();
  const cached = await readCache<HealthScoreResult>(cacheKey);
  if (cached) return { result: cached };

  const params = new URLSearchParams({ name, city, state });
  const data = await apiFetch<{ result: HealthScoreResult }>(
    `/lookup?${params.toString()}`,
  );
  await writeCache(cacheKey, data.result);
  return { result: data.result };
}

export async function geoSearch(
  latitude: number,
  longitude: number,
  radiusMeters: number = 1000,
): Promise<GeoSearchResult> {
  const cacheKey = `geo:${latitude.toFixed(4)}:${longitude.toFixed(4)}:${radiusMeters}`;
  const cached = await readCache<GeoSearchResult>(cacheKey);
  if (cached) return cached;

  const params = new URLSearchParams({
    lat: String(latitude),
    lng: String(longitude),
    radius: String(radiusMeters),
    sort: 'score_desc',
  });
  const data = await apiFetch<GeoSearchResult>(`/geo?${params.toString()}`);
  await writeCache(cacheKey, data);
  return data;
}

export async function getInspectionHistory(
  restaurantId: string,
): Promise<HealthScoreResult> {
  const cacheKey = `history:${restaurantId}`;
  const cached = await readCache<HealthScoreResult>(cacheKey);
  if (cached) return cached;

  const data = await apiFetch<HealthScoreResult>(`/restaurants/${restaurantId}`);
  await writeCache(cacheKey, data);
  return data;
}
API Key Security

Use EXPO_PUBLIC_ prefixed environment variables for Expo managed workflow, or react-native-config for bare projects. Never commit API keys to source control. For production apps, proxy requests through your own backend so the key is never shipped in the bundle.

Step 4 - The GradeBadge Component

The grade badge is the most visually prominent element in the UI. It maps grades to colors aligned with familiar traffic-light conventions so users immediately understand the risk level.

// src/components/GradeBadge.tsx

import React from 'react';
import { View, Text, StyleSheet } from 'react-native';
import type { RiskGrade } from '../types/healthScore';

interface Props {
  grade: RiskGrade;
  score: number;
  size?: 'sm' | 'md' | 'lg';
}

const GRADE_COLORS: Record<RiskGrade, { bg: string; text: string; border: string }> = {
  A: { bg: '#052e16', text: '#22c55e', border: '#166534' },
  B: { bg: '#1c1917', text: '#84cc16', border: '#3f6212' },
  C: { bg: '#1c1400', text: '#f59e0b', border: '#92400e' },
  F: { bg: '#2d0a0a', text: '#ef4444', border: '#991b1b' },
  'N/A': { bg: '#1a2235', text: '#8494a7', border: '#1e2d45' },
};

const SIZE_CONFIG = {
  sm: { container: 40, score: 11, grade: 14, radius: 6 },
  md: { container: 60, score: 13, grade: 20, radius: 8 },
  lg: { container: 80, score: 15, grade: 28, radius: 10 },
};

export const GradeBadge: React.FC<Props> = ({ grade, score, size = 'md' }) => {
  const colors = GRADE_COLORS[grade];
  const sizing = SIZE_CONFIG[size];

  return (
    <View
      style={[
        styles.container,
        {
          width: sizing.container,
          height: sizing.container,
          borderRadius: sizing.radius,
          backgroundColor: colors.bg,
          borderColor: colors.border,
        },
      ]}
    >
      <Text style={[styles.gradeText, { fontSize: sizing.grade, color: colors.text }]}>
        {grade}
      </Text>
      {grade !== 'N/A' && (
        <Text style={[styles.scoreText, { fontSize: sizing.score, color: colors.text }]}>
          {score}
        </Text>
      )}
    </View>
  );
};

const styles = StyleSheet.create({
  container: {
    borderWidth: 1,
    alignItems: 'center',
    justifyContent: 'center',
    paddingVertical: 4,
  },
  gradeText: {
    fontWeight: '800',
    lineHeight: 1.1 * 20,
  },
  scoreText: {
    fontWeight: '600',
    opacity: 0.85,
  },
});

Step 5 - The Search Screen

The search screen lets users type a restaurant name and city to get an instant health score lookup. It handles the three states every network screen needs: loading, error, and success.

// src/screens/SearchScreen.tsx

import React, { useState } from 'react';
import {
  View, Text, TextInput, TouchableOpacity,
  ActivityIndicator, ScrollView, StyleSheet, Alert,
} from 'react-native';
import { GradeBadge } from '../components/GradeBadge';
import { lookupByName } from '../services/HealthScoreService';
import type { HealthScoreResult } from '../types/healthScore';

export const SearchScreen: React.FC = () => {
  const [name, setName] = useState('');
  const [city, setCity] = useState('');
  const [state, setState] = useState('');
  const [loading, setLoading] = useState(false);
  const [result, setResult] = useState<HealthScoreResult | null>(null);
  const [error, setError] = useState<string | null>(null);

  const handleSearch = async () => {
    if (!name.trim() || !city.trim()) {
      Alert.alert('Missing fields', 'Please enter both restaurant name and city.');
      return;
    }
    setLoading(true);
    setError(null);
    setResult(null);
    try {
      const { result: res } = await lookupByName(name.trim(), city.trim(), state.trim());
      setResult(res);
      if (!res) setError('No restaurant found matching that name and city.');
    } catch (err) {
      setError(err instanceof Error ? err.message : 'Something went wrong.');
    } finally {
      setLoading(false);
    }
  };

  return (
    <ScrollView style={styles.container} keyboardShouldPersistTaps="handled">
      <Text style={styles.heading}>Restaurant Health Score</Text>
      <Text style={styles.subheading}>Look up any restaurant's latest inspection result.</Text>

      <TextInput
        style={styles.input}
        placeholder="Restaurant name"
        placeholderTextColor="#8494a7"
        value={name}
        onChangeText={setName}
        autoCorrect={false}
      />
      <TextInput
        style={styles.input}
        placeholder="City"
        placeholderTextColor="#8494a7"
        value={city}
        onChangeText={setCity}
        autoCorrect={false}
      />
      <TextInput
        style={styles.input}
        placeholder="State (optional, e.g. NY)"
        placeholderTextColor="#8494a7"
        value={state}
        onChangeText={setState}
        autoCapitalize="characters"
        maxLength={2}
      />

      <TouchableOpacity
        style={[styles.button, loading && styles.buttonDisabled]}
        onPress={handleSearch}
        disabled={loading}
      >
        {loading
          ? <ActivityIndicator color="#fff" />
          : <Text style={styles.buttonText}>Search</Text>
        }
      </TouchableOpacity>

      {error && (
        <View style={styles.errorBox}>
          <Text style={styles.errorText}>{error}</Text>
        </View>
      )}

      {result && (
        <View style={styles.resultCard}>
          <View style={styles.resultHeader}>
            <View style={styles.resultInfo}>
              <Text style={styles.resultName}>{result.name}</Text>
              <Text style={styles.resultAddress}>
                {result.address}, {result.city}, {result.state}
              </Text>
              <Text style={styles.resultDate}>
                Last inspected: {new Date(result.last_inspected).toLocaleDateString()}
              </Text>
              <Text style={[
                styles.resultTrend,
                result.trend === 'improving' && styles.trendUp,
                result.trend === 'declining' && styles.trendDown,
              ]}>
                Trend: {result.trend}
              </Text>
            </View>
            <GradeBadge grade={result.grade} score={result.score} size="lg" />
          </View>

          {result.inspection_history.length > 0 && (
            <View style={styles.historySection}>
              <Text style={styles.sectionLabel}>INSPECTION HISTORY</Text>
              {result.inspection_history.slice(0, 5).map((record, idx) => (
                <View key={idx} style={styles.historyRow}>
                  <Text style={styles.historyDate}>
                    {new Date(record.date).toLocaleDateString()}
                  </Text>
                  <GradeBadge grade={record.grade} score={record.score} size="sm" />
                </View>
              ))}
            </View>
          )}
        </View>
      )}
    </ScrollView>
  );
};

const styles = StyleSheet.create({
  container: { flex: 1, backgroundColor: '#0b0f19', padding: 20 },
  heading: { fontSize: 26, fontWeight: '800', color: '#f1f5f9', marginBottom: 6 },
  subheading: { fontSize: 14, color: '#8494a7', marginBottom: 24 },
  input: {
    backgroundColor: '#1a2235', borderWidth: 1, borderColor: '#1e2d45',
    borderRadius: 8, padding: 14, color: '#f1f5f9', fontSize: 16, marginBottom: 12,
  },
  button: {
    backgroundColor: '#3b82f6', borderRadius: 8, padding: 16,
    alignItems: 'center', marginBottom: 24,
  },
  buttonDisabled: { opacity: 0.6 },
  buttonText: { color: '#fff', fontWeight: '700', fontSize: 16 },
  errorBox: { backgroundColor: '#2d0a0a', borderRadius: 8, padding: 14, marginBottom: 16 },
  errorText: { color: '#ef4444', fontSize: 14 },
  resultCard: {
    backgroundColor: '#1a2235', borderWidth: 1, borderColor: '#1e2d45',
    borderRadius: 12, padding: 20, marginBottom: 32,
  },
  resultHeader: { flexDirection: 'row', gap: 16, alignItems: 'flex-start' },
  resultInfo: { flex: 1 },
  resultName: { fontSize: 18, fontWeight: '700', color: '#f1f5f9', marginBottom: 4 },
  resultAddress: { fontSize: 13, color: '#8494a7', marginBottom: 4 },
  resultDate: { fontSize: 12, color: '#8494a7', marginBottom: 4 },
  resultTrend: { fontSize: 12, fontWeight: '600', color: '#8494a7' },
  trendUp: { color: '#22c55e' },
  trendDown: { color: '#ef4444' },
  historySection: { marginTop: 20, borderTopWidth: 1, borderTopColor: '#1e2d45', paddingTop: 16 },
  sectionLabel: { fontSize: 10, fontWeight: '700', letterSpacing: 1, color: '#8494a7', marginBottom: 12 },
  historyRow: { flexDirection: 'row', justifyContent: 'space-between', alignItems: 'center', marginBottom: 10 },
  historyDate: { fontSize: 13, color: '#a8b8cc' },
});

Step 6 - The Nearby Screen with GPS

The nearby screen uses expo-location to get the device's current position, then passes those coordinates to the geo search endpoint. Results are sorted by health score descending so the safest restaurants appear first.

// src/screens/NearbyScreen.tsx

import React, { useState, useCallback } from 'react';
import {
  View, Text, FlatList, TouchableOpacity,
  ActivityIndicator, StyleSheet, ListRenderItemInfo,
} from 'react-native';
import * as Location from 'expo-location';
import { GradeBadge } from '../components/GradeBadge';
import { geoSearch } from '../services/HealthScoreService';
import type { HealthScoreResult } from '../types/healthScore';

const RADIUS_OPTIONS = [
  { label: '500 m', value: 500 },
  { label: '1 km', value: 1000 },
  { label: '2 km', value: 2000 },
  { label: '5 km', value: 5000 },
];

export const NearbyScreen: React.FC = () => {
  const [radius, setRadius] = useState(1000);
  const [loading, setLoading] = useState(false);
  const [results, setResults] = useState<HealthScoreResult[]>([]);
  const [error, setError] = useState<string | null>(null);
  const [locationUsed, setLocationUsed] = useState<string | null>(null);

  const handleSearch = useCallback(async () => {
    setLoading(true);
    setError(null);
    setResults([]);
    setLocationUsed(null);

    try {
      const { status } = await Location.requestForegroundPermissionsAsync();
      if (status !== 'granted') {
        setError('Location permission was denied. Please enable it in Settings.');
        return;
      }

      const location = await Location.getCurrentPositionAsync({
        accuracy: Location.Accuracy.Balanced,
      });
      const { latitude, longitude } = location.coords;
      setLocationUsed(`${latitude.toFixed(4)}, ${longitude.toFixed(4)}`);

      const data = await geoSearch(latitude, longitude, radius);
      setResults(data.results);
      if (data.results.length === 0) {
        setError('No inspected restaurants found within the selected radius.');
      }
    } catch (err) {
      setError(err instanceof Error ? err.message : 'Could not fetch nearby restaurants.');
    } finally {
      setLoading(false);
    }
  }, [radius]);

  const renderItem = ({ item }: ListRenderItemInfo<HealthScoreResult>) => (
    <View style={styles.card}>
      <View style={styles.cardContent}>
        <View style={styles.cardInfo}>
          <Text style={styles.cardName} numberOfLines={1}>{item.name}</Text>
          <Text style={styles.cardAddress} numberOfLines={1}>{item.address}</Text>
          <Text style={styles.cardDate}>
            Inspected {new Date(item.last_inspected).toLocaleDateString()}
          </Text>
        </View>
        <GradeBadge grade={item.grade} score={item.score} size="md" />
      </View>
    </View>
  );

  return (
    <View style={styles.container}>
      <Text style={styles.heading}>Nearby Restaurants</Text>
      <Text style={styles.subheading}>Sorted by health score - best first.</Text>

      <View style={styles.radiusRow}>
        {RADIUS_OPTIONS.map(opt => (
          <TouchableOpacity
            key={opt.value}
            style={[styles.radiusBtn, radius === opt.value && styles.radiusBtnActive]}
            onPress={() => setRadius(opt.value)}
          >
            <Text style={[
              styles.radiusBtnText,
              radius === opt.value && styles.radiusBtnTextActive,
            ]}>{opt.label}</Text>
          </TouchableOpacity>
        ))}
      </View>

      <TouchableOpacity
        style={[styles.button, loading && styles.buttonDisabled]}
        onPress={handleSearch}
        disabled={loading}
      >
        {loading
          ? <ActivityIndicator color="#fff" />
          : <Text style={styles.buttonText}>Find Nearby</Text>
        }
      </TouchableOpacity>

      {locationUsed && (
        <Text style={styles.locationLabel}>Location: {locationUsed}</Text>
      )}

      {error && (
        <View style={styles.errorBox}>
          <Text style={styles.errorText}>{error}</Text>
        </View>
      )}

      <FlatList
        data={results}
        keyExtractor={item => item.restaurant_id}
        renderItem={renderItem}
        contentContainerStyle={styles.list}
        showsVerticalScrollIndicator={false}
      />
    </View>
  );
};

const styles = StyleSheet.create({
  container: { flex: 1, backgroundColor: '#0b0f19', padding: 20 },
  heading: { fontSize: 26, fontWeight: '800', color: '#f1f5f9', marginBottom: 6 },
  subheading: { fontSize: 14, color: '#8494a7', marginBottom: 20 },
  radiusRow: { flexDirection: 'row', gap: 8, marginBottom: 16 },
  radiusBtn: {
    borderWidth: 1, borderColor: '#1e2d45', borderRadius: 20,
    paddingHorizontal: 14, paddingVertical: 8, backgroundColor: '#1a2235',
  },
  radiusBtnActive: { backgroundColor: '#1d3460', borderColor: '#3b82f6' },
  radiusBtnText: { fontSize: 13, color: '#8494a7', fontWeight: '600' },
  radiusBtnTextActive: { color: '#60a5fa' },
  button: {
    backgroundColor: '#3b82f6', borderRadius: 8, padding: 16,
    alignItems: 'center', marginBottom: 12,
  },
  buttonDisabled: { opacity: 0.6 },
  buttonText: { color: '#fff', fontWeight: '700', fontSize: 16 },
  locationLabel: { fontSize: 11, color: '#8494a7', marginBottom: 16, textAlign: 'center' },
  errorBox: { backgroundColor: '#2d0a0a', borderRadius: 8, padding: 14, marginBottom: 16 },
  errorText: { color: '#ef4444', fontSize: 14 },
  list: { paddingBottom: 40 },
  card: {
    backgroundColor: '#1a2235', borderWidth: 1, borderColor: '#1e2d45',
    borderRadius: 10, padding: 16, marginBottom: 10,
  },
  cardContent: { flexDirection: 'row', alignItems: 'center', gap: 12 },
  cardInfo: { flex: 1 },
  cardName: { fontSize: 15, fontWeight: '700', color: '#f1f5f9', marginBottom: 2 },
  cardAddress: { fontSize: 12, color: '#8494a7', marginBottom: 2 },
  cardDate: { fontSize: 11, color: '#8494a7' },
});

Step 7 - Wire Up Navigation

If you are using React Navigation, add both screens to your navigator:

// App.tsx (excerpt)

import { NavigationContainer } from '@react-navigation/native';
import { createBottomTabNavigator } from '@react-navigation/bottom-tabs';
import { SearchScreen } from './src/screens/SearchScreen';
import { NearbyScreen } from './src/screens/NearbyScreen';

const Tab = createBottomTabNavigator();

export default function App() {
  return (
    <NavigationContainer>
      <Tab.Navigator
        screenOptions={{ tabBarStyle: { backgroundColor: '#111827' }, headerShown: false }}
      >
        <Tab.Screen name="Search" component={SearchScreen} />
        <Tab.Screen name="Nearby" component={NearbyScreen} />
      </Tab.Navigator>
    </NavigationContainer>
  );
}

AsyncStorage Caching - Design Notes

The caching layer in HealthScoreService uses a simple key-value scheme with a timestamp-based TTL. A few practical considerations:

Error States Worth Handling

Beyond the obvious network error, these are the scenarios your UI should handle gracefully:

For a deeper look at the underlying scoring methodology - specifically how critical violations are weighted versus minor ones - see Restaurant Inspection Scoring Systems Compared. And if you are building for a food delivery platform rather than a consumer app, Food Delivery Platform Health Score Integration covers the additional considerations around order-time risk gating and bulk pre-loading.

Next Steps

With the foundation in place, there are several directions you can take this integration further:

For more on how the normalized 0-100 score is constructed from raw government data, see How to Normalize Food Safety Scores Across Jurisdictions.

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.