ExploreIt Technical Walkthrough

Complete end-to-end pipeline from database to UI

šŸ”„ Data Pipeline Overview

1
Database Schema
docker/init.sql
→
2
Seed Data
scripts/seed-locations.ts
→
3
Wikipedia Scraper
src/lib/wikipedia.ts
→
4
DB Queries
src/lib/db.ts
→
5
API Routes
src/app/api/
→
6
UI Components
src/components/
SECTION 1

Database Schema & PostGIS Setup

šŸ“ docker/init.sql

šŸ” What This Does

This SQL file initializes the PostgreSQL database with:

  • PostGIS extension for geospatial queries and spatial indexing
  • Custom enum type for location categories (museum, park, landmark, etc.)
  • Locations table with comprehensive fields for storing place data
  • Automatic geometry updates via database triggers
  • Multiple indexes for fast searching and filtering
SQL 77 lines
-- Enable PostGIS extension
CREATE EXTENSION IF NOT EXISTS postgis;
CREATE EXTENSION IF NOT EXISTS postgis_topology;

-- Create category enum type
DO $$
BEGIN
    IF NOT EXISTS (SELECT 1 FROM pg_type WHERE typname = 'location_category') THEN
        CREATE TYPE location_category AS ENUM (
            'attraction',
            'landmark',
            'museum',
            'park',
            'restaurant',
            'shopping',
            'entertainment',
            'other'
        );
    END IF;
END $$;

-- Create locations table
CREATE TABLE IF NOT EXISTS locations (
    id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
    name TEXT NOT NULL,
    description TEXT,
    latitude DECIMAL(10, 8) NOT NULL CHECK (latitude >= -90 AND latitude <= 90),
    longitude DECIMAL(11, 8) NOT NULL CHECK (longitude >= -180 AND longitude <= 180),
    category location_category DEFAULT 'other',
    wikipedia_url TEXT,
    created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP,
    updated_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP,
    geom GEOMETRY(Point, 4326)
);

-- Create spatial index on coordinates
CREATE INDEX IF NOT EXISTS idx_locations_geom ON locations USING GIST (geom);

-- Create index on category for filtering
CREATE INDEX IF NOT EXISTS idx_locations_category ON locations (category);

-- Create trigger to automatically update geom column
CREATE OR REPLACE FUNCTION update_location_geom()
RETURNS TRIGGER AS $$
BEGIN
    NEW.geom = ST_SetSRID(ST_MakePoint(NEW.longitude, NEW.latitude), 4326);
    NEW.updated_at = CURRENT_TIMESTAMP;
    RETURN NEW;
END;
$$ LANGUAGE plpgsql;

DROP TRIGGER IF EXISTS trigger_update_location_geom ON locations;
CREATE TRIGGER trigger_update_location_geom
    BEFORE INSERT OR UPDATE ON locations
    FOR EACH ROW
    EXECUTE FUNCTION update_location_geom();

-- Create index for full-text search on name and description
CREATE INDEX IF NOT EXISTS idx_locations_search ON locations 
    USING gin(to_tsvector('english', COALESCE(name, '') || ' ' || COALESCE(description, '')));

-- Create indexes for case-insensitive search (used by LOWER queries)
CREATE INDEX IF NOT EXISTS idx_locations_name_lower ON locations(LOWER(name));
CREATE INDEX IF NOT EXISTS idx_locations_description_lower ON locations(LOWER(description)) WHERE description IS NOT NULL;

-- Create index on wikipedia_url for conflict detection
CREATE INDEX IF NOT EXISTS idx_locations_wikipedia_url ON locations(wikipedia_url) WHERE wikipedia_url IS NOT NULL;

-- Add image_url column for storing Wikipedia thumbnails
ALTER TABLE locations
ADD COLUMN IF NOT EXISTS image_url TEXT;

-- Create index on image_url for efficient null checks
CREATE INDEX IF NOT EXISTS idx_locations_image_url ON locations(image_url) WHERE image_url IS NOT NULL;

-- Add comment for documentation
COMMENT ON COLUMN locations.image_url IS 'Wikipedia thumbnail URL (300px width) for the location';

PostGIS Extension

Enables geospatial capabilities like ST_Distance, ST_DWithin, and spatial indexing with GIST for fast radius searches.

Database Triggers

Automatically calculates geometry from lat/lng and updates the timestamp whenever a row is inserted or modified.

Partial Indexes

Conditional indexes (WHERE clause) reduce index size and improve performance for specific query patterns.

Full-Text Search

GIN index with to_tsvector enables fast text search across name and description fields.

SECTION 2

Database Seeding Script

šŸ“ scripts/seed-locations.ts

šŸ” What This Does

The seeding script populates the database with initial location data for Ghent, Belgium:

  • Idempotent execution - checks if data exists before inserting
  • Transaction safety - uses BEGIN/COMMIT/ROLLBACK for data integrity
  • Hardcoded dataset of 18+ popular Ghent attractions
  • Categorized data - landmarks, museums, parks, entertainment, etc.
TypeScript 244 lines
#!/usr/bin/env tsx

import { Pool } from 'pg';

const pool = new Pool({
  host: process.env.DB_HOST ?? 'localhost',
  port: parseInt(process.env.DB_PORT ?? '5432', 10),
  database: process.env.DB_NAME ?? 'exploreit',
  user: process.env.DB_USER ?? 'exploreit',
  password: process.env.DB_PASSWORD ?? 'exploreit',
});

interface Location {
  name: string;
  description: string;
  latitude: number;
  longitude: number;
  category: string;
  wikipedia_url: string;
}

const ghentLocations: Location[] = [
  // Landmarks
  {
    name: 'Belfry of Ghent',
    description: 'Medieval bell tower and one of three medieval towers in the old city centre of Ghent. It stands 91 meters tall and is a UNESCO World Heritage site.',
    latitude: 51.0536,
    longitude: 3.7256,
    category: 'landmark',
    wikipedia_url: 'https://en.wikipedia.org/wiki/Belfry_of_Ghent',
  },
  {
    name: 'Gravensteen Castle',
    description: 'A medieval castle in Ghent, Belgium. It was built in 1180 by Count Philip of Alsace and served as the seat of the Counts of Flanders until 1353.',
    latitude: 51.0573,
    longitude: 3.7205,
    category: 'landmark',
    wikipedia_url: 'https://en.wikipedia.org/wiki/Gravensteen',
  },
  {
    name: 'St. Bavo Cathedral',
    description: 'A Gothic cathedral in Ghent containing the famous Ghent Altarpiece by Jan van Eyck. Construction began in 942 and continued through the 16th century.',
    latitude: 51.0530,
    longitude: 3.7266,
    category: 'landmark',
    wikipedia_url: 'https://en.wikipedia.org/wiki/St._Bavo_Cathedral',
  },
  {
    name: 'Museum of Fine Arts Ghent',
    description: 'Municipal museum with a collection spanning from the Middle Ages to the mid-20th century, including works by Bosch, Rubens, and Van Gogh.',
    latitude: 51.0380,
    longitude: 3.7247,
    category: 'museum',
    wikipedia_url: 'https://en.wikipedia.org/wiki/Museum_of_Fine_Arts,_Ghent',
  },
  {
    name: 'Design Museum Gent',
    description: 'The only museum in Belgium with an international design collection, housed in an 18th-century mansion with a modern wing by Philippe Samyn.',
    latitude: 51.0567,
    longitude: 3.7237,
    category: 'museum',
    wikipedia_url: 'https://en.wikipedia.org/wiki/Design_Museum_Gent',
  },
  {
    name: 'STAM - Ghent City Museum',
    description: 'The city museum located in the former Bijloke Abbey, telling the story of Ghent from the Middle Ages to the present day.',
    latitude: 51.0442,
    longitude: 3.7308,
    category: 'museum',
    wikipedia_url: 'https://en.wikipedia.org/wiki/STAM_Ghent_City_Museum',
  },
  {
    name: 'Citadel Park',
    description: 'A large urban park built on the site of the old citadel, featuring the Museum of Fine Arts, STAM, and the botanical garden.',
    latitude: 51.0385,
    longitude: 3.7250,
    category: 'park',
    wikipedia_url: 'https://en.wikipedia.org/wiki/Citadel_Park',
  },
  {
    name: 'Ghent University Botanical Garden',
    description: 'A botanical garden housing over 10,000 plant species, featuring tropical greenhouses, a bamboo collection, and an arboretum.',
    latitude: 51.0375,
    longitude: 3.7280,
    category: 'park',
    wikipedia_url: 'https://en.wikipedia.org/wiki/Ghent_University_Botanical_Garden',
  },
  {
    name: 'Ghent University',
    description: 'One of the largest universities in Belgium, founded in 1817. The main building is a prominent landmark with its Neoclassical architecture.',
    latitude: 51.0510,
    longitude: 3.7235,
    category: 'attraction',
    wikipedia_url: 'https://en.wikipedia.org/wiki/Ghent_University',
  },
  {
    name: 'St. Nicholas Church',
    description: 'One of the oldest and most prominent landmarks in Ghent, with Gothic architecture dating from the 13th to 15th centuries.',
    latitude: 51.0539,
    longitude: 3.7235,
    category: 'landmark',
    wikipedia_url: 'https://en.wikipedia.org/wiki/St._Nicholas_Church,_Ghent',
  },
  {
    name: 'Graslei and Korenlei',
    description: 'Historic quays along the Leie river, lined with picturesque medieval guild houses. One of the most photographed spots in Ghent.',
    latitude: 51.0545,
    longitude: 3.7220,
    category: 'attraction',
    wikipedia_url: 'https://en.wikipedia.org/wiki/Graslei',
  },
  {
    name: 'SMAK - Municipal Museum of Contemporary Art',
    description: 'Museum of contemporary art featuring works from the 1950s onwards, including pieces by Andy Warhol, Joseph Beuys, and Panamarenko.',
    latitude: 51.0382,
    longitude: 3.7270,
    category: 'museum',
    wikipedia_url: 'https://en.wikipedia.org/wiki/S.M.A.K.',
  },
  {
    name: 'Vrijdagmarkt',
    description: 'A historic square in the heart of Ghent, surrounded by cafes and restaurants. Known for its Friday market tradition since the 12th century.',
    latitude: 51.0573,
    longitude: 3.7265,
    category: 'attraction',
    wikipedia_url: 'https://en.wikipedia.org/wiki/Vrijdagmarkt_(Ghent)',
  },
  {
    name: 'Patershol',
    description: 'A charming historic neighborhood with narrow cobblestone streets, medieval houses, and a high concentration of restaurants.',
    latitude: 51.0575,
    longitude: 3.7240,
    category: 'attraction',
    wikipedia_url: 'https://en.wikipedia.org/wiki/Patershol',
  },
  {
    name: 'St. Peter Abbey',
    description: 'A former Benedictine abbey founded in the 7th century, now housing a museum, vineyard, and the Caermers monastery.',
    latitude: 51.0535,
    longitude: 3.7285,
    category: 'landmark',
    wikipedia_url: 'https://en.wikipedia.org/wiki/St._Peter_Abbey,_Ghent',
  },
  // Entertainment
  {
    name: 'Vooruit Arts Centre',
    description: 'A historic arts centre housed in a monumental complex built in 1913, featuring concerts, theatre, dance performances, and cultural events.',
    latitude: 51.0475,
    longitude: 3.7275,
    category: 'entertainment',
    wikipedia_url: 'https://en.wikipedia.org/wiki/Vooruit',
  },
  {
    name: 'Kinepolis Ghent',
    description: 'A modern multiplex cinema showing the latest international films, located near the city center with multiple screens and premium viewing experiences.',
    latitude: 51.0420,
    longitude: 3.7320,
    category: 'entertainment',
    wikipedia_url: 'https://en.wikipedia.org/wiki/Kinepolis',
  },
  // Other category
  {
    name: 'Ghent Railway Station',
    description: 'The main railway station of Ghent, a beautiful building with Neo-Renaissance architecture, connecting the city to Brussels and other major Belgian cities.',
    latitude: 51.0360,
    longitude: 3.7110,
    category: 'other',
    wikipedia_url: 'https://en.wikipedia.org/wiki/Ghent-Sint-Pieters_railway_station',
  },
];

async function seedLocations(): Promise<void> {
  const client = await pool.connect();

  try {
    await client.query('BEGIN');

    const countResult = await client.query(
      'SELECT COUNT(*) as count FROM locations'
    );
    const count = parseInt(countResult.rows[0].count, 10);

    if (count > 0) {
      console.log(`Database already contains ${count} locations. Skipping seed.`);
      await client.query('ROLLBACK');
      return;
    }

    console.log('Seeding locations database with Ghent attractions...\n');

    const insertQuery = `
      INSERT INTO locations (
        name,
        description,
        latitude,
        longitude,
        category,
        wikipedia_url
      ) VALUES ($1, $2, $3, $4, $5::location_category, $6)
      RETURNING id, name, category
    `;

    const inserted: Array<{ id: string; name: string; category: string }> = [];

    for (const location of ghentLocations) {
      const result = await client.query(insertQuery, [
        location.name,
        location.description,
        location.latitude,
        location.longitude,
        location.category,
        location.wikipedia_url,
      ]);
      inserted.push(result.rows[0]);
      console.log(`  āœ“ Added: ${location.name} (${location.category})`);
    }

    await client.query('COMMIT');

    console.log(`\nāœ… Successfully seeded ${inserted.length} locations`);

    const verifyResult = await pool.query(
      'SELECT category, COUNT(*) as count FROM locations GROUP BY category ORDER BY count DESC'
    );

    console.log('\nšŸ“Š Locations by category:');
    for (const row of verifyResult.rows) {
      console.log(`  ${row.category}: ${row.count}`);
    }

  } catch (error) {
    await client.query('ROLLBACK');
    console.error('āŒ Error seeding locations:', error);
    throw error;
  } finally {
    client.release();
    await pool.end();
  }
}

seedLocations().catch((error) => {
  console.error('Failed to seed locations:', error);
  process.exit(1);
});

tsx Shebang

Uses tsx to run TypeScript directly without pre-compilation. Execute with ./scripts/seed-locations.ts

Transaction Safety

BEGIN/COMMIT/ROLLBACK ensures all inserts succeed or none do. Prevents partial data corruption.

Idempotent Design

Checks row count before seeding. Safe to run multiple times without duplicating data.

Category Casting

Uses ::location_category cast to ensure values match the database enum type.

SECTION 3

Wikipedia API Integration

šŸ“ src/lib/wikipedia.ts

šŸ” What This Does

A comprehensive Wikipedia scraper that fetches location data from Wikipedia's API:

  • GeoSearch API - finds Wikipedia pages near Ghent coordinates
  • Multi-endpoint fetching - coordinates, extracts, categories, images
  • Batch processing - groups requests to minimize API calls
  • Retry logic with exponential backoff - handles rate limits gracefully
  • Category mapping - converts Wikipedia categories to app categories
TypeScript 549 lines (excerpt)
import { Pool, PoolClient } from 'pg';

const WIKIPEDIA_API_URL = 'https://en.wikipedia.org/w/api.php';
const GHENT_LATITUDE = 51.0543;
const GHENT_LONGITUDE = 3.7174;
const SEARCH_RADIUS_METERS = 10000;

// === Type Definitions ===
interface LocationData {
  readonly name: string;
  readonly description: string;
  readonly latitude: number;
  readonly longitude: number;
  readonly category: string;
  readonly wikipediaUrl: string;
  readonly imageUrl: string | null;
}

interface WikipediaScraperConfig {
  readonly dbPool: Pool;
  readonly delayBetweenRequestsMs: number;
  readonly maxRetries: number;
  readonly batchSize: number;
}

// === Custom Error Classes ===
class WikipediaAPIError extends Error {
  constructor(
    message: string,
    public readonly statusCode?: number,
    public readonly responseBody?: string
  ) {
    super(message);
    this.name = 'WikipediaAPIError';
  }
}

class DatabaseError extends Error {
  constructor(message: string, public readonly originalError: Error) {
    super(message);
    this.name = 'DatabaseError';
  }
}

// === Retry Logic with Exponential Backoff ===
const fetchWithRetry = async (
  url: string,
  maxRetries: number,
  retryDelayMs: number
): Promise<Response> => {
  let lastError: Error | undefined;

  for (let attempt = 0; attempt <= maxRetries; attempt++) {
    try {
      const response = await fetch(url, {
        headers: {
          'User-Agent': 'ExploreIt/1.0 (exploreit@example.com)',
          'Accept': 'application/json',
        },
      });

      if (response.ok) {
        return response;
      }

      const errorBody = await response.text();
      throw new WikipediaAPIError(
        `HTTP error! status: ${response.status}`,
        response.status,
        errorBody
      );
    } catch (error) {
      lastError = error instanceof Error ? error : new Error(String(error));

      if (attempt < maxRetries) {
        // Exponential backoff: 1s, 2s, 4s
        await sleep(retryDelayMs * Math.pow(2, attempt));
      }
    }
  }

  throw lastError ?? new Error('Unknown error during fetch');
};

// === URL Builders for Different API Endpoints ===
const buildGeoSearchUrl = (latitude: number, longitude: number, radius: number): string => {
  const params = new URLSearchParams({
    action: 'query',
    format: 'json',
    list: 'geosearch',
    gscoord: `${latitude}|${longitude}`,
    gsradius: String(radius),
    gslimit: '50',
    origin: '*',
  });
  return `${WIKIPEDIA_API_URL}?${params.toString()}`;
};

const buildExtractsUrl = (pageIds: ReadonlyArray<number>): string => {
  const params = new URLSearchParams({
    action: 'query',
    format: 'json',
    pageids: pageIds.join('|'),
    prop: 'extracts',
    exintro: 'true',
    exlimit: 'max',
    explaintext: 'true',
    origin: '*',
  });
  return `${WIKIPEDIA_API_URL}?${params.toString()}`;
};

const buildImagesUrl = (pageIds: ReadonlyArray<number>): string => {
  const params = new URLSearchParams({
    action: 'query',
    format: 'json',
    pageids: pageIds.join('|'),
    prop: 'pageimages',
    pithumbsize: '300',
    pilimit: 'max',
    origin: '*',
  });
  return `${WIKIPEDIA_API_URL}?${params.toString()}`;
};

// === Category Mapping ===
const mapCategoryToLocationCategory = (
  categories: ReadonlyArray<string>
): string => {
  const categoryMap: Record<string, string> = {
    'Museums in Ghent': 'museum',
    'Museums': 'museum',
    'Parks': 'park',
    'Parks in Ghent': 'park',
    'Gardens': 'park',
    'Restaurants': 'restaurant',
    'Shopping': 'shopping',
    'Entertainment': 'entertainment',
    'Theatres': 'entertainment',
    'Cinemas': 'entertainment',
    'Tourist attractions': 'attraction',
    'Visitor attractions': 'attraction',
    'Landmarks': 'landmark',
    'Monuments and memorials': 'landmark',
    'Buildings and structures': 'landmark',
  };

  for (const category of categories) {
    for (const [key, value] of Object.entries(categoryMap)) {
      if (category.toLowerCase().includes(key.toLowerCase())) {
        return value;
      }
    }
  }
  return 'other';
};

// === Database Operations ===
const saveLocationToDatabase = async (
  client: PoolClient,
  location: LocationData
): Promise<void> => {
  const query = `
    INSERT INTO locations (name, description, latitude, longitude, 
      category, wikipedia_url, image_url)
    VALUES ($1, $2, $3, $4, $5::location_category, $6, $7)
    ON CONFLICT (wikipedia_url) DO UPDATE SET
      name = EXCLUDED.name,
      description = EXCLUDED.description,
      latitude = EXCLUDED.latitude,
      longitude = EXCLUDED.longitude,
      category = EXCLUDED.category,
      image_url = EXCLUDED.image_url,
      updated_at = CURRENT_TIMESTAMP
  `;

  const values = [
    location.name,
    location.description,
    location.latitude,
    location.longitude,
    location.category,
    location.wikipediaUrl,
    location.imageUrl,
  ];

  try {
    await client.query(query, values);
  } catch (error) {
    throw new DatabaseError(
      `Failed to save location: ${location.name}`,
      error instanceof Error ? error : new Error(String(error))
    );
  }
};

// === Batch Processing ===
const processBatch = async (
  geoResults: ReadonlyArray<GeoSearchResult>,
  config: WikipediaScraperConfig
): Promise<BatchResult> => {
  const pageIds = geoResults.map((result) => result.pageid);

  // Fetch all data in parallel
  const [pagesWithCoords, extracts, categories, images] = await Promise.all([
    fetchCoordinates(pageIds, config),
    fetchExtracts(pageIds, config),
    fetchCategories(pageIds, config),
    fetchImages(pageIds, config),
  ]);

  // Create lookup maps for O(1) access
  const coordsMap = new Map(
    pagesWithCoords.map((page) => [
      page.pageid,
      page.coordinates?.[0] ?? { lat: 0, lon: 0 },
    ])
  );
  const extractsMap = new Map(
    extracts.map((extract) => [extract.pageid, extract.extract ?? ''])
  );
  const categoriesMap = new Map(
    categories.map((cat) => [cat.pageid, cat.categories])
  );
  const imagesMap = new Map(
    images.map((img) => [img.pageid, img.imageUrl])
  );

  const client = await config.dbPool.connect();
  const errors: Array<Error> = [];
  let savedCount = 0;

  try {
    for (const result of geoResults) {
      const coords = coordsMap.get(result.pageid);
      const extract = extractsMap.get(result.pageid) ?? '';
      const pageCategories = categoriesMap.get(result.pageid) ?? [];
      const imageUrl = imagesMap.get(result.pageid) ?? null;

      const location: LocationData = {
        name: result.title,
        description: extract,
        latitude: coords?.lat ?? result.lat,
        longitude: coords?.lon ?? result.lon,
        category: mapCategoryToLocationCategory(pageCategories),
        wikipediaUrl: buildWikipediaUrl(result.title),
        imageUrl,
      };

      try {
        await client.query('BEGIN');
        await saveLocationToDatabase(client, location);
        await client.query('COMMIT');
        savedCount++;
      } catch (error) {
        await client.query('ROLLBACK');
        errors.push(new Error(`Failed to save location "${result.title}"`));
      }
    }

    return { savedCount, errors };
  } finally {
    client.release();
  }
};

// === Main Export ===
export const scrapeWikipediaLocations = async (
  config: WikipediaScraperConfig
): Promise<{ readonly totalLocations: number; readonly errors: ReadonlyArray<Error> }> => {
  const errors: Array<Error> = [];

  try {
    const geoResults = await fetchLocationsNearGhent(config);

    if (geoResults.length === 0) {
      return { totalLocations: 0, errors: [] };
    }

    let totalSaved = 0;

    for (let i = 0; i < geoResults.length; i += config.batchSize) {
      const batch = geoResults.slice(i, i + config.batchSize);
      const batchResult = await processBatch(batch, config);
      totalSaved += batchResult.savedCount;
      errors.push(...batchResult.errors);

      // Delay between batches to respect rate limits
      if (i + config.batchSize < geoResults.length) {
        await sleep(config.delayBetweenRequestsMs);
      }
    }

    return { totalLocations: totalSaved, errors };
  } catch (error) {
    const errorMessage =
      error instanceof Error ? error.message : String(error);
    errors.push(new Error(`Failed to scrape Wikipedia: ${errorMessage}`));
    return { totalLocations: 0, errors };
  }
};

Exponential Backoff

retryDelayMs * Math.pow(2, attempt) creates delays of 1s, 2s, 4s between retries to handle API rate limits.

Parallel Fetching

Promise.all fetches coordinates, extracts, categories, and images simultaneously for better performance.

Upsert Pattern

ON CONFLICT (wikipedia_url) DO UPDATE enables idempotent inserts - updates existing records instead of failing.

Map-based Lookup

Converts arrays to Maps for O(1) access when correlating data from different API responses.

SECTION 4

Database Query Layer

šŸ“ src/lib/db.ts

šŸ” What This Does

The database abstraction layer provides typed, validated queries using Zod:

  • Connection pooling - reuses database connections efficiently
  • Zod schema validation - validates all database responses at runtime
  • PostGIS geospatial queries - ST_Distance and ST_DWithin for location searches
  • Row transformation - converts database types to application types
TypeScript 247 lines (excerpt)
import { Pool, PoolConfig, QueryResult } from 'pg';
import { z, type ZodIssue } from 'zod';
import {
  LocationWithDistanceSchema,
  type Location,
  type NewLocation,
  type LocationWithDistance,
} from '@/types/schema';
import { validateLocation, validateNewLocation, ValidationError } from '@/lib/validation';

// === Zod Schema for Database Rows ===
const LocationRowSchema = z.object({
  id: z.union([z.string(), z.number()]),
  name: z.string(),
  category: z.enum(['landmark', 'museum', 'park', 'entertainment', 'other']),
  latitude: z.union([z.number(), z.string()]),
  longitude: z.union([z.number(), z.string()]),
  description: z.string().nullable(),
  address: z.string().nullable(),
  image_url: z.string().nullable(),
  created_at: z.union([z.date(), z.string()]),
  updated_at: z.union([z.date(), z.string()]),
});

const LocationWithDistanceRowSchema = LocationRowSchema.extend({
  distance: z.number(),
});

// === Connection Pool Configuration ===
const poolConfig: PoolConfig = {
  host: process.env.DB_HOST ?? 'localhost',
  port: parseInt(process.env.DB_PORT ?? '5432', 10),
  database: process.env.DB_NAME ?? 'exploreit',
  user: process.env.DB_USER ?? 'postgres',
  password: process.env.DB_PASSWORD ?? '',
  max: parseInt(process.env.DB_POOL_SIZE ?? '20', 10),
  idleTimeoutMillis: 30000,
  connectionTimeoutMillis: 2000,
};

const pool: Pool = new Pool(poolConfig);

pool.on('error', (err: Error) => {
  console.error('Unexpected error on idle client', err);
  // Pool auto-recovers - no need to kill the process
});

// === Row Transformation ===
function transformRowToLocation(row: unknown): Location {
  const parsed = LocationRowSchema.parse(row);

  // Handle string latitude/longitude from database
  const latitude = typeof parsed.latitude === 'string' 
    ? parseFloat(parsed.latitude) 
    : parsed.latitude;
  const longitude = typeof parsed.longitude === 'string' 
    ? parseFloat(parsed.longitude) 
    : parsed.longitude;

  const location = {
    id: String(parsed.id),
    name: parsed.name,
    category: parsed.category,
    latitude,
    longitude,
    description: parsed.description,
    address: parsed.address,
    image_url: parsed.image_url,
    created_at: parseDate(parsed.created_at),
    updated_at: parseDate(parsed.updated_at),
  };

  return validateLocation(location);
}

// === Geospatial Query: Find Locations Within Radius ===
export async function getLocationsWithinRadius(
  latitude: number,
  longitude: number,
  radiusInMeters: number
): Promise<LocationWithDistance[]> {
  const query = `
    SELECT 
      id,
      name,
      category,
      latitude,
      longitude,
      description,
      wikipedia_url as address,
      image_url,
      created_at,
      updated_at,
      ST_Distance(
        ST_SetSRID(ST_MakePoint(longitude, latitude), 4326)::geography,
        ST_SetSRID(ST_MakePoint($2, $1), 4326)::geography
      ) as distance
    FROM locations
    WHERE ST_DWithin(
      ST_SetSRID(ST_MakePoint(longitude, latitude), 4326)::geography,
      ST_SetSRID(ST_MakePoint($2, $1), 4326)::geography,
      $3
    )
    ORDER BY distance;
  `;

  const result = await pool.query(query, [
    latitude,
    longitude,
    radiusInMeters,
  ]);

  return result.rows.map(transformRowToLocationWithDistance);
}

// === Category Filter Query ===
export async function getLocationsByCategory(category: string): Promise<Location[]> {
  const query = `
    SELECT 
      id, name, category, latitude, longitude,
      description, wikipedia_url as address, image_url,
      created_at, updated_at
    FROM locations
    WHERE category = $1
    ORDER BY name;
  `;

  const result = await pool.query(query, [category]);
  return result.rows.map(transformRowToLocation);
}

// === Insert New Location ===
export async function insertLocation(location: NewLocation): Promise<Location> {
  const validatedLocation = validateNewLocation(location);

  const query = `
    INSERT INTO locations (
      name, category, latitude, longitude,
      description, wikipedia_url, image_url
    ) VALUES ($1, $2, $3, $4, $5, $6, $7)
    RETURNING 
      id, name, category, latitude, longitude,
      description, wikipedia_url as address, image_url,
      created_at, updated_at;
  `;

  const values = [
    validatedLocation.name,
    validatedLocation.category,
    validatedLocation.latitude,
    validatedLocation.longitude,
    validatedLocation.description ?? null,
    validatedLocation.address ?? null,
    validatedLocation.image_url ?? null,
  ];

  const result = await pool.query(query, values);

  if (result.rows.length === 0) {
    throw new Error('Failed to insert location');
  }

  return transformRowToLocation(result.rows[0]);
}

// === Single Location Lookup ===
export async function getLocationById(id: string | number): Promise<Location | null> {
  const query = `
    SELECT 
      id, name, category, latitude, longitude,
      description, wikipedia_url as address, image_url,
      created_at, updated_at
    FROM locations
    WHERE id = $1;
  `;

  const result = await pool.query(query, [id]);

  if (result.rows.length === 0) {
    return null;
  }

  return transformRowToLocation(result.rows[0]);
}

export async function closePool(): Promise<void> {
  await pool.end();
}

export { pool };

ST_Distance Geography

Casts geometry to geography for accurate distance calculations in meters (spherical model).

ST_DWithin

Uses spatial index for efficient radius queries - only checks points within bounding box first.

SRID 4326

WGS 84 coordinate system used by GPS. Required for proper geospatial calculations.

Zod Validation

Every database row is validated at runtime. Catches schema mismatches between code and DB.

SECTION 5

Next.js API Routes

šŸ“ src/app/api/locations/route.ts

šŸ” What This Does

Next.js App Router API endpoint for location queries:

  • Parameter validation - validates lat, lng, radius, category from URL
  • Type-safe responses - uses Zod schemas for response validation
  • Error handling - distinguishes validation vs database errors
  • Category filtering - optional client-side filter after DB query
TypeScript 132 lines
import { NextRequest, NextResponse } from 'next/server';
import { z } from 'zod';
import { getLocationsWithinRadius } from '@/lib/db';
import { ApiLocationResponseSchema, type ApiLocationResponse } from '@/types/schema';
import {
  validateRadiusSearchFromUrl,
  createValidationErrorResponse,
  ValidationError,
} from '@/lib/validation';

// === Response Schema Definitions ===
const ApiSuccessResponseSchema = <T extends z.ZodType>(dataSchema: T) =>
  z.object({
    success: z.literal(true),
    data: dataSchema,
  });

const ApiErrorResponseSchema = z.object({
  success: z.literal(false),
  error: z.string(),
});

const LocationResponseSchema = z.object({
  id: z.string(),
  name: z.string(),
  category: z.string(),
  latitude: z.number(),
  longitude: z.number(),
  description: z.string().nullable(),
  address: z.string().nullable(),
  image_url: z.string().nullable(),
  created_at: z.union([z.string(), z.date()]),
  updated_at: z.union([z.string(), z.date()]),
  distance: z.number(),
});

const SuccessResponseSchema = ApiSuccessResponseSchema(z.array(LocationResponseSchema));
const ErrorResponseSchema = ApiErrorResponseSchema;

type ApiResponse = z.infer<typeof SuccessResponseSchema> | z.infer<typeof ErrorResponseSchema>;

// === Data Transformation ===
function mapLocationToResponse(location: {
  id: string;
  name: string;
  category: string;
  latitude: number;
  longitude: number;
  description: string | null;
  address: string | null;
  image_url: string | null;
  created_at: Date;
  updated_at: Date;
  distance: number;
}): z.infer<typeof LocationResponseSchema> {
  return {
    id: location.id,
    name: location.name,
    category: location.category,
    latitude: location.latitude,
    longitude: location.longitude,
    description: location.description,
    address: location.address,
    image_url: location.image_url,
    created_at: location.created_at.toISOString(),
    updated_at: location.updated_at.toISOString(),
    distance: location.distance,
  };
}

// === Main Handler ===
export async function GET(request: NextRequest): Promise<NextResponse<ApiResponse>> {
  try {
    const url = new URL(request.url);

    // Validate query parameters
    const params = validateRadiusSearchFromUrl(url);
    const { lat, lng, radius, category } = params;

    // Query database
    let locations: Array<{
      id: string;
      name: string;
      category: string;
      latitude: number;
      longitude: number;
      description: string | null;
      address: string | null;
      image_url: string | null;
      created_at: Date;
      updated_at: Date;
      distance: number;
    }>;
    
    try {
      locations = await getLocationsWithinRadius(lat, lng, radius);
    } catch (error) {
      console.error('Database query failed:', error);
      const errorResponse = {
        success: false as const,
        error: 'Failed to query database',
      };
      return NextResponse.json(errorResponse, { status: 500 });
    }

    // Optional category filter
    let filteredLocations = locations;
    if (category !== undefined) {
      filteredLocations = locations.filter(
        (location) => location.category.toLowerCase() === category.toLowerCase()
      );
    }

    // Transform and validate response
    const responseData = filteredLocations.map(mapLocationToResponse);
    const validatedData = z.array(LocationResponseSchema).parse(responseData);

    const successResponse = {
      success: true as const,
      data: validatedData,
    };

    return NextResponse.json(successResponse);
  } catch (error) {
    console.error('Unexpected error:', error);

    // Handle validation errors specifically
    if (error instanceof ValidationError) {
      const errorResponse = createValidationErrorResponse(error);
      return NextResponse.json(errorResponse, { status: 400 });
    }

    const errorResponse = {
      success: false as const,
      error: 'Internal server error',
    };
    return NextResponse.json(errorResponse, { status: 500 });
  }
}

Schema-First API

Define request/response shapes with Zod first, then implement handlers. Prevents drift.

Discriminated Unions

success: true/false discriminant enables TypeScript to narrow response types correctly.

Client-Side Filtering

Category filter applied after DB query because PostGIS geography doesn't use B-tree index.

Date Serialization

Explicit toISOString() conversion ensures JSON serialization works correctly.

SECTION 6

Validation Layer

šŸ“ src/lib/validation.ts

šŸ” What This Does

Centralized validation logic with custom error handling:

  • Custom ValidationError class - carries Zod issues for detailed error messages
  • URL parameter parsing - extracts and validates query string parameters
  • Type guards - safe validation that returns success/failure without throwing
  • Error response helpers - consistent error formatting for API responses
TypeScript 237 lines (excerpt)
import { z, type ZodIssue } from 'zod';
import {
  LocationSchema,
  NewLocationSchema,
  CoordinatesSchema,
  RadiusSearchParamsSchema,
  TextSearchParamsSchema,
  type Location,
  type NewLocation,
  type Coordinates,
  type RadiusSearchParams,
  type TextSearchParams,
  type ApiResponse,
} from '@/types/schema';

// === Custom Error Class ===
export class ValidationError extends Error {
  public readonly issues: readonly z.ZodIssue[];

  constructor(message: string, issues: readonly z.ZodIssue[]) {
    super(message);
    this.name = 'ValidationError';
    this.issues = issues;
  }
}

// === Type-Safe Validators ===
export function validateLocation(data: unknown): Location {
  const result = LocationSchema.safeParse(data);

  if (!result.success) {
    throw new ValidationError(
      `Location validation failed: ${result.error.issues.map((e: ZodIssue) => e.message).join(', ')}`,
      result.error.issues
    );
  }

  return result.data;
}

export function validateNewLocation(data: unknown): NewLocation {
  const result = NewLocationSchema.safeParse(data);

  if (!result.success) {
    throw new ValidationError(
      `New location validation failed: ${result.error.issues.map((e: ZodIssue) => e.message).join(', ')}`,
      result.error.issues
    );
  }

  return result.data;
}

export function validateCoordinates(latitude: unknown, longitude: unknown): Coordinates {
  const result = CoordinatesSchema.safeParse({ latitude, longitude });

  if (!result.success) {
    throw new ValidationError(
      `Coordinates validation failed: ${result.error.issues.map((e: ZodIssue) => e.message).join(', ')}`,
      result.error.issues
    );
  }

  return result.data;
}

// === URL Parameter Validation ===
export function validateRadiusSearchFromUrl(url: URL): RadiusSearchParams {
  const latParam = url.searchParams.get('lat');
  const lngParam = url.searchParams.get('lng');
  const radiusParam = url.searchParams.get('radius');
  const categoryParam = url.searchParams.get('category');

  if (latParam === null || lngParam === null || radiusParam === null) {
    throw new ValidationError('Missing required parameters: lat, lng, radius', []);
  }

  const lat = parseFloat(latParam);
  const lng = parseFloat(lngParam);
  const radius = parseInt(radiusParam, 10);

  const params: Record<string, number | string> = { lat, lng, radius };

  if (categoryParam !== null) {
    params.category = categoryParam;
  }

  return validateRadiusSearchParams(params);
}

export function validateTextSearchFromUrl(url: URL): TextSearchParams {
  const queryParam = url.searchParams.get('query');

  if (queryParam === null) {
    throw new ValidationError('Missing required parameter: query', []);
  }

  return validateTextSearchParams({ query: queryParam });
}

// === Error Response Helpers ===
export function createValidationErrorResponse(error: unknown): ApiResponse<never> {
  if (error instanceof ValidationError) {
    return {
      success: false,
      error: error.message,
    };
  }

  if (error instanceof z.ZodError) {
    return {
      success: false,
      error: `Validation failed: ${error.issues.map((e: z.ZodIssue) => e.message).join(', ')}`,
    };
  }

  if (error instanceof Error) {
    return {
      success: false,
      error: error.message,
    };
  }

  return {
    success: false,
    error: 'Unknown validation error',
  };
}

// === Safe Validation (No Throw) ===
export function safeValidate<T>(
  validator: (data: unknown) => T,
  data: unknown
): { success: true; data: T } | { success: false; error: string } {
  try {
    const validated = validator(data);
    return { success: true, data: validated };
  } catch (error) {
    if (error instanceof ValidationError) {
      return { success: false, error: error.message };
    }
    if (error instanceof Error) {
      return { success: false, error: error.message };
    }
    return { success: false, error: 'Unknown error' };
  }
}

safeParse vs parse

safeParse returns {success, data/error} instead of throwing. Better for control flow.

Custom Error Class

Carries ZodIssue array for detailed field-level error reporting in APIs.

Type Predicates

instanceof checks narrow TypeScript types for proper error handling branches.

URLSearchParams

Native Web API for parsing query strings. Works in both Node.js and browsers.

SECTION 7

Type Definitions & Zod Schemas

šŸ“ src/types/schema.ts (excerpt)

šŸ” What This Does

Single source of truth for all data shapes in the application:

  • Zod schemas - runtime validation that mirrors TypeScript types
  • Type inference - derive TypeScript types from Zod schemas
  • Validation constants - centralized min/max values
  • API response types - consistent response shapes
TypeScript 253 lines (excerpt)
import { z } from 'zod';

// === Validation Constants ===
const LATITUDE_MIN = -90;
const LATITUDE_MAX = 90;
const LONGITUDE_MIN = -180;
const LONGITUDE_MAX = 180;
const MIN_SEARCH_RADIUS = 1;
const MAX_SEARCH_RADIUS = 50000;
const MIN_QUERY_LENGTH = 1;
const MAX_QUERY_LENGTH = 200;
const MAX_DESCRIPTION_LENGTH = 5000;
const MAX_NAME_LENGTH = 200;
const MAX_ADDRESS_LENGTH = 500;
const MAX_IMAGE_URL_LENGTH = 1000;

// === Category Enum ===
export const CategoryEnum = z.enum([
  'landmark',
  'museum',
  'park',
  'entertainment',
  'other',
]);

export const CategorySchema = CategoryEnum;
export const CategoriesArraySchema = z.array(CategoryEnum);

// === Coordinates Schema ===
export const CoordinatesSchema = z.object({
  latitude: z
    .number()
    .min(LATITUDE_MIN, `Latitude must be at least ${LATITUDE_MIN}`)
    .max(LATITUDE_MAX, `Latitude must be at most ${LATITUDE_MAX}`),
  longitude: z
    .number()
    .min(LONGITUDE_MIN, `Longitude must be at least ${LONGITUDE_MIN}`)
    .max(LONGITUDE_MAX, `Longitude must be at most ${LONGITUDE_MAX}`),
});

// === Location Schema ===
export const LocationBaseSchema = z.object({
  id: z.string(),
  name: z.string()
    .min(1, 'Name is required')
    .max(MAX_NAME_LENGTH, `Name must be at most ${MAX_NAME_LENGTH} characters`),
  category: CategoryEnum,
  latitude: z.number()
    .min(LATITUDE_MIN)
    .max(LATITUDE_MAX),
  longitude: z.number()
    .min(LONGITUDE_MIN)
    .max(LONGITUDE_MAX),
  description: z.string()
    .max(MAX_DESCRIPTION_LENGTH)
    .nullable(),
  address: z.string()
    .max(MAX_ADDRESS_LENGTH)
    .nullable(),
  image_url: z.string()
    .url('Image URL must be a valid URL')
    .max(MAX_IMAGE_URL_LENGTH)
    .nullable(),
});

export const LocationTimestampsSchema = z.object({
  created_at: z.date(),
  updated_at: z.date(),
});

export const LocationSchema = LocationBaseSchema.merge(LocationTimestampsSchema);

export const LocationWithDistanceSchema = LocationSchema.extend({
  distance: z.number().min(0, 'Distance must be non-negative'),
});

// === New Location Schema (for inserts) ===
export const NewLocationSchema = z.object({
  name: z.string().min(1).max(MAX_NAME_LENGTH),
  category: CategoryEnum,
  latitude: z.number().min(LATITUDE_MIN).max(LATITUDE_MAX),
  longitude: z.number().min(LONGITUDE_MIN).max(LONGITUDE_MAX),
  description: z.string().max(MAX_DESCRIPTION_LENGTH).optional(),
  address: z.string().max(MAX_ADDRESS_LENGTH).optional(),
  image_url: z.string().url().max(MAX_IMAGE_URL_LENGTH).optional(),
});

// === Search Parameter Schemas ===
export const RadiusSearchParamsSchema = z.object({
  lat: z.number().min(LATITUDE_MIN).max(LATITUDE_MAX),
  lng: z.number().min(LONGITUDE_MIN).max(LONGITUDE_MAX),
  radius: z.number().min(MIN_SEARCH_RADIUS).max(MAX_SEARCH_RADIUS),
  category: CategoryEnum.optional(),
});

export const TextSearchParamsSchema = z.object({
  query: z.string().min(MIN_QUERY_LENGTH).max(MAX_QUERY_LENGTH),
});

// === API Response Schemas ===
export const ApiLocationResponseSchema = z.object({
  id: z.string(),
  name: z.string(),
  category: z.string(),
  latitude: z.number(),
  longitude: z.number(),
  description: z.string().nullable(),
  address: z.string().nullable(),
  image_url: z.string().nullable(),
  created_at: z.union([z.date(), z.string()]),
  updated_at: z.union([z.date(), z.string()]),
  distance: z.number().optional(),
});

export const ApiSuccessResponseSchema = <T extends z.ZodType>(dataSchema: T) =>
  z.object({
    success: z.literal(true),
    data: dataSchema,
  });

export const ApiErrorResponseSchema = z.object({
  success: z.literal(false),
  error: z.string(),
});

// === Type Exports (inferred from schemas) ===
export type Category = z.infer<typeof CategoryEnum>;
export type Coordinates = z.infer<typeof CoordinatesSchema>;
export type Location = z.infer<typeof LocationSchema>;
export type LocationWithDistance = z.infer<typeof LocationWithDistanceSchema>;
export type NewLocation = z.infer<typeof NewLocationSchema>;
export type RadiusSearchParams = z.infer<typeof RadiusSearchParamsSchema>;
export type TextSearchParams = z.infer<typeof TextSearchParamsSchema>;
export type ApiLocationResponse = z.infer<typeof ApiLocationResponseSchema>;

export type ApiSuccessResponse<T> = {
  success: true;
  data: T;
};

export type ApiErrorResponse = z.infer<typeof ApiErrorResponseSchema>;

export type ApiResponse<T> = ApiSuccessResponse<T> | ApiErrorResponse;

Schema = Type

Single source of truth. Zod validates at runtime, TypeScript checks at compile time.

z.infer

Derives TypeScript types from Zod schemas. Never duplicate type definitions again.

.merge() & .extend()

Compose schemas from smaller pieces. DRY principle for validation logic.

Discriminated Unions

success: boolean discriminant for safe response type narrowing.

SECTION 9

Lazy Image Component

šŸ“ src/components/LazyImage.tsx

šŸ” What This Does

Optimized image loading with fallbacks and loading states:

  • Lazy loading - images load only when entering viewport
  • Skeleton/shimmer loading - visual feedback while image loads
  • Category fallbacks - SVG icons when images fail to load
  • Zod prop validation - validates all props at runtime
TypeScript/React 141 lines
"use client";

import React, { useState, useCallback, memo } from "react";
import { motion, AnimatePresence } from "framer-motion";
import Image from "next/image";
import { z } from "zod";

// === Prop Validation Schema ===
const LazyImagePropsSchema = z.object({
  src: z.string().url().optional().nullable(),
  alt: z.string().min(1),
  placeholder: z.string().optional(),
  className: z.string().optional(),
  aspectRatio: z.enum(["video", "square", "portrait", "auto"]).optional(),
  priority: z.boolean().optional(),
  fill: z.boolean().optional(),
  width: z.number().optional(),
  height: z.number().optional(),
  sizes: z.string().optional(),
  objectFit: z.enum(["cover", "contain", "fill", "none", "scale-down"]).optional(),
});

type LazyImageProps = z.infer<typeof LazyImagePropsSchema>;

// === CSS Class Maps ===
const aspectRatioClasses: Record<string, string> = {
  video: "aspect-video",
  square: "aspect-square",
  portrait: "aspect-[3/4]",
  auto: "",
};

const objectFitClasses: Record<string, string> = {
  cover: "object-cover",
  contain: "object-contain",
  fill: "object-fill",
  none: "object-none",
  "scale-down": "object-scale-down",
};

// === Category Icons (SVG) ===
const categoryIcons: Record<string, string> = {
  landmark: `<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" 
    stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" 
    stroke-width="2" d="M19 21V5a2 2 0 00-2-2H7a2 2 0 00-2 2v16m14 0h2m-2 0h-5m-9 0H3m2 0h5M9 7h1m-1 4h1m4-4h1m-1 4h1m-5 10v-5a1 1 0 011-1h2a1 1 0 011 1v5m-4 0h4"/></svg>`,
  museum: `<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" 
    stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" 
    stroke-width="2" d="M8 14v3m4-3v3m4-3v3M3 21h18M3 10h18M3 7l9-4 9 4M4 10h16v11H4V10z"/></svg>`,
  park: `<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" 
    stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" 
    stroke-width="2" d="M3.055 11H5a2 2 0 012 2v1a2 2 0 002 2 2 2 0 012 2v2.945M8 3.935V5.5A2.5 2.5..."/></svg>`,
  nature: `<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" 
    stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" 
    stroke-width="2" d="M5 3v4M3 5h4M6 17v4m-2-2h4m5-16l2.286 6.857L21 12l-5.714 2.143L13 21l-2.286-6.857L5 12l5.714-2.143L13 3z"/></svg>`,
  restaurant: `<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" 
    stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" 
    stroke-width="2" d="M12 6.253v13m0-13C10.832 5.477 9.246 5 7.5 5S4.168 5.477 3 6.253v13C4.168 18.477 5.754 18 7.5 18s3.332.477 4.5 1.253m0-13C13.168 5.477 14.754 5 16.5 5c1.747 0 3.332.477 4.5 1.253v13C19.832 18.477 18.247 18 16.5 18c-1.746 0-3.332.477-4.5 1.253"/></svg>`,
};

const defaultIcon = `<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" 
  stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" 
  stroke-width="2" d="M17.657 16.657L13.414 20.9a1.998 1.998 0 01-2.827 0l-4.244-4.243a8 8 0 1111.314 0z"/>
  <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 11a3 3 0 11-6 0 3 3 0 016 0z"/></svg>`;

// === Category Fallback Component ===
interface CategoryFallbackProps {
  category?: string;
}

const CategoryFallback = memo(function CategoryFallback({ category }: CategoryFallbackProps): React.JSX.Element {
  const iconSvg = category && categoryIcons[category.toLowerCase()]
    ? categoryIcons[category.toLowerCase()]
    : defaultIcon;

  return (
    <div className="absolute inset-0 flex items-center justify-center 
      bg-gradient-to-br from-gray-100 to-gray-200 dark:from-gray-800 dark:to-gray-900">
      <div
        className="w-16 h-16 text-gray-400 dark:text-gray-600"
        dangerouslySetInnerHTML={{ __html: iconSvg }}
      />
    </div>
  );
});

// === Main Component ===
export const LazyImage = memo(function LazyImage(props: LazyImageProps): React.JSX.Element {
  // Validate props at runtime
  const validatedProps = LazyImagePropsSchema.parse(props);
  const {
    src,
    alt,
    className = "",
    aspectRatio = "video",
    priority = false,
    fill = false,
    width,
    height,
    sizes = "(max-width: 768px) 100vw, (max-width: 1200px) 50vw, 33vw",
    objectFit = "cover",
  } = validatedProps;

  const [isLoading, setIsLoading] = useState(true);
  const [hasError, setHasError] = useState(false);

  const handleLoad = useCallback((): void => {
    setIsLoading(false);
  }, []);

  const handleError = useCallback((): void => {
    setIsLoading(false);
    setHasError(true);
  }, []);

  const containerClasses = `relative overflow-hidden ${aspectRatioClasses[aspectRatio]} ${className}`;
  const imageClasses = `transition-opacity duration-500 ${objectFitClasses[objectFit]} ${isLoading ? "opacity-0" : "opacity-100"}`;

  const showFallback = !src || hasError;

  return (
    <div className={containerClasses}>
      <AnimatePresence>
        {isLoading && !showFallback && (
          <motion.div
            initial={{ opacity: 1 }}
            exit={{ opacity: 0 }}
            transition={{ duration: 0.3 }}
            className="absolute inset-0 bg-gray-200 dark:bg-gray-800 animate-pulse"
          >
            <div className="absolute inset-0 bg-gradient-to-r from-transparent 
              via-white/20 to-transparent animate-shimmer" />
          </motion.div>
        )}
      </AnimatePresence>

      {showFallback ? (
        <CategoryFallback category={alt.split(" ")[0].toLowerCase()} />
      ) : (
        <Image
          src={src}
          alt={alt}
          fill={fill || (!width && !height)}
          width={!fill ? width : undefined}
          height={!fill ? height : undefined}
          sizes={sizes}
          className={imageClasses}
          loading={priority ? "eager" : "lazy"}
          priority={priority}
          onLoad={handleLoad}
          onError={handleError}
        />
      )}
    </div>
  );
});

export default LazyImage;

memo()

Prevents re-renders when parent updates but props stay the same. Critical for lists.

Next.js Image

Automatic optimization, WebP conversion, responsive sizes, and blur placeholder.

dangerouslySetInnerHTML

Required for SVG icons. Safe here because content is hardcoded, not user input.

AnimatePresence

Framer Motion component that animates elements when they leave the React tree.

SUMMARY

Complete Architecture Overview

End-to-End Data Flow

Data Sources

Wikipedia API
Seed Script
Manual Entry

Database Layer

PostgreSQL
PostGIS
Spatial Indexes

Backend Layer

Next.js API Routes
Zod Validation
pg Pool

Frontend Layer

React Components
Zod Validation
Leaflet Maps
1

Wikipedia Scraping

The scraper calls Wikipedia's geosearch API to find pages near Ghent, then fetches coordinates, extracts, categories, and images in parallel batches.

2

Data Persistence

Location data is validated and inserted into PostgreSQL. PostGIS automatically calculates geometry from lat/lng via database triggers.

3

API Request

User searches trigger API calls. Parameters are validated with Zod, then PostGIS ST_DWithin queries find locations within radius.

4

Response Validation

Database rows are transformed and validated against Zod schemas before being sent to the client. Errors return structured responses.

5

Client Rendering

React components validate API responses with Zod, then render maps with Leaflet and images with Next.js Image component.