Location-Based Directory Apps: How We Built Spots Mexico
Spots Mexico is a photography location directory. Photographers use it to discover spots for shoots — rooftops with city views, colorful streets, abandoned buildings, golden-hour beaches. Each location has photos, GPS coordinates, access notes, best times to visit, and community ratings.
The platform has thousands of location pages, each optimized for search. It handles map-based browsing, category filtering, user submissions, and location-specific SEO — all while staying fast on mobile, which is where most users access it while they are already out scouting.
Here is how we built it and what we learned about directory applications along the way.
The Location Data Model
Getting the data model right was the first and most important decision. A location directory lives or dies on its data structure. Too rigid and you cannot accommodate the variety of real-world locations. Too flexible and querying becomes a nightmare.
We landed on a model that balances structure with extensibility:
CREATE TABLE locations (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
slug text UNIQUE NOT NULL,
name text NOT NULL,
description text,
-- Geography
city_id uuid REFERENCES cities(id),
state_id uuid REFERENCES states(id),
latitude double precision NOT NULL,
longitude double precision NOT NULL,
address text,
-- Categorization
primary_category_id uuid REFERENCES categories(id),
tags text[] DEFAULT '{}',
-- Photography-specific metadata
best_time_of_day text[], -- ['golden_hour', 'blue_hour', 'midday']
accessibility text, -- 'public', 'permission_required', 'private'
parking_available boolean,
tripod_friendly boolean,
-- Content
cover_image_url text,
gallery_urls text[] DEFAULT '{}',
-- SEO
meta_title text,
meta_description text,
-- Moderation
status text DEFAULT 'pending', -- 'pending', 'approved', 'rejected'
submitted_by uuid REFERENCES auth.users(id),
approved_by uuid REFERENCES auth.users(id),
created_at timestamptz DEFAULT now(),
updated_at timestamptz DEFAULT now()
);
-- Spatial index for geographic queries
CREATE INDEX idx_locations_geo
ON locations USING gist (
ST_SetSRID(ST_MakePoint(longitude, latitude), 4326)
);
-- Text search index
CREATE INDEX idx_locations_search
ON locations USING gin (
to_tsvector('spanish', coalesce(name, '') || ' ' || coalesce(description, ''))
);
A few decisions worth calling out:
We store latitude and longitude as separate columns, not a PostGIS geography type. PostGIS is powerful, but for our use case — finding locations within a bounding box and calculating distances — simple coordinate columns with a GiST index are sufficient and easier to work with in application code.
Tags are a text array, not a separate table. For a directory where tags are used for filtering but not as first-class entities with their own metadata, arrays with GIN indexes are simpler and faster than a many-to-many join table.
Photography-specific fields are columns, not JSON. We tried a JSONB metadata column initially. The query ergonomics were worse, and we lost type safety in the application layer. When you know your domain, use explicit columns.
Map Integration
Map-based browsing is the primary discovery interface. Users pan and zoom the map, and locations appear as pins. Tapping a pin shows a preview card with the location’s cover image, name, and category.
We use Mapbox GL JS for the map renderer. Google Maps is the obvious alternative, but Mapbox gives us more control over map styling (we custom-styled the basemap to match the brand), better clustering performance with large datasets, and more predictable pricing.
The map loads locations dynamically based on the visible bounding box. As the user pans, we query for locations within the new bounds:
// Fetch locations within the current map bounds
async function loadLocationsInBounds(bounds: LngLatBounds) {
const sw = bounds.getSouthWest();
const ne = bounds.getNorthEast();
const { data: locations } = await supabase
.rpc('locations_in_bounds', {
min_lat: sw.lat,
max_lat: ne.lat,
min_lng: sw.lng,
max_lng: ne.lng,
category_filter: selectedCategory,
limit_count: 200,
});
return locations;
}
-- Server-side function for bounding box queries
CREATE OR REPLACE FUNCTION locations_in_bounds(
min_lat double precision,
max_lat double precision,
min_lng double precision,
max_lng double precision,
category_filter uuid DEFAULT NULL,
limit_count integer DEFAULT 200
)
RETURNS SETOF locations AS $$
BEGIN
RETURN QUERY
SELECT l.*
FROM locations l
WHERE l.status = 'approved'
AND l.latitude BETWEEN min_lat AND max_lat
AND l.longitude BETWEEN min_lng AND max_lng
AND (category_filter IS NULL OR l.primary_category_id = category_filter)
ORDER BY l.latitude, l.longitude
LIMIT limit_count;
END;
$$ LANGUAGE plpgsql STABLE;
When zoomed out far enough that individual pins would overlap, we switch to clustering. Mapbox GL JS handles this natively with its supercluster implementation. Clusters show a count badge, and clicking a cluster zooms in to reveal individual locations.
One lesson learned: do not load all locations on initial page load. On the first version, we fetched every approved location and passed them all to the map. With a few hundred locations it was fine. Past two thousand, initial load time became unacceptable. The bounding-box approach with a 200-location cap per viewport solved this.

Category Taxonomy
Spots Mexico uses a two-level category hierarchy:
Urban
├── Rooftops
├── Street Art
├── Architecture
├── Markets
└── Neon Signs
Nature
├── Beaches
├── Cenotes
├── Mountains
├── Waterfalls
└── Deserts
Cultural
├── Churches
├── Ruins
├── Haciendas
├── Museums
└── Cemeteries
Lifestyle
├── Cafes
├── Hotels
├── Restaurants
└── Pools
We deliberately kept it to two levels. Three-level hierarchies create navigation complexity that does not pay off until you have tens of thousands of locations. At our scale, two levels provide enough specificity for filtering without overwhelming the UI.
Each category has its own page (/category/rooftops, /category/cenotes) that serves as both a browsing interface and an SEO landing page. More on that in the SEO section.
Search and Filtering
Search combines full-text search with faceted filtering. Users can type a query (city name, location name, keyword) and narrow results by category, accessibility, best time of day, and tags.
The search API uses PostgreSQL’s full-text search with Spanish language support — critical for a Mexico-focused directory where location names and descriptions are often in Spanish.
// Search API endpoint
export const GET: RequestHandler = async ({ url, locals }) => {
const query = url.searchParams.get('q') || '';
const category = url.searchParams.get('category');
const city = url.searchParams.get('city');
const accessibility = url.searchParams.get('access');
const timeOfDay = url.searchParams.get('time');
const page = parseInt(url.searchParams.get('page') || '1');
const perPage = 24;
let dbQuery = locals.supabase
.from('locations')
.select('*, city:cities(name, slug), category:categories(name, slug)', {
count: 'exact',
})
.eq('status', 'approved')
.range((page - 1) * perPage, page * perPage - 1);
// Full-text search
if (query) {
dbQuery = dbQuery.textSearch('search_vector', query, {
type: 'websearch',
config: 'spanish',
});
}
// Faceted filters
if (category) dbQuery = dbQuery.eq('primary_category_id', category);
if (city) dbQuery = dbQuery.eq('city_id', city);
if (accessibility) dbQuery = dbQuery.eq('accessibility', accessibility);
if (timeOfDay) dbQuery = dbQuery.contains('best_time_of_day', [timeOfDay]);
const { data, count } = await dbQuery;
return json({ locations: data, total: count, page, perPage });
};
We added search suggestions that show city names and popular categories as the user types. This guides users toward queries that will return results instead of dead-end searches.

SEO for Directory Pages
A photography location directory is inherently an SEO play. Photographers search for things like “best rooftop photography spots in Mexico City” or “cenotes near Tulum for photos.” Every location page, category page, and city page is an opportunity to capture that traffic.
We built Spots Mexico on Astro specifically for its static generation capabilities. Every location page is pre-rendered at build time with all its content, images, and structured data baked in. No client-side hydration needed for the content — the page loads fast, search engines get complete HTML, and Core Web Vitals stay green.
Each location page includes structured data using the Place and ImageObject schemas:
// Structured data for location pages
function locationSchema(location: Location) {
return {
'@context': 'https://schema.org',
'@type': 'Place',
name: location.name,
description: location.description,
geo: {
'@type': 'GeoCoordinates',
latitude: location.latitude,
longitude: location.longitude,
},
address: {
'@type': 'PostalAddress',
addressLocality: location.city.name,
addressRegion: location.state.name,
addressCountry: 'MX',
},
image: location.gallery_urls.map((url) => ({
'@type': 'ImageObject',
url,
name: `${location.name} photography`,
})),
aggregateRating: location.avgRating
? {
'@type': 'AggregateRating',
ratingValue: location.avgRating,
reviewCount: location.reviewCount,
}
: undefined,
};
}
The URL structure is designed for both users and search engines:
/spots/mexico-city/rooftops/torre-latinoamericana
^ ^ ^ ^
base city category slug
This creates a natural hierarchy that search engines understand. It also enables breadcrumb navigation and internal linking between city pages, category pages, and individual locations.
We generate city landing pages (/spots/mexico-city) that list all locations in that city grouped by category. Category landing pages (/category/rooftops) list all locations in that category across all cities. Both serve as hub pages that pass link equity to individual location pages.
One optimization that made a measurable difference: generating unique meta descriptions for each location page using a template system instead of truncating the description field.
// Template-based meta descriptions
function generateMetaDescription(location: Location): string {
const templates = [
`Discover ${location.name} in ${location.city.name} — a ${location.category.name.toLowerCase()} photography spot. ${location.accessibility === 'public' ? 'Open to the public.' : 'Permission may be required.'} Best during ${formatTimeOfDay(location.best_time_of_day)}.`,
`${location.name} is a popular photography location in ${location.city.name}, Mexico. Find directions, access info, best times to shoot, and community photos.`,
];
// Pick template that fits under 155 characters
return templates.find((t) => t.length <= 155) || templates[0].slice(0, 152) + '...';
}
User-Submitted Locations
Community submissions are how the directory grows. Photographers know spots that no editorial team would find. We built a submission flow that balances quality with ease of contribution.
The submission form collects:
- Location name and description
- GPS coordinates (auto-detected from the map pin the user places, or extracted from photo EXIF data)
- Category and tags
- At least one photo
- Access notes (public, permission required, private)
- Best time of day for photography
Every submission goes through moderation before appearing on the site. Moderators can approve, reject, or edit submissions. We built a simple moderation queue in the admin panel that shows the submission with its photos on a map.
Photo EXIF extraction was a nice touch that reduced friction. When a user uploads a photo taken at the location, we extract GPS coordinates from the EXIF data and pre-fill the map pin. Most smartphone photos include GPS data, so this works for the majority of submissions.
// Extract GPS from uploaded photo EXIF data
import ExifReader from 'exifreader';
async function extractGpsFromImage(file: File): Promise<{lat: number, lng: number} | null> {
try {
const buffer = await file.arrayBuffer();
const tags = ExifReader.load(buffer);
const lat = tags['GPSLatitude']?.description;
const lng = tags['GPSLongitude']?.description;
const latRef = tags['GPSLatitudeRef']?.value?.[0];
const lngRef = tags['GPSLongitudeRef']?.value?.[0];
if (lat && lng) {
return {
lat: latRef === 'S' ? -parseFloat(lat) : parseFloat(lat),
lng: lngRef === 'W' ? -parseFloat(lng) : parseFloat(lng),
};
}
} catch {
// EXIF extraction failed — not critical
}
return null;
}

Performance on Mobile
Most Spots Mexico users access the site on their phones, often on mobile data while out scouting locations. Performance is not a nice-to-have — it determines whether the site is usable in the field.
Key optimizations:
- Image lazy loading with blur placeholders: Location cover images load as tiny blurred thumbnails (inlined as base64) and swap to full images when scrolled into view.
- Map tiles cached aggressively: Mapbox tiles are cached by the browser’s HTTP cache and the service worker. Once a user has browsed an area, returning to it is instant.
- Minimal JavaScript: Astro’s island architecture means the map component hydrates on the client, but everything else — location content, images, navigation — is pure HTML and CSS.
- Responsive images: We serve different image sizes based on viewport width using
srcset. A phone gets a 400px-wide image. A desktop gets 800px.
The result: location pages load in under 2 seconds on a 3G connection. The map view is heavier because of the Mapbox library, but we defer its load until the user navigates to the map tab.
What We Would Do Differently
If we started Spots Mexico over, two things would change:
We would use a dedicated geocoding service from the start. Early on, we stored raw addresses and geocoded them on demand. This created inconsistencies and slow page builds. We switched to geocoding on submission and storing the result, which is what we should have done from day one.
We would build the admin moderation tools earlier. We underestimated how quickly submissions would pile up once the community started contributing. The moderation queue was an afterthought that became a bottleneck. For any directory app, build moderation tooling in the first sprint.
Directory Apps Are Deceptively Complex
From the outside, a directory app looks simple: a list of things on a map with some filters. In practice, the data model, search quality, SEO strategy, moderation pipeline, and mobile performance all compound into significant complexity.
The good news is that directory apps are incredibly durable. Once a location directory has good data and organic traffic, it compounds. Every new location page is a new search entry point. Every community submission adds content you did not have to create. The flywheel effect is real.
If you are building a location-based directory or any content-heavy platform that needs strong SEO and community features, we have been through the full lifecycle. Reach out at hello@threshline.com.