Module 28 — Search and Filtering in Next.js
Search and filtering are essential features for applications that contain a large amount of content.
A course platform might have:
1500+ Courses 21000+ Topics 3Thousands of Lessons
Showing everything on one page is not practical.
Instead, users should be able to:
1Search 2 ↓ 3Filter 4 ↓ 5Sort 6 ↓ 7Paginate 8 ↓ 9Find the required content
Next.js App Router works particularly well with URL-based search and filtering because search state can be represented directly in the URL.
Why URL Search Parameters Matter
Consider:
1/courses?search=nextjs
The URL contains:
1search=nextjs
This is called a query parameter or URL search parameter.
More examples:
1/courses?category=frontend 2 3/courses?page=2 4 5/courses?level=advanced 6 7/courses?search=react&level=beginner
The important advantage is that the search state becomes part of the URL.
1User searches "Next.js" 2 ↓ 3URL changes 4 ↓ 5/courses?search=nextjs 6 ↓ 7Server reads search parameter 8 ↓ 9Courses are filtered
Search Parameters vs Route Parameters
These concepts are different.
Route Parameter
Example:
1/courses/nextjs
Here:
1nextjs
is part of the route.
A dynamic route might be:
1app/ 2└── courses/ 3 └── [slug]/ 4 └── page.tsx
Search Parameter
Example:
1/courses?search=nextjs
Here:
1search=nextjs
is a URL search parameter.
The route is still:
1/courses
The search state is:
1?search=nextjs
Think:
1Route Parameter 2 ↓ 3/courses/nextjs 4 5Search Parameter 6 ↓ 7/courses?search=nextjs
Common Search URLs
A course platform might use:
1/courses?search=nextjs
Search by keyword:
1/courses?search=react
Filter by category:
1/courses?category=frontend
Filter by level:
1/courses?level=advanced
Pagination:
1/courses?page=2
Sorting:
1/courses?sort=newest
Multiple filters:
1/courses?category=frontend&level=advanced
Search + filtering:
1/courses?search=nextjs&category=frontend&level=advanced
Understanding searchParams
In a Next.js App Router page, search parameters can be accessed through the page's searchParams prop.
Example:
1type Props = { 2 searchParams: Promise<{ 3 search?: string; 4 }>; 5}; 6 7export default async function CoursesPage({ 8 searchParams, 9}: Props) { 10 const params = await searchParams; 11 12 const search = params.search ?? ""; 13 14 return ( 15 <main> 16 <h1>Courses</h1> 17 18 <p>Search: {search}</p> 19 </main> 20 ); 21}
For:
1/courses?search=nextjs
the value becomes:
1search = "nextjs"
Basic Search Implementation
A simple course page could look like:
1type Course = { 2 id: number; 3 title: string; 4}; 5 6type Props = { 7 searchParams: Promise<{ 8 search?: string; 9 }>; 10}; 11 12export default async function CoursesPage({ 13 searchParams, 14}: Props) { 15 const params = await searchParams; 16 17 const search = params.search ?? ""; 18 19 const courses: Course[] = [ 20 { 21 id: 1, 22 title: "Next.js Complete Course", 23 }, 24 { 25 id: 2, 26 title: "React Fundamentals", 27 }, 28 { 29 id: 3, 30 title: "Node.js Backend Development", 31 }, 32 ]; 33 34 const filteredCourses = courses.filter((course) => 35 course.title 36 .toLowerCase() 37 .includes(search.toLowerCase()) 38 ); 39 40 return ( 41 <main> 42 <h1>Courses</h1> 43 44 {filteredCourses.map((course) => ( 45 <div key={course.id}> 46 {course.title} 47 </div> 48 ))} 49 </main> 50 ); 51}
Now:
1/courses
shows all courses.
But:
1/courses?search=nextjs
returns:
1Next.js Complete Course
Building a Search Input
A search input can update the URL.
1"use client"; 2 3import { useRouter, useSearchParams } from "next/navigation"; 4 5export default function CourseSearch() { 6 const router = useRouter(); 7 const searchParams = useSearchParams(); 8 9 function handleSearch(value: string) { 10 const params = new URLSearchParams(searchParams); 11 12 if (value) { 13 params.set("search", value); 14 } else { 15 params.delete("search"); 16 } 17 18 router.push(`/courses?${params.toString()}`); 19 } 20 21 return ( 22 <input 23 type="search" 24 placeholder="Search courses..." 25 defaultValue={searchParams.get("search") ?? ""} 26 onChange={(event) => 27 handleSearch(event.target.value) 28 } 29 className="rounded-lg border px-4 py-2" 30 /> 31 ); 32}
The architecture becomes:
1Search Input 2 ↓ 3User types "nextjs" 4 ↓ 5URLSearchParams 6 ↓ 7router.push() 8 ↓ 9/courses?search=nextjs 10 ↓ 11Server reads searchParams 12 ↓ 13Filtered courses
Why Store Search in the URL?
URL-based search has several advantages.
Shareable
A user can copy:
1/courses?search=nextjs
and send it to another person.
Bookmarkable
The user can bookmark:
1/courses?category=frontend&level=advanced
and return to exactly that filtered view.
Browser History
Users can use:
1Back 2Forward
to navigate between search states.
SEO-Friendly
For appropriate public search/filter pages, URL parameters make the state discoverable as a distinct URL.
However, not every filter combination should necessarily be indexed. Large combinations of parameters can create many near-duplicate URLs, so canonical and robots strategies should be considered for production SEO.
Multiple Search Parameters
Real applications usually have more than one filter.
For example:
1/courses?search=nextjs&category=frontend&level=advanced
The page can read all of them:
1type Props = { 2 searchParams: Promise<{ 3 search?: string; 4 category?: string; 5 level?: string; 6 }>; 7}; 8 9export default async function CoursesPage({ 10 searchParams, 11}: Props) { 12 const params = await searchParams; 13 14 const search = params.search ?? ""; 15 const category = params.category ?? ""; 16 const level = params.level ?? ""; 17 18 return ( 19 <main> 20 <p>Search: {search}</p> 21 <p>Category: {category}</p> 22 <p>Level: {level}</p> 23 </main> 24 ); 25}
This gives you a clean filtering model:
1URL 2 │ 3 ├── search 4 ├── category 5 └── level 6 ↓ 7Search Parameters 8 ↓ 9Filtering Logic 10 ↓ 11Course Results
Combining Search and Filtering
Suppose the database contains:
1Next.js Fundamentals 2Next.js Advanced 3React Fundamentals 4Node.js Advanced 5Docker Fundamentals
The user selects:
1Search: next 2Category: frontend 3Level: advanced
The URL becomes:
1/courses?search=next&category=frontend&level=advanced
The backend can then apply:
1search = next 2AND 3category = frontend 4AND 5level = advanced
Result:
1Next.js Advanced
Server-Side Filtering
For a large application, filtering should usually happen on the server or database rather than downloading every course to the browser.
Instead of:
1Database 2 ↓ 310,000 Courses 4 ↓ 5Browser 6 ↓ 7JavaScript filters 10,000 records
prefer:
1Browser 2 ↓ 3/courses?search=nextjs 4 ↓ 5Server 6 ↓ 7Database query 8 ↓ 9Only matching courses 10 ↓ 11Browser
This reduces unnecessary data transfer and client-side work.
Database Filtering Example
Imagine a Django API:
1GET /api/courses?search=nextjs&level=advanced
The Next.js server can call it:
1async function getCourses( 2 search: string, 3 level: string 4) { 5 const params = new URLSearchParams(); 6 7 if (search) { 8 params.set("search", search); 9 } 10 11 if (level) { 12 params.set("level", level); 13 } 14 15 const response = await fetch( 16 `https://api.example.com/courses?${params.toString()}` 17 ); 18 19 if (!response.ok) { 20 throw new Error("Failed to fetch courses"); 21 } 22 23 return response.json(); 24}
Then:
1export default async function CoursesPage({ 2 searchParams, 3}: { 4 searchParams: Promise<{ 5 search?: string; 6 level?: string; 7 }>; 8}) { 9 const params = await searchParams; 10 11 const courses = await getCourses( 12 params.search ?? "", 13 params.level ?? "" 14 ); 15 16 return ( 17 <main> 18 {courses.map((course: Course) => ( 19 <div key={course.id}> 20 {course.title} 21 </div> 22 ))} 23 </main> 24 ); 25}
The architecture is:
1URL 2 ↓ 3searchParams 4 ↓ 5getCourses() 6 ↓ 7Backend API 8 ↓ 9Database 10 ↓ 11Filtered Results
Pagination
Pagination is necessary when the result set becomes large.
Instead of:
1/courses
returning:
110,000 courses
you can return:
1/courses?page=1 2/courses?page=2 3/courses?page=3
A typical API might support:
1/api/courses?page=2&limit=20
The server returns only 20 courses.
Pagination Example
1type Props = { 2 searchParams: Promise<{ 3 page?: string; 4 }>; 5}; 6 7export default async function CoursesPage({ 8 searchParams, 9}: Props) { 10 const params = await searchParams; 11 12 const page = Number(params.page ?? "1"); 13 14 const response = await fetch( 15 `https://api.example.com/courses?page=${page}&limit=20` 16 ); 17 18 const data = await response.json(); 19 20 return ( 21 <main> 22 <h1>Courses</h1> 23 24 {data.results.map((course: Course) => ( 25 <div key={course.id}> 26 {course.title} 27 </div> 28 ))} 29 </main> 30 ); 31}
Combining Pagination and Search
A real URL might be:
1/courses?search=nextjs&page=2
This means:
1Search: 2nextjs 3 4Page: 52
The API request can become:
1/api/courses?search=nextjs&page=2&limit=20
Architecture:
1Search 2 ↓ 3Filter 4 ↓ 5Pagination 6 ↓ 7Database 8 ↓ 920 matching results
Important Pagination Rule
When the search changes, you generally want to reset the page.
Suppose the user is currently on:
1/courses?search=react&page=5
Then they search for:
1nextjs
You usually want:
1/courses?search=nextjs&page=1
rather than:
1/courses?search=nextjs&page=5
because the new search may have only one or two pages.
Sorting
Users often need sorting options:
1Newest 2Oldest 3Most Popular 4A-Z 5Z-A
Represent the selection in the URL:
1/courses?sort=newest
or:
1/courses?sort=popular
Example:
1type Props = { 2 searchParams: Promise<{ 3 sort?: string; 4 }>; 5}; 6 7export default async function CoursesPage({ 8 searchParams, 9}: Props) { 10 const params = await searchParams; 11 12 const sort = params.sort ?? "newest"; 13 14 const courses = await getCourses({ 15 sort, 16 }); 17 18 return ( 19 <main> 20 <h1>Courses</h1> 21 22 {/* Render courses */} 23 </main> 24 ); 25}
Search + Filter + Sort + Pagination
A complete course URL could be:
1/courses? 2search=nextjs 3&category=frontend 4&level=advanced 5&sort=newest 6&page=2
In one line:
1/courses?search=nextjs&category=frontend&level=advanced&sort=newest&page=2
The architecture becomes:
1 URL 2 ↓ 3 searchParams 4 ↓ 5 ┌────────────┼────────────┐ 6 ↓ ↓ ↓ 7 Search Filters Sort 8 │ │ │ 9 └────────────┼────────────┘ 10 ↓ 11 Pagination 12 ↓ 13 API / DB 14 ↓ 15 Results
Debouncing Search
Without debouncing:
1User types: 2 3n 4ne 5nex 6next 7nextj 8nextjs
The browser might send six requests:
1n → API 2ne → API 3nex → API 4next → API 5nextj → API 6nextjs → API
This can create unnecessary network requests.
With debouncing:
1User types 2 ↓ 3Wait 300ms 4 ↓ 5No additional typing? 6 ↓ 7Send request
Only the final search is sent.
Debounce Example
1"use client"; 2 3import { useEffect, useState } from "react"; 4import { useRouter, useSearchParams } from "next/navigation"; 5 6export default function SearchInput() { 7 const router = useRouter(); 8 const searchParams = useSearchParams(); 9 10 const [value, setValue] = useState( 11 searchParams.get("search") ?? "" 12 ); 13 14 useEffect(() => { 15 const timer = setTimeout(() => { 16 const params = new URLSearchParams(searchParams); 17 18 if (value) { 19 params.set("search", value); 20 } else { 21 params.delete("search"); 22 } 23 24 params.delete("page"); 25 26 router.push(`/courses?${params.toString()}`); 27 }, 300); 28 29 return () => clearTimeout(timer); 30 }, [value, router, searchParams]); 31 32 return ( 33 <input 34 type="search" 35 value={value} 36 onChange={(event) => setValue(event.target.value)} 37 placeholder="Search courses..." 38 className="rounded-lg border px-4 py-2" 39 /> 40 ); 41}
The important concept is:
1Typing 2 ↓ 3Local state 4 ↓ 5300ms delay 6 ↓ 7Update URL 8 ↓ 9Server receives search parameter
Server-Side Search vs Client-Side Search
There are two major approaches.
Client-Side Search
All data is loaded first:
1API 2 ↓ 3All Courses 4 ↓ 5Browser 6 ↓ 7JavaScript Filter 8 ↓ 9Results
This works well when:
1Small dataset 2Simple filtering 3Already-loaded data 4Offline-like interaction
Example:
1const filteredCourses = courses.filter((course) => 2 course.title 3 .toLowerCase() 4 .includes(search.toLowerCase()) 5);
Server-Side Search
The search query is sent to the server:
1User 2 ↓ 3Search 4 ↓ 5URL 6 ↓ 7Next.js Server 8 ↓ 9API 10 ↓ 11Database 12 ↓ 13Matching courses
This is generally better for large datasets.
For example:
110 courses 2 ↓ 3Client-side filtering is easy 4 5100,000 courses 6 ↓ 7Server/database search is much more appropriate
When Should You Use Client-Side Search?
Use client-side filtering when:
1Dataset is small 2 + 3Data is already loaded 4 + 5Filtering is simple 6 + 7Fast local interaction is important
Example:
1Dashboard 2 ↓ 320 already-loaded notifications 4 ↓ 5Filter locally
When Should You Use Server-Side Search?
Prefer server-side searching when:
1Large dataset 2 + 3Database filtering 4 + 5Pagination 6 + 7Complex queries 8 + 9Authorization 10 + 11Search indexing
Example:
1Course Platform 2 ↓ 3100,000 courses 4 ↓ 5Search database 6 ↓ 7Return 20 results
Search Parameters and Server Components
A powerful App Router pattern is:
1Server Component 2 ↓ 3searchParams 4 ↓ 5API / Database 6 ↓ 7Filtered Results
Example:
1export default async function CoursesPage({ 2 searchParams, 3}: { 4 searchParams: Promise<{ 5 search?: string; 6 }>; 7}) { 8 const { search = "" } = await searchParams; 9 10 const courses = await getCourses(search); 11 12 return ( 13 <main> 14 <h1>Courses</h1> 15 16 {courses.map((course: Course) => ( 17 <article key={course.id}> 18 <h2>{course.title}</h2> 19 </article> 20 ))} 21 </main> 22 ); 23}
This keeps the data-fetching logic on the server.
Search Form with GET
For simple search, an HTML form using GET is a very useful pattern.
1export default function SearchForm() { 2 return ( 3 <form action="/courses" method="GET"> 4 <input 5 type="search" 6 name="search" 7 placeholder="Search courses..." 8 /> 9 10 <button type="submit"> 11 Search 12 </button> 13 </form> 14 ); 15}
When the user enters:
1nextjs
the browser navigates to:
1/courses?search=nextjs
This is simple, semantic, and naturally URL-based.
Filter Form
You can also combine fields:
1<form action="/courses" method="GET"> 2 <input 3 type="search" 4 name="search" 5 placeholder="Search..." 6 /> 7 8 <select name="category"> 9 <option value="">All categories</option> 10 <option value="frontend">Frontend</option> 11 <option value="backend">Backend</option> 12 </select> 13 14 <select name="level"> 15 <option value="">All levels</option> 16 <option value="beginner">Beginner</option> 17 <option value="advanced">Advanced</option> 18 </select> 19 20 <button type="submit"> 21 Apply Filters 22 </button> 23</form>
A submission might produce:
1/courses?search=nextjs&category=frontend&level=advanced
This is an excellent pattern for content-heavy applications.
Preserving Existing Filters
When adding a new filter, avoid accidentally removing existing search parameters.
Use:
1const params = new URLSearchParams(searchParams); 2 3params.set("category", "frontend"); 4 5router.push(`/courses?${params.toString()}`);
Instead of creating an entirely new query string.
For example:
1Before: 2/courses?search=nextjs&level=advanced 3 4Add category: 5 6/courses?search=nextjs&level=advanced&category=frontend
This preserves the existing state.
Reset Filters
A reset button can simply navigate back to:
1/courses
Example:
1import Link from "next/link"; 2 3export function ResetFilters() { 4 return ( 5 <Link href="/courses"> 6 Reset Filters 7 </Link> 8 ); 9}
This clears:
1search 2category 3level 4sort 5page
and returns to the default course listing.
Search Architecture for a Course Platform
A production course platform might use:
1 /courses 2 ↓ 3 Search Controls 4 ↓ 5 URL Search Parameters 6 ↓ 7 ┌─────────────────────┼─────────────────────┐ 8 ↓ ↓ ↓ 9 Search Filters Sort 10 │ │ │ 11 └─────────────────────┼─────────────────────┘ 12 ↓ 13 Pagination 14 ↓ 15 Server Component 16 ↓ 17 Service Function 18 ↓ 19 API 20 ↓ 21 Database 22 ↓ 23 Filtered Results
For example:
1/courses?search=nextjs
becomes:
1Courses Page 2 ↓ 3searchParams.search 4 ↓ 5getCourses({ search: "nextjs" }) 6 ↓ 7GET /api/courses?search=nextjs 8 ↓ 9Backend 10 ↓ 11PostgreSQL 12 ↓ 13Matching courses
Recommended Project Structure
A scalable implementation can separate the UI, service, and database/API logic:
1app/ 2└── courses/ 3 ├── page.tsx 4 ├── loading.tsx 5 └── components/ 6 ├── search-form.tsx 7 ├── course-filters.tsx 8 ├── sort-select.tsx 9 └── pagination.tsx 10 11lib/ 12└── api/ 13 └── courses.ts
Then:
1page.tsx 2 ↓ 3searchParams 4 ↓ 5getCourses() 6 ↓ 7API
The page does not need to contain every API request or filtering implementation.
Complete Example
lib/api/courses.ts
1export type CourseFilters = { 2 search?: string; 3 category?: string; 4 level?: string; 5 sort?: string; 6 page?: number; 7}; 8 9export async function getCourses( 10 filters: CourseFilters 11) { 12 const params = new URLSearchParams(); 13 14 if (filters.search) { 15 params.set("search", filters.search); 16 } 17 18 if (filters.category) { 19 params.set("category", filters.category); 20 } 21 22 if (filters.level) { 23 params.set("level", filters.level); 24 } 25 26 if (filters.sort) { 27 params.set("sort", filters.sort); 28 } 29 30 if (filters.page) { 31 params.set("page", String(filters.page)); 32 } 33 34 const response = await fetch( 35 `https://api.example.com/courses?${params.toString()}` 36 ); 37 38 if (!response.ok) { 39 throw new Error("Failed to fetch courses"); 40 } 41 42 return response.json(); 43}
app/courses/page.tsx
1import { getCourses } from "@/lib/api/courses"; 2 3type Props = { 4 searchParams: Promise<{ 5 search?: string; 6 category?: string; 7 level?: string; 8 sort?: string; 9 page?: string; 10 }>; 11}; 12 13export default async function CoursesPage({ 14 searchParams, 15}: Props) { 16 const params = await searchParams; 17 18 const courses = await getCourses({ 19 search: params.search, 20 category: params.category, 21 level: params.level, 22 sort: params.sort, 23 page: Number(params.page ?? "1"), 24 }); 25 26 return ( 27 <main> 28 <h1>Courses</h1> 29 30 {courses.results.map((course: Course) => ( 31 <article key={course.id}> 32 <h2>{course.title}</h2> 33 </article> 34 ))} 35 </main> 36 ); 37}
This creates a clean architecture:
1URL 2 ↓ 3searchParams 4 ↓ 5CourseFilters 6 ↓ 7getCourses() 8 ↓ 9API 10 ↓ 11Database 12 ↓ 13Results
Performance Considerations
Search and filtering can become expensive at scale.
For large applications, consider:
1Database indexes 2Search indexes 3Pagination 4Debouncing 5Caching 6Server-side filtering 7Result limits 8Efficient SQL queries
For example, searching millions of database rows should not normally mean loading millions of records into a Next.js server and filtering them with JavaScript.
Instead:
1Bad: 2 3Database 4 ↓ 51,000,000 rows 6 ↓ 7Next.js 8 ↓ 9JavaScript filter
Prefer:
1Better: 2 3Search Query 4 ↓ 5Database Index 6 ↓ 7Matching Rows 8 ↓ 9Pagination 10 ↓ 11Next.js
Common Mistakes
Mistake 1 — Filtering Everything in the Browser
Avoid:
1100,000 courses 2 ↓ 3Browser 4 ↓ 5JavaScript filter
for large datasets.
Use server/database filtering instead.
Mistake 2 — Putting Search State Only in React State
This:
1const [search, setSearch] = useState("");
can be useful for temporary input state, but if search is part of the page's actual navigation state, the URL is often a better source of truth.
Prefer:
1Input 2 ↓ 3URL 4 ↓ 5searchParams 6 ↓ 7Server
Mistake 3 — Losing Existing Filters
If the URL is:
1/courses?search=nextjs&level=advanced
and the user changes the category, don't accidentally produce:
1/courses?category=frontend
unless clearing the other filters is intentional.
Mistake 4 — Forgetting Pagination
Returning every matching record can become expensive.
Prefer:
120 results 2+ 3next page
instead of:
110,000 results
Mistake 5 — Not Resetting Pagination
When search or filters change:
1page=5
may no longer be valid.
Usually reset:
1page=1
when the primary search/filter changes.
Learning Goal
After completing this module, you should understand:
- Search inputs
- URL search parameters
searchParams- Query strings
- Server-side filtering
- Client-side filtering
- Pagination
- Sorting
- Debouncing
- Search forms
- Multiple filters
- URL state
- API filtering
- Database filtering
- Preserving query parameters
- Resetting filters
- Search architecture
- Performance considerations
The most important architecture to remember is:
1User Input 2 ↓ 3URL Search Parameters 4 ↓ 5searchParams 6 ↓ 7Server Component 8 ↓ 9Service Function 10 ↓ 11API / Database 12 ↓ 13Filtered + Sorted + Paginated Results
For a production Next.js application, treat the URL as the source of truth for navigational search and filter state, and move large-scale filtering, sorting, and pagination to the server/database.
SEO Metadata for This Tutorial Page
Meta Title:
Meta Description: SEO Keywords: