Learn Build a Real SaaS with Claude Code UI Components for Dashboard List and Detail Views

UI Components for Dashboard List and Detail Views

Intermediate 🕐 24 min Lesson 9 of 25
What you'll learn
  • Build small, single-purpose badge/rating components that share a type with the data layer
  • Render the dashboard list view as an async Server Component that queries the database directly
  • Use searchParams as the source of truth for active filters instead of client state
  • Reuse the join-based authorization pattern from API routes inside a Server Component page
  • Return notFound() rather than a 403 to avoid confirming a resource's existence to the wrong user
  • Compose shared components (SourceBadge, StarRating, StatusBadge) consistently across list and detail views

Three Small Components Before the Page

With the API routes from lesson 8 in place, the dashboard needs somewhere to render that data. Rather than starting with the page, start with three small, single-purpose components, since the list and detail views both need them and duplicating the styling logic twice is how badges drift out of sync.

components/SourceBadge.tsx maps each review source to a color and label using plain object lookups -- no conditional chains:

const STYLES: Record = {
  google: "bg-amber-50 text-amber-700 ring-1 ring-amber-200",
  yelp: "bg-rose-50 text-rose-700 ring-1 ring-rose-200",
  facebook: "bg-sky-50 text-sky-700 ring-1 ring-sky-200",
};

export function SourceBadge({ source }: { source: ReviewSource }) {
  return (
    
      {LABELS[source]}
    
  );
}

components/StarRating.tsx renders a 1-5 rating as filled and unfilled star characters, with an aria-label so screen readers get the rating as text rather than a string of glyphs:

export function StarRating({ rating }: { rating: number }) {
  return (
    
      {"★".repeat(rating)}
      {"★".repeat(5 - rating)}
    
  );
}

components/StatusBadge.tsx follows the identical pattern for new/responded/flagged/archived. All three import their type (ReviewSource, ReviewStatus) from db/queries.ts rather than redeclaring it -- one source of truth for the allowed values, used by the database layer, the API routes, and the UI alike.

The List View: app/dashboard/page.tsx

The dashboard page is a Server Component -- it runs on the server, queries the database directly, and never ships a data-fetching waterfall to the browser. It reads searchParams (also a Promise, same convention as Route Handler params), validates each filter against an allowed list, and renders filter links as plain <a>-backed Link elements:

export default async function DashboardPage({
  searchParams,
}: {
  searchParams: Promise<{ status?: string; source?: string; sort?: string }>;
}) {
  const user = await getCurrentUser();
  if (!user) redirect("/login");

  const business = await getBusinessForOwner(user.id);
  // ...
  const [reviews, stats] = await Promise.all([
    getReviewsForBusiness(business.id, { status, source, sort }),
    getReviewStats(business.id),
  ]);

Notice the filter and sort controls are links and a plain <form>, not buttons wired to onClick handlers. Clicking "Flagged" navigates to /dashboard?status=flagged, which re-runs the Server Component with the new searchParams and fetches a fresh, already-filtered set of reviews. There is no client-side filter state to keep in sync with the URL, because the URL is the state -- lesson 10 makes this the explicit teaching point.

The "no business yet" empty state is worth keeping rather than deleting: it tells a reader exactly how to seed demo data (SEED_OWNER_ID="..." npm run db:seed) instead of staring at a blank dashboard wondering what broke.

The Detail View: app/dashboard/reviews/[id]/page.tsx

The detail page repeats the same authorization pattern from lesson 8, but in a Server Component instead of a Route Handler -- fetch the review, then separately confirm the owning business matches the signed-in user, and call notFound() rather than leaking a 403 that would confirm the review exists:

const { id } = await params;
const result = await getReviewWithResponses(id);
if (!result) notFound();

const { review, responses } = result;

const [business] = await db.select().from(businesses).where(eq(businesses.id, review.businessId)).limit(1);
if (!business || business.ownerId !== user.id) {
  notFound();
}

Returning notFound() instead of a 403-style message for both "doesn't exist" and "not yours" is a deliberate choice -- it avoids confirming to an attacker that a given review id is valid but belongs to someone else.

The page composes the same three badge components used on the list view, then renders previous responses and the response form -- which is where lesson 11 picks up, since that form needs client-side interactivity the rest of this page does not.

Why This Composition Holds Up

Every visual piece -- badge, star, status pill -- is a small server-renderable component with no internal state. The pages that use them are themselves Server Components. Nothing here needs "use client" yet, because nothing here needs to react to a click without a full navigation. That restraint is what makes lesson 10's argument land: state management only becomes a real design decision once you add the one piece of the UI that genuinely needs it.

Key takeaways
  • Components that only need a prop and return markup don't need to be Client Components -- keep them server-renderable by default
  • Importing a shared type (ReviewSource, ReviewStatus) from the data layer keeps the UI, API routes, and database in lockstep
  • searchParams and dynamic route params are both Promises in current Next.js and must be awaited before use
  • Filter links that change the URL let a Server Component re-fetch already-filtered data on navigation, with no client state to manage
  • notFound() is the safer response for unauthorized access to a specific resource id, since a 403 confirms the id is valid
  • A useful empty state (e.g. show the seed command) is worth keeping in a tutorial app instead of stripping it for brevity