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.
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.
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.
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 & RegisterDiscussion
0Join the discussion