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#
PostgreSQL Full-Text Search#
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`` : Prisma.empty}
43 ${filters?.author ? Prisma.sql`` : Prisma.empty}
44 ${filters?.dateFrom ? Prisma.sql`` : Prisma.empty}
45 ${filters?.dateTo ? Prisma.sql`` : 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#
- Create GIN indexes - Essential for full-text search performance
- Use generated columns - Pre-compute search vectors
- Debounce search input - Reduce unnecessary queries
- Implement search suggestions - Improve user experience
- Track search queries - Analyze what users are looking for