Search & Filtering with URL Search Params
Step 26 of 31 — Next.js Tutorial Series | Source code for this step
What You Will Build
Our posts page has pagination (from Steps 12 and 20) but no way to search for posts. Users have to scroll through pages to find what they're looking for.
In this step, you will:
- Add a search input to the posts page
- Use URL search params (
?q=hello&page=1) to store the search query — making searches shareable and bookmark-friendly - Filter posts server-side using Prisma's
containsfilter - Build a SearchBar Client Component that updates the URL as the user types
- Reset pagination to page 1 when the search query changes
Goal: Learn the URL-as-state pattern for search — combining searchParams (server), useSearchParams (client), and useRouter (client) to build a search feature that works entirely through the URL.
Table of Contents
- The URL-as-State Pattern
- Updating PostList to Support Search
- Building the SearchBar Component
- Updating the Posts Page
- How It All Fits Together
- Debouncing the Search Input
- Verify the Search
- Summary & Key Takeaways
The URL-as-State Pattern
In Step 20, we moved pagination from useState to the URL (?page=2). We're doing the same thing for search: the query lives in the URL as ?q=hello.
/posts?q=hello&page=2
Why the URL?
| Approach | Shareable? | Survives refresh? | Bookmarkable? | Server-side? |
|---|---|---|---|---|
useState | No | No | No | No |
URL searchParams | Yes | Yes | Yes | Yes |
When the search query is in the URL:
- Users can share a search link: "Here are the posts about React →
/posts?q=react" - The page works on refresh — the search query is preserved
- Search engines can index search results (each query is a unique URL)
- The query is available on the server — Prisma filters the data before sending it to the client
The data flow
1. User types "hello" in SearchBar (Client Component)
2. SearchBar updates the URL → /posts?q=hello&page=1
3. Next.js re-renders page.tsx (Server Component)
4. page.tsx reads searchParams.q → "hello"
5. PostList queries Prisma with WHERE title CONTAINS "hello"
6. Filtered posts are rendered
Updating PostList to Support Search
Update app/posts/PostList.tsx to accept a query prop and filter posts:
import prisma from "@/lib/prisma";
import Link from "next/link";
const POSTS_PER_PAGE = 5;
type Props = {
page: number;
query: string;
};
export default async function PostList({ page, query }: Props) {
const skip = (page - 1) * POSTS_PER_PAGE;
const where = query
? {
title: {
contains: query,
mode: "insensitive" as const,
},
}
: {};
const [posts, total] = await Promise.all([
prisma.post.findMany({
where,
include: { author: true },
orderBy: { createdAt: "desc" },
take: POSTS_PER_PAGE,
skip,
}),
prisma.post.count({ where }),
]);
const totalPages = Math.ceil(total / POSTS_PER_PAGE);
if (posts.length === 0) {
return (
<p className="text-gray-500">
{query
? `No posts found for "${query}".`
: "No posts yet."}
</p>
);
}
return (
<>
<p className="mb-8 text-gray-600">
{query && (
<span>
Results for “{query}” ·{" "}
</span>
)}
{total} {total === 1 ? "post" : "posts"} · Page {page} of{" "}
{totalPages}
</p>
<div className="space-y-4">
{posts.map((post) => (
<div key={post.id} className="p-6 bg-white rounded-lg shadow-md">
<Link href={`/posts/${post.id}`}>
<h3 className="mb-1 text-xl font-semibold text-teal-600 hover:text-teal-700">
{post.title}
</h3>
</Link>
<p className="mb-3 text-sm text-gray-500">
By {post.author?.name ?? "Unknown"} ·{" "}
{new Date(post.createdAt).toLocaleDateString()}
</p>
<p className="text-gray-700">{post.content}</p>
</div>
))}
</div>
<div className="flex items-center gap-4 mt-8">
{page > 1 ? (
<Link
href={`/posts?${new URLSearchParams({ ...(query && { q: query }), page: String(page - 1) })}`}
className="px-4 py-2 text-sm font-medium text-white bg-teal-600 rounded hover:bg-teal-700"
>
← Previous
</Link>
) : (
<span />
)}
<span className="text-sm text-gray-600">
Page {page} of {totalPages}
</span>
{page < totalPages ? (
<Link
href={`/posts?${new URLSearchParams({ ...(query && { q: query }), page: String(page + 1) })}`}
className="px-4 py-2 text-sm font-medium text-white bg-teal-600 rounded hover:bg-teal-700"
>
Next →
</Link>
) : (
<span />
)}
</div>
</>
);
}
What changed
- New
queryprop — receives the search query from the parent page whereclause — uses Prisma'scontainswithmode: "insensitive"for case-insensitive search. Whenqueryis empty,whereis{}(no filter — show all posts).- Count respects the filter —
prisma.post.count({ where })counts only matching posts, so the pagination numbers are correct - Empty state — shows a helpful message when no posts match the search
- Pagination links preserve the query — uses
URLSearchParamsto includeqin the pagination links. If the user searches for "react" and goes to page 2, the URL is/posts?q=react&page=2.
Prisma's contains filter
prisma.post.findMany({
where: {
title: {
contains: "hello", // SQL: WHERE title LIKE '%hello%'
mode: "insensitive", // SQL: WHERE title ILIKE '%hello%' (case-insensitive)
},
},
});
This generates a SQL ILIKE query on PostgreSQL, which matches "Hello", "HELLO", "hello", etc.
Building the SearchBar Component
Create app/posts/SearchBar.tsx:
"use client";
import { useRouter, useSearchParams } from "next/navigation";
export default function SearchBar() {
const router = useRouter();
const searchParams = useSearchParams();
const query = searchParams.get("q") ?? "";
function handleSearch(term: string) {
const params = new URLSearchParams();
if (term) {
params.set("q", term);
}
// Always reset to page 1 when searching
params.set("page", "1");
router.push(`/posts?${params.toString()}`);
}
return (
<input
type="search"
placeholder="Search posts by title..."
defaultValue={query}
onChange={(e) => handleSearch(e.target.value)}
className="w-full px-4 py-2 mb-6 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-teal-500"
/>
);
}
Key details
-
"use client"is required — this component usesuseRouteranduseSearchParams, which are client-side hooks -
useSearchParams()— reads the current URL search params. We use it to get the current query and set it as the input'sdefaultValue, so if the user refreshes the page or follows a search link, the input shows the current search term. -
useRouter().push()— navigates to a new URL, which triggers a server-side re-render. When the user types, we build a new URL and push it. -
defaultValuevsvalue— we usedefaultValue(uncontrolled input) because the source of truth is the URL, not React state. The input initializes from the URL but isn't continuously controlled by it. This avoids the input "jumping" during navigation. -
Page reset — when the search term changes, we always set
page=1. If the user is on page 3 and searches for something, they should see page 1 of the results, not page 3 (which might not exist for the filtered results).
Updating the Posts Page
Update app/posts/page.tsx to read the search query and pass it to both the SearchBar and PostList:
import type { Metadata } from "next";
import { Suspense } from "react";
import PostList from "./PostList";
import SearchBar from "./SearchBar";
export const metadata: Metadata = {
title: "All Posts",
description:
"Browse all blog posts. A fullstack blog built with Next.js, Prisma, and PostgreSQL.",
};
type Props = {
searchParams: Promise<{ page?: string; q?: string }>;
};
export default async function PostsPage({ searchParams }: Props) {
const { page: pageParam, q } = await searchParams;
const page = Math.max(1, Number(pageParam) || 1);
const query = q ?? "";
return (
<>
<h2 className="mb-4 text-3xl font-bold text-gray-900">All Posts</h2>
<Suspense fallback={null}>
<SearchBar />
</Suspense>
<Suspense
key={`${query}-${page}`}
fallback={<p className="text-gray-500">Loading...</p>}
>
<PostList page={page} query={query} />
</Suspense>
</>
);
}
What changed
searchParamstype updated — now includesq?: stringalongsidepage?: stringqueryextracted —const query = q ?? ""— defaults to empty string if no search param- SearchBar rendered — wrapped in
<Suspense>because it usesuseSearchParams(), which Next.js requires to be wrapped in Suspense (to enable streaming) - Suspense key includes query — changed from
key={page}tokey={`${query}-${page}`}so the loading state re-triggers when the search query changes too
Why wrap SearchBar in Suspense?
useSearchParams() marks the component as reading from the browser's URL at render time. In Next.js, any component using useSearchParams() should be wrapped in <Suspense> to avoid blocking the entire page from being server-rendered. We use fallback={null} because the search bar is small and doesn't need a loading skeleton.
How It All Fits Together
User types "react" in SearchBar
│
▼
SearchBar calls router.push("/posts?q=react&page=1")
│
▼
Next.js re-renders PostsPage (Server Component)
│
├── reads searchParams: { q: "react", page: "1" }
│
▼
PostList runs on the server
│
├── prisma.post.findMany({ where: { title: { contains: "react" } } })
├── prisma.post.count({ where: ... })
│
▼
Filtered posts are streamed to the browser
The entire search is server-side. The database does the filtering, not the browser. This means:
- No wasted bandwidth — only matching posts are sent to the client
- Works with large datasets — the database handles the query efficiently
- SEO-friendly — search results pages are server-rendered
Debouncing the Search Input
Right now, every keystroke triggers a navigation. If the user types "react" (5 characters), that's 5 navigations: /posts?q=r, /posts?q=re, /posts?q=rea, /posts?q=reac, /posts?q=react. Each one triggers a server render and database query.
To avoid this, add debouncing — wait until the user stops typing before navigating.
Update app/posts/SearchBar.tsx:
"use client";
import { useRouter, useSearchParams } from "next/navigation";
import { useRef } from "react";
export default function SearchBar() {
const router = useRouter();
const searchParams = useSearchParams();
const query = searchParams.get("q") ?? "";
const timerRef = useRef<ReturnType<typeof setTimeout>>(null);
function handleSearch(term: string) {
if (timerRef.current) {
clearTimeout(timerRef.current);
}
timerRef.current = setTimeout(() => {
const params = new URLSearchParams();
if (term) {
params.set("q", term);
}
params.set("page", "1");
router.push(`/posts?${params.toString()}`);
}, 300);
}
return (
<input
type="search"
placeholder="Search posts by title..."
defaultValue={query}
onChange={(e) => handleSearch(e.target.value)}
className="w-full px-4 py-2 mb-6 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-teal-500"
/>
);
}
How debouncing works
- When the user types,
handleSearchis called - If there's already a pending timeout, cancel it
- Start a new timeout for 300ms
- Only navigate when the user stops typing for 300ms
This means typing "react" triggers one navigation instead of five. The 300ms delay is a sweet spot — fast enough to feel responsive, slow enough to batch keystrokes.
Why useRef for the timer?
We use useRef instead of useState because:
- Changing a ref does not trigger a re-render
- We don't want to re-render the component every time the timer resets
- The timer ID is internal state that the UI doesn't depend on
Verify the Search
- Start the dev server:
pnpm dev - Visit
http://localhost:3000/posts - You should see the search input above the posts
- Type a word that matches some post titles — the list should filter
- Check the URL — it should update to
/posts?q=yourquery&page=1 - Clear the search input — all posts should reappear
- Search for something, then use the pagination links — the query should be preserved in the URL
Edge cases to test
- Search for a term with no results — you should see "No posts found for [term]"
- Refresh the page while searching — the search should persist (because it's in the URL)
- Copy the URL and open in a new tab — the search should work
- Navigate away and back — the search should reset (this is expected since the URL changes)
Summary & Key Takeaways
| Concept | Details |
|---|---|
| URL as state | Search query lives in ?q=... — shareable, bookmarkable, server-accessible |
searchParams | Server-side access to URL query params (a Promise in Next.js 16) |
useSearchParams() | Client-side hook to read URL query params |
useRouter().push() | Client-side navigation — updates the URL and triggers a server re-render |
Prisma contains | Case-insensitive substring search — mode: "insensitive" |
| Debouncing | Wait 300ms after the last keystroke before navigating — reduces server requests |
useRef for timer | Store the debounce timer without causing re-renders |
| Page reset | Always reset to page 1 when the search query changes |
defaultValue | Uncontrolled input — initializes from the URL but doesn't fight with navigation updates |
What is Next
In Step 27, we will refactor our create post form to use useActionState — the React 19 pattern for pending states, server validation errors, and progressive enhancement in forms.