Full-Text Search

Patterns for implementing search functionality with PostgreSQL full-text search.

Overview#

Full-text search enables efficient content searching:

  • Natural language queries
  • Relevance ranking
  • Stemming and tokenization
  • PostgreSQL native support

Prerequisites:

  • PostgreSQL database
  • Prisma ORM setup

Implementation#

1// lib/search.ts 2import { prisma } from '@/lib/db' 3 4// PostgreSQL full-text search 5export async function searchPosts(query: string, options?: { 6 limit?: number 7 offset?: number 8}) { 9 const { limit = 10, offset = 0 } = options ?? {} 10 11 // Convert query to tsquery format 12 const searchQuery = query 13 .trim() 14 .split(/\s+/) 15 .map(term => `${term}:*`) 16 .join(' & ') 17 18 const posts = await prisma.$queryRaw` 19 SELECT 20 id, 21 title, 22 content, 23 ts_rank( 24 to_tsvector('english', title || ' ' || content), 25 to_tsquery('english', ${searchQuery}) 26 ) as rank 27 FROM "Post" 28 WHERE to_tsvector('english', title || ' ' || content) @@ to_tsquery('english', ${searchQuery}) 29 ORDER BY rank DESC 30 LIMIT ${limit} 31 OFFSET ${offset} 32 ` 33 34 return posts 35} 36 37// Prisma native search (preview) 38export async function searchWithPrisma(query: string) { 39 return prisma.post.findMany({ 40 where: { 41 OR: [ 42 { title: { search: query } }, 43 { content: { search: query } } 44 ] 45 }, 46 orderBy: { 47 _relevance: { 48 fields: ['title', 'content'], 49 search: query, 50 sort: 'desc' 51 } 52 } 53 }) 54}

Search API Route#

1// app/api/search/route.ts 2import { searchPosts } from '@/lib/search' 3 4export async function GET(request: Request) { 5 const { searchParams } = new URL(request.url) 6 const query = searchParams.get('q') 7 const page = parseInt(searchParams.get('page') ?? '1') 8 const limit = parseInt(searchParams.get('limit') ?? '10') 9 10 if (!query || query.length < 2) { 11 return Response.json({ results: [] }) 12 } 13 14 const offset = (page - 1) * limit 15 16 const results = await searchPosts(query, { limit, offset }) 17 18 return Response.json({ 19 results, 20 page, 21 limit, 22 query 23 }) 24}

Search Input with Debounce#

1// components/SearchInput.tsx 2'use client' 3 4import { useState, useCallback } from 'react' 5import { useDebouncedCallback } from 'use-debounce' 6import { useRouter, useSearchParams } from 'next/navigation' 7 8export function SearchInput() { 9 const router = useRouter() 10 const searchParams = useSearchParams() 11 const [query, setQuery] = useState(searchParams.get('q') ?? '') 12 13 const handleSearch = useDebouncedCallback((term: string) => { 14 const params = new URLSearchParams(searchParams) 15 16 if (term) { 17 params.set('q', term) 18 } else { 19 params.delete('q') 20 } 21 22 router.push(`/search?${params.toString()}`) 23 }, 300) 24 25 return ( 26 <input 27 type="search" 28 value={query} 29 onChange={(e) => { 30 setQuery(e.target.value) 31 handleSearch(e.target.value) 32 }} 33 placeholder="Search..." 34 className="w-full rounded-lg border px-4 py-2" 35 /> 36 ) 37}

Search Results Page#

1// app/search/page.tsx 2import { searchPosts } from '@/lib/search' 3import { SearchInput } from '@/components/SearchInput' 4 5export default async function SearchPage({ 6 searchParams 7}: { 8 searchParams: { q?: string; page?: string } 9}) { 10 const query = searchParams.q ?? '' 11 const page = parseInt(searchParams.page ?? '1') 12 13 const results = query 14 ? await searchPosts(query, { limit: 10, offset: (page - 1) * 10 }) 15 : [] 16 17 return ( 18 <div className="mx-auto max-w-2xl py-8"> 19 <SearchInput /> 20 21 <div className="mt-8"> 22 {query && ( 23 <p className="mb-4 text-gray-600"> 24 Results for "{query}" 25 </p> 26 )} 27 28 {results.length === 0 && query && ( 29 <p className="text-gray-500">No results found</p> 30 )} 31 32 <div className="space-y-4"> 33 {results.map((post: any) => ( 34 <article key={post.id} className="rounded-lg border p-4"> 35 <h2 className="font-semibold">{post.title}</h2> 36 <p className="mt-1 text-gray-600">{post.content?.slice(0, 150)}...</p> 37 </article> 38 ))} 39 </div> 40 </div> 41 </div> 42 ) 43}

Search Index Migration#

1-- migrations/add_search_index.sql 2 3-- Create GIN index for full-text search 4CREATE INDEX IF NOT EXISTS post_search_idx ON "Post" 5USING GIN (to_tsvector('english', title || ' ' || COALESCE(content, ''))); 6 7-- Or create a generated column for the search vector 8ALTER TABLE "Post" ADD COLUMN search_vector tsvector 9GENERATED ALWAYS AS (to_tsvector('english', title || ' ' || COALESCE(content, ''))) STORED; 10 11CREATE INDEX post_search_vector_idx ON "Post" USING GIN (search_vector);

Search Suggestions#

1// app/api/search/suggestions/route.ts 2import { prisma } from '@/lib/db' 3 4export async function GET(request: Request) { 5 const { searchParams } = new URL(request.url) 6 const query = searchParams.get('q') 7 8 if (!query || query.length < 2) { 9 return Response.json({ suggestions: [] }) 10 } 11 12 // Get popular searches matching query 13 const suggestions = await prisma.searchQuery.findMany({ 14 where: { 15 query: { startsWith: query.toLowerCase() } 16 }, 17 orderBy: { count: 'desc' }, 18 take: 5, 19 select: { query: true } 20 }) 21 22 return Response.json({ 23 suggestions: suggestions.map(s => s.query) 24 }) 25}

Highlighting Results#

1// lib/search.ts 2export async function searchWithHighlight(query: string) { 3 const searchQuery = query 4 .trim() 5 .split(/\s+/) 6 .map(term => `${term}:*`) 7 .join(' & ') 8 9 const results = await prisma.$queryRaw` 10 SELECT 11 id, 12 title, 13 ts_headline( 14 'english', 15 content, 16 to_tsquery('english', ${searchQuery}), 17 'StartSel=<mark>, StopSel=</mark>, MaxWords=35, MinWords=15' 18 ) as highlighted_content, 19 ts_rank( 20 to_tsvector('english', title || ' ' || content), 21 to_tsquery('english', ${searchQuery}) 22 ) as rank 23 FROM "Post" 24 WHERE search_vector @@ to_tsquery('english', ${searchQuery}) 25 ORDER BY rank DESC 26 LIMIT 10 27 ` 28 29 return results 30}

Advanced Search Options#

1// lib/search.ts 2interface SearchOptions { 3 query: string 4 filters?: { 5 category?: string 6 author?: string 7 dateFrom?: Date 8 dateTo?: Date 9 } 10 sort?: 'relevance' | 'date' | 'popularity' 11 limit?: number 12 offset?: number 13} 14 15export async function advancedSearch(options: SearchOptions) { 16 const { query, filters, sort = 'relevance', limit = 10, offset = 0 } = options 17 18 const searchQuery = query 19 .trim() 20 .split(/\s+/) 21 .map(term => `${term}:*`) 22 .join(' & ') 23 24 let orderBy = 'rank DESC' 25 if (sort === 'date') orderBy = '"createdAt" DESC' 26 if (sort === 'popularity') orderBy = '"viewCount" DESC' 27 28 const results = await prisma.$queryRaw` 29 SELECT 30 p.id, 31 p.title, 32 p.content, 33 p."createdAt", 34 p."viewCount", 35 u.name as "authorName", 36 c.name as "categoryName", 37 ts_rank(search_vector, to_tsquery('english', ${searchQuery})) as rank 38 FROM "Post" p 39 LEFT JOIN "User" u ON p."authorId" = u.id 40 LEFT JOIN "Category" c ON p."categoryId" = c.id 41 WHERE search_vector @@ to_tsquery('english', ${searchQuery}) 42 ${filters?.category ? Prisma.sql`AND c.slug = ${filters.category}` : Prisma.empty} 43 ${filters?.author ? Prisma.sql`AND u.id = ${filters.author}` : Prisma.empty} 44 ${filters?.dateFrom ? Prisma.sql`AND p."createdAt" >= ${filters.dateFrom}` : Prisma.empty} 45 ${filters?.dateTo ? Prisma.sql`AND p."createdAt" <= ${filters.dateTo}` : Prisma.empty} 46 ORDER BY ${Prisma.raw(orderBy)} 47 LIMIT ${limit} 48 OFFSET ${offset} 49 ` 50 51 return results 52}

Search Analytics#

1// lib/search.ts 2export async function trackSearch(query: string, resultsCount: number) { 3 await prisma.searchQuery.upsert({ 4 where: { query: query.toLowerCase() }, 5 update: { 6 count: { increment: 1 }, 7 lastSearchedAt: new Date() 8 }, 9 create: { 10 query: query.toLowerCase(), 11 count: 1, 12 resultsCount 13 } 14 }) 15} 16 17// Track after search 18const results = await searchPosts(query) 19await trackSearch(query, results.length)

Best Practices#

  1. Create GIN indexes - Essential for full-text search performance
  2. Use generated columns - Pre-compute search vectors
  3. Debounce search input - Reduce unnecessary queries
  4. Implement search suggestions - Improve user experience
  5. Track search queries - Analyze what users are looking for