Module P-14·30 min read

A full authenticated dashboard with real data and mutations — Server vs Client Component decisions, optimistic UI, error boundaries, per-page caching strategy, after() for side effects, and a pre-ship checklist.

JJS
Written by Jatin Jain Saraf · Senior Software Engineer

P-14 — Building a Production-Grade Feature End-to-End

Most tutorials build features in a vacuum. A to-do app in a single file, no auth, no error handling, no thought given to what happens when the database is slow or the user submits the form twice. That's not how real software gets built, and the gap between tutorial code and production code is exactly where engineers get burned.

This module closes that gap. We're going to build an authenticated task management dashboard — think a stripped-down Linear board — and every decision will be made explicitly. At each fork in the road, we'll name the alternatives we rejected and explain why we took the path we did.

The Spec

The requirements are deliberately simple so the architecture can take center stage. Authenticated users can manage their own tasks. Each task has a title, a status (todo, in-progress, or done), and a priority (low, medium, high). Users can only see and modify their own tasks. The board should feel instant — optimistic updates on status changes, no full-page reloads.

That last requirement is the interesting one. It rules out the naive "update status → redirect" Server Action pattern and forces us to deal with optimistic UI properly.

Data Model with Prisma

Start with the schema. Two models: User and Task. The relationship is straightforward — a user has many tasks, and every task has an owner.

prisma

The composite indexes deserve a note. We index on (userId, status) because filtering tasks by user and then by status is the most common query pattern on the board view. We also index on (userId, createdAt) because the default sort is newest first within a user's tasks. Without these indexes, every board load is a full table scan as the dataset grows.

The onDelete: Cascade on the user relation means deleting a user automatically deletes all their tasks. You may or may not want that in production — archive instead of delete is often safer — but for this module it keeps the model clean.

The Page Structure Decision

Before writing a single line of page code, you need a clear answer to: which components are Server Components and which are Client Components? This isn't an aesthetic decision — it determines what data flows where, and getting it wrong means either unnecessary client-side fetching or accidental loss of server-side capabilities.

Here's the breakdown for the task board:

The page itself (app/dashboard/page.tsx) is a Server Component. It authenticates the user, fetches their tasks, and passes the data down. No user interaction happens at this level.

The TaskBoard component is a Client Component. It owns the visual state of the board — which column the tasks are in, the optimistic updates when dragging or clicking a status change. It receives tasks as props from the Server Component above.

The TaskCard component is a Client Component. Each card has interactive elements (the status toggle, a delete button) and needs access to the state that TaskBoard manages.

The CreateTaskForm component is a Client Component. It has a controlled input and submits to a Server Action. It uses useFormStatus to show a pending state.

The pattern is: Server Component shell → data fetch → pass serialisable props to the Client Component tree. The Server Component never needs to re-render because the Client Component handles all visual mutations optimistically.

The Server Component Data Layer

The data fetching function lives in a dedicated file, not in the page component itself. This separation matters because the function can be cached independently.

ts

Two layers of caching are in play here. React.cache() deduplicates calls within a single render — if the layout and the page both call getTasks(userId), the database only gets hit once. The use cache directive with cacheTag stores the result in the Data Cache so subsequent requests for the same user's tasks don't hit the database at all until the cache is invalidated.

Why cacheTag(tasks:${userId}) and not a global tasks tag? Because when Alice creates a task, we only want to invalidate Alice's cache entry, not every user's. User-scoped tags give you surgical invalidation.

The TaskBoard Client Component

Sign in to keep reading

The rest of this module is free — sign in with Google to unlock it and track your progress.

Sign in & Register

Discussion

0

Join the discussion

Loading comments...

© 2026 Jatin Jain Saraf (JJS). All rights reserved.