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:
- Shareable URLs: Users can share a search result URL and recipients see exactly the same filtered data.
- Browser history: The back button works naturally when navigating between different searches.
- Bookmarking: Users can save search results as bookmarks and return to them later.
- SEO-friendly: Search engines can index different variations of filtered content.
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:
- User enters search text in a form input.
- Form submits (or navigation occurs) with the search query in the URL as a parameter.
- Next.js page component receives
searchParams. - Page component passes the search query to a server function or API route.
- Database query filters results based on the search term.
- 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:
- Server-side search: Scales to any data size, but requires a network request for each search. Best for production applications.
- Client-side search: Instant results from already-loaded data, but limited to data already on the page. Good for small, pre-loaded datasets.
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:
useSearchParams()— reads current URL query paramsusePathname()— reads the current route pathuseRouter()— providesreplace()to update the URL
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.
- Create a
ProjectSearchClient Component that usesuseSearchParams,usePathname, anduseRouterto write the search term and a resetpage=1to the URL on every (debounced) keystroke. - Update
app/projects/page.tsxto readsearchParams.queryandsearchParams.pageand pass them to your data-fetching functions. - Add
fetchFilteredProjectsandfetchProjectsPagesfunctions that filter and count your projects by query string. - Build a
PaginationClient Component that receivestotalPagesand renders prev/next links using acreatePageURLhelper (constructed fromusePathnameanduseSearchParams). - 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.