Search and Pagination: Advanced Topics
Overview
This supplemental activity covers advanced techniques for building robust, performant search and pagination features. You'll learn how to distinguish between filtering and search, retrieve data efficiently from databases, add type safety with TypeScript, optimize the user experience, and implement performance best practices.
Prepare
Filtering vs. Search
While often used together, filtering and search are distinct operations with different purposes and implementations:
| Aspect | Filtering | Search |
|---|---|---|
| Purpose | Narrow results by predefined categories or ranges | Find content matching user-entered keywords |
| Examples | Category, Price range, Rating, Date range | Product name, Description, Author name |
| User input | Dropdowns, checkboxes, sliders (structured) | Text input field (freeform) |
| Query type | Exact match or range check | Pattern matching or full-text search |
| Combined URL | /products?category=electronics&minPrice=100&maxPrice=500&q=laptop |
|
In a real application, search and filters work together. Users can filter by category, then search within that category.
Data Retrieval and Filtering
Retrieving and filtering data efficiently requires careful consideration of database queries:
// Building a complex query with multiple filters and search
import { Prisma } from '@prisma/client';
async function fetchProductsFiltered(params: {
q?: string; // Search query
category?: string; // Filter by category
minPrice?: number; // Filter by price range
maxPrice?: number;
sortBy?: string; // 'relevance', 'price', 'newest'
page?: number;
limit?: number;
}) {
const { q = '', category, minPrice, maxPrice, sortBy = 'relevance', page = 1, limit = 10 } = params;
const where: Prisma.ProductWhereInput = {};
// Add search condition
if (q) {
where.OR = [
{ title: { contains: q, mode: 'insensitive' } },
{ description: { contains: q, mode: 'insensitive' } },
{ tags: { hasSome: [q] } }
];
}
// Add filter conditions
if (category) where.category = category;
if (minPrice !== undefined || maxPrice !== undefined) {
where.price = {};
if (minPrice !== undefined) where.price.gte = minPrice;
if (maxPrice !== undefined) where.price.lte = maxPrice;
}
// Determine sort order
let orderBy: Prisma.ProductOrderByWithRelationInput = {};
if (sortBy === 'price') {
orderBy.price = 'asc';
} else if (sortBy === 'newest') {
orderBy.createdAt = 'desc';
} else if (q) {
orderBy._relevance = { fields: ['title', 'description'], search: q, sort: 'desc' };
} else {
orderBy.createdAt = 'desc'; // fallback when no search term
}
const offset = (page - 1) * limit;
const [results, total] = await Promise.all([
db.product.findMany({ where, orderBy, skip: offset, take: limit }),
db.product.count({ where })
]);
return {
results,
total,
page,
limit,
hasNextPage: offset + results.length < total
};
}
Key points:
- Use
Promise.all()to fetch results and total count in parallel - Always include a total count for pagination controls
- Return a
hasNextPageflag for easier pagination rendering - Handle undefined filter values to avoid unnecessary query conditions
TypeScript Typing for Search
Adding TypeScript types to search functionality prevents bugs and improves developer experience:
// types/search.ts
export interface SearchParams {
q?: string;
category?: string;
minPrice?: number;
maxPrice?: number;
sortBy?: 'relevance' | 'price' | 'newest';
page?: number;
limit?: number;
}
export interface SearchResult<T> {
results: T[];
total: number;
page: number;
limit: number;
hasNextPage: boolean;
totalPages: number;
}
export interface Product {
id: string;
title: string;
description: string;
price: number;
category: string;
rating: number;
tags: string[];
}
// Using the types
async function fetchProducts(params: SearchParams): Promise<SearchResult<Product>> {
// Implementation...
}
// On the page component
export default async function ProductsPage({ searchParams }: { searchParams: SearchParams }) {
const result = await fetchProducts(searchParams);
return (
<div>
<p>Found {result.total} products</p>
{result.results.map(product => (
<ProductCard key={product.id} product={product} />
))}
</div>
);
}
Search UX Patterns
Beyond the basic search functionality, several UX patterns improve the search experience:
Debounced Search
If implementing real-time search suggestions, debounce input to avoid excessive server requests:
'use client';
import { useRouter, useSearchParams } from 'next/navigation';
import { useEffect, useState } from 'react';
export function SearchInput() {
const router = useRouter();
const searchParams = useSearchParams();
const [query, setQuery] = useState(searchParams.get('q') || '');
const [isSearching, setIsSearching] = useState(false);
useEffect(() => {
const timer = setTimeout(() => {
if (query) {
setIsSearching(true);
const newParams = new URLSearchParams(searchParams);
newParams.set('q', query);
newParams.set('page', '1');
router.push(`/products?${newParams.toString()}`);
setIsSearching(false);
}
}, 300); // Wait 300ms after user stops typing
return () => clearTimeout(timer);
}, [query, searchParams, router]);
return (
<div>
<input
type="text"
value={query}
onChange={(e) => setQuery(e.target.value)}
placeholder="Search products..."
disabled={isSearching}
/>
{isSearching && <span>Searching...</span>}
</div>
);
}
Empty State Handling
Always provide helpful feedback when no results are found:
export function SearchResults({ results, query }: { results: Product[], query: string }) {
if (results.length === 0) {
return (
<div>
<h3>No products found</h3>
<p>We couldn't find any products matching "{query}". Try:</p>
<ul>
<li>Using different keywords</li>
<li>Checking your spelling</li>
<li>Removing filters</li>
<li><a href="/products">Browse all products</a></li>
</ul>
</div>
);
}
return (
<div>
<p>Found {results.length} products</p>
{/* Render results... */}
</div>
);
}
Loading States
During data fetching, show loading indicators to reassure users:
export function SearchPage({ searchParams }: { searchParams: SearchParams }) {
return (
<Suspense fallback={<SearchSkeleton />}>
<SearchContent searchParams={searchParams} />
</Suspense>
);
}
function SearchSkeleton() {
return (
<div className="space-y-2">
{[...Array(5)].map((_, i) => (
<div key={i} className="h-20 bg-gray-200 rounded animate-pulse" />
))}
</div>
);
}
Performance Optimization
Search can become a performance bottleneck as datasets grow. Follow these optimization practices:
Database Indexing
Add database indexes on searchable fields to dramatically speed up queries:
// Prisma schema
model Product {
id String @id @default(cuid())
title String @db.String(255)
description String @db.Text
category String
price Float
// Add indexes for frequently searched fields
@@index([title])
@@index([category])
@@index([price])
@@fulltext([title, description]) // Full-text search index (MySQL)
}
// After updating schema: npx prisma migrate dev
Result Limits
Always limit the number of results returned to prevent memory exhaustion:
// Set reasonable defaults
const DEFAULT_LIMIT = 10;
const MAX_LIMIT = 100; // Prevent users from requesting massive datasets
function validateLimit(limit?: number): number {
if (!limit) return DEFAULT_LIMIT;
if (limit > MAX_LIMIT) return MAX_LIMIT;
return Math.max(1, limit);
}
Caching Strategies
Cache search results when appropriate to reduce database load:
// app/api/products/route.ts
import { unstable_cache } from 'next/cache';
const getCachedProducts = unstable_cache(
async (query: string, category: string) => {
return await db.product.findMany({
where: {
title: { contains: query },
category: category || undefined
},
take: 50
});
},
['products-search'], // Cache key
{ revalidate: 3600 } // Cache for 1 hour
);
export async function GET(request: Request) {
const { searchParams } = new URL(request.url);
const query = searchParams.get('q') || '';
const category = searchParams.get('category') || '';
const products = await getCachedProducts(query, category);
return Response.json(products);
}
Use the revalidateTag() or revalidatePath() functions to invalidate cached results when products are added, updated, or deleted.
Activity Instructions
- Compare filter approaches: Implement a product page with both filter dropdowns (category, price range) and a search input. Observe how they work together in the URL.
- Add TypeScript types: Create a
types/search.tsfile with all search-related interfaces. Apply them to your page and server functions. - Implement debounced search: Build a search component that waits 300ms after the user stops typing before triggering a search.
- Handle edge cases: Add empty state messaging, error boundaries, and loading skeletons to your search results.
- Profile performance: Use browser DevTools to measure how long search queries take. Add database indexes to searchable fields and compare the improvement.
Security note: Never expose database-specific details (like exact error messages) to the client. Always validate search parameters on the server and implement rate limiting to prevent search-based abuse or data scraping.