Search & Filtering with URL Search Params

Step 26 of 31Next.js Tutorial Series | Source code for this step

Live Demo →


What You Will Build

↑ Index

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:

  1. Add a search input to the posts page
  2. Use URL search params (?q=hello&page=1) to store the search query — making searches shareable and bookmark-friendly
  3. Filter posts server-side using Prisma's contains filter
  4. Build a SearchBar Client Component that updates the URL as the user types
  5. 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

  1. The URL-as-State Pattern
  2. Updating PostList to Support Search
  3. Building the SearchBar Component
  4. Updating the Posts Page
  5. How It All Fits Together
  6. Debouncing the Search Input
  7. Verify the Search
  8. Summary & Key Takeaways

The URL-as-State Pattern

↑ Index

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?

ApproachShareable?Survives refresh?Bookmarkable?Server-side?
useStateNoNoNoNo
URL searchParamsYesYesYesYes

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

↑ Index

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 &ldquo;{query}&rdquo; &middot;{" "}
          </span>
        )}
        {total} {total === 1 ? "post" : "posts"} &middot; 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"} &middot;{" "}
              {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"
          >
            &larr; 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 &rarr;
          </Link>
        ) : (
          <span />
        )}
      </div>
    </>
  );
}

What changed

  1. New query prop — receives the search query from the parent page
  2. where clause — uses Prisma's contains with mode: "insensitive" for case-insensitive search. When query is empty, where is {} (no filter — show all posts).
  3. Count respects the filterprisma.post.count({ where }) counts only matching posts, so the pagination numbers are correct
  4. Empty state — shows a helpful message when no posts match the search
  5. Pagination links preserve the query — uses URLSearchParams to include q in 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

↑ Index

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

  1. "use client" is required — this component uses useRouter and useSearchParams, which are client-side hooks

  2. useSearchParams() — reads the current URL search params. We use it to get the current query and set it as the input's defaultValue, so if the user refreshes the page or follows a search link, the input shows the current search term.

  3. 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.

  4. defaultValue vs value — we use defaultValue (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.

  5. 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

↑ Index

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

  1. searchParams type updated — now includes q?: string alongside page?: string
  2. query extractedconst query = q ?? "" — defaults to empty string if no search param
  3. SearchBar rendered — wrapped in <Suspense> because it uses useSearchParams(), which Next.js requires to be wrapped in Suspense (to enable streaming)
  4. Suspense key includes query — changed from key={page} to key={`${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

↑ Index

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

↑ Index

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

  1. When the user types, handleSearch is called
  2. If there's already a pending timeout, cancel it
  3. Start a new timeout for 300ms
  4. 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

↑ Index

  1. Start the dev server: pnpm dev
  2. Visit http://localhost:3000/posts
  3. You should see the search input above the posts
  4. Type a word that matches some post titles — the list should filter
  5. Check the URL — it should update to /posts?q=yourquery&page=1
  6. Clear the search input — all posts should reappear
  7. 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

↑ Index

ConceptDetails
URL as stateSearch query lives in ?q=... — shareable, bookmarkable, server-accessible
searchParamsServer-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 containsCase-insensitive substring search — mode: "insensitive"
DebouncingWait 300ms after the last keystroke before navigating — reduces server requests
useRef for timerStore the debounce timer without causing re-renders
Page resetAlways reset to page 1 when the search query changes
defaultValueUncontrolled 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.