Building the Map - Food Bank Discovery with Mapbox
The Map-First Philosophy
When you're building a hyperlocal food discovery app, the map isn't a feature - it's the interface.
People don't think "I need food from zip code 95814." They think "What's near me right now?" The map answers that question instantly.
So on Day 2 of the FoodShare pivot (November 6, 2025), I had a clear priority: get the map working.
Why Mapbox Over Google Maps?
The choice of mapping provider mattered. Here's why I went with Mapbox GL JS:
Pros:
- Beautiful, customizable styling
- Generous free tier (50,000 loads/month)
- Excellent React integration via
react-map-gl - WebGL-based rendering (smooth performance)
- Great documentation
Cons:
- Steeper learning curve than Google Maps
- Smaller ecosystem
- More manual work for features like geocoding
The styling control was the deciding factor. Mapbox let me create a map that felt like part of the app, not a generic Google Maps embed.
The Initial Implementation
The first version (e7ac814) was delightfully simple:
// src/components/map/MapView.tsx
"use client";
import Map, { Marker, Popup } from 'react-map-gl';
import { MapPin } from 'lucide-react';
import 'mapbox-gl/dist/mapbox-gl.css';
export function MapView({ locations, center }: MapViewProps) {
const [selectedLocation, setSelectedLocation] = useState<string | null>(null);
return (
<Map
mapboxAccessToken={process.env.NEXT_PUBLIC_MAPBOX_TOKEN}
initialViewState={{
longitude: center.lng,
latitude: center.lat,
zoom: 12
}}
style={{ width: '100%', height: '100%' }}
mapStyle="mapbox://styles/mapbox/streets-v12"
>
{locations.map((location) => (
<Marker
key={location.id}
longitude={location.longitude}
latitude={location.latitude}
onClick={() => setSelectedLocation(location.id)}
>
<MapPin className="text-primary h-8 w-8" />
</Marker>
))}
{selectedLocation && (
<Popup
longitude={selectedLocation.longitude}
latitude={selectedLocation.latitude}
onClose={() => setSelectedLocation(null)}
>
<LocationPopup location={selectedLocation} />
</Popup>
)}
</Map>
);
}
Clean, functional, and working in under 100 lines. This is the power of good abstractions.
Seeding Sacramento Data
With the map rendering, I needed data. Rather than building a scraper, I manually researched and seeded Sacramento food banks:
// scripts/seed-food-banks.ts
const sacramentoFoodBanks = [
{
name: "Sacramento Food Bank & Family Services",
address: "3333 3rd Ave",
city: "Sacramento",
state: "CA",
zipCode: "95817",
latitude: 38.5599,
longitude: -121.4590,
phone: "(916) 456-1980",
website: "https://www.sacramentofoodbank.org",
services: ["Emergency Food", "CalFresh Assistance", "Senior Support"],
hours: {
mon: { open: "09:00", close: "16:00" },
tue: { open: "09:00", close: "16:00" },
wed: { open: "09:00", close: "16:00" },
thu: { open: "09:00", close: "16:00" },
fri: { open: "09:00", close: "16:00" },
sat: { closed: true },
sun: { closed: true }
}
},
// ... 15 more hand-curated entries
];
This manual process was tedious but valuable. I learned:
- Hours vary wildly (some only open 2 days/week)
- Services aren't standardized ("Food Pantry" vs "Emergency Groceries")
- Many lack websites or have outdated info
- Geocoding street addresses requires care (building entrance vs parking lot)
The Hours Challenge
Displaying "Open Now" status seems trivial until you implement it:
// src/lib/geolocation.ts
export type HoursType = {
[K in 'mon' | 'tue' | 'wed' | 'thu' | 'fri' | 'sat' | 'sun']?: {
open?: string;
close?: string;
closed?: boolean;
};
};
export function isCurrentlyOpen(hours: HoursType | null): boolean {
if (!hours) return false;
const now = new Date();
const dayNames = ['sun', 'mon', 'tue', 'wed', 'thu', 'fri', 'sat'];
const todayKey = dayNames[now.getDay()] as keyof HoursType;
const todayHours = hours[todayKey];
if (!todayHours || todayHours.closed) return false;
const [currentHour, currentMinute] = [now.getHours(), now.getMinutes()];
const currentTime = currentHour * 60 + currentMinute;
const [openHour, openMinute] = todayHours.open!.split(':').map(Number);
const openTime = openHour * 60 + openMinute;
const [closeHour, closeMinute] = todayHours.close!.split(':').map(Number);
const closeTime = closeHour * 60 + closeMinute;
return currentTime >= openTime && currentTime < closeTime;
}
This function has subtle bugs (timezone handling, midnight crossover), but it worked well enough for MVP.
The Popup Problem
Initial map markers showed a popup on click:
// src/components/map/LocationPopup.tsx
export function LocationPopup({ location }: { location: FoodBank }) {
const directionsUrl = `https://www.google.com/maps/dir/?api=1&destination=${encodeURIComponent(
`${location.address}, ${location.city}, ${location.state}`
)}`;
return (
<div className="max-w-sm p-4 space-y-3">
<div className="flex items-start justify-between">
<h3 className="font-semibold text-lg">{location.name}</h3>
<StatusBadge isOpen={isCurrentlyOpen(location.hours)} />
</div>
<div className="text-sm text-muted-foreground">
<MapPin className="inline h-4 w-4 mr-1" />
{location.address}, {location.city}
</div>
{location.phone && (
<Button asChild variant="outline" size="sm">
<a href={`tel:${location.phone}`}>
<Phone className="h-4 w-4 mr-2" />
Call
</a>
</Button>
)}
<Button asChild className="w-full">
<a href={directionsUrl} target="_blank">
<Navigation className="h-4 w-4 mr-2" />
Get Directions
</a>
</Button>
</div>
);
}
This worked but had UX issues:
- Mobile: Popups covered half the screen
- Accessibility: Clicking outside to close wasn't obvious
- Content: Limited space for full location details
These problems would eventually lead to a bottom sheet redesign (Part 11), but for MVP, popups were good enough.
Geolocation and User Position
A food discovery app must answer "What's near me?" So I added browser geolocation:
// src/app/map/pageClient.tsx
export default function MapPageClient({ initialLocations }: Props) {
const [userLocation, setUserLocation] = useState<[number, number] | null>(null);
const [center, setCenter] = useState<[number, number]>([-121.4944, 38.5816]); // Sacramento default
useEffect(() => {
if ('geolocation' in navigator) {
navigator.geolocation.getCurrentPosition(
(position) => {
const coords: [number, number] = [
position.coords.longitude,
position.coords.latitude
];
setUserLocation(coords);
setCenter(coords); // Re-center map on user
},
(error) => {
console.warn('Geolocation denied:', error);
// Fall back to Sacramento default
}
);
}
}, []);
return (
<MapView
locations={initialLocations}
center={center}
userLocation={userLocation}
/>
);
}
This introduced the server/client split pattern that would become standard:
- Server component (
page.tsx): Fetch data from database - Client component (
pageClient.tsx): Handle interactivity, geolocation, state
Clean separation of concerns.
The Distance Calculation
Once I had user location, I could show "X miles away":
// src/lib/geolocation.ts
export function calculateDistance(
lat1: number,
lon1: number,
lat2: number,
lon2: number
): number {
const R = 3959; // Earth's radius in miles
const dLat = (lat2 - lat1) * (Math.PI / 180);
const dLon = (lon2 - lon1) * (Math.PI / 180);
const a =
Math.sin(dLat / 2) * Math.sin(dLat / 2) +
Math.cos(lat1 * (Math.PI / 180)) *
Math.cos(lat2 * (Math.PI / 180)) *
Math.sin(dLon / 2) *
Math.sin(dLon / 2);
const c = 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a));
const distance = R * c;
return Math.round(distance * 10) / 10; // Round to 1 decimal
}
The Haversine formula - a classic that every geo app eventually implements. It's accurate enough for walking distances (< 10 miles), though it assumes a perfect sphere (Earth isn't).
The Search Bar
Finding food banks by address required a search interface:
// src/components/map/MapSearchBar.tsx
export function MapSearchBar({ onSearch }: MapSearchBarProps) {
const [query, setQuery] = useState('');
const handleSearch = async (e: FormEvent) => {
e.preventDefault();
// Use Mapbox Geocoding API
const response = await fetch(
`https://api.mapbox.com/geocoding/v5/mapbox.places/${encodeURIComponent(query)}.json?access_token=${process.env.NEXT_PUBLIC_MAPBOX_TOKEN}`
);
const data = await response.json();
if (data.features && data.features[0]) {
const [lng, lat] = data.features[0].center;
onSearch({ lat, lng });
}
};
return (
<form onSubmit={handleSearch} className="relative">
<Input
type="text"
placeholder="Search address..."
value={query}
onChange={(e) => setQuery(e.target.value)}
className="pr-10"
/>
<Button type="submit" size="icon" className="absolute right-1 top-1">
<Search className="h-4 w-4" />
</Button>
</form>
);
}
This worked but highlighted a pattern: every API call to Mapbox exposed the public token. Fine for MVP, but a reverse geocoding proxy would be better long-term.
The Food Bank Queries
Fetching food banks required efficient database queries:
// src/lib/food-bank-queries.ts
export async function searchFoodBanks(params: {
latitude: number;
longitude: number;
radiusMiles?: number;
limit?: number;
}) {
const { latitude, longitude, radiusMiles = 10, limit = 50 } = params;
// Simple implementation: fetch all, filter in memory
const allBanks = await db.select().from(foodBanks);
const results = allBanks
.map((bank) => ({
...bank,
distance: calculateDistance(latitude, longitude, bank.latitude, bank.longitude)
}))
.filter((bank) => bank.distance <= radiusMiles)
.sort((a, b) => a.distance - b.distance)
.slice(0, limit);
return results;
}
This in-memory filtering was shockingly slow with 500+ food banks. It worked for Sacramento's 20 locations, but foreshadowed the PostGIS migration needed later (Part 12).
UI Polish: The Location Card
Food banks needed a consistent visual treatment:
// src/components/foodshare/location-card.tsx
export function LocationCard({ location, distance }: LocationCardProps) {
const isOpen = isCurrentlyOpen(location.hours);
return (
<Card className="overflow-hidden">
<CardHeader>
<div className="flex items-start justify-between">
<div>
<CardTitle className="text-lg">{location.name}</CardTitle>
<CardDescription>
<MapPin className="inline h-3 w-3 mr-1" />
{location.address}, {location.city}
</CardDescription>
</div>
<StatusBadge isOpen={isOpen} />
</div>
</CardHeader>
<CardContent className="space-y-4">
{distance && (
<p className="text-sm font-medium text-muted-foreground">
{distance.toFixed(1)} miles away
</p>
)}
{location.services && (
<div className="flex flex-wrap gap-2">
{location.services.map((service) => (
<Badge key={service} variant="outline">
{service}
</Badge>
))}
</div>
)}
{location.hours && (
<Accordion type="single" collapsible>
<AccordionItem value="hours">
<AccordionTrigger>
<Clock className="inline h-4 w-4 mr-2" />
Hours
</AccordionTrigger>
<AccordionContent>
<HoursDisplay hours={location.hours} />
</AccordionContent>
</AccordionItem>
</Accordion>
)}
<BigActionButton
href={`https://www.google.com/maps/dir/?api=1&destination=${encodeURIComponent(
`${location.address}, ${location.city}, ${location.state}`
)}`}
icon={<Navigation className="h-5 w-5" />}
>
Get Directions
</BigActionButton>
</CardContent>
</Card>
);
}
The BigActionButton component became a pattern - high-contrast, large tap targets for critical mobile actions.
What Went Right
Several decisions paid off:
-
Mapbox Choice: The styling control and performance were worth the learning curve
-
Manual Seeding: Taking time to curate data meant every food bank was accurate
-
Server/Client Split: Clear separation made the codebase maintainable
-
Distance-First: Showing "X miles away" made locations immediately relevant
-
Mobile-First: Building for small screens first ensured good UX everywhere
What I'd Do Differently
Looking back, some early mistakes:
Mistake 1: In-Memory Filtering
Fetching all food banks and filtering in JavaScript was wasteful. Should have used SQL WHERE clauses from day one:
-- Better approach (added later)
SELECT * FROM food_banks
WHERE ST_DWithin(
geom::geography,
ST_MakePoint($1, $2)::geography,
$3 * 1609.34 -- miles to meters
)
ORDER BY ST_Distance(geom::geography, ST_MakePoint($1, $2)::geography)
LIMIT $4;
This PostGIS query (Part 12) made searches 100x faster.
Mistake 2: Public Token Exposure
Putting NEXT_PUBLIC_MAPBOX_TOKEN in client code exposed it to abuse. Should have proxied geocoding requests through API routes.
Mistake 3: No Caching
Every map load fetched food banks from database. A simple Redis cache (or even SWR on client) would have reduced load.
What I Learned
This map implementation taught key lessons:
-
Start Simple: The first version was <200 lines total. It worked. Don't over-engineer upfront.
-
Manual Work Teaches: Hand-seeding data forced me to understand food bank operations deeply.
-
Performance Comes Later: In-memory filtering worked for MVP. Optimize when metrics demand it.
-
Visual Hierarchy Matters: StatusBadge, distance display, and BigActionButton made the interface scannable.
-
Mobile Changes Everything: Desktop map UX patterns don't translate. Test on real phones early.
The User Experience
With the map working, users could:
- See nearby food banks visually
- Check if locations were currently open
- Get one-tap directions
- View services offered
- Access phone numbers and websites
This was functional but not yet magical. The magic would come when we connected the map to AI chat (Part 4), letting users ask "I'm hungry, what's open now?" and get instant, personalized answers.
Up Next
In Part 4, I'll cover building the AI Sous-Chef - integrating OpenRouter, implementing tool calling, and creating a chat experience that could search food banks, generate directions, and provide empathetic guidance.
Key Commits:
e7ac814- Added Sacramento food bank data41149ed- Complete FoodShare branding and save location feature
Related Files:
src/components/map/MapView.tsx- Map rendering componentsrc/components/map/LocationPopup.tsx- Popup UIsrc/lib/geolocation.ts- Distance calculations and hours parsingsrc/lib/food-bank-queries.ts- Database queriesscripts/seed-food-banks.ts- Data seeding script
Jordan Hindo
Full-stack Developer & AI Engineer building in public. Exploring the future of agentic coding and AI-generated assets.
Get in touch