Add Pagination
Add Pagination
With only three test contacts, pagination has nothing to prove yet — but
the pattern matters for when this list grows. Like search, it’ll be driven
entirely by the URL.
Extend the query function again
Open lib/contacts.ts. Add a page size constant and page/offset handling:
export const PAGE_SIZE = 20;
export type ContactQuery = {
q?: string;
page?: number;
};
export async function getContacts(
opts: ContactQuery = {}
): Promise<{ contacts: Contact[]; total: number }> {
const page = Math.max(1, opts.page || 1);
const offset = (page - 1) * PAGE_SIZE;
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 countResult = await pool.query(
`SELECT COUNT(*)::int AS count FROM contacts c ${where}`,
params
);
const total = countResult.rows[0]?.count ?? 0;
const listParams = [...params, PAGE_SIZE, offset];
const result = await pool.query(
`SELECT c.* FROM contacts c ${where}
ORDER BY lower(c.name) ASC
LIMIT $${params.length + 1} OFFSET $${params.length + 2}`,
listParams
);
return { contacts: result.rows, total };
}
Two queries run here: one counts all matching rows (so we know how many
pages exist), and one fetches just the current page’s slice with
LIMIT/OFFSET. The return type changed from Contact[] to
{ contacts, total } — you’ll need to update every place that calls this
function.
Build the Pagination component
Create components/Pagination.tsx:
type Props = {
page: number;
total: number;
pageSize: number;
basePath: string;
query?: string;
};
export default function Pagination({ page, total, pageSize, basePath, query }: Props) {
const totalPages = Math.max(1, Math.ceil(total / pageSize));
if (totalPages <= 1) return null;
const buildHref = (p: number) => {
const params = new URLSearchParams();
if (query) params.set("q", query);
params.set("page", String(p));
return `${basePath}?${params.toString()}`;
};
const prevDisabled = page <= 1;
const nextDisabled = page >= totalPages;
return (
<div className="pagination">
<a href={prevDisabled ? undefined : buildHref(page - 1)} className={prevDisabled ? "disabled" : ""}>
← Prev
</a>
<span className="current">Page {page} of {totalPages}</span>
<a href={nextDisabled ? undefined : buildHref(page + 1)} className={nextDisabled ? "disabled" : ""}>
Next →
</a>
</div>
);
}
It renders nothing at all when there’s only one page — no point showing
pagination controls for a list that doesn’t need them. When there’s more
than one page, “Prev” and “Next” are plain links to ?page=N, preserving
the current search term.
Update the Home page
import { getContacts, PAGE_SIZE } from "@/lib/contacts";
import ContactList from "@/components/ContactList";
import SearchBar from "@/components/SearchBar";
import Pagination from "@/components/Pagination";
export const dynamic = "force-dynamic";
export default async function HomePage({
searchParams,
}: {
searchParams: { q?: string; page?: string };
}) {
const q = searchParams.q || "";
const page = Number(searchParams.page) || 1;
const { contacts, total } = await getContacts({ q, page });
return (
<>
<div className="page-heading">
<h1>All Contacts</h1>
<div className="meta">{total} {total === 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."}
/>
<Pagination page={page} total={total} pageSize={PAGE_SIZE} basePath="/" query={q} />
</>
);
}
Note the destructuring change: getContacts now returns
{ contacts, total }, and total (not contacts.length) is what the page
count and the pagination component both use — contacts.length would only
ever tell you the size of the current page, not the whole result set.
Test it
With only three contacts and a page size of 20, you won’t see pagination
controls yet — that’s correct, since Pagination returns null when
there’s only one page. To confirm the logic works, temporarily change
PAGE_SIZE to 2 in lib/contacts.ts, reload, and you should see “Page 1
of 2” with working Prev/Next links. Change it back to 20 afterward.
Checkpoint
getContacts supports search and pagination together, both fully
controlled by the URL (/?q=grace&page=1), and the pagination UI
correctly disappears when it isn’t needed.