CodingNic

Data Layer & Home List

Render the Home Page Contact List

Data Layer & Home List 30 min read

Render the Home Page Contact List

Render the Home Page Contact List

The list groups contacts alphabetically and shows a jump rail down the
left side — like a rolodex index. We’ll build the pieces from the bottom
up: helpers, then a card, then the rail, then the list that ties them
together.

Presentation helpers

Create lib/utils.ts:

typescript
import type { Contact } from "./types";

export function initials(name: string): string {
  const parts = name.trim().split(/\s+/).filter(Boolean);
  if (!parts.length) return "?";
  if (parts.length === 1) return parts[0].charAt(0).toUpperCase();
  return (parts[0].charAt(0) + parts[parts.length - 1].charAt(0)).toUpperCase();
}

export function groupLetter(name: string): string {
  const c = (name || "").trim().charAt(0).toUpperCase();
  return /[A-Z]/.test(c) ? c : "#";
}

export const ALPHABET = "#ABCDEFGHIJKLMNOPQRSTUVWXYZ".split("");

export function groupContactsByLetter(contacts: Contact[]): Record<string, Contact[]> {
  const groups: Record<string, Contact[]> = {};
  for (const c of contacts) {
    const l = groupLetter(c.name);
    (groups[l] = groups[l] || []).push(c);
  }
  return groups;
}

initials powers the little avatar circle on each card. groupLetter and
groupContactsByLetter do the alphabetical bucketing the Home page needs —
anything that doesn’t start with A-Z (empty name, symbol) falls into a #
bucket, so it’s never silently dropped.

ContactCard

Create components/ContactCard.tsx:

tsx
import Link from "next/link";
import type { Contact } from "@/lib/types";
import { initials } from "@/lib/utils";

export default function ContactCard({ contact }: { contact: Contact }) {
  const sub = [contact.phone, contact.email].filter(Boolean).join(" · ") || contact.company || "";
  return (
    <Link href={`/contacts/${contact.id}`} className={`card ${contact.favorite ? "favorite" : ""}`}>
      <div className="avatar">{initials(contact.name)}</div>
      <div className="card-main">
        <div className="card-name">
          {contact.name} {contact.favorite ? <span className="star">&#9733;</span> : null}
        </div>
        {sub ? <div className="card-sub">{sub}</div> : null}
      </div>
      <svg className="chevron" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
        <polyline points="9 6 15 12 9 18" />
      </svg>
    </Link>
  );
}

This whole card is a Link — clicking anywhere on it navigates to the
contact’s detail page. The .card and .favorite classes (border, spacing,
the tan accent stripe on favorites) already exist in globals.css.

AlphabetRail

Create components/AlphabetRail.tsx:

tsx
import { ALPHABET } from "@/lib/utils";

export default function AlphabetRail({ present }: { present: Set<string> }) {
  return (
    <nav className="rail" aria-label="Jump to letter">
      {ALPHABET.map((letter) =>
        present.has(letter) ? (
          <a key={letter} href={`#letter-${letter}`} className="has-entries">
            {letter}
          </a>
        ) : (
          <span key={letter}>{letter}</span>
        )
      )}
    </nav>
  );
}

Letters with no contacts render as plain, dimmed <span>s. Letters that do
have contacts render as anchor links pointing at #letter-A, #letter-B,
and so on — no client-side JavaScript needed for the “jump to letter”
behavior, just an anchor link to a section the list will render below.

ContactList

Create components/ContactList.tsx, which ties the three pieces together:

tsx
import { ALPHABET, groupContactsByLetter } from "@/lib/utils";
import type { Contact } from "@/lib/types";
import ContactCard from "./ContactCard";
import AlphabetRail from "./AlphabetRail";

export default function ContactList({
  contacts,
  emptyTitle,
  emptyBody,
}: {
  contacts: Contact[];
  emptyTitle: string;
  emptyBody: string;
}) {
  if (contacts.length === 0) {
    return (
      <div className="empty-state">
        <h2>{emptyTitle}</h2>
        <p>{emptyBody}</p>
      </div>
    );
  }

  const groups = groupContactsByLetter(contacts);
  const present = new Set(Object.keys(groups));
  const orderedLetters = ALPHABET.filter((l) => groups[l]);

  return (
    <div className="layout">
      <AlphabetRail present={present} />
      <div className="contacts">
        {orderedLetters.map((letter) => (
          <div key={letter} id={`letter-${letter}`}>
            <div className="letter-heading">{letter}</div>
            {groups[letter].map((c) => (
              <ContactCard key={c.id} contact={c} />
            ))}
          </div>
        ))}
      </div>
    </div>
  );
}

Note the empty-state handling up front — you’ll reuse this same
emptyTitle / emptyBody pattern on the Favorites and Groups pages later.

Wire it into the Home page

Replace the contents of app/page.tsx:

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

export const dynamic = "force-dynamic";

export default async function HomePage() {
  const contacts = await getContacts();

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

HomePage is an async Server Component — it can await a database call
directly in the component body, no useEffect or loading state required.

export const dynamic = "force-dynamic" tells Next.js not to try to
pre-render this page at build time. It needs to run on every request,
since the contact list can change. You’ll add this line to every page that
reads from the database.

Test it

Load http://localhost:3000. You should see your three test contacts,
grouped under “A” (Ada, Alan) and “G” (Grace), with the alphabet rail on
the left showing “A” and “G” highlighted and every other letter dimmed.
Click a contact’s card — it’ll try to navigate to /contacts/<id>, which
still shows the Module 4 placeholder. That’s expected for now.

Checkpoint

The Home page renders real, live data from Postgres: contacts grouped
alphabetically, favorites marked with a star and a tan accent stripe, and a
working alphabet jump rail.