Build a Store Locator with React and ZIP Code Radius API - Complete 2026 Guide
Create a production-ready store locator in React using the ZIP Code Radius API. Includes interactive map, distance calculation, and mobile-responsive design.
Store locators are essential for businesses with physical locations. Whether you're building an e-commerce site, franchise directory, or service area finder, a good store locator improves customer experience and drives foot traffic.
In this tutorial, you'll build a production-ready store locator in React using the Payloadic ZIP Code & Radius API.
What You'll Build
A fully-featured store locator with:
- ZIP code search input
- Adjustable radius (5-100 miles)
- List of nearby stores with distances
- Interactive map visualization
- Mobile-responsive design
- Loading and error states
- TypeScript support
Live Demo: CodeSandbox →
Prerequisites
- React 18+ (works with Next.js, Vite, CRA)
- Basic knowledge of React hooks
- RapidAPI account for ZIP Code API
- Optional: Mapbox or Google Maps account
- 30 minutes of your time
Architecture Overview
User Input (ZIP + Radius)
↓
ZIP Code API (Get nearby ZIP codes)
↓
Filter Stores (Match store ZIPs to results)
↓
Calculate Distances (Sort by proximity)
↓
Display Results (List + Map)
Step 1: Project Setup
Create a new React app:
# Using Vite (recommended)
npm create vite@latest store-locator -- --template react-ts
cd store-locator
npm install
# Install dependencies
npm install axios lucide-react
Step 2: Get Your API Key
- Sign up at RapidAPI
- Subscribe to free tier (500 requests/month)
- Copy your API key
Create .env:
VITE_RAPIDAPI_KEY=your_api_key_here
Step 3: Create Store Data
// data/stores.ts
export interface Store {
id: number
name: string
address: string
city: string
state: string
zip: string
phone: string
hours?: string
}
export const stores: Store[] = [
{
id: 1,
name: 'Downtown Location',
address: '123 Main Street',
city: 'Beverly Hills',
state: 'CA',
zip: '90210',
phone: '(310) 555-0100',
hours: 'Mon-Fri 9AM-6PM',
},
{
id: 2,
name: 'Airport Plaza',
address: '456 LAX Boulevard',
city: 'Los Angeles',
state: 'CA',
zip: '90045',
phone: '(310) 555-0101',
hours: 'Mon-Sat 10AM-8PM',
},
{
id: 3,
name: 'Beach Store',
address: '789 Ocean Avenue',
city: 'Santa Monica',
state: 'CA',
zip: '90401',
phone: '(310) 555-0102',
hours: 'Daily 9AM-9PM',
},
// Add more stores
]
Step 4: Create API Service
// services/zipCodeApi.ts
import axios from 'axios'
interface ZipCodeResult {
zipcode: string
city: string
state: string
distance: number
}
interface ApiResponse {
success: boolean
data: {
zipcode: string
nearby_zipcodes: ZipCodeResult[]
}
}
class ZipCodeService {
private apiKey: string
private baseURL = 'https://us-zipcode-radius-api.p.rapidapi.com'
constructor(apiKey: string) {
this.apiKey = apiKey
}
async searchRadius(zip: string, radius: number): Promise<ZipCodeResult[]> {
try {
const response = await axios.get<ApiResponse>(`${this.baseURL}/radius`, {
params: { zip, radius },
headers: {
'X-RapidAPI-Key': this.apiKey,
'X-RapidAPI-Host': 'us-zipcode-radius-api.p.rapidapi.com',
},
})
if (!response.data.success) {
throw new Error('Invalid ZIP code')
}
return response.data.data.nearby_zipcodes
} catch (error) {
if (axios.isAxiosError(error)) {
throw new Error(error.response?.data?.message || 'API request failed')
}
throw error
}
}
}
export default new ZipCodeService(import.meta.env.VITE_RAPIDAPI_KEY)
Step 5: Create Store Locator Hook
// hooks/useStoreLocator.ts
import { useState } from 'react'
import zipCodeService from '../services/zipCodeApi'
import { stores, Store } from '../data/stores'
interface StoreWithDistance extends Store {
distance: number
}
export function useStoreLocator() {
const [loading, setLoading] = useState(false)
const [error, setError] = useState<string | null>(null)
const [results, setResults] = useState<StoreWithDistance[]>([])
const searchStores = async (zip: string, radius: number) => {
setLoading(true)
setError(null)
setResults([])
try {
// Get nearby ZIP codes from API
const nearbyZips = await zipCodeService.searchRadius(zip, radius)
// Create a map for quick distance lookup
const zipDistances = new Map(
nearbyZips.map(z => [z.zipcode, z.distance])
)
// Filter stores within radius and add distances
const nearbyStores: StoreWithDistance[] = stores
.filter(store => zipDistances.has(store.zip))
.map(store => ({
...store,
distance: zipDistances.get(store.zip)!,
}))
.sort((a, b) => a.distance - b.distance) // Sort by distance
setResults(nearbyStores)
if (nearbyStores.length === 0) {
setError(`No stores found within ${radius} miles of ${zip}`)
}
} catch (err) {
setError(err instanceof Error ? err.message : 'Search failed')
} finally {
setLoading(false)
}
}
return {
loading,
error,
results,
searchStores,
}
}
Step 6: Build Search Component
// components/StoreSearch.tsx
import { useState } from 'react'
import { Search } from 'lucide-react'
interface StoreSearchProps {
onSearch: (zip: string, radius: number) => void
loading: boolean
}
export function StoreSearch({ onSearch, loading }: StoreSearchProps) {
const [zip, setZip] = useState('')
const [radius, setRadius] = useState(25)
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault()
// Validate ZIP code
if (!/^\d{5}$/.test(zip)) {
alert('Please enter a valid 5-digit ZIP code')
return
}
onSearch(zip, radius)
}
return (
<form onSubmit={handleSubmit} className="search-form">
<h2>Find Stores Near You</h2>
<div className="form-group">
<label htmlFor="zip">ZIP Code</label>
<input
id="zip"
type="text"
value={zip}
onChange={(e) => setZip(e.target.value.slice(0, 5))}
placeholder="90210"
maxLength={5}
required
disabled={loading}
/>
</div>
<div className="form-group">
<label htmlFor="radius">Search Radius</label>
<select
id="radius"
value={radius}
onChange={(e) => setRadius(Number(e.target.value))}
disabled={loading}
>
<option value={5}>5 miles</option>
<option value={10}>10 miles</option>
<option value={25}>25 miles</option>
<option value={50}>50 miles</option>
<option value={100}>100 miles</option>
</select>
</div>
<button type="submit" disabled={loading}>
<Search size={20} />
{loading ? 'Searching...' : 'Find Stores'}
</button>
</form>
)
}
Step 7: Build Results Component
// components/StoreResults.tsx
import { MapPin, Phone, Clock } from 'lucide-react'
interface Store {
id: number
name: string
address: string
city: string
state: string
zip: string
phone: string
hours?: string
distance: number
}
interface StoreResultsProps {
stores: Store[]
}
export function StoreResults({ stores }: StoreResultsProps) {
if (stores.length === 0) return null
return (
<div className="results-container">
<h3>Found {stores.length} store(s) nearby</h3>
<div className="store-list">
{stores.map((store) => (
<div key={store.id} className="store-card">
<div className="store-header">
<h4>{store.name}</h4>
<span className="distance">{store.distance.toFixed(1)} mi</span>
</div>
<div className="store-details">
<div className="detail">
<MapPin size={16} />
<span>
{store.address}, {store.city}, {store.state} {store.zip}
</span>
</div>
<div className="detail">
<Phone size={16} />
<a href={`tel:${store.phone.replace(/\D/g, '')}`}>
{store.phone}
</a>
</div>
{store.hours && (
<div className="detail">
<Clock size={16} />
<span>{store.hours}</span>
</div>
)}
</div>
<div className="store-actions">
<a
href={`https://maps.google.com/?q=${encodeURIComponent(
`${store.address}, ${store.city}, ${store.state} ${store.zip}`
)}`}
target="_blank"
rel="noopener noreferrer"
className="btn-directions"
>
Get Directions
</a>
<a href={`tel:${store.phone.replace(/\D/g, '')}`} className="btn-call">
Call Store
</a>
</div>
</div>
))}
</div>
</div>
)
}
Step 8: Main App Component
// App.tsx
import { StoreSearch } from './components/StoreSearch'
import { StoreResults } from './components/StoreResults'
import { useStoreLocator } from './hooks/useStoreLocator'
import './App.css'
function App() {
const { loading, error, results, searchStores } = useStoreLocator()
return (
<div className="app">
<header>
<h1>Store Locator</h1>
<p>Find our locations near you</p>
</header>
<main>
<StoreSearch onSearch={searchStores} loading={loading} />
{loading && (
<div className="loading">
<div className="spinner" />
<p>Searching for stores...</p>
</div>
)}
{error && (
<div className="error">
<p>{error}</p>
</div>
)}
{!loading && !error && results.length > 0 && (
<StoreResults stores={results} />
)}
</main>
</div>
)
}
export default App
Step 9: Styling (CSS)
Add beautiful, mobile-responsive styles:
/* App.css */
* {
box-sizing: border-box;
margin: 0;
padding: 0;
}
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
background: #f5f5f5;
}
.app {
max-width: 1200px;
margin: 0 auto;
padding: 20px;
}
header {
text-align: center;
margin-bottom: 40px;
}
header h1 {
font-size: 2.5rem;
color: #333;
margin-bottom: 10px;
}
header p {
color: #666;
font-size: 1.1rem;
}
.search-form {
background: white;
padding: 30px;
border-radius: 12px;
box-shadow: 0 2px 8px rgba(0,0,0,0.1);
margin-bottom: 30px;
}
.search-form h2 {
margin-bottom: 20px;
color: #333;
}
.form-group {
margin-bottom: 20px;
}
.form-group label {
display: block;
margin-bottom: 8px;
color: #555;
font-weight: 500;
}
.form-group input,
.form-group select {
width: 100%;
padding: 12px;
border: 1px solid #ddd;
border-radius: 6px;
font-size: 16px;
}
button {
width: 100%;
padding: 14px;
background: #0066cc;
color: white;
border: none;
border-radius: 6px;
font-size: 16px;
font-weight: 600;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
gap: 8px;
}
button:hover {
background: #0052a3;
}
button:disabled {
background: #ccc;
cursor: not-allowed;
}
.loading {
text-align: center;
padding: 40px;
}
.spinner {
width: 40px;
height: 40px;
border: 4px solid #f3f3f3;
border-top: 4px solid #0066cc;
border-radius: 50%;
animation: spin 1s linear infinite;
margin: 0 auto 20px;
}
@keyframes spin {
to { transform: rotate(360deg); }
}
.error {
background: #fee;
padding: 20px;
border-radius: 8px;
color: #c33;
text-align: center;
}
.results-container {
background: white;
padding: 30px;
border-radius: 12px;
box-shadow: 0 2px 8px rgba(0,0,0,0.1);
}
.results-container h3 {
margin-bottom: 24px;
color: #333;
}
.store-card {
padding: 20px;
border: 1px solid #eee;
border-radius: 8px;
margin-bottom: 16px;
}
.store-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 16px;
}
.store-header h4 {
font-size: 1.25rem;
color: #333;
}
.distance {
background: #0066cc;
color: white;
padding: 4px 12px;
border-radius: 20px;
font-weight: 600;
font-size: 0.9rem;
}
.store-details {
margin-bottom: 16px;
}
.detail {
display: flex;
align-items: center;
gap: 8px;
color: #666;
margin-bottom: 8px;
}
.detail svg {
flex-shrink: 0;
}
.store-actions {
display: flex;
gap: 12px;
}
.btn-directions,
.btn-call {
flex: 1;
padding: 10px;
text-align: center;
border-radius: 6px;
text-decoration: none;
font-weight: 500;
}
.btn-directions {
background: #0066cc;
color: white;
}
.btn-call {
background: #f5f5f5;
color: #333;
}
@media (max-width: 768px) {
.store-header {
flex-direction: column;
align-items: flex-start;
gap: 8px;
}
.store-actions {
flex-direction: column;
}
}
Step 10: Run Your App
npm run dev
Visit http://localhost:5173 and test it out!
Advanced Features
1. Add Map Visualization
Install Leaflet:
npm install react-leaflet leaflet
2. Geolocation
Auto-detect user's location:
function detectLocation() {
navigator.geolocation.getCurrentPosition(
async (position) => {
const { latitude, longitude } = position.coords
// Reverse geocode to get ZIP
}
)
}
3. Store Filters
Add filtering by:
- Store features (parking, wheelchair accessible)
- Services offered
- Current availability
Performance Optimization
- Debounce searches: Wait for user to finish typing
- Cache results: Store recent searches in localStorage
- Lazy load maps: Only load when needed
- Pagination: Show 10 stores at a time
Production Checklist
- [ ] Environment variables configured
- [ ] Error boundaries implemented
- [ ] Analytics tracking added
- [ ] Mobile tested on real devices
- [ ] Accessibility (ARIA labels, keyboard nav)
- [ ] SEO meta tags
- [ ] Loading skeletons
- [ ] Rate limiting handled
Common Issues
Issue: "Invalid API key"
Solution: Check your .env file and restart dev server
Issue: No stores found
Solution: Verify your store ZIP codes are correct
Issue: Slow loading
Solution: Implement caching and pagination
Next Steps
Enhance your store locator:
- Add interactive maps (Leaflet/Google Maps)
- Store hours with "Open Now" indicator
- Reviews and ratings
- Appointment booking
- Multi-language support
Conclusion
You now have a production-ready store locator! This implementation provides:
- ✅ Fast ZIP code search
- ✅ Accurate distance calculation
- ✅ Mobile-responsive design
- ✅ USPS-verified data
- ✅ TypeScript type safety
View the complete code: GitHub repository →
Ready to get started? Get your free API key →
For more React examples, visit our Cookbook.
Questions? Reach out to support@payloadic.com
Tags:
Ready to Try It Yourself?
Get started with Payloadic APIs and integrate phone validation or ZIP code lookup into your application today.