Search and Autocomplete — Find Campsites Fast

Why Search Matters

A map with hundreds of campsites is overwhelming. Users need to quickly find campsites by name, location, or features. A good search with autocomplete turns a messy map into a usable tool.

Why this matters for your career:

  • Search is the most common feature users interact with
  • Autocomplete dramatically improves UX by reducing typing and preventing errors
  • Full-text search in PostgreSQL/PostGIS enables fast, flexible searching
  • Search implementation is a standard skill for full-stack developers

Search Architecture

User types in search box
        ↓
Debounce (300ms) → Cancel previous request
        ↓
API call to Supabase / search endpoint
        ↓
Full-text search on campsites table
        ↓
Return matching results with scores
        ↓
Display autocomplete dropdown
        ↓
User selects → map flies to campsite

Setting Up Full-Text Search

Create Search Index

-- Add a tsvector column for full-text search
ALTER TABLE campsites
ADD COLUMN search_vector tsvector
GENERATED ALWAYS AS (
  to_tsvector('english',
    coalesce(name, '') || ' ' ||
    coalesce(description, '') || ' ' ||
    coalesce(array_to_string(amenities, ' '), '')
  )
) STORED;

-- Create GIN index for fast full-text search
CREATE INDEX idx_campsites_search ON campsites USING GIN (search_vector);

Search Query

SELECT
  id,
  name,
  description,
  ST_Y(location::geometry) AS latitude,
  ST_X(location::geometry) AS longitude,
  ts_rank(search_vector, plainto_tsquery('english', 'mountain lake')) AS rank
FROM campsites
WHERE search_vector @@ plainto_tsquery('english', 'mountain lake')
ORDER BY rank DESC
LIMIT 10;

Search RPC Function

CREATE OR REPLACE FUNCTION search_campsites(search_query TEXT)
RETURNS TABLE(
  id UUID,
  name TEXT,
  description TEXT,
  latitude DOUBLE PRECISION,
  longitude DOUBLE PRECISION,
  rank DOUBLE PRECISION,
  price_level INTEGER
)
LANGUAGE SQL
STABLE
AS $$
  SELECT
    c.id,
    c.name,
    c.description,
    ST_Y(c.location::geometry) AS latitude,
    ST_X(c.location::geometry) AS longitude,
    ts_rank(c.search_vector, plainto_tsquery('english', search_query)) AS rank,
    c.price_level
  FROM campsites c
  WHERE c.search_vector @@ plainto_tsquery('english', search_query)
  ORDER BY rank DESC
  LIMIT 10;
$$;

Combined Search + Location Query

CREATE OR REPLACE FUNCTION search_campsites_nearby(
  search_query TEXT,
  ref_lat DOUBLE PRECISION,
  ref_lng DOUBLE PRECISION,
  radius_meters DOUBLE PRECISION DEFAULT 50000
)
RETURNS TABLE(
  id UUID,
  name TEXT,
  description TEXT,
  latitude DOUBLE PRECISION,
  longitude DOUBLE PRECISION,
  distance_meters DOUBLE PRECISION,
  rank DOUBLE PRECISION
)
LANGUAGE SQL
STABLE
AS $$
  SELECT
    c.id,
    c.name,
    c.description,
    ST_Y(c.location::geometry) AS latitude,
    ST_X(c.location::geometry) AS longitude,
    ST_Distance(c.location, ST_SetSRID(ST_MakePoint(ref_lng, ref_lat), 4326)) AS distance_meters,
    ts_rank(c.search_vector, plainto_tsquery('english', search_query)) AS rank
  FROM campsites c
  WHERE c.search_vector @@ plainto_tsquery('english', search_query)
    AND ST_DWithin(c.location, ST_SetSRID(ST_MakePoint(ref_lng, ref_lat), 4326), radius_meters)
  ORDER BY rank DESC, distance_meters ASC
  LIMIT 10;
$$;

Frontend Implementation

Search Component

---
// src/components/Search.astro
---

<div class="search-container">
  <input
    type="text"
    id="search-input"
    class="search-input"
    placeholder="Search campsites by name, location, or feature..."
    autocomplete="off"
  />
  <div id="search-results" class="search-results"></div>
</div>

<script>
import { supabase } from '../lib/supabase';
import { flyToCampsite } from '../lib/map';

const searchInput = document.getElementById('search-input');
const searchResults = document.getElementById('search-results');

// Debounce function
function debounce(func, delay) {
  let timeoutId;
  return (...args) => {
    clearTimeout(timeoutId);
    timeoutId = setTimeout(() => func.apply(this, args), delay);
  };
}

async function performSearch(query) {
  if (query.length < 2) {
    searchResults.innerHTML = '';
    searchResults.classList.remove('visible');
    return;
  }

  try {
    const { data, error } = await supabase.rpc('search_campsites', {
      search_query: query
    });

    if (error) throw error;

    if (data.length === 0) {
      searchResults.innerHTML = '<div class="no-results">No campsites found</div>';
    } else {
      searchResults.innerHTML = data.map(site => `
        <div class="search-result-item" data-lat="${site.latitude}" data-lng="${site.longitude}" data-id="${site.id}">
          <div class="result-name">${highlightMatch(site.name, query)}</div>
          <div class="result-meta">
            <span class="result-rank">Relevance: ${(site.rank * 100).toFixed(0)}%</span>
          </div>
        </div>
      `).join('');

      // Add click handlers
      searchResults.querySelectorAll('.search-result-item').forEach(item => {
        item.addEventListener('click', () => {
          const lat = parseFloat(item.dataset.lat);
          const lng = parseFloat(item.dataset.lng);
          flyToCampsite(lat, lng);
          searchResults.classList.remove('visible');
          searchInput.value = item.querySelector('.result-name').textContent;
        });
      });
    }

    searchResults.classList.add('visible');
  } catch (err) {
    console.error('Search failed:', err);
    searchResults.innerHTML = '<div class="search-error">Search failed. Try again.</div>';
  }
}

// Highlight matching text
function highlightMatch(text, query) {
  const regex = new RegExp(`(${query.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')})`, 'gi');
  return text.replace(regex, '<mark>$1</mark>');
}

// Debounced search on input
const debouncedSearch = debounce(performSearch, 300);
searchInput.addEventListener('input', (e) => debouncedSearch(e.target.value));

// Close results on blur
searchInput.addEventListener('blur', () => {
  setTimeout(() => searchResults.classList.remove('visible'), 200);
});

// Open results on focus if there's a query
searchInput.addEventListener('focus', () => {
  if (searchInput.value.length >= 2) {
    searchResults.classList.add('visible');
  }
});

// Keyboard navigation
searchInput.addEventListener('keydown', (e) => {
  const items = searchResults.querySelectorAll('.search-result-item');
  const active = searchResults.querySelector('.active');
  let index = Array.from(items).indexOf(active);

  if (e.key === 'ArrowDown') {
    e.preventDefault();
    index = Math.min(index + 1, items.length - 1);
  } else if (e.key === 'ArrowUp') {
    e.preventDefault();
    index = Math.max(index - 1, 0);
  } else if (e.key === 'Enter' && active) {
    e.preventDefault();
    active.click();
  } else if (e.key === 'Escape') {
    searchResults.classList.remove('visible');
  }

  items.forEach(el => el.classList.remove('active'));
  if (items[index]) items[index].classList.add('active');
});
</script>

<style>
.search-container {
  position: relative;
  width: 100%;
  max-width: 400px;
}

.search-input {
  width: 100%;
  padding: 12px 16px;
  border: 2px solid #e5e7eb;
  border-radius: 8px;
  font-size: 16px;
  outline: none;
  transition: border-color 0.2s;
  box-sizing: border-box;
}

.search-input:focus {
  border-color: #22c55e;
}

.search-results {
  display: none;
  position: absolute;
  top: 100%;
  left: 0;
  right: 0;
  background: white;
  border: 1px solid #e5e7eb;
  border-radius: 0 0 8px 8px;
  box-shadow: 0 4px 12px rgba(0,0,0,0.1);
  max-height: 300px;
  overflow-y: auto;
  z-index: 1000;
}

.search-results.visible {
  display: block;
}

.search-result-item {
  padding: 12px 16px;
  cursor: pointer;
  border-bottom: 1px solid #f3f4f6;
}

.search-result-item:last-child {
  border-bottom: none;
}

.search-result-item:hover,
.search-result-item.active {
  background: #f0fdf4;
}

.result-name {
  font-weight: 500;
  color: #1a1a2e;
}

.result-name mark {
  background: #dcfce7;
  color: #166534;
  padding: 0 2px;
  border-radius: 2px;
}

.result-meta {
  font-size: 12px;
  color: #6b7280;
  margin-top: 2px;
}

.no-results,
.search-error {
  padding: 16px;
  text-align: center;
  color: #6b7280;
}

.search-error {
  color: #ef4444;
}

@media (max-width: 480px) {
  .search-container {
    max-width: 100%;
  }
}
</style>

Geocoding (Address to Coordinates)

For searching by address instead of campsite name:

async function geocodeAddress(query) {
  const response = await fetch(
    `https://nominatim.openstreetmap.org/search?format=json&q=${encodeURIComponent(query)}&limit=5&countrycodes=TW`
  );
  const results = await response.json();

  return results.map(r => ({
    name: r.display_name,
    lat: parseFloat(r.lat),
    lng: parseFloat(r.lon),
    type: r.type
  }));
}

// Use Nominatim with proper attribution
// Note: Max 1 request per second, include app name in User-Agent

Best Practices

| Practice | Reason | |----------|--------| | Debounce search input (300ms) | Reduce API calls while user types | | Minimum 2 characters before searching | Avoid unnecessary results for short queries | | Show results near the search box | Users don't want to move their eyes far | | Highlight matching text | Users see why each result matched | | Support keyboard navigation | Arrow keys + Enter + Escape | | Close results on blur | Don't block the map | | Use full-text search with GIN index | Fast, accurate text matching | | Include relevant fields in search vector | Name + description + amenities | | Limit results to 10 | Don't overwhelm with choices | | Add geocoding for address search | Let users search by location name |

Summary

Search with autocomplete makes your camping map user-friendly and efficient. Set up full-text search with PostgreSQL tsvector and GIN indexes, implement debounced autocomplete with keyboard navigation, and combine text search with location queries for nearby results.

Key takeaways:

  • Add tsvector column with GIN index for full-text search
  • plainto_tsquery('english', query) converts user input to search terms
  • ts_rank() sorts results by relevance
  • Debounce API calls by 300ms to reduce server load
  • Highlight matching text in results (<mark> tag)
  • Support keyboard navigation (up, down, enter, escape)
  • Combine text search + location for "nearby mountain campsites" queries
  • Add geocoding for address-based searches
  • Limit results to 10 for fast rendering
  • Close search results on blur to free the map view

What's Next: Astro Camping Aggregator

The next project chapter combines everything into a complete camping map application — Astro + Leaflet + Supabase + PostGIS + marker clustering + search.

Unlock Full Tutorial

This chapter is paid content. Join the project to unlock over 5000 words of deep analysis, including 10+ god-tier Prompts and real Source Code examples!