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.
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;
}
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:
- TTL length. 24 hours is appropriate for inspection data - scores only change when an inspector visits. For the geo search cache, you may want a shorter TTL (4-6 hours) since new restaurants open and licenses change more frequently than inspection outcomes.
- Cache size. AsyncStorage on iOS is unbounded in practice but performs poorly above a few megabytes. If your app searches hundreds of unique locations, implement a simple LRU eviction by maintaining an index of cached keys sorted by last-access time.
- Cache invalidation on pull-to-refresh. Pass an optional
forceRefresh: booleanparameter to each service function and skip the cache read when true. Wire this to aRefreshControlon the ScrollView/FlatList. - Offline handling. If the fetch throws a network error, fall back to the cached value even if it is expired rather than showing an error. Stale health data is better than no data.
Error States Worth Handling
Beyond the obvious network error, these are the scenarios your UI should handle gracefully:
- Location timeout.
getCurrentPositionAsynccan hang on devices with poor GPS signal. Set a timeout via thetimeIntervaloption and show a friendly message if it expires. - Restaurant not found (null result). The lookup endpoint returns a null result - not a 404 - when no matching record exists. Check for null explicitly and show a "not found" state distinct from an error state.
- API key missing. If the environment variable is undefined at build time, all requests will fail with 401. Add a startup check that warns in development.
- Jurisdiction not covered. Some smaller cities are not yet in the FoodSafe Score dataset. The API returns a
coverage: falseflag in this case. Surface this to the user rather than showing a blank result.
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:
- Add a detail screen that renders the full violation list from
inspection_history, color-coding critical violations in red. - Implement push notifications via Expo Notifications that alert users when a saved restaurant's score drops below their threshold - call
getInspectionHistoryin a background task once per day. - Build a map view using
react-native-mapsthat plots geo search results as colored pins matching the grade badges. - For franchise or chain apps, use the bulk zip endpoint to pre-populate the cache for all locations on app launch rather than waiting for user searches.
For more on how the normalized 0-100 score is constructed from raw government data, see How to Normalize Food Safety Scores Across Jurisdictions.