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
tsvectorcolumn with GIN index for full-text search plainto_tsquery('english', query)converts user input to search termsts_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.