We use cookies to enhance your experience on the site
CodeWorlds

Implementing Search in an Application

Effective search is a key element of most web applications. In this module, we will learn various search implementation techniques in Next.js applications, both on the server and client side.

Types of Search in Web Applications

Before we proceed to implementation, it's worth understanding the different types of search that can be applied in an application:

  1. Simple search - basic data filtering by specific criteria
  2. Full-text search - advanced text search with logical operator support
  3. Faceted search - filtering using categories and ranges
  4. Search with suggestions - real-time query autocomplete
  5. Error-correcting search - tolerance for typos and spelling mistakes

Server-Side Search Implementation

Server-side search is usually more efficient for large datasets because we can leverage database indexes and don't need to send all data to the client.

1. Search Using Server Components

1// app/products/page.tsx
2import { prisma } from '@/lib/prisma';
3import { ProductCard } from '@/components/ProductCard';
4import { SearchForm } from './SearchForm';
5
6export default async function ProductsPage({
7  searchParams
8}: {
9  searchParams: { q?: string; category?: string }
10}) {
11  // Get search parameters from URL
12  const query = searchParams.q || '';
13  const category = searchParams.category;
14  
15  // Build search conditions
16  const where: any = {};
17  
18  if (query) {
19    where.OR = [
20      { name: { contains: query, mode: 'insensitive' } },
21      { description: { contains: query, mode: 'insensitive' } },
22    ];
23  }
24  
25  if (category) {
26    where.categoryId = category;
27  }
28  
29  // Fetch data from database
30  const products = await prisma.product.findMany({
31    where,
32    include: {
33      category: true,
34    },
35    orderBy: {
36      createdAt: 'desc',
37    },
38  });
39  
40  // Fetch all categories (for filters)
41  const categories = await prisma.category.findMany({
42    orderBy: {
43      name: 'asc',
44    },
45  });
46  
47  return (
48    <div className="container mx-auto py-8">
49      <h1 className="text-3xl font-bold mb-6">Products</h1>
50      
51      {/* Formularz wyszukiwania */}
52      <SearchForm 
53        initialQuery={query} 
54        initialCategory={category}
55        categories={categories}
56      />
57      
58      {/* Number of results */}
59      <p className="mb-4">
60        {products.length} products found
61        {query && ` matching "${query}"`}
62        {category && ` in selected category`}
63      </p>
64      
65      {/* Product list */}
66      {products.length > 0 ? (
67        <div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
68          {products.map(product => (
69            <ProductCard key={product.id} product={product} />
70          ))}
71        </div>
72      ) : (
73        <p className="text-gray-500">
74          No products found. Try a different search term.
75        </p>
76      )}
77    </div>
78  );
79}

2. Search Form Component

1// app/products/SearchForm.tsx
2'use client';
3
4import { useRouter } from 'next/navigation';
5import { useState } from 'react';
6
7export function SearchForm({ 
8  initialQuery = '', 
9  initialCategory = '',
10  categories = []
11}) {
12  const router = useRouter();
13  const [query, setQuery] = useState(initialQuery);
14  const [category, setCategory] = useState(initialCategory);
15  
16  const handleSubmit = (e) => {
17    e.preventDefault();
18    
19    // Build URL parameters
20    const params = new URLSearchParams();
21    if (query) params.set('q', query);
22    if (category) params.set('category', category);
23    
24    // Navigate to new URL with search parameters
25    router.push(`/products?${params.toString()}`);
26  };
27  
28  const handleReset = () => {
29    setQuery('');
30    setCategory('');
31    router.push('/products');
32  };
33  
34  return (
35    <form onSubmit={handleSubmit} className="mb-8 p-4 bg-gray-50 rounded-lg">
36      <div className="flex flex-wrap gap-4">
37        <div className="flex-grow">
38          <label htmlFor="search" className="block mb-1 font-medium">
39            Search
40          </label>
41          <input
42            type="search"
43            id="search"
44            value={query}
45            onChange={(e) => setQuery(e.target.value)}
46            className="w-full p-2 border rounded"
47          />
48        </div>
49        
50        <div className="w-full md:w-auto md:min-w-[200px]">
51          <label htmlFor="category" className="block mb-1 font-medium">
52            Category
53          </label>
54          <select
55            id="category"
56            value={category}
57            onChange={(e) => setCategory(e.target.value)}
58            className="w-full p-2 border rounded"
59          >
60            <option value="">All Categories</option>
61            {categories.map((cat) => (
62              <option key={cat.id} value={cat.id}>
63                {cat.name}
64              </option>
65            ))}
66          </select>
67        </div>
68      </div>
69      
70      <div className="mt-4 flex gap-2">
71        <button
72          type="submit"
73          className="px-4 py-2 bg-blue-500 text-white rounded hover:bg-blue-600"
74        >
75          Search
76        </button>
77        <button
78          type="button"
79          onClick={handleReset}
80          className="px-4 py-2 bg-gray-200 rounded hover:bg-gray-300"
81        >
82          Reset
83        </button>
84      </div>
85    </form>
86  );
87}

3. Search Implementation with Pagination

For larger datasets, it's worth adding result pagination:

1// app/products/page.tsx
2export default async function ProductsPage({
3  searchParams
4}: {
5  searchParams: { q?: string; category?: string; page?: string }
6}) {
7  const query = searchParams.q || '';
8  const category = searchParams.category;
9  const page = Number(searchParams.page) || 1;
10  const itemsPerPage = 12;
11  
12  // Search conditions as before
13  const where = { /* ... */ };
14  
15  // Fetch data with pagination
16  const products = await prisma.product.findMany({
17    where,
18    include: {
19      category: true,
20    },
21    orderBy: {
22      createdAt: 'desc',
23    },
24    skip: (page - 1) * itemsPerPage,
25    take: itemsPerPage,
26  });
27  
28  // Get total result count for pagination
29  const totalCount = await prisma.product.count({ where });
30  const totalPages = Math.ceil(totalCount / itemsPerPage);
31  
32  return (
33    <div>
34      {/* ... */}
35      
36      {/* Product list */}
37      <div className="grid grid-cols-1 md:grid-cols-3 gap-6 mb-8">
38        {products.map(product => (
39          <ProductCard key={product.id} product={product} />
40        ))}
41      </div>
42      
43      {/* Pagination */}
44      <Pagination 
45        currentPage={page} 
46        totalPages={totalPages}
47        query={query}
48        category={category}
49      />
50    </div>
51  );
52}
53
54// app/products/Pagination.tsx
55'use client';
56
57import Link from 'next/link';
58
59export function Pagination({ 
60  currentPage, 
61  totalPages,
62  query,
63  category
64}) {
65  // Generate URL for a given page
66  const getPageUrl = (page) => {
67    const params = new URLSearchParams();
68    if (query) params.set('q', query);
69    if (category) params.set('category', category);
70    params.set('page', String(page));
71    
72    return `/products?${params.toString()}`;
73  };
74  
75  // Generate array of pages to display
76  const getPageNumbers = () => {
77    const pages = [];
78    const maxVisiblePages = 5;
79    
80    if (totalPages <= maxVisiblePages) {
81      // If there are few pages, show all
82      for (let i = 1; i <= totalPages; i++) {
83        pages.push(i);
84      }
85    } else {
86      // Always show the first page
87      pages.push(1);
88      
89      // Show ellipses or pages around the current one
90      if (currentPage <= 3) {
91        pages.push(2, 3, 4, '...');
92      } else if (currentPage >= totalPages - 2) {
93        pages.push('...', totalPages - 3, totalPages - 2, totalPages - 1);
94      } else {
95        pages.push(
96          '...',
97          currentPage - 1,
98          currentPage,
99          currentPage + 1,
100          '...'
101        );
102      }
103      
104      // Always show the last page
105      if (totalPages > 1) {
106        pages.push(totalPages);
107      }
108    }
109    
110    return pages;
111  };
112  
113  if (totalPages <= 1) return null;
114  
115  return (
116    <div className="flex justify-center mt-8">
117      <nav className="flex items-center gap-1">
118        {/* "Previous" button */}
119        {currentPage > 1 && (
120          <Link
121            href={getPageUrl(currentPage - 1)}
122            className="px-3 py-2 border rounded hover:bg-gray-100"
123          >
124            &laquo; Previous
125          </Link>
126        )}
127        
128        {/* Page numbers */}
129        {getPageNumbers().map((page, index) => (
130          <React.Fragment key={index}>
131            {page === '...' ? (
132              <span className="px-3 py-2">...</span>
133            ) : (
134              <Link
135                href={getPageUrl(page)}
136                className={`px-3 py-2 border rounded ${
137                  currentPage === page
138                    ? 'bg-blue-500 text-white'
139                    : 'hover:bg-gray-100'
140                }`}
141              >
142                {page}
143              </Link>
144            )}
145          </React.Fragment>
146        ))}
147        
148        {/* "Next" button */}
149        {currentPage < totalPages && (
150          <Link
151            href={getPageUrl(currentPage + 1)}
152            className="px-3 py-2 border rounded hover:bg-gray-100"
153          >
154            Next &raquo;
155          </Link>
156        )}
157      </nav>
158    </div>
159  );
160}

4. Faceted Search (with Filters)

Faceted search, with multiple filters, is useful for e-commerce sites and other applications with large datasets:

1// app/products/page.tsx
2export default async function ProductsPage({
3  searchParams
4}: {
5  searchParams: { 
6    q?: string; 
7    category?: string;
8    minPrice?: string;
9    maxPrice?: string;
10    brand?: string;
11    sort?: string;
12    page?: string;
13  }
14}) {
15  const query = searchParams.q || '';
16  const category = searchParams.category;
17  const minPrice = Number(searchParams.minPrice) || undefined;
18  const maxPrice = Number(searchParams.maxPrice) || undefined;
19  const brand = searchParams.brand;
20  const sortBy = searchParams.sort || 'createdAt_desc';
21  const page = Number(searchParams.page) || 1;
22  const itemsPerPage = 12;
23  
24  // Build search conditions
25  const where: any = {};
26  
27  if (query) {
28    where.OR = [
29      { name: { contains: query, mode: 'insensitive' } },
30      { description: { contains: query, mode: 'insensitive' } },
31    ];
32  }
33  
34  if (category) {
35    where.categoryId = category;
36  }
37  
38  if (brand) {
39    where.brand = brand;
40  }
41  
42  // Filter by price
43  if (minPrice !== undefined || maxPrice !== undefined) {
44    where.price = {};
45    
46    if (minPrice !== undefined) {
47      where.price.gte = minPrice;
48    }
49    
50    if (maxPrice !== undefined) {
51      where.price.lte = maxPrice;
52    }
53  }
54  
55  // Parse sorting
56  const [sortField, sortOrder] = sortBy.split('_');
57  const orderBy = {
58    [sortField]: sortOrder,
59  };
60  
61  // Fetch data with pagination
62  const products = await prisma.product.findMany({
63    where,
64    include: {
65      category: true,
66    },
67    orderBy,
68    skip: (page - 1) * itemsPerPage,
69    take: itemsPerPage,
70  });
71  
72  // Get total result count
73  const totalCount = await prisma.product.count({ where });
74  
75  // Fetch aggregations for faceted filters
76  const aggregations = await Promise.all([
77    // Number of products in each category
78    prisma.category.findMany({
79      select: {
80        id: true,
81        name: true,
82        _count: {
83          select: {
84            products: {
85              where: { ...where, categoryId: undefined },
86            },
87          },
88        },
89      },
90      orderBy: {
91        name: 'asc',
92      },
93    }),
94    
95    // Available brands
96    prisma.product.groupBy({
97      by: ['brand'],
98      where: { ...where, brand: undefined },
99      _count: {
100        _all: true,
101      },
102      orderBy: {
103        brand: 'asc',
104      },
105    }),
106    
107    // Price range
108    prisma.product.aggregate({
109      where: { ...where, price: undefined },
110      _min: {
111        price: true,
112      },
113      _max: {
114        price: true,
115      },
116    }),
117  ]);
118  
119  const [categories, brands, priceRange] = aggregations;
120  
121  return (
122    <div className="container mx-auto py-8 px-4">
123      <h1 className="text-3xl font-bold mb-6">Products</h1>
124      
125      <div className="flex flex-col md:flex-row gap-8">
126        {/* Filter bar */}
127        <div className="w-full md:w-1/4">
128          <FacetFilters
129            categories={categories}
130            brands={brands.filter(b => b.brand)}
131            priceRange={priceRange}
132            currentFilters={searchParams}
133          />
134        </div>
135        
136        {/* Results */}
137        <div className="w-full md:w-3/4">
138          {/* Sorting bar */}
139          <div className="flex justify-between items-center mb-4">
140            <p>{totalCount} products found</p>
141            <SortSelect 
142              currentSort={sortBy}
143              query={searchParams}
144            />
145          </div>
146          
147          {/* Product list */}
148          <div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6 mb-8">
149            {products.map(product => (
150              <ProductCard key={product.id} product={product} />
151            ))}
152          </div>
153          
154          {/* Pagination */}
155          <Pagination 
156            currentPage={page} 
157            totalPages={Math.ceil(totalCount / itemsPerPage)}
158            searchParams={searchParams}
159          />
160        </div>
161      </div>
162    </div>
163  );
164}
165
166// Implementation of FacetFilters and SortSelect components
167//

5. Utilizing Full-Text Search Indexes

For more advanced search scenarios, it's worth utilizing full-text indexes offered by databases like PostgreSQL:

1// Adding a migration creating a full-text index in Prisma
2
3// prisma/migrations/YYYYMMDDHHMMSS_add_full_text_search/migration.sql
4-- Create a GIN index for full-text search in PostgreSQL
5CREATE EXTENSION IF NOT EXISTS pg_trgm;
6
7ALTER TABLE "Product" ADD COLUMN "tsv" TSVECTOR 
8  GENERATED ALWAYS AS (
9    setweight(to_tsvector('english', coalesce(name, '')), 'A') || 
10    setweight(to_tsvector('english', coalesce(description, '')), 'B')
11  ) STORED;
12
13CREATE INDEX product_tsv_idx ON "Product" USING GIN ("tsv");
14
15-- Create a function to search products
16CREATE OR REPLACE FUNCTION search_products(search_query TEXT)
17RETURNS TABLE (
18  id TEXT,
19  name TEXT,
20  description TEXT,
21  price DECIMAL,
22  rank REAL
23) AS $$
24BEGIN
25  RETURN QUERY
26  SELECT 
27    p.id,
28    p.name,
29    p.description,
30    p.price,
31    ts_rank(p.tsv, websearch_to_tsquery('english', search_query)) AS rank
32  FROM 
33    "Product" p
34  WHERE 
35    p.tsv @@ websearch_to_tsquery('english', search_query)
36  ORDER BY 
37    rank DESC;
38END;
39$$ LANGUAGE plpgsql;

Now we can use this function in our application:

1// app/search/page.tsx
2import { prisma } from '@/lib/prisma';
3import { ProductCard } from '@/components/ProductCard';
4
5export default async function SearchPage({
6  searchParams
7}: {
8  searchParams: { q?: string }
9}) {
10  const query = searchParams.q || '';
11  
12  if (!query) {
13    return (
14      <div className="container mx-auto py-8">
15        <h1 className="text-3xl font-bold mb-6">Search Products</h1>
16        <p>Enter a search term to find products.</p>
17      </div>
18    );
19  }
20  
21  // Using SQL for advanced search
22  const products = await prisma.$queryRaw`
23    SELECT * FROM search_products(${query})
24  `;
25  
26  return (
27    <div className="container mx-auto py-8">
28      <h1 className="text-3xl font-bold mb-6">Search Results</h1>
29      
30      <p className="mb-4">
31        {products.length} results for "{query}"
32      </p>
33      
34      {products.length > 0 ? (
35        <div className="grid grid-cols-1 md:grid-cols-3 gap-6">
36          {products.map(product => (
37            <ProductCard 
38              key={product.id} 
39              product={product} 
40              searchScore={product.rank}
41            />
42          ))}
43        </div>
44      ) : (
45        <p>No products found. Try a different search term.</p>
46      )}
47    </div>
48  );
49}

Client-Side Search Implementation

Client-side search is useful for smaller datasets or for filtering already fetched data.

1. Simple Search Using useState

1'use client';
2
3import { useState } from 'react';
4
5export default function ClientSearch({ initialProducts }) {
6  const [searchTerm, setSearchTerm] = useState('');
7  const [filteredProducts, setFilteredProducts] = useState(initialProducts);
8  
9  const handleSearch = (e) => {
10    const term = e.target.value;
11    setSearchTerm(term);
12    
13    if (!term) {
14      setFilteredProducts(initialProducts);
15      return;
16    }
17    
18    const lowerCaseTerm = term.toLowerCase();
19    const filtered = initialProducts.filter(product => 
20      product.name.toLowerCase().includes(lowerCaseTerm) ||
21      product.description.toLowerCase().includes(lowerCaseTerm)
22    );
23    
24    setFilteredProducts(filtered);
25  };
26  
27  return (
28    <div>
29      <div className="mb-4">
30        <input
31          type="search"
32          value={searchTerm}
33          onChange={handleSearch}
34          className="w-full p-2 border rounded"
35        />
36      </div>
37      
38      <p className="mb-2">
39        {filteredProducts.length} products found
40      </p>
41      
42      <div className="grid grid-cols-1 md:grid-cols-3 gap-4">
43        {filteredProducts.map(product => (
44          <div key={product.id} className="border p-4 rounded">
45            <h3 className="font-bold">{product.name}</h3>
46            <p>${product.price.toFixed(2)}</p>
47          </div>
48        ))}
49      </div>
50    </div>
51  );
52}

2. Multi-Filter Client-Side Search

1'use client';
2
3import { useState, useMemo } from 'react';
4
5export default function AdvancedClientSearch({ initialProducts, categories }) {
6  const [filters, setFilters] = useState({
7    searchTerm: '',
8    category: '',
9    minPrice: '',
10    maxPrice: '',
11  });
12  
13  // Memoize filtered results
14  const filteredProducts = useMemo(() => {
15    let results = [...initialProducts];
16    
17    // Filter by search term
18    if (filters.searchTerm) {
19      const term = filters.searchTerm.toLowerCase();
20      results = results.filter(
21        product =>
22          product.name.toLowerCase().includes(term) ||
23          product.description.toLowerCase().includes(term)
24      );
25    }
26    
27    // Filter by category
28    if (filters.category) {
29      results = results.filter(
30        product => product.categoryId === filters.category
31      );
32    }
33    
34    // Filter by minimum price
35    if (filters.minPrice) {
36      const min = parseFloat(filters.minPrice);
37      results = results.filter(product => product.price >= min);
38    }
39    
40    // Filter by maximum price
41    if (filters.maxPrice) {
42      const max = parseFloat(filters.maxPrice);
43      results = results.filter(product => product.price <= max);
44    }
45    
46    return results;
47  }, [initialProducts, filters]);
48  
49  // Function to update filters
50  const handleFilterChange = (e) => {
51    const { name, value } = e.target;
52    setFilters(prev => ({
53      ...prev,
54      [name]: value
55    }));
56  };
57  
58  // Function to reset filters
59  const resetFilters = () => {
60    setFilters({
61      searchTerm: '',
62      category: '',
63      minPrice: '',
64      maxPrice: '',
65    });
66  };
67  
68  return (
69    <div>
70      <div className="bg-gray-50 p-4 rounded-lg mb-6">
71        <h2 className="text-lg font-bold mb-4">Filter Products</h2>
72        
73        <div className="grid grid-cols-1 md:grid-cols-2 gap-4 mb-4">
74          <div>
75            <label className="block mb-1">Search</label>
76            <input
77              type="search"
78              name="searchTerm"
79              value={filters.searchTerm}
80              onChange={handleFilterChange}
81              className="w-full p-2 border rounded"
82            />
83          </div>
84          
85          <div>
86            <label className="block mb-1">Category</label>
87            <select
88              name="category"
89              value={filters.category}
90              onChange={handleFilterChange}
91              className="w-full p-2 border rounded"
92            >
93              <option value="">All Categories</option>
94              {categories.map(category => (
95                <option key={category.id} value={category.id}>
96                  {category.name}
97                </option>
98              ))}
99            </select>
100          </div>
101          
102          <div>
103            <label className="block mb-1">Min Price</label>
104            <input
105              type="number"
106              name="minPrice"
107              value={filters.minPrice}
108              onChange={handleFilterChange}
109              min="0"
110              step="0.01"
111              className="w-full p-2 border rounded"
112            />
113          </div>
114          
115          <div>
116            <label className="block mb-1">Max Price</label>
117            <input
118              type="number"
119              name="maxPrice"
120              value={filters.maxPrice}
121              onChange={handleFilterChange}
122              min="0"
123              step="0.01"
124              className="w-full p-2 border rounded"
125            />
126          </div>
127        </div>
128        
129        <button
130          onClick={resetFilters}
131          className="px-4 py-2 bg-gray-200 rounded hover:bg-gray-300"
132        >
133          Reset Filters
134        </button>
135      </div>
136      
137      <p className="mb-4">
138        {filteredProducts.length} products found
139      </p>
140      
141      {filteredProducts.length > 0 ? (
142        <div className="grid grid-cols-1 md:grid-cols-3 gap-4">
143          {filteredProducts.map(product => (
144            <div key={product.id} className="border p-4 rounded">
145              <h3 className="font-bold">{product.name}</h3>
146              <p>${product.price.toFixed(2)}</p>
147              <p className="text-sm text-gray-600">
148                {product.category?.name}
149              </p>
150            </div>
151          ))}
152        </div>
153      ) : (
154        <p className="text-gray-500">
155          No products match your filters. Try adjusting your criteria.
156        </p>
157      )}
158    </div>
159  );
160}

3. Search with Suggestions (Autocomplete)

1'use client';
2
3import { useState, useEffect, useRef } from 'react';
4import { useRouter } from 'next/navigation';
5import { useDebounce } from '@/hooks/useDebounce';
6import { searchProducts } from '@/lib/api';
7
8export function SearchWithAutocomplete() {
9  const router = useRouter();
10  const [query, setQuery] = useState('');
11  const [suggestions, setSuggestions] = useState([]);
12  const [isLoading, setIsLoading] = useState(false);
13  const [showSuggestions, setShowSuggestions] = useState(false);
14  const debouncedQuery = useDebounce(query, 300);
15  const inputRef = useRef(null);
16  
17  // Fetch suggestions when query changes
18  useEffect(() => {
19    const fetchSuggestions = async () => {
20      if (!debouncedQuery || debouncedQuery.length < 2) {
21        setSuggestions([]);
22        return;
23      }
24      
25      setIsLoading(true);
26      try {
27        const results = await searchProducts(debouncedQuery, { limit: 5 });
28        setSuggestions(results);
29      } catch (error) {
30        console.error('Error fetching suggestions:', error);
31      } finally {
32        setIsLoading(false);
33      }
34    };
35    
36    fetchSuggestions();
37  }, [debouncedQuery]);
38  
39  // Handle click outside suggestions list
40  useEffect(() => {
41    const handleClickOutside = (event) => {
42      if (inputRef.current && !inputRef.current.contains(event.target)) {
43        setShowSuggestions(false);
44      }
45    };
46    
47    document.addEventListener('mousedown', handleClickOutside);
48    return () => {
49      document.removeEventListener('mousedown', handleClickOutside);
50    };
51  }, []);
52  
53  // Handle search
54  const handleSearch = (e) => {
55    e.preventDefault();
56    if (query) {
57      router.push(`/search?q=${encodeURIComponent(query)}`);
58      setShowSuggestions(false);
59    }
60  };
61  
62  return (
63    <div className="relative" ref={inputRef}>
64      <form onSubmit={handleSearch} className="flex">
65        <input
66          type="search"
67          value={query}
68          onChange={(e) => {
69            setQuery(e.target.value);
70            setShowSuggestions(true);
71          }}
72          onFocus={() => setShowSuggestions(!!suggestions.length)}
73          className="flex-grow p-2 border rounded-l"
74          aria-label="Search"
75        />
76        <button
77          type="submit"
78          className="px-4 py-2 bg-blue-500 text-white rounded-r hover:bg-blue-600"
79          aria-label="Search button"
80        >
81          Search
82        </button>
83      </form>
84      
85      {/* Loader */}
86      {isLoading && (
87        <div className="absolute right-12 top-3">
88          <div className="animate-spin w-4 h-4 border-2 border-blue-500 border-t-transparent rounded-full"></div>
89        </div>
90      )}
91      
92      {/* Sugestie */}
93      {showSuggestions && suggestions.length > 0 && (
94        <ul className="absolute z-10 w-full mt-1 bg-white border rounded shadow-lg max-h-60 overflow-auto">
95          {suggestions.map(product => (
96            <li
97              key={product.id}
98              className="p-2 hover:bg-gray-100 cursor-pointer"
99              onClick={() => {
100                router.push(`/products/${product.id}`);
101                setShowSuggestions(false);
102              }}
103            >
104              <div className="flex items-center">
105                {product.image && (
106                  <img
107                    src={product.image}
108                    alt={product.name}
109                    className="w-10 h-10 object-cover mr-2"
110                  />
111                )}
112                <div>
113                  <div className="font-medium">{product.name}</div>
114                  <div className="text-sm text-gray-600">
115                    ${product.price.toFixed(2)}
116                  </div>
117                </div>
118              </div>
119            </li>
120          ))}
121          <li className="p-2 text-center text-blue-500 hover:bg-gray-100 cursor-pointer">
122            <button
123              onClick={handleSearch}
124              className="w-full text-left"
125            >
126              See all results for "{query}"
127            </button>
128          </li>
129        </ul>
130      )}
131    </div>
132  );
133}
134
135// hooks/useDebounce.js
136import { useEffect, useState } from 'react';
137
138export function useDebounce(value, delay) {
139  const [debouncedValue, setDebouncedValue] = useState(value);
140  
141  useEffect(() => {
142    const handler = setTimeout(() => {
143      setDebouncedValue(value);
144    }, delay);
145    
146    return () => {
147      clearTimeout(handler);
148    };
149  }, [value, delay]);
150  
151  return debouncedValue;
152}
153
154// lib/api.js
155export async function searchProducts(query, options = {}) {
156  const { limit = 10 } = options;
157  
158  const response = await fetch(
159    `/api/search?q=${encodeURIComponent(query)}&limit=${limit}`
160  );
161  
162  if (!response.ok) {
163    throw new Error('Failed to fetch suggestions');
164  }
165  
166  return response.json();
167}
168
169// app/api/search/route.ts
170import { NextResponse } from 'next/server';
171import { prisma } from '@/lib/prisma';
172
173export async function GET(request: Request) {
174  const { searchParams } = new URL(request.url);
175  const query = searchParams.get('q');
176  const limit = Number(searchParams.get('limit') || '10');
177  
178  if (!query) {
179    return NextResponse.json([]);
180  }
181  
182  try {
183    const products = await prisma.product.findMany({
184      where: {
185        OR: [
186          { name: { contains: query, mode: 'insensitive' } },
187          { description: { contains: query, mode: 'insensitive' } },
188        ],
189      },
190      select: {
191        id: true,
192        name: true,
193        price: true,
194        image: true,
195      },
196      take: limit,
197    });
198    
199    return NextResponse.json(products);
200  } catch (error) {
201    console.error('Search API error:', error);
202    return NextResponse.json(
203      { error: 'Failed to perform search' },
204      { status: 500 }
205    );
206  }
207}

Advanced Search Techniques

1. Integration with Elasticsearch or Algolia

For very large datasets or advanced search features, it's worth considering integration with a dedicated search engine:

1// lib/search.js (Algolia integration)
2import algoliasearch from 'algoliasearch';
3
4const client = algoliasearch(
5  process.env.ALGOLIA_APP_ID,
6  process.env.ALGOLIA_API_KEY
7);
8const index = client.initIndex('products');
9
10export async function searchWithAlgolia(query, options = {}) {
11  const {
12    page = 0,
13    hitsPerPage = 20,
14    filters = '',
15  } = options;
16  
17  const { hits, nbHits, nbPages } = await index.search(query, {
18    page,
19    hitsPerPage,
20    filters,
21  });
22  
23  return {
24    results: hits,
25    totalHits: nbHits,
26    totalPages: nbPages,
27  };
28}
29
30// app/api/search/route.ts (with Algolia)
31import { NextResponse } from 'next/server';
32import { searchWithAlgolia } from '@/lib/search';
33
34export async function GET(request: Request) {
35  const { searchParams } = new URL(request.url);
36  const query = searchParams.get('q') || '';
37  const page = Number(searchParams.get('page') || '0');
38  const category = searchParams.get('category');
39  
40  try {
41    // Build Algolia filters
42    let filters = '';
43    if (category) {
44      filters = `categoryId:${category}`;
45    }
46    
47    const { results, totalHits, totalPages } = await searchWithAlgolia(
48      query,
49      {
50        page,
51        hitsPerPage: 20,
52        filters,
53      }
54    );
55    
56    return NextResponse.json({
57      results,
58      totalHits,
59      totalPages,
60      page,
61    });
62  } catch (error) {
63    console.error('Search API error:', error);
64    return NextResponse.json(
65      { error: 'Failed to perform search' },
66      { status: 500 }
67    );
68  }
69}

2. Search with Error Correction

Implementing a function for spelling error correction:

1// lib/search.js
2export function levenshteinDistance(a, b) {
3  if (a.length === 0) return b.length;
4  if (b.length === 0) return a.length;
5  
6  const matrix = Array(a.length + 1)
7    .fill()
8    .map(() => Array(b.length + 1).fill(0));
9  
10  for (let i = 0; i <= a.length; i++) {
11    matrix[i][0] = i;
12  }
13  
14  for (let j = 0; j <= b.length; j++) {
15    matrix[0][j] = j;
16  }
17  
18  for (let i = 1; i <= a.length; i++) {
19    for (let j = 1; j <= b.length; j++) {
20      const cost = a[i - 1] === b[j - 1] ? 0 : 1;
21      matrix[i][j] = Math.min(
22        matrix[i - 1][j] + 1, // deletion
23        matrix[i][j - 1] + 1, // insertion
24        matrix[i - 1][j - 1] + cost // substitution
25      );
26    }
27  }
28  
29  return matrix[a.length][b.length];
30}
31
32export function findSimilarWords(query, wordsList, threshold = 2) {
33  const queryLower = query.toLowerCase();
34  return wordsList.filter(word => {
35    const wordLower = word.toLowerCase();
36    return levenshteinDistance(queryLower, wordLower) <= threshold;
37  });
38}
39
40// app/search/page.tsx (with error correction)
41import { prisma } from '@/lib/prisma';
42import { findSimilarWords } from '@/lib/search';
43
44export default async function SearchPage({
45  searchParams
46}: {
47  searchParams: { q?: string }
48}) {
49  const query = searchParams.q || '';
50  
51  if (!query) {
52    return /* ... */;
53  }
54  
55  let products = await prisma.product.findMany({
56    where: {
57      OR: [
58        { name: { contains: query, mode: 'insensitive' } },
59        { description: { contains: query, mode: 'insensitive' } },
60      ],
61    },
62    take: 20,
63  });
64  
65  // If no results found, try with error correction
66  let didYouMean = null;
67  
68  if (products.length === 0) {
69    // Fetch all product names for comparison
70    const allProductNames = await prisma.product.findMany({
71      select: { name: true },
72    });
73    
74    const wordsList = allProductNames.map(p => p.name);
75    const similarWords = findSimilarWords(query, wordsList);
76    
77    if (similarWords.length > 0) {
78      // Find the best match
79      didYouMean = similarWords[0];
80      
81      // Search again with corrected term
82      products = await prisma.product.findMany({
83        where: {
84          OR: [
85            { name: { contains: didYouMean, mode: 'insensitive' } },
86            { description: { contains: didYouMean, mode: 'insensitive' } },
87          ],
88        },
89        take: 20,
90      });
91    }
92  }
93  
94  return (
95    <div className="container mx-auto py-8">
96      <h1 className="text-3xl font-bold mb-6">Search Results</h1>
97      
98      {didYouMean && (
99        <p className="mb-4">
100          No results for "{query}". Showing results for "{didYouMean}" instead.
101          <a href={`/search?q=${encodeURIComponent(didYouMean)}`} className="ml-2 text-blue-500">
102            Search for "{didYouMean}" instead
103          </a>
104        </p>
105      )}
106      
107      <p className="mb-4">
108        {products.length} results {didYouMean ? `for "${didYouMean}"` : `for "${query}"`}
109      </p>
110      
111      {/* Render products */}
112    </div>
113  );
114}

3. Implementing Real-Time Search

Real-time search can be implemented using WebSockets:

1// app/search/realtime/page.tsx
2'use client';
3
4import { useState, useEffect } from 'react';
5import { io } from 'socket.io-client';
6
7export default function RealtimeSearch() {
8  const [socket, setSocket] = useState(null);
9  const [query, setQuery] = useState('');
10  const [results, setResults] = useState([]);
11  const [isConnected, setIsConnected] = useState(false);
12  const [isLoading, setIsLoading] = useState(false);
13  
14  // Connect to WebSocket server
15  useEffect(() => {
16    const newSocket = io('/api/search-socket');
17    
18    newSocket.on('connect', () => {
19      setIsConnected(true);
20    });
21    
22    newSocket.on('disconnect', () => {
23      setIsConnected(false);
24    });
25    
26    newSocket.on('searchResults', (data) => {
27      setResults(data.results);
28      setIsLoading(false);
29    });
30    
31    setSocket(newSocket);
32    
33    return () => {
34      newSocket.disconnect();
35    };
36  }, []);
37  
38  // Send search queries
39  useEffect(() => {
40    if (!socket || !isConnected || query.length < 2) {
41      return;
42    }
43    
44    setIsLoading(true);
45    socket.emit('search', { query });
46    
47  }, [socket, query, isConnected]);
48  
49  return (
50    <div className="container mx-auto py-8">
51      <h1 className="text-3xl font-bold mb-6">Real-Time Search</h1>
52      
53      <div className="mb-4">
54        <input
55          type="search"
56          value={query}
57          onChange={(e) => setQuery(e.target.value)}
58          className="w-full p-2 border rounded"
59        />
60      </div>
61      
62      {isLoading && (
63        <div className="flex justify-center my-4">
64          <div className="animate-spin w-6 h-6 border-2 border-blue-500 border-t-transparent rounded-full"></div>
65        </div>
66      )}
67      
68      <div>
69        {results.length > 0 ? (
70          <div className="grid grid-cols-1 md:grid-cols-3 gap-4">
71            {results.map(product => (
72              <div key={product.id} className="border p-4 rounded">
73                <h3 className="font-bold">{product.name}</h3>
74                <p>${product.price.toFixed(2)}</p>
75              </div>
76            ))}
77          </div>
78        ) : (
79          query.length > 1 && !isLoading && (
80            <p className="text-gray-500">No results found for "{query}"</p>
81          )
82        )}
83      </div>
84    </div>
85  );
86}
87
88// pages/api/search-socket.js (API Route for WebSockets)
89import { Server } from 'socket.io';
90import { prisma } from '@/lib/prisma';
91
92export default function handler(req, res) {
93  if (!res.socket.server.io) {
94    const io = new Server(res.socket.server);
95    res.socket.server.io = io;
96    
97    io.on('connection', (socket) => {
98      socket.on('search', async (data) => {
99        const { query } = data;
100        
101        if (!query || query.length < 2) {
102          socket.emit('searchResults', { results: [] });
103          return;
104        }
105        
106        try {
107          const results = await prisma.product.findMany({
108            where: {
109              OR: [
110                { name: { contains: query, mode: 'insensitive' } },
111                { description: { contains: query, mode: 'insensitive' } },
112              ],
113            },
114            select: {
115              id: true,
116              name: true,
117              price: true,
118            },
119            take: 20,
120          });
121          
122          socket.emit('searchResults', { results });
123        } catch (error) {
124          console.error('Search error:', error);
125          socket.emit('searchResults', { 
126            results: [],
127            error: 'Failed to perform search'
128          });
129        }
130      });
131    });
132  }
133  
134  res.end();
135}

Search Performance Optimization

1. Caching Search Results

1// lib/redis.js
2import { Redis } from '@upstash/redis';
3
4export const redis = new Redis({
5  url: process.env.UPSTASH_REDIS_URL,
6  token: process.env.UPSTASH_REDIS_TOKEN,
7});
8
9// lib/search.js (with caching)
10import { redis } from './redis';
11
12export async function searchProductsWithCache(query, options = {}) {
13  const { category, page = 1, limit = 20 } = options;
14  
15  // Create cache key
16  const cacheKey = `search:${query}:${category || 'all'}:${page}:${limit}`;
17  
18  // Check if results are in cache
19  const cachedResults = await redis.get(cacheKey);
20  
21  if (cachedResults) {
22    return JSON.parse(cachedResults);
23  }
24  
25  // If not, perform search
26  const where = {};
27  
28  if (query) {
29    where.OR = [
30      { name: { contains: query, mode: 'insensitive' } },
31      { description: { contains: query, mode: 'insensitive' } },
32    ];
33  }
34  
35  if (category) {
36    where.categoryId = category;
37  }
38  
39  // Fetch results from database
40  const [products, totalCount] = await Promise.all([
41    prisma.product.findMany({
42      where,
43      include: {
44        category: true,
45      },
46      skip: (page - 1) * limit,
47      take: limit,
48    }),
49    prisma.product.count({ where }),
50  ]);
51  
52  const results = {
53    products,
54    totalCount,
55    totalPages: Math.ceil(totalCount / limit),
56    currentPage: page,
57  };
58  
59  // Save results in cache for 10 minutes
60  await redis.set(cacheKey, JSON.stringify(results), {
61    ex: 600, // 10 minut
62  });
63  
64  return results;
65}
66
67// app/api/search/route.ts (with caching)
68import { NextResponse } from 'next/server';
69import { searchProductsWithCache } from '@/lib/search';
70
71export async function GET(request: Request) {
72  const { searchParams } = new URL(request.url);
73  const query = searchParams.get('q') || '';
74  const category = searchParams.get('category');
75  const page = Number(searchParams.get('page') || '1');
76  const limit = Number(searchParams.get('limit') || '20');
77  
78  try {
79    const results = await searchProductsWithCache(query, {
80      category,
81      page,
82      limit,
83    });
84    
85    return NextResponse.json(results);
86  } catch (error) {
87    console.error('Search API error:', error);
88    return NextResponse.json(
89      { error: 'Failed to perform search' },
90      { status: 500 }
91    );
92  }
93}

2. Indexing and Pagination

To improve search performance for large datasets, it's worth applying appropriate database indexes:

1// Example migration for Prisma
2
3// prisma/migrations/YYYYMMDDHHMMSS_add_search_indexes/migration.sql
4-- Index for the 'name' column (used in search)
5CREATE INDEX idx_product_name ON "Product" ("name");
6
7-- Index for the 'description' column (used in search)
8CREATE INDEX idx_product_description ON "Product" ("description");
9
10-- Composite index for filtering by category and sorting by price
11CREATE INDEX idx_product_category_price ON "Product" ("categoryId", "price");
12
13-- Index for newest products
14CREATE INDEX idx_product_created_at ON "Product" ("createdAt" DESC);

3. Implementing Cursor Pagination

For very large datasets, cursor-based pagination is more efficient than traditional offset/limit pagination:

1// lib/search.js (with Cursor Pagination)
2export async function searchProductsWithCursor(query, options = {}) {
3  const { 
4    category, 
5    limit = 20, 
6    cursor = null,
7    sortBy = 'createdAt',
8    sortDirection = 'desc'
9  } = options;
10  
11  // Build search conditions
12  const where: any = {};
13  
14  if (query) {
15    where.OR = [
16      { name: { contains: query, mode: 'insensitive' } },
17      { description: { contains: query, mode: 'insensitive' } },
18    ];
19  }
20  
21  if (category) {
22    where.categoryId = category;
23  }
24  
25  // Sorting configuration
26  const orderBy = {
27    [sortBy]: sortDirection,
28  };
29  
30  // Configure cursor condition
31  const cursorOptions = cursor
32    ? {
33        cursor: {
34          id: cursor,
35        },
36        skip: 1, // Skip current cursor element
37      }
38    : {};
39  
40  // Fetch data with cursor-based pagination
41  const products = await prisma.product.findMany({
42    where,
43    ...cursorOptions,
44    take: limit,
45    orderBy,
46    include: {
47      category: true,
48    },
49  });
50  
51  // Get the last item's ID as the next cursor
52  const nextCursor = products.length === limit ? products[products.length - 1].id : null;
53  
54  return {
55    products,
56    nextCursor,
57    hasMore: nextCursor !== null,
58  };
59}
60
61// app/api/search-cursor/route.ts
62import { NextResponse } from 'next/server';
63import { searchProductsWithCursor } from '@/lib/search';
64
65export async function GET(request: Request) {
66  const { searchParams } = new URL(request.url);
67  const query = searchParams.get('q') || '';
68  const category = searchParams.get('category');
69  const cursor = searchParams.get('cursor');
70  const limit = Number(searchParams.get('limit') || '20');
71  
72  try {
73    const { products, nextCursor, hasMore } = await searchProductsWithCursor(
74      query,
75      {
76        category,
77        cursor,
78        limit,
79      }
80    );
81    
82    return NextResponse.json({
83      products,
84      nextCursor,
85      hasMore,
86    });
87  } catch (error) {
88    console.error('Search API error:', error);
89    return NextResponse.json(
90      { error: 'Failed to perform search' },
91      { status: 500 }
92    );
93  }
94}

Client component for handling cursor-based pagination:

1'use client';
2
3import { useState, useEffect } from 'react';
4import { useSearchParams, useRouter } from 'next/navigation';
5import { ProductCard } from '@/components/ProductCard';
6
7export default function CursorPaginatedSearch() {
8  const searchParams = useSearchParams();
9  const router = useRouter();
10  const [products, setProducts] = useState([]);
11  const [nextCursor, setNextCursor] = useState(null);
12  const [hasMore, setHasMore] = useState(true);
13  const [isLoading, setIsLoading] = useState(false);
14  
15  const query = searchParams.get('q') || '';
16  const category = searchParams.get('category');
17  const cursor = searchParams.get('cursor');
18  
19  // Fetch data on initial render and when URL parameters change
20  useEffect(() => {
21    const fetchData = async () => {
22      setIsLoading(true);
23      
24      try {
25        const params = new URLSearchParams();
26        if (query) params.set('q', query);
27        if (category) params.set('category', category);
28        if (cursor) params.set('cursor', cursor);
29        
30        const response = await fetch(`/api/search-cursor?${params.toString()}`);
31        
32        if (!response.ok) {
33          throw new Error('Failed to fetch data');
34        }
35        
36        const data = await response.json();
37        
38        // Update state
39        setProducts(data.products);
40        setNextCursor(data.nextCursor);
41        setHasMore(data.hasMore);
42      } catch (error) {
43        console.error('Error fetching data:', error);
44      } finally {
45        setIsLoading(false);
46      }
47    };
48    
49    fetchData();
50  }, [query, category, cursor]);
51  
52  // Handle "Load More" button
53  const handleLoadMore = () => {
54    if (nextCursor) {
55      const params = new URLSearchParams(searchParams);
56      params.set('cursor', nextCursor);
57      router.push(`/search?${params.toString()}`);
58    }
59  };
60  
61  return (
62    <div className="container mx-auto py-8">
63      <h1 className="text-3xl font-bold mb-6">Search Results</h1>
64      
65      {query && (
66        <p className="mb-4">
67          Showing results for "{query}"
68          {category && " in selected category"}
69        </p>
70      )}
71      
72      <div className="grid grid-cols-1 md:grid-cols-3 gap-6 mb-8">
73        {products.map(product => (
74          <ProductCard key={product.id} product={product} />
75        ))}
76      </div>
77      
78      {isLoading && (
79        <div className="flex justify-center my-4">
80          <div className="animate-spin w-6 h-6 border-2 border-blue-500 border-t-transparent rounded-full"></div>
81        </div>
82      )}
83      
84      {hasMore && !isLoading && (
85        <div className="flex justify-center">
86          <button
87            onClick={handleLoadMore}
88            className="px-4 py-2 bg-blue-500 text-white rounded hover:bg-blue-600"
89          >
90            Load More
91          </button>
92        </div>
93      )}
94      
95      {!hasMore && products.length > 0 && (
96        <p className="text-center text-gray-500 mt-4">
97          No more products to load
98        </p>
99      )}
100      
101      {products.length === 0 && !isLoading && (
102        <p className="text-center text-gray-500">
103          No products found. Try a different search term.
104        </p>
105      )}
106    </div>
107  );
108}

Summary

Effective search is a key element of every web application. In this module, we learned various approaches to implementing search in Next.js:

  1. Server-side search using Server Components, which provides:

    • Better performance for large datasets
    • Ability to leverage native database functions
    • SEO-friendly URLs with search parameters
    • Less load on the user's browser
  2. Client-side search, which is suitable for:

    • Smaller datasets
    • Quick filtering of already fetched data
    • Dynamic interaction without page refresh
  3. Advanced techniques such as:

    • Integration with dedicated search engines (Elasticsearch, Algolia)
    • Search with spelling error correction
    • Full-text search
    • Autocomplete and search suggestions
  4. Performance optimizations:

    • Caching search results
    • Appropriate database indexing
    • Cursor-based pagination
    • Query deduplication

Choosing the appropriate search techniques depends on the application specifics, data size, and performance and UX requirements. For most cases, a combination of server-side search with client-side elements (like autocomplete) provides the best user experience.

In the next module, we will discuss how to efficiently implement pagination and data paging in Next.js applications.

Go to CodeWorlds