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.
Before we proceed to implementation, it's worth understanding the different types of search that can be applied in an application:
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// 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}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}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 « 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 »
155 </Link>
156 )}
157 </nav>
158 </div>
159 );
160}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//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 is useful for smaller datasets or for filtering already fetched data.
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}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}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}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}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}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}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}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);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}Effective search is a key element of every web application. In this module, we learned various approaches to implementing search in Next.js:
Server-side search using Server Components, which provides:
Client-side search, which is suitable for:
Advanced techniques such as:
Performance optimizations:
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.