WDD 430: Web Full-Stack Development

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:

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

  1. 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.
  2. Add TypeScript types: Create a types/search.ts file with all search-related interfaces. Apply them to your page and server functions.
  3. Implement debounced search: Build a search component that waits 300ms after the user stops typing before triggering a search.
  4. Handle edge cases: Add empty state messaging, error boundaries, and loading skeletons to your search results.
  5. 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.