Back/Module P-13 Building a Production-Grade Feature — End-to-End
Module P-13·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.

P-13 — 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
model User { id String @id @default(cuid()) email String @unique name String? tasks Task[] createdAt DateTime @default(now()) updatedAt DateTime @updatedAt } model Task { id String @id @default(cuid()) title String status TaskStatus @default(TODO) priority Priority @default(MEDIUM) userId String user User @relation(fields: [userId], references: [id], onDelete: Cascade) createdAt DateTime @default(now()) updatedAt DateTime @updatedAt @@index([userId, status]) @@index([userId, createdAt]) } enum TaskStatus { TODO IN_PROGRESS DONE } enum Priority { LOW MEDIUM HIGH }

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
// lib/data/tasks.ts import { db } from '@/lib/db' import { cache } from 'react' import { cacheTag } from 'next/cache' export const getTasks = cache(async (userId: string) => { 'use cache' cacheTag(`tasks:${userId}`) return db.task.findMany({ where: { userId }, orderBy: { createdAt: 'desc' }, select: { id: true, title: true, status: true, priority: true, createdAt: true, }, }) })

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

TaskBoard is where the optimistic UI lives. The useOptimistic hook lets us update the UI immediately when a user changes a task's status, without waiting for the Server Action to complete.

tsx
// components/TaskBoard.tsx 'use client' import { useOptimistic } from 'react' import { updateTaskStatus } from '@/app/actions/tasks' import type { Task, TaskStatus } from '@prisma/client' interface Props { initialTasks: Pick<Task, 'id' | 'title' | 'status' | 'priority' | 'createdAt'>[] } export function TaskBoard({ initialTasks }: Props) { const [tasks, addOptimisticUpdate] = useOptimistic( initialTasks, (state, { taskId, newStatus }: { taskId: string; newStatus: TaskStatus }) => state.map((t) => t.id === taskId ? { ...t, status: newStatus } : t ) ) async function handleStatusChange(taskId: string, newStatus: TaskStatus) { addOptimisticUpdate({ taskId, newStatus }) await updateTaskStatus({ taskId, newStatus }) } const columns: TaskStatus[] = ['TODO', 'IN_PROGRESS', 'DONE'] return ( <div className="grid grid-cols-3 gap-4"> {columns.map((status) => ( <div key={status} className="space-y-2"> <h2 className="font-semibold">{status}</h2> {tasks .filter((t) => t.status === status) .map((task) => ( <TaskCard key={task.id} task={task} onStatusChange={handleStatusChange} /> ))} </div> ))} </div> ) }

useOptimistic takes two arguments: the authoritative state (from props, which comes from the server), and a reducer function that describes how to apply an optimistic update. When the Server Action completes and the page re-renders with fresh data from the cache, useOptimistic automatically reverts to the server truth. If the action fails, the optimistic update is also reverted — which is why you should always surface errors to the user even when the UI reverts automatically.

The createTask Server Action

ts
// app/actions/tasks.ts 'use server' import { z } from 'zod' import { auth } from '@/lib/auth' import { db } from '@/lib/db' import { revalidateTag, after } from 'next/cache' import { sendNotification } from '@/lib/notifications' const CreateTaskSchema = z.object({ title: z.string().min(1).max(255), priority: z.enum(['LOW', 'MEDIUM', 'HIGH']).default('MEDIUM'), }) export async function createTask(formData: FormData) { const session = await auth() if (!session?.user?.id) { throw new Error('Unauthorized') } const parsed = CreateTaskSchema.safeParse({ title: formData.get('title'), priority: formData.get('priority'), }) if (!parsed.success) { return { error: parsed.error.flatten().fieldErrors } } const task = await db.task.create({ data: { title: parsed.data.title, priority: parsed.data.priority, userId: session.user.id, }, }) revalidateTag(`tasks:${session.user.id}`) after(async () => { await sendNotification({ userId: session.user.id, message: `Task "${task.title}" created`, }) }) return { success: true, taskId: task.id } }

The auth check comes first, before anything else. Validation comes second. The database write comes third. This order matters: you never want to do expensive work — Zod parsing, database queries — for unauthenticated requests.

revalidateTag invalidates the cache for this user's tasks, which triggers a fresh database fetch on the next page render. The user sees their new task immediately because the optimistic update in CreateTaskForm shows it while the action is in flight.

The after() call handles the notification. The notification send happens after the response has been flushed to the client. The user's request completes quickly, and the side effect happens in the background within the same serverless invocation. If the notification fails, it doesn't fail the task creation.

The updateTaskStatus Server Action

ts
export async function updateTaskStatus({ taskId, newStatus, }: { taskId: string newStatus: 'TODO' | 'IN_PROGRESS' | 'DONE' }) { const session = await auth() if (!session?.user?.id) { throw new Error('Unauthorized') } // Ownership check — this is non-negotiable const task = await db.task.findUnique({ where: { id: taskId }, select: { userId: true }, }) if (!task || task.userId !== session.user.id) { throw new Error('Not found') } await db.task.update({ where: { id: taskId }, data: { status: newStatus }, }) revalidateTag(`tasks:${session.user.id}`) }

The ownership check is the part engineers skip in demos and regret in production. Without it, any authenticated user who knows a task ID can update any task in the system. The pattern is always: get the record first, verify the userId field matches the session user, then do the write. The cost is one extra database round trip. Pay it.

The error message says "Not found" rather than "Forbidden." This is intentional — you don't want to confirm to an attacker that a resource exists; you just want to deny access.

Error Boundaries

Error handling has two levels. Page-level errors — the auth failing, a catastrophic database outage — are caught by error.tsx in the dashboard segment. Task-level errors — a single task failing to update — should be caught closer to the task without taking down the whole board.

tsx
// components/TaskCard.tsx 'use client' import { ErrorBoundary } from 'react-error-boundary' function TaskCardError({ error, resetErrorBoundary }: { error: Error; resetErrorBoundary: () => void }) { return ( <div className="border border-red-200 rounded p-2"> <p className="text-sm text-red-600">Failed to update task</p> <button onClick={resetErrorBoundary} className="text-xs underline"> Retry </button> </div> ) } export function TaskCard({ task, onStatusChange }: TaskCardProps) { return ( <ErrorBoundary FallbackComponent={TaskCardError}> <TaskCardInner task={task} onStatusChange={onStatusChange} /> </ErrorBoundary> ) }

React error boundaries only catch render-phase errors, not async errors from event handlers. Server Action errors need to be caught explicitly in the action caller and surfaced via component state. The error boundary here catches cases where the component tree itself enters an error state.

Loading States with Suspense

The initial page load wraps the task board in a Suspense boundary with a skeleton fallback.

tsx
// app/dashboard/page.tsx import { Suspense } from 'react' import { auth } from '@/lib/auth' import { redirect } from 'next/navigation' import { getTasks } from '@/lib/data/tasks' import { TaskBoard } from '@/components/TaskBoard' import { TaskBoardSkeleton } from '@/components/TaskBoardSkeleton' import { CreateTaskForm } from '@/components/CreateTaskForm' async function TaskBoardLoader({ userId }: { userId: string }) { const tasks = await getTasks(userId) return <TaskBoard initialTasks={tasks} /> } export default async function DashboardPage() { const session = await auth() if (!session?.user?.id) { redirect('/login') } return ( <div className="p-6"> <div className="flex items-center justify-between mb-6"> <h1 className="text-2xl font-bold">My Tasks</h1> <CreateTaskForm /> </div> <Suspense fallback={<TaskBoardSkeleton />}> <TaskBoardLoader userId={session.user.id} /> </Suspense> </div> ) }

TaskBoardSkeleton renders the same three-column layout with grey placeholder cards. Match the skeleton dimensions to the real content as closely as possible. Layout shift when real content loads is jarring and measurable in your Web Vitals.

The Caching Strategy Decision

User tasks are NOT cached with a long TTL, and the reasons are worth spelling out explicitly because this contradicts the "cache everything aggressively" instinct.

User tasks are private data. Serving a cached response meant for Alice to Bob is a security incident. The tasks:${userId} tag scoping prevents this by construction — Alice's cache entry is never served to Bob because they have different tags — but it also means you can't use shared CDN caching. The cache lives in the server-side Data Cache, not at the CDN edge.

Mutation frequency is also high. Users change task statuses constantly. A five-minute TTL would mean users see stale boards for up to five minutes after a change. The pattern here is: short TTL (or none) + explicit invalidation on mutation. The revalidateTag call in each Server Action ensures the cache is cleared immediately after any write.

The Data Cache entry for tasks could theoretically have a TTL of zero — pure invalidation-based freshness — but that would hit the database on every page load. A short TTL (say 30 seconds) gives a reasonable read-through buffer for users who navigate away and back quickly, while revalidateTag ensures post-mutation freshness. In practice, for a high-interaction dashboard, you might skip the Data Cache entirely and rely on React.cache() deduplication within a render, accepting a database hit per page load in exchange for simplicity.

Pre-Ship Checklist

Before this feature ships, run through this list. Every item corresponds to a real class of production incident.

Auth verified: every Server Action checks session?.user?.id before touching the database. Check.

Inputs validated: Zod schemas on all Server Actions, server-side. Never trust client-provided data even from your own form. Check.

Ownership enforced: the updateTaskStatus action fetches the task and verifies userId before writing. Check.

Cache invalidated: both Server Actions call revalidateTag with the user-scoped tag after writes. Check.

Error boundary present: task-level and page-level error boundaries both in place. Check.

Loading state present: Suspense with skeleton fallback on initial load. Check.

after() for side effects: notifications happen post-response, not blocking the user. Check.

SQL injection impossible: Prisma's query builder parameterises all inputs by default. No raw query string interpolation. Check.

The Full Assembled Page

The complete DashboardPage as shown above is the entry point. The data flow is: page.tsx (Server Component) authenticates → TaskBoardLoader (Server Component) fetches data → TaskBoard (Client Component) handles all interaction. Server Actions handle writes, invalidate caches, and fire side effects after the response.

The key insight is that the page component itself is deliberately thin. It does auth, wires up Suspense, and delegates everything else. Complex pages that put business logic directly in the page component become impossible to test and painful to maintain. The page component is plumbing; the real logic lives in lib/data/tasks.ts, app/actions/tasks.ts, and the client components.

This architecture scales. You can add new task fields, new board columns, or task filtering without touching the page structure. You can unit test the Server Actions in isolation. You can replace the Prisma calls with a different ORM without changing the components. That's the point.

Discussion