Back to Blog
Tutorial

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.

Payloadic Team9 min read

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

  1. Sign up at RapidAPI
  2. Subscribe to free tier (500 requests/month)
  3. 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

  1. Debounce searches: Wait for user to finish typing
  2. Cache results: Store recent searches in localStorage
  3. Lazy load maps: Only load when needed
  4. 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:

reactstore locatorzip codemapsgeolocationecommerce

Ready to Try It Yourself?

Get started with Payloadic APIs and integrate phone validation or ZIP code lookup into your application today.