CodingNic

Search & Pagination

Add Search to the Contacts Query

Search & Pagination 20 min read

Add Search to the Contacts Query

Add Search to the Contacts Query

Extend the query function

Open lib/contacts.ts. Replace getContacts with a version that accepts
an optional search term:

typescript
export type ContactQuery = {
  q?: string;
};

export async function getContacts(opts: ContactQuery = {}): Promise<Contact[]> {
  const conditions: string[] = [];
  const params: any[] = [];

  if (opts.q) {
    params.push(`%${opts.q.toLowerCase()}%`);
    conditions.push(
      `(lower(c.name) LIKE $${params.length} OR lower(coalesce(c.email,'')) LIKE $${params.length} OR lower(coalesce(c.phone,'')) LIKE $${params.length} OR lower(coalesce(c.company,'')) LIKE $${params.length})`
    );
  }

  const where = conditions.length ? `WHERE ${conditions.join(" AND ")}` : "";

  const result = await pool.query(
    `SELECT c.* FROM contacts c ${where} ORDER BY lower(c.name) ASC`,
    params
  );
  return result.rows;
}

A few things worth understanding here:

  • params and $1, $2, … are parameterized query placeholders — pg
    substitutes them safely, so a search term can never be used to inject
    SQL, even if it contains quotes or SQL keywords.
  • coalesce(c.email,'') turns a NULL email into an empty string before
    comparing, so contacts with no email don’t cause the LIKE to silently
    skip them.
  • The search matches name, email, phone, or company — whichever the
    learner remembers about the person they’re looking for.

Add the search bar UI

Create components/SearchBar.tsx:

tsx
export default function SearchBar({
  action,
  defaultValue,
}: {
  action: string;
  defaultValue?: string;
}) {
  return (
    <form className="search-form" action={action} method="GET">
      <input
        type="search"
        name="q"
        placeholder="Search contacts…"
        defaultValue={defaultValue}
        autoComplete="off"
      />
    </form>
  );
}

This is a plain HTML form with method="GET". Submitting it navigates the
browser to action?q=<value> — no onChange, no fetch, no client
JavaScript at all. The browser itself turns form input into a URL query
string.

Read the query param on the Home page

Update app/page.tsx:

tsx
import { getContacts } from "@/lib/contacts";
import ContactList from "@/components/ContactList";
import SearchBar from "@/components/SearchBar";

export const dynamic = "force-dynamic";

export default async function HomePage({
  searchParams,
}: {
  searchParams: { q?: string };
}) {
  const q = searchParams.q || "";
  const contacts = await getContacts({ q });

  return (
    <>
      <div className="page-heading">
        <h1>All Contacts</h1>
        <div className="meta">{contacts.length} {contacts.length === 1 ? "contact" : "contacts"}</div>
      </div>
      <SearchBar action="/" defaultValue={q} />
      <ContactList
        contacts={contacts}
        emptyTitle={q ? "No matches" : "No contacts yet"}
        emptyBody={q ? `No contacts match "${q}".` : "Your address book is empty."}
      />
    </>
  );
}

Every Server Component page in the App Router automatically receives a
searchParams prop reflecting the current URL’s query string — you don’t
need any routing library or hook to read it.

Test it

Search for “grace”. The URL should change to /?q=grace, and the list
should shrink to just Grace Hopper. Clear the search box and submit again —
you’re back to all three contacts. Try refreshing the page while a search
is active: the search box still shows your term, because defaultValue is
reading it straight from the URL.

Checkpoint

Searching filters the contact list by name, email, phone, or company, and
the result is reflected in the URL and survives a page refresh.