Back to Blog

Directory Website Development: From Concept to Launch

Directory Website Development: From Concept to Launch

Directory websites are one of those deceptively simple product categories. A list of places, some filters, a search bar — how hard could it be? When we built Spots Mexico, a photography location directory for Mexico, we discovered that the answer is “harder than you expect, in ways you do not anticipate.”

The technical challenges of a directory site are not about complex business logic. They are about data quality, search performance, and SEO at scale. Get those right and you have a product that grows organically. Get them wrong and you have a database nobody can find.

The Data Model

A directory is fundamentally a collection of listings with attributes. The data model needs to balance structure (so you can filter and search) with flexibility (so different listing types can have different attributes).

For Spots Mexico, a “spot” is a photography location. Each spot has fixed attributes (name, coordinates, city, state) and variable attributes that depend on the type of location (a beach has tidal information; a building has access hours; a mountain trail has difficulty level).

Here is the core schema:

-- Locations are the primary entity
create table locations (
  id uuid primary key default gen_random_uuid(),
  name text not null,
  slug text unique not null,
  description text not null,
  short_description text not null, -- for cards and meta descriptions
  latitude numeric(10, 7) not null,
  longitude numeric(10, 7) not null,
  address text,

  -- Geographic hierarchy
  state_id uuid references states(id) not null,
  city_id uuid references cities(id),
  neighborhood text,

  -- Classification
  primary_category_id uuid references categories(id) not null,
  tags text[] not null default '{}',

  -- Media
  cover_image_url text not null,
  gallery_urls text[] not null default '{}',

  -- Photography-specific metadata
  best_time_of_day text[] default '{}', -- ['golden_hour', 'blue_hour', 'midday']
  best_seasons text[] default '{}', -- ['spring', 'fall']
  access_type text not null default 'public'
    check (access_type in ('public', 'restricted', 'private', 'paid')),
  difficulty text default 'easy'
    check (difficulty in ('easy', 'moderate', 'difficult')),

  -- Flexible attributes for category-specific data
  attributes jsonb not null default '{}',

  -- SEO and content
  seo_title text,
  seo_description text,

  -- Status
  status text not null default 'draft'
    check (status in ('draft', 'published', 'archived')),
  featured boolean not null default false,
  submitted_by uuid references auth.users(id),
  published_at timestamptz,
  created_at timestamptz not null default now(),
  updated_at timestamptz not null default now()
);

-- Categories with hierarchy
create table categories (
  id uuid primary key default gen_random_uuid(),
  name text not null,
  slug text unique not null,
  parent_id uuid references categories(id),
  description text,
  icon text,
  sort_order integer not null default 0
);

-- States and cities for geographic browsing
create table states (
  id uuid primary key default gen_random_uuid(),
  name text not null,
  slug text unique not null,
  abbreviation text not null,
  location_count integer not null default 0
);

create table cities (
  id uuid primary key default gen_random_uuid(),
  name text not null,
  slug text unique not null,
  state_id uuid references states(id) not null,
  latitude numeric(10, 7),
  longitude numeric(10, 7),
  location_count integer not null default 0
);

The attributes JSONB column is the escape valve. Photography-specific metadata that does not apply to every location type goes here. A beach might have {"tide_info": "low tide best", "sand_type": "white"} while a rooftop might have {"floor": 12, "requires_booking": true}. JSONB lets us query and index these attributes without rigid schema changes every time we add a location type.

-- Index on JSONB for filtered queries
create index idx_locations_attributes on locations using gin (attributes);

-- Query locations that require booking
select * from locations
where attributes @> '{"requires_booking": true}'
  and status = 'published';

Search and Filtering

Search is where directory sites are won or lost. Users need to find relevant listings quickly, and the search experience needs to handle multiple dimensions simultaneously: text query, geographic proximity, category, tags, and custom attributes.

We built the search layer with three tiers:

Tier 1: PostgreSQL full-text search for text queries. For a directory of a few thousand listings, PostgreSQL’s built-in full-text search is more than enough. You do not need Elasticsearch or Algolia unless you are at tens of thousands of listings with complex relevance requirements.

-- Add a search vector column
alter table locations add column search_vector tsvector
  generated always as (
    setweight(to_tsvector('spanish', coalesce(name, '')), 'A') ||
    setweight(to_tsvector('spanish', coalesce(short_description, '')), 'B') ||
    setweight(to_tsvector('spanish', coalesce(description, '')), 'C') ||
    setweight(to_tsvector('spanish', coalesce(array_to_string(tags, ' '), '')), 'B')
  ) stored;

create index idx_locations_search on locations using gin (search_vector);

-- Search query
select id, name, slug, short_description,
  ts_rank(search_vector, plainto_tsquery('spanish', 'playa atardecer')) as rank
from locations
where search_vector @@ plainto_tsquery('spanish', 'playa atardecer')
  and status = 'published'
order by rank desc
limit 20;

Notice we use the spanish text search configuration. This matters. The default English configuration would not stem Spanish words correctly — “playas” would not match “playa.” PostgreSQL ships with language-specific configurations that handle stemming, stop words, and accents correctly.

Tier 2: Geographic proximity search. Users often want to find spots near a specific location. PostGIS makes this straightforward:

-- Enable PostGIS (available on Supabase)
create extension if not exists postgis;

-- Add a geometry column
alter table locations add column geom geometry(Point, 4326)
  generated always as (ST_SetSRID(ST_MakePoint(longitude, latitude), 4326)) stored;

create index idx_locations_geom on locations using gist (geom);

-- Find spots within 25 km of a point
select id, name, slug,
  ST_Distance(geom, ST_SetSRID(ST_MakePoint(-99.1332, 19.4326), 4326)::geography) / 1000 as distance_km
from locations
where ST_DWithin(
  geom,
  ST_SetSRID(ST_MakePoint(-99.1332, 19.4326), 4326)::geography,
  25000 -- meters
)
and status = 'published'
order by distance_km
limit 20;

Tier 3: Faceted filtering. Category, tags, access type, difficulty, best time of day — all of these are filter dimensions. We build the filter query dynamically on the server:

// src/lib/server/search.ts
interface SearchFilters {
  query?: string;
  categorySlug?: string;
  stateSlug?: string;
  citySlug?: string;
  tags?: string[];
  accessType?: string;
  difficulty?: string;
  nearLat?: number;
  nearLng?: number;
  radiusKm?: number;
}

export async function searchLocations(filters: SearchFilters, page = 1, perPage = 20) {
  let query = supabase
    .from('locations')
    .select(`
      id, name, slug, short_description, cover_image_url,
      latitude, longitude, access_type, difficulty, tags,
      categories!inner(name, slug),
      states!inner(name, slug),
      cities(name, slug)
    `)
    .eq('status', 'published')
    .range((page - 1) * perPage, page * perPage - 1);

  if (filters.query) {
    query = query.textSearch('search_vector', filters.query, {
      config: 'spanish',
      type: 'plain',
    });
  }

  if (filters.categorySlug) {
    query = query.eq('categories.slug', filters.categorySlug);
  }

  if (filters.stateSlug) {
    query = query.eq('states.slug', filters.stateSlug);
  }

  if (filters.citySlug) {
    query = query.eq('cities.slug', filters.citySlug);
  }

  if (filters.tags?.length) {
    query = query.overlaps('tags', filters.tags);
  }

  if (filters.accessType) {
    query = query.eq('access_type', filters.accessType);
  }

  if (filters.difficulty) {
    query = query.eq('difficulty', filters.difficulty);
  }

  const { data, count, error } = await query;
  return { locations: data ?? [], total: count ?? 0 };
}

For geographic filtering, we use a Supabase RPC call to the PostGIS function rather than the query builder, since the Supabase client does not have native PostGIS support.

Map with location pins showing search results for nearby places

Map Integration

A directory of physical locations needs a map. We use Mapbox GL JS (via the maplibre-gl open-source fork) for the interactive map component. The key technical decisions:

Cluster markers at low zoom levels. Showing 500 individual markers when zoomed out is unusable. We cluster markers server-side and break them apart as the user zooms in.

Lazy-load map tiles. The map component is heavy (200+ KB). We lazy-load it with Astro’s client:visible directive so it only loads when scrolled into view.

Static map fallback for SEO. Crawlers cannot interact with JavaScript maps. We generate static map images via the Mapbox Static Images API for use as placeholders and Open Graph images.

// src/lib/utils/static-map.ts
export function getStaticMapUrl(lat: number, lng: number, zoom = 14): string {
  const token = import.meta.env.PUBLIC_MAPBOX_TOKEN;
  const marker = `pin-l+e74c3c(${lng},${lat})`;
  return `https://api.mapbox.com/styles/v1/mapbox/outdoors-v12/static/${marker}/${lng},${lat},${zoom}/600x400@2x?access_token=${token}`;
}

SEO for Directory Pages

SEO is arguably the most important technical concern for a directory website. Directories live and die by organic search traffic. If someone searches “photography locations in Oaxaca” and your directory does not appear, it might as well not exist.

We structured the URL hierarchy to create SEO-optimized category and location pages:

/spots                          → All locations
/spots/oaxaca                   → State page (all spots in Oaxaca)
/spots/oaxaca/puerto-escondido  → City page
/spots/oaxaca/puerto-escondido/playa-zicatela → Individual listing
/categories/beaches             → Category page
/categories/beaches/oaxaca      → Category + State intersection

Each level in the hierarchy gets its own page with unique content:

State pages include an overview of the photography scene in that state, a map of all spots, and curated lists (most popular, recently added, editor picks).

City pages include a more focused overview, all spots in the city, and neighborhood breakdowns.

Individual listing pages include the full description, gallery, map, nearby spots, and structured data markup.

The structured data is critical for rich results in Google:

// src/lib/seo/structured-data.ts
export function getLocationSchema(location: Location) {
  return {
    '@context': 'https://schema.org',
    '@type': 'Place',
    name: location.name,
    description: location.short_description,
    geo: {
      '@type': 'GeoCoordinates',
      latitude: location.latitude,
      longitude: location.longitude,
    },
    address: {
      '@type': 'PostalAddress',
      addressRegion: location.state.name,
      addressLocality: location.city?.name,
      addressCountry: 'MX',
    },
    image: location.cover_image_url,
    url: `https://spotsmexico.com/spots/${location.state.slug}/${location.city?.slug}/${location.slug}`,
  };
}

We also generate an XML sitemap dynamically that includes all published locations, state pages, city pages, and category pages. For a directory with hundreds of pages, sitemap submission to Google Search Console is essential for complete crawl coverage.

Business directory listing page with organized category entries

User Submissions and Content Quality

A directory is only as good as its data. We allow users to submit new locations, but every submission goes through a review queue before publishing.

The submission flow:

  1. User fills out a submission form (name, description, location on map, photos, category, tags).
  2. Submission enters a review queue with status “pending.”
  3. A moderator reviews for accuracy, quality of photos, completeness of description, and duplicate checking.
  4. If approved, the listing is published. If rejected, the user gets feedback on what to improve.

Duplicate detection is important. We check for duplicates in two ways:

Name similarity. Using PostgreSQL’s pg_trgm extension for trigram-based fuzzy matching:

create extension if not exists pg_trgm;

-- Find potential duplicates by name
select id, name, similarity(name, 'Playa Zicatela') as sim
from locations
where similarity(name, 'Playa Zicatela') > 0.4
order by sim desc
limit 5;

Geographic proximity. If a submission is within 100 meters of an existing location, it is likely a duplicate:

select id, name
from locations
where ST_DWithin(
  geom,
  ST_SetSRID(ST_MakePoint(-97.0484, 15.8582), 4326)::geography,
  100
);

Both checks run automatically when a submission is created, and potential duplicates are flagged for the moderator.

Content Strategy for Long-Term Growth

The technical foundation is important, but a directory ultimately grows through content.

Seed content aggressively. We launched with 150+ locations that we researched ourselves. A directory with 10 listings does not attract users or search engines. You need critical mass before organic growth kicks in.

Unique descriptions, not templates. Google’s helpful content update penalizes thin, template-generated directory pages. Every location page needs a unique description. If your copy could be swapped with another listing and nobody would notice, it is not good enough.

Blog content for top-of-funnel traffic. Guides like “Best Photography Spots in Mexico City” capture informational search queries and funnel readers into the directory. We covered our approach to content-driven sites in Building Modern Web Apps with Astro.

Search results listing page showing filtered and sorted directory entries

Lessons from Building a Directory

Data quality beats data quantity. 200 well-documented locations outperform 2,000 thin listings with a name and an address. Quality is what keeps users coming back and what Google rewards.

SEO structure is a day-one decision. Your URL hierarchy, page structure, and structured data markup are difficult to change once Google has indexed thousands of pages. Get it right at launch.

Geographic search needs PostGIS. Do not try to implement radius search with raw latitude/longitude math in application code. PostGIS exists for this reason and it is available on Supabase out of the box.

User submissions need moderation. An unmoderated directory quickly fills with spam, duplicates, and low-quality listings that degrade the experience for everyone. Budget for moderation tooling and human review time.

If you are planning a directory website and want to talk through the technical approach, reach out at hello@threshline.com.