WDD 430: Web Full-Stack Development

Adding Search and Pagination

Overview

As your application grows and you have more content to display, implementing search and pagination becomes essential. These features help users find specific content efficiently and navigate through large datasets without overwhelming performance or the user interface. In this activity, you'll add URL-based search, server-side filtering, and pagination to your portfolio project.

Prepare

Query Parameters and URL State

Modern web applications use URL query parameters to store search and filter state. Instead of keeping search state in React component state, URL-based state has significant advantages:

A search URL for your portfolio might the following: /projects?query=typescript&page=1

In Next.js, the query parameters are accessed via the searchParams prop passed to page components.

Server-Side Search Logic

Search should typically happen on the server for best performance and security. When a user submits a search query via URL parameters, the server processes it to filter database results.

A typical server-side search flow:

  1. User enters search text in a form input.
  2. Form submits (or navigation occurs) with the search query in the URL as a parameter.
  3. Next.js page component receives searchParams.
  4. Page component passes the search query to a server function or API route.
  5. Database query filters results based on the search term.
  6. Results render on the page.

Example database query with search:

SELECT * FROM projects
WHERE title ILIKE '%typescript%' OR description ILIKE '%typescript%'
LIMIT 6;

Client-Side vs. Server-Side Search

While server-side search is preferred for large datasets, you should understand the tradeoffs:

When implementing search in Next.js, follow this pattern:

// app/projects/page.tsx
export default async function ProjectsPage(props: {
  searchParams?: Promise<{ query?: string; page?: string }>;
}) {
  const searchParams = await props.searchParams;
  const query = searchParams?.query || '';
  const currentPage = Number(searchParams?.page) || 1;

  const projects = await fetchFilteredProjects(query, currentPage);

  return (
    <div>
      <ProjectSearch />
      <ProjectList projects={projects} />
    </div>
  );
}

The ProjectSearch component is a Client Component that captures user input and updates the URL, triggering a server-side re-render with the new search parameters.

URL Synchronization in Next.js

In Next.js App Router, Client Components use three hooks together to read and update URL params without a full page reload:

Using replace() (not push()) avoids adding a new browser history entry on every keystroke, keeping the back button predictable.

// components/ProjectSearch.tsx
'use client';

import { useSearchParams, usePathname, useRouter } from 'next/navigation';

export function ProjectSearch() {
  const searchParams = useSearchParams();
  const pathname = usePathname();
  const { replace } = useRouter();

  function handleSearch(term: string) {
    const params = new URLSearchParams(searchParams);
    params.set('page', '1'); // reset to page 1 on every new search
    if (term) {
      params.set('query', term);
    } else {
      params.delete('query');
    }
    replace(`${pathname}?${params.toString()}`);
  }

  return (
    <input
      type="search"
      placeholder="Search projects..."
      defaultValue={searchParams.get('query')?.toString()}
      onChange={(e) => handleSearch(e.target.value)}
    />
  );
}

Why defaultValue instead of value? Because the URL is the source of truth — not component state. Using defaultValue lets the browser manage the input's state, and the input stays in sync with the URL when a user shares or bookmarks the link.

Pagination as Companion to Search

Pagination divides large result sets into manageable pages. Like search, pagination state should be stored in the URL:

/projects?query=typescript&page=2

Paginated data-fetching functions for your portfolio:

const ITEMS_PER_PAGE = 6;

export async function fetchFilteredProjects(query: string, currentPage: number) {
  const offset = (currentPage - 1) * ITEMS_PER_PAGE;

  const projects = await db.project.findMany({
    where: {
      OR: [
        { title: { contains: query, mode: 'insensitive' } },
        { description: { contains: query, mode: 'insensitive' } },
        { technologies: { hasSome: [query] } },
      ],
    },
    skip: offset,
    take: ITEMS_PER_PAGE,
  });

  return projects;
}

export async function fetchProjectsPages(query: string) {
  const count = await db.project.count({
    where: {
      OR: [
        { title: { contains: query, mode: 'insensitive' } },
        { description: { contains: query, mode: 'insensitive' } },
        { technologies: { hasSome: [query] } },
      ],
    },
  });

  return Math.ceil(count / ITEMS_PER_PAGE);
}

The total count is needed to calculate how many pages exist so pagination controls can render correctly.

Best Practice: Debouncing

With onChange, handleSearch fires on every keystroke — sending a server request for each character typed. Debouncing delays execution until the user pauses typing.

Install the use-debounce library:

npm install use-debounce

Then wrap handleSearch with useDebouncedCallback:

import { useDebouncedCallback } from 'use-debounce';

const handleSearch = useDebouncedCallback((term: string) => {
  const params = new URLSearchParams(searchParams);
  params.set('page', '1');
  if (term) {
    params.set('query', term);
  } else {
    params.delete('query');
  }
  replace(`${pathname}?${params.toString()}`);
}, 300);

The 300 is a millisecond delay. The function only runs after the user stops typing for 300ms, reducing server requests from one-per-keystroke to one-per-pause.

Activity Instructions

Add search and pagination to the /projects page in your wdd430-portfolio project.

  1. Create a ProjectSearch Client Component that uses useSearchParams, usePathname, and useRouter to write the search term and a reset page=1 to the URL on every (debounced) keystroke.
  2. Update app/projects/page.tsx to read searchParams.query and searchParams.page and pass them to your data-fetching functions.
  3. Add fetchFilteredProjects and fetchProjectsPages functions that filter and count your projects by query string.
  4. Build a Pagination Client Component that receives totalPages and renders prev/next links using a createPageURL helper (constructed from usePathname and useSearchParams).
  5. Verify observable behaviors:
    • Typing a search term updates the URL and filters cards — without a full page reload.
    • Pasting the URL in a new tab restores the same filtered state.
    • The browser back button returns to the previous search.
    • Starting a new search always resets to page 1.

Never trust user-provided search queries in URLs. Always validate and sanitize search parameters on the server before using them in database queries to prevent SQL injection attacks.