CodingNic

Groups

Build the Group Detail Page

Groups 20 min read

Build the Group Detail Page

Build the Group Detail Page

Each group card links to /groups/[groupId] — a filtered contact list,
built the same way the Favorites page was in Module 7.

Add a group filter to getContacts

Open lib/contacts.ts. Add groupId to ContactQuery:

typescript
export type ContactQuery = {
  q?: string;
  page?: number;
  favoritesOnly?: boolean;
  groupId?: string;
};

And another condition inside getContacts, alongside the existing ones:

typescript
if (opts.groupId) {
  params.push(opts.groupId);
  conditions.push(
    `c.id IN (SELECT contact_id FROM contact_groups WHERE group_id = $${params.length})`
  );
}

This is a subquery: it first finds every contact_id linked to this
group in contact_groups, then filters the main contacts query down to
just those ids. Like favoritesOnly, it combines with search and
pagination automatically, since it’s just one more entry in the same
conditions array.

Build the page

Replace the placeholder in app/groups/[groupId]/page.tsx:

tsx
import { notFound } from "next/navigation";
import { getGroupById } from "@/lib/groups";
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 GroupDetailPage({
  params,
  searchParams,
}: {
  params: { groupId: string };
  searchParams: { q?: string; page?: string };
}) {
  const group = await getGroupById(params.groupId);
  if (!group) notFound();

  const q = searchParams.q || "";
  const page = Number(searchParams.page) || 1;
  const { contacts, total } = await getContacts({ q, page, groupId: params.groupId });

  return (
    <>
      <div className="page-heading">
        <h1>{group.name}</h1>
        <div className="meta">{total} {total === 1 ? "contact" : "contacts"}</div>
      </div>
      <SearchBar action={`/groups/${group.id}`} defaultValue={q} />
      <ContactList
        contacts={contacts}
        emptyTitle={q ? "No matches" : "No contacts in this group"}
        emptyBody={q ? `No contacts match "${q}".` : "Add this group to a contact to see them here."}
      />
      <Pagination page={page} total={total} pageSize={PAGE_SIZE} basePath={`/groups/${group.id}`} query={q} />
    </>
  );
}

Same shape as the Favorites page: fetch the thing the URL refers to
(getGroupById, with a notFound() guard), then fetch its filtered
contacts, then render the same three shared components you’ve used since
Module 2.

Test it

Click into the “Work” group you created. You should see “0 contacts” and
the empty state, since no contact has been linked to any group yet —
that’s the next lesson.

Checkpoint

Every group has a working detail page with its own URL, search, and
pagination — currently showing zero contacts, which is correct until the
next lesson links them up.