Knowledge > Products > PewSearch Directory > Search System
PewSearch Search System
What It Is
The PewSearch search system powers the /directory page -- the primary interface for finding churches. It combines full-text search (FTS), geographic filtering, denomination filtering, theological lens filtering, and map-based discovery into a single unified query pipeline.
All search logic lives in pewsearch/web/src/lib/queries.ts. The directory page (/directory/page.tsx) is a server component that calls these query functions and renders results.
URL Parameters
All search state is encoded in URL parameters for SEO and shareability:
| Parameter | Type | Example | Purpose |
|---|---|---|---|
q | string | ?q=grace+baptist | Free-text search query |
state | string | ?state=TX | Two-letter state code filter |
city | string | ?city=Dallas | City name filter (ILIKE) |
denomination | string | ?denomination=Baptist | Denomination filter |
lens | number | ?lens=3 | Theological lens ID |
page | number | ?page=2 | Pagination (1-indexed) |
persona | string | ?persona=pastor | Analytics tracking only (pastor, leader, visitor, planter) |
claim | boolean | ?claim=true | Show claim CTA banner at top |
lat | number | ?lat=32.7767 | User geolocation latitude |
lng | number | ?lng=-96.7970 | User geolocation longitude |
Parameters can be combined: ?q=community&state=TX&denomination=Baptist&page=2
Core Search Flow
The searchChurches() function is the main entry point. Here is the complete query logic:
pseudocode: searchChurches(params)
INPUT: { q, state, city, denomination, lensId, page, limit }
DEFAULT: page=1, limit=50
// Step 1: Base query with mandatory filters
query = supabase
.from('churches')
.select('id, name, slug, address, city, state, state_code, phone, website,
denomination, rating, reviews_count, photo_url, logo_url,
is_premium, latitude, longitude, description')
.eq('directory_visible', true)
.eq('business_status', 'OPERATIONAL')
// Step 2: Apply optional filters
if state:
query = query.eq('state_code', state)
if city:
query = query.ilike('city', city) // Case-insensitive exact city match
if denomination:
// Denomination uses alias expansion via RPC
aliases = await getDenominationAliases(denomination)
if aliases.length > 0:
query = query.in('denomination', aliases)
else:
query = query.ilike('denomination', `%${denomination}%`)
// Step 3: Text search
if q:
// Try full-text search first (tsvector column)
ftsQuery = query.textSearch('fts', q, { type: 'websearch' })
results = await ftsQuery
if results.length == 0:
// Fallback to ILIKE on name, city, address
results = await query
.or(`name.ilike.%${q}%,city.ilike.%${q}%,address.ilike.%${q}%`)
// Step 4: Theological lens filter (separate path)
if lensId:
// Uses RPC because junction table has too many IDs for .in()
results = await supabase.rpc('search_churches_by_lens', {
p_lens_id: lensId,
p_query: q || null,
p_state: state || null,
p_denomination: denomination || null,
p_limit: limit,
p_offset: (page - 1) * limit
})
// Step 5: Sort order
ORDER BY:
1. photo_url IS NOT NULL DESC // Churches with photos first
2. reviews_count DESC NULLS LAST // Then by review count
3. name ASC // Then alphabetical
// Step 6: Pagination
offset = (page - 1) * limit
query = query.range(offset, offset + limit - 1)
return { churches, totalCount }
FTS Fallback Strategy
The full-text search uses a fts tsvector column on the churches table. This column is auto-maintained by a PostgreSQL trigger that concatenates name, city, state, denomination, and address.
When FTS returns zero results (common for misspellings or partial matches), the system falls back to ILIKE pattern matching:
pseudocode: ftsWithFallback(query, searchText)
// Primary: PostgreSQL websearch FTS
results = query.textSearch('fts', searchText, { type: 'websearch' })
if results.count == 0:
// Fallback: ILIKE on multiple columns
results = query.or(
`name.ilike.%${searchText}%,
city.ilike.%${searchText}%,
address.ilike.%${searchText}%`
)
return results
This ensures typos like "Babtist" or partial names like "Grace Com" still return results.
Key Query Functions
All functions live in pewsearch/web/src/lib/queries.ts:
searchChurches()
Main search function. Accepts all filter parameters, returns paginated results.
getFeaturedChurches()
Returns a curated set of featured churches for the directory home state. Featured churches are excluded from regular pagination to prevent duplicates appearing in both the featured section and the main results grid.
pseudocode: getFeaturedChurches(state)
SELECT * FROM churches
WHERE directory_visible = true
AND business_status = 'OPERATIONAL'
AND is_premium = true
AND (state_code = state OR state IS NULL)
ORDER BY rating DESC, reviews_count DESC
LIMIT 3
getNearbyChurches()
PostGIS-based proximity search for the "Nearby Churches" section on detail pages:
pseudocode: getNearbyChurches(lat, lng, radiusMiles=10, limit=6)
SELECT *, haversine_distance(lat, lng, latitude, longitude) as distance
FROM churches
WHERE directory_visible = true
AND business_status = 'OPERATIONAL'
AND latitude IS NOT NULL
AND longitude IS NOT NULL
AND haversine_distance(lat, lng, latitude, longitude) <= radiusMiles
ORDER BY distance ASC
LIMIT limit
getDenominations()
Returns all denominations grouped by family, with optional state filtering:
pseudocode: getDenominations(state?)
SELECT name, family, church_count
FROM denominations
WHERE (state IS NULL OR church_count > 0 in that state)
ORDER BY family, display_order, name
getStates()
Returns unique state codes with church counts for the state filter dropdown:
pseudocode: getStates()
SELECT state_code, COUNT(*) as count
FROM churches
WHERE directory_visible = true
AND business_status = 'OPERATIONAL'
GROUP BY state_code
ORDER BY state_code
getTheologicalLenses()
Returns all 18 theological lenses for the lens filter:
pseudocode: getTheologicalLenses()
SELECT id, name, description, church_count
FROM sai_theological_lenses
ORDER BY display_order
getTopDenominationsForState()
Returns the most common denominations in a given state (for state landing pages):
pseudocode: getTopDenominationsForState(state, limit=10)
SELECT denomination, COUNT(*) as count
FROM churches
WHERE directory_visible = true
AND business_status = 'OPERATIONAL'
AND state_code = state
AND denomination IS NOT NULL
GROUP BY denomination
ORDER BY count DESC
LIMIT limit
getChurchMapPins()
Returns lightweight data for map display (up to 500 pins):
pseudocode: getChurchMapPins(filters)
SELECT id, name, slug, latitude, longitude, denomination, is_premium
FROM churches
WHERE directory_visible = true
AND business_status = 'OPERATIONAL'
AND latitude IS NOT NULL
AND longitude IS NOT NULL
[+ optional state, city, denomination, lens filters]
LIMIT 500
The 500-pin limit prevents the map from becoming unusable. When more than 500 results exist, only the first 500 (by sort order) are shown as pins.
UI Components
| Component | File | Purpose |
|---|---|---|
SmartSearchBar | SmartSearchBar.tsx | AI-powered NLP search bar with auto-suggestions |
ChurchCard | ChurchCard.tsx | Standard church result card (photo, name, city, denomination, rating) |
FeaturedChurchCard | FeaturedChurchCard.tsx | Larger card for featured/premium churches |
DenominationFilter | DenominationFilter.tsx | Denomination dropdown with family grouping |
ChurchMapWrapper | ChurchMapWrapper.tsx | Leaflet map with church pins (client component) |
NearbyChurchFinder | NearbyChurchFinder.tsx | Geolocation-based "Churches Near Me" widget |
StateFilter | StateFilter.tsx | State dropdown filter |
LensFilter | LensFilter.tsx | Theological lens filter dropdown |
PaginationControls | PaginationControls.tsx | Page navigation (prev/next/page numbers) |
SmartSearchBar -- AI NLP
The SmartSearchBar parses natural language queries into structured filters:
Input: "Baptist churches in Dallas, Texas"
Parsed: { denomination: "Baptist", city: "Dallas", state: "TX" }
Input: "churches near me with wheelchair access"
Parsed: { lat: [browser geolocation], lng: [...], q: "wheelchair access" }
Input: "Reformed Presbyterian"
Parsed: { denomination: "Reformed Presbyterian" }
This parsing happens client-side before the search parameters are sent to the server.
Pagination
| Setting | Value | Notes |
|---|---|---|
| Results per page (query) | 50 | Server-side fetch limit |
| Results per page (display) | 21 | UI grid shows 21 cards (7 rows x 3 columns) |
| Featured exclusion | Yes | Featured churches excluded from main pagination |
| Maximum browsable pages | ~10,000 | 218K / 21 = ~10K pages (practical limit) |
The query fetches 50 results but the UI renders 21. This provides a buffer for client-side filtering (e.g., hiding churches with no photo in certain views) without requiring additional server requests.
Pagination with Supabase .range()
pseudocode: paginate(page, limit=50)
offset = (page - 1) * limit
query = query.range(offset, offset + limit - 1)
// IMPORTANT: Supabase has a 1000-row default limit
// .range() overrides it, but if omitted, max 1000 rows returned
// Always use .range() for paginated queries
Denomination Alias Expansion
When a user filters by denomination, the system expands aliases to catch variant spellings:
pseudocode: getDenominationAliases(denomination)
// RPC: get_denomination_values(p_denomination)
// Returns all variant names that map to the same denomination family
Example: "Baptist" →
["Baptist", "Southern Baptist Convention", "SBC",
"American Baptist Churches USA", "Independent Baptist",
"Missionary Baptist", "Primitive Baptist", "Free Will Baptist",
"National Baptist Convention", "General Baptist", ...]
// Fallback if RPC returns nothing: ILIKE '%denomination%'
This ensures searching for "Baptist" returns all Baptist-affiliated churches, not just those with the exact string "Baptist" in their denomination field.
Map Integration
The map is rendered by ChurchMapWrapper.tsx using Leaflet:
- Server-side:
getChurchMapPins()returns up to 500 lightweight pin objects - Client-side: Leaflet renders pins with clustering for dense areas
- Interaction: Clicking a pin shows a popup with church name, denomination, and link to detail page
- Premium pins: Premium churches get a gold pin icon (vs. blue for free listings)
- Bounds: Map auto-fits to show all current pins; user can pan/zoom freely
Performance Notes
- Server Components: The directory page is a React Server Component. All database queries run server-side.
- No client-side fetching: Filter changes trigger a full page navigation (URL params), not client-side API calls. This ensures SEO for all filter combinations.
- Streaming: Results stream progressively using React Suspense boundaries.
- Caching: Supabase queries are cached at the Next.js fetch cache level with revalidation.
See Also
- PewSearch Directory Overview -- parent document
- Denomination Taxonomy -- how denominations and lenses are structured
- Church Detail Page -- where search results link to
- Data Quality -- why some search results have missing data