Comprehensive Project: CRUD and Server Actions—TaskFlow Task Management and File Upload
Task management is at the heart of a SaaS platform—creating, editing, deleting, dragging and dropping to reorder, uploading files, searching, and filtering. A comprehensive CRUD system enables users to manage workflows efficiently.
1. What You'll Learn
- Implement Task CRUD (create / update / delete) using Server Actions
- Zod validation +
useOptimisticoptimistic update mode useActionStateConfirmation Pop-up and Error Handling- Implement drag-and-drop sorting to toggle between the "Todo," "In Progress," and "Done" columns
- Linking Uploadthing file uploads to the Attachment model
- URL search parameters + shadcn/ui DataTable to implement search, filtering, and pagination
2. A True Story of a Project Manager
(1) Pain Point: A team of 50 people wastes 3 hours a day on task management
Bob is a project manager at Acme Corp who manages 5 projects and over 200 tasks. The team spends a significant amount of time every day on: creating tasks (filling out 10-field forms repeatedly), dragging and dropping to update statuses (like sticking Post-it notes on a whiteboard), asking colleagues for files (passing them back and forth on WeChat), and searching for tasks (scrolling through Excel spreadsheets). Alice calculated that in a team of 50 people, each person wastes 36 minutes a day on inefficient task management, which amounts to a monthly productivity loss of 600 hours.
(2) Server Actions + Optimistic Updates + A Solution for Drag-and-Drop
Implement zero-refresh CRUD using Server Actions + useOptimistic for instant feedback + drag-and-drop state switching.
// One line Server Action Complete the task creation + Cache Refresh
<form action={createTask}>
<input name="title" required />
<input name="projectId" type="hidden" value={project.id} />
<button type="submit">Create Task</button>
</form>
(3) Revenue
| Scenario | Before Optimization | After Optimization | Efficiency Improvement |
|---|---|---|---|
| Create Task | 3 minutes (full form) | 15 seconds (quick creation) | 12x |
| Drag-and-Drop Sorting | Handwritten Whiteboard | Click the Status Column | Instant |
| File Sharing | Send Files via WeChat | Upload via Uploadthing | 5x |
| Search Task | Browsing Excel | Real-time DataTable search | 20x |
| Monthly Productivity | 600 hours wasted | ~50 hours wasted | 92% reduction |
3. Server Actions Task CRUD
(1) Operational Architecture
graph TB
subgraph "Server Actions"
A[createTask] --> B[Zod validate]
B --> C[Prisma create]
C --> D[revalidateTag]
D --> E[redirect]
end
subgraph "Client"
F[TaskForm] -->|action| A
G[useOptimistic] -->|instant UI| F
H[useActionState] -->|confirm| J[deleteTask]
end
subgraph "Cache"
D --> K[revalidateTag<br/>"tasks"]
K --> L[Refresh UI]
end
style A fill:#cce5ff
style G fill:#d4edda
style K fill:#ffeeba
(2) Comparison of Task Operations
| Operation | Server Action | Validation | Caching Policy | UI Feedback |
|---|---|---|---|---|
| createTask | "use server" |
Zod | revalidateTag("tasks") |
useOptimistic |
| updateTask | "use server" |
Zod partial | revalidateTag("tasks") |
Optimistic Update |
| deleteTask | "use server" |
Confirm Check | revalidateTag("tasks") |
useActionState |
| reorderTask | "use server" |
Verify Number | revalidateTag("tasks") |
Reorder Now |
▶ Example: Server Actions Definition
// src/lib/actions/task-actions.ts
"use server"
import { revalidateTag } from "next/cache"
import { redirect } from "next/navigation"
import { z } from "zod"
import { prisma } from "@/lib/db"
import { auth } from "@/lib/auth"
import { TaskStatus, TaskPriority } from "@prisma/client"
const createTaskSchema = z.object({
title: z.string().min(1, "Title is required").max(200, "Title too long"),
description: z.string().max(2000).optional(),
projectId: z.string().min(1),
assigneeId: z.string().optional(),
priority: z.nativeEnum(TaskPriority).optional(),
dueDate: z.string().optional(),
})
const updateTaskSchema = z.object({
id: z.string().min(1),
title: z.string().min(1).max(200).optional(),
description: z.string().max(2000).optional(),
status: z.nativeEnum(TaskStatus).optional(),
priority: z.nativeEnum(TaskPriority).optional(),
assigneeId: z.string().nullable().optional(),
dueDate: z.string().nullable().optional(),
order: z.number().int().optional(),
})
export async function createTask(formData: FormData) {
const session = await auth()
if (!session?.user?.id) {
throw new Error("Unauthorized")
}
const rawData = {
title: formData.get("title") as string,
description: formData.get("description") as string | undefined,
projectId: formData.get("projectId") as string,
assigneeId: formData.get("assigneeId") as string | undefined,
priority: (formData.get("priority") as TaskPriority) || "MEDIUM",
dueDate: formData.get("dueDate") as string | undefined,
}
const validated = createTaskSchema.safeParse(rawData)
if (!validated.success) {
return {
error: validated.error.flatten().fieldErrors,
message: "Validation failed",
}
}
const data = validated.data
const maxOrder = await prisma.task.findFirst({
where: { projectId: data.projectId, status: "TODO" },
orderBy: { order: "desc" },
select: { order: true },
})
await prisma.task.create({
data: {
title: data.title,
description: data.description || null,
status: "TODO",
priority: data.priority || "MEDIUM",
projectId: data.projectId,
creatorId: session.user.id,
assigneeId: data.assigneeId || null,
dueDate: data.dueDate ? new Date(data.dueDate) : null,
order: (maxOrder?.order ?? -1) + 1,
},
})
revalidateTag("tasks")
revalidateTag(`project-${data.projectId}`)
}
export async function updateTask(formData: FormData) {
const session = await auth()
if (!session?.user?.id) {
throw new Error("Unauthorized")
}
const rawData = {
id: formData.get("id") as string,
title: formData.get("title") as string | undefined,
description: formData.get("description") as string | undefined,
status: formData.get("status") as TaskStatus | undefined,
priority: formData.get("priority") as TaskPriority | undefined,
assigneeId: formData.get("assigneeId") as string | null | undefined,
dueDate: formData.get("dueDate") as string | null | undefined,
}
const validated = updateTaskSchema.safeParse(rawData)
if (!validated.success) {
return {
error: validated.error.flatten().fieldErrors,
message: "Validation failed",
}
}
const data = validated.data
const updateData: Record<string, unknown> = {}
if (data.title !== undefined) updateData.title = data.title
if (data.description !== undefined) updateData.description = data.description
if (data.status !== undefined) updateData.status = data.status
if (data.priority !== undefined) updateData.priority = data.priority
if (data.assigneeId !== undefined) updateData.assigneeId = data.assigneeId
if (data.dueDate !== undefined) updateData.dueDate = data.dueDate ? new Date(data.dueDate) : null
if (data.order !== undefined) updateData.order = data.order
await prisma.task.update({
where: { id: data.id },
data: updateData,
})
revalidateTag("tasks")
}
export async function deleteTask(prevState: { message: string }, formData: FormData) {
const session = await auth()
if (!session?.user?.id) {
return { message: "Unauthorized" }
}
const taskId = formData.get("id") as string
if (!taskId) {
return { message: "Task ID is required" }
}
const task = await prisma.task.findUnique({
where: { id: taskId },
select: { creatorId: true, projectId: true },
})
if (!task) {
return { message: "Task not found" }
}
if (task.creatorId !== session.user.id) {
return { message: "Only the creator can delete this task" }
}
await prisma.task.delete({ where: { id: taskId } })
revalidateTag("tasks")
revalidateTag(`project-${task.projectId}`)
return { message: "Task deleted successfully" }
}
export async function reorderTask(taskId: string, newStatus: TaskStatus, newOrder: number) {
const session = await auth()
if (!session?.user?.id) {
throw new Error("Unauthorized")
}
await prisma.task.update({
where: { id: taskId },
data: { status: newStatus, order: newOrder },
})
revalidateTag("tasks")
}
4. Optimistic Updates and Confirmation Pop-ups
(1) Three UI Modes
graph LR
subgraph "Optimistic Update"
A1[User clicks] --> B1[Instant UI update]
B1 --> C1[Server Action]
C1 --> D1{Rollback?}
D1 -->|Success| E1[Keep]
D1 -->|Error| F1[Revert]
end
subgraph "Action State"
A2[User clicks delete] --> B2[Show confirm dialog]
B2 --> C2[If confirmed<br/>call deleteTask]
C2 --> D2[Show result message]
end
subgraph "Traditional"
A3[Submit form] --> B3[Loading spinner]
B3 --> C3[Server Action]
C3 --> D3[revalidateTag + refresh]
end
style B1 fill:#d4edda
style B2 fill:#ffeeba
style B3 fill:#f8d7da
▶ Example: useOptimistic—Optimistic Updates
// src/components/tasks/task-list.tsx
"use client"
import { useOptimistic } from "react"
import { TaskCard } from "@/components/tasks/task-card"
import type { TaskWithAssignee } from "@/types"
interface TaskListProps {
tasks: TaskWithAssignee[]
status: "TODO" | "IN_PROGRESS" | "DONE"
onStatusChange: (taskId: string, newStatus: "TODO" | "IN_PROGRESS" | "DONE") => void
}
export function TaskList({ tasks, status, onStatusChange }: TaskListProps) {
const [optimisticTasks, addOptimisticTask] = useOptimistic(
tasks,
(state, newTask: TaskWithAssignee) => {
return [newTask, ...state]
}
)
return (
<div className="space-y-2">
<h3 className="text-sm font-medium text-muted-foreground uppercase tracking-wide">
{status.replace("_", " ")}
<span className="ml-2 text-xs">({optimisticTasks.length})</span>
</h3>
{optimisticTasks.length === 0 ? (
<div className="rounded-lg border border-dashed p-8 text-center text-sm text-muted-foreground">
No tasks
</div>
) : (
<div className="space-y-2">
{optimisticTasks.map((task) => (
<TaskCard
key={task.id}
task={task}
onStatusChange={onStatusChange}
/>
))}
</div>
)}
</div>
)
}
▶ Example: useActionState to Confirm Deletion
// src/components/tasks/delete-task-button.tsx
"use client"
import { useActionState, useState } from "react"
import { deleteTask } from "@/lib/actions/task-actions"
import { Button } from "@/components/ui/button"
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
DialogTrigger,
} from "@/components/ui/dialog"
interface DeleteTaskButtonProps {
taskId: string
taskTitle: string
}
export function DeleteTaskButton({ taskId, taskTitle }: DeleteTaskButtonProps) {
const [open, setOpen] = useState(false)
const [state, formAction, isPending] = useActionState(deleteTask, {
message: "",
})
return (
<Dialog open={open} onOpenChange={setOpen}>
<DialogTrigger asChild>
<Button variant="ghost" size="icon" className="h-8 w-8 text-muted-foreground hover:text-destructive">
<svg className="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16" />
</svg>
</Button>
</DialogTrigger>
<DialogContent>
<form action={formAction}>
<input type="hidden" name="id" value={taskId} />
<DialogHeader>
<DialogTitle>Delete Task</DialogTitle>
<DialogDescription>
Are you sure you want to delete "{taskTitle}"? This action cannot be undone.
</DialogDescription>
</DialogHeader>
{state.message && state.message !== "Task deleted successfully" && (
<div className="my-4 rounded-md bg-destructive/10 p-3 text-sm text-destructive">
{state.message}
</div>
)}
{state.message === "Task deleted successfully" && (
<div className="my-4 rounded-md bg-green-50 p-3 text-sm text-green-700">
{state.message}
</div>
)}
<DialogFooter className="mt-4">
<Button
type="button"
variant="outline"
onClick={() => setOpen(false)}
>
Cancel
</Button>
<Button
type="submit"
variant="destructive"
disabled={isPending}
>
{isPending ? "Deleting..." : "Delete"}
</Button>
</DialogFooter>
</form>
</DialogContent>
</Dialog>
)
}
5. Drag-and-Drop Sorting (Three-Column Kanban)
(1) Kanban Structure
graph LR
subgraph "Task Board"
A["Todo<br/>(Column)"] -->|Drag| B["In Progress<br/>(Column)"]
B -->|Drag| C["Done<br/>(Column)"]
C -->|Drag back| B
B -->|Drag back| A
end
style A fill:#fef3c7
style B fill:#dbeafe
style C fill:#d1fae5
▶ Example: Three-column layout for a task board
// src/components/tasks/task-board.tsx
"use client"
import { useCallback } from "react"
import { TaskList } from "@/components/tasks/task-list"
import { reorderTask } from "@/lib/actions/task-actions"
import type { TaskWithAssignee } from "@/types"
interface TaskBoardProps {
tasks: TaskWithAssignee[]
}
export function TaskBoard({ tasks }: TaskBoardProps) {
const todoTasks = tasks.filter((t) => t.status === "TODO")
const inProgressTasks = tasks.filter((t) => t.status === "IN_PROGRESS")
const doneTasks = tasks.filter((t) => t.status === "DONE")
const handleStatusChange = useCallback(
async (taskId: string, newStatus: "TODO" | "IN_PROGRESS" | "DONE") => {
const targetList = tasks.filter((t) => t.status === newStatus)
const maxOrder = targetList.length > 0
? Math.max(...targetList.map((t) => t.order))
: 0
try {
await reorderTask(taskId, newStatus, maxOrder + 1)
} catch (error) {
console.error("Failed to reorder task:", error)
}
},
[tasks]
)
return (
<div className="grid grid-cols-1 gap-6 md:grid-cols-3">
<div className="rounded-lg bg-muted/50 p-4">
<TaskList
tasks={todoTasks}
status="TODO"
onStatusChange={handleStatusChange}
/>
</div>
<div className="rounded-lg bg-muted/50 p-4">
<TaskList
tasks={inProgressTasks}
status="IN_PROGRESS"
onStatusChange={handleStatusChange}
/>
</div>
<div className="rounded-lg bg-muted/50 p-4">
<TaskList
tasks={doneTasks}
status="DONE"
onStatusChange={handleStatusChange}
/>
</div>
</div>
)
}
▶ Example: Task Card Component
// src/components/tasks/task-card.tsx
"use client"
import { DeleteTaskButton } from "@/components/tasks/delete-task-button"
import { Badge } from "@/components/ui/badge"
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar"
import type { TaskWithAssignee } from "@/types"
interface TaskCardProps {
task: TaskWithAssignee
onStatusChange: (taskId: string, newStatus: "TODO" | "IN_PROGRESS" | "DONE") => void
}
const statusActions: Record<string, ("TODO" | "IN_PROGRESS" | "DONE")[]> = {
TODO: ["IN_PROGRESS"],
IN_PROGRESS: ["TODO", "DONE"],
DONE: ["IN_PROGRESS"],
}
const priorityColors: Record<string, string> = {
LOW: "bg-gray-100 text-gray-700",
MEDIUM: "bg-blue-100 text-blue-700",
HIGH: "bg-orange-100 text-orange-700",
URGENT: "bg-red-100 text-red-700",
}
export function TaskCard({ task, onStatusChange }: TaskCardProps) {
const initials = task.assignee?.name
?.split(" ")
.map((n) => n[0])
.join("")
.toUpperCase()
return (
<div className="group rounded-lg border bg-card p-3 shadow-sm transition-all hover:shadow-md">
<div className="flex items-start justify-between">
<h4 className="text-sm font-medium">{task.title}</h4>
<DeleteTaskButton taskId={task.id} taskTitle={task.title} />
</div>
{task.description && (
<p className="mt-1 line-clamp-2 text-xs text-muted-foreground">
{task.description}
</p>
)}
<div className="mt-3 flex items-center justify-between">
<div className="flex items-center gap-2">
<Badge
variant="secondary"
className={priorityColors[task.priority]}
>
{task.priority}
</Badge>
{task.dueDate && (
<span className="text-xs text-muted-foreground">
{new Date(task.dueDate).toLocaleDateString("en-US", {
month: "short",
day: "numeric",
})}
</span>
)}
</div>
<div className="flex items-center gap-2">
<div className="flex gap-1 opacity-0 transition-opacity group-hover:opacity-100">
{statusActions[task.status].map((action) => (
<button
key={action}
onClick={() => onStatusChange(task.id, action)}
className="rounded px-2 py-1 text-[10px] font-medium uppercase transition-colors hover:bg-accent"
>
{action === "TODO"
? "\u21A9"
: action === "IN_PROGRESS"
? "\u25B6"
: "\u2713"}
</button>
))}
</div>
{task.assignee && (
<Avatar className="h-6 w-6">
<AvatarImage src={task.assignee.image ?? undefined} />
<AvatarFallback className="text-[10px]">
{initials}
</AvatarFallback>
</Avatar>
)}
</div>
</div>
{task._count?.comments > 0 && (
<div className="mt-2 flex items-center gap-1 text-xs text-muted-foreground">
<svg className="h-3 w-3" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M8 12h.01M12 12h.01M16 12h.01M21 12c0 4.418-4.03 8-9 8a9.863 9.863 0 01-4.255-.949L3 20l1.395-3.72C3.512 15.042 3 13.574 3 12c0-4.418 4.03-8 9-8s9 3.582 9 8z" />
</svg>
{task._count.comments}
</div>
)}
</div>
)
}
6. File Upload (uploadthing)
(1) Upload Architecture
sequenceDiagram
participant C as Client
participant U as UploadThing
participant S as Server Action
participant DB as Database
C->>U: Upload file (multipart)
U-->>C: Return fileUrl + metadata
C->>S: Server Action(formData with fileUrl)
S->>DB: Create Attachment record
DB-->>S: Attachment created
S->>S: revalidateTag("tasks")
S-->>C: Success
▶ Example: UploadThing Configuration
// src/lib/uploadthing.ts
import { createUploadthing, type FileRouter } from "uploadthing/next"
import { auth } from "@/lib/auth"
const f = createUploadthing()
export const ourFileRouter = {
taskAttachment: f({
image: { maxFileSize: "4MB", maxFileCount: 5 },
pdf: { maxFileSize: "8MB", maxFileCount: 3 },
"text/*": { maxFileSize: "2MB", maxFileCount: 5 },
})
.middleware(async () => {
const session = await auth()
if (!session?.user) throw new Error("Unauthorized")
return { userId: session.user.id }
})
.onUploadComplete(async ({ metadata, file }) => {
return { uploadedBy: metadata.userId, fileUrl: file.url }
}),
} satisfies FileRouter
export type OurFileRouter = typeof ourFileRouter
// src/app/api/uploadthing/core.ts
import { createRouteHandler } from "uploadthing/next"
import { ourFileRouter } from "@/lib/uploadthing"
export const { GET, POST } = createRouteHandler({
router: ourFileRouter,
})
▶ Example: File Upload Component
// src/components/tasks/task-attachment-upload.tsx
"use client"
import { useCallback, useState } from "react"
import { useDropzone } from "@uploadthing/react"
import { generateClientDropzoneAccept } from "uploadthing/client"
import { Button } from "@/components/ui/button"
import { Progress } from "@/components/ui/progress"
interface TaskAttachmentUploadProps {
taskId: string
onUploadComplete: (fileUrl: string, fileName: string) => void
}
export function TaskAttachmentUpload({ taskId, onUploadComplete }: TaskAttachmentUploadProps) {
const [isUploading, setIsUploading] = useState(false)
const [progress, setProgress] = useState(0)
const onDrop = useCallback(
async (acceptedFiles: File[]) => {
setIsUploading(true)
setProgress(0)
for (const file of acceptedFiles) {
const formData = new FormData()
formData.append("file", file)
formData.append("taskId", taskId)
try {
const response = await fetch("/api/upload", {
method: "POST",
body: formData,
})
if (!response.ok) throw new Error("Upload failed")
const data = await response.json()
onUploadComplete(data.fileUrl, data.fileName)
setProgress((prev) => prev + 100 / acceptedFiles.length)
} catch (error) {
console.error("Upload error:", error)
}
}
setIsUploading(false)
setProgress(100)
},
[taskId, onUploadComplete]
)
const { getRootProps, getInputProps, isDragActive } = useDropzone({
onDrop,
accept: generateClientDropzoneAccept([
"image/*",
"application/pdf",
"text/*",
]),
maxFiles: 5,
})
return (
<div>
<div
{...getRootProps()}
className={`cursor-pointer rounded-lg border-2 border-dashed p-6 text-center transition-colors ${
isDragActive
? "border-primary bg-primary/5"
: "border-muted-foreground/25 hover:border-muted-foreground/50"
}`}
>
<input {...getInputProps()} />
{isUploading ? (
<div className="space-y-2">
<p className="text-sm text-muted-foreground">Uploading...</p>
<Progress value={progress} className="h-2" />
</div>
) : isDragActive ? (
<p className="text-sm text-muted-foreground">Drop files here</p>
) : (
<div className="space-y-2">
<svg
className="mx-auto h-8 w-8 text-muted-foreground"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M7 16a4 4 0 01-.88-7.903A5 5 0 1115.9 6L16 6a5 5 0 011 9.9M15 13l-3-3m0 0l-3 3m3-3v12"
/>
</svg>
<p className="text-sm text-muted-foreground">
Drag & drop or click to upload
</p>
<p className="text-xs text-muted-foreground">
Images, PDFs, text files (max 8MB)
</p>
</div>
)}
</div>
</div>
)
}
▶ Example: Upload API Route
// src/app/api/upload/route.ts
import { NextResponse } from "next/server"
import { writeFile, mkdir } from "fs/promises"
import path from "path"
import { auth } from "@/lib/auth"
import { prisma } from "@/lib/db"
export async function POST(request: Request) {
const session = await auth()
if (!session?.user?.id) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 })
}
try {
const formData = await request.formData()
const file = formData.get("file") as File
const taskId = formData.get("taskId") as string
if (!file || !taskId) {
return NextResponse.json(
{ error: "File and taskId are required" },
{ status: 400 }
)
}
const task = await prisma.task.findUnique({
where: { id: taskId },
select: { id: true },
})
if (!task) {
return NextResponse.json({ error: "Task not found" }, { status: 404 })
}
const bytes = await file.arrayBuffer()
const buffer = Buffer.from(bytes)
const uploadDir = path.join(process.cwd(), "public", "uploads")
await mkdir(uploadDir, { recursive: true })
const uniqueName = `${Date.now()}-${file.name.replace(/[^a-zA-Z0-9.-]/g, "_")}`
const filePath = path.join(uploadDir, uniqueName)
await writeFile(filePath, buffer)
const fileUrl = `/uploads/${uniqueName}`
await prisma.attachment.create({
data: {
fileName: file.name,
fileUrl,
fileSize: file.size,
mimeType: file.type,
taskId,
uploaderId: session.user.id,
},
})
return NextResponse.json({
fileUrl,
fileName: file.name,
fileSize: file.size,
})
} catch (error) {
console.error("Upload error:", error)
return NextResponse.json(
{ error: "Upload failed" },
{ status: 500 }
)
}
}
7. Search, Filtering, and Pagination
(1) URL Search Parameter Structure
graph LR
A[URL Search Params] --> B[Server Component]
B --> C[Prisma Query]
C --> D[Filtered Results]
D --> E[shadcn/ui DataTable]
E -->|User interaction| F[Update URL params]
F -->|Next.js navigation| A
style A fill:#cce5ff
style E fill:#d4edda
style F fill:#ffeeba
▶ Example: Search and Pagination on the Task List Page
// src/app/(dashboard)/dashboard/tasks/page.tsx
import { prisma } from "@/lib/db"
import { auth } from "@/lib/auth"
import { TasksDataTable } from "@/components/tasks/tasks-data-table"
import { TasksToolbar } from "@/components/tasks/tasks-toolbar"
interface TasksPageProps {
searchParams: Promise<{
search?: string
status?: string
priority?: string
projectId?: string
page?: string
pageSize?: string
}>
}
export default async function TasksPage({ searchParams }: TasksPageProps) {
const session = await auth()
const params = await searchParams
const search = params.search || ""
const status = params.status || ""
const priority = params.priority || ""
const projectId = params.projectId || ""
const page = parseInt(params.page || "1", 10)
const pageSize = parseInt(params.pageSize || "10", 10)
const where: Record<string, unknown> = {
project: {
organization: {
users: { some: { id: session?.user?.id } },
},
},
}
if (search) {
where.OR = [
{ title: { contains: search, mode: "insensitive" } },
{ description: { contains: search, mode: "insensitive" } },
]
}
if (status) {
where.status = status
}
if (priority) {
where.priority = priority
}
if (projectId) {
where.projectId = projectId
}
const [tasks, totalCount] = await Promise.all([
prisma.task.findMany({
where,
include: {
project: { select: { name: true, color: true } },
assignee: { select: { name: true, image: true } },
_count: { select: { comments: true, attachments: true } },
},
orderBy: { createdAt: "desc" },
skip: (page - 1) * pageSize,
take: pageSize,
}),
prisma.task.count({ where }),
])
const totalPages = Math.ceil(totalCount / pageSize)
return (
<div className="space-y-6">
<div>
<h1 className="text-2xl font-bold">Tasks</h1>
<p className="text-muted-foreground">
{totalCount} total tasks · Page {page} of {totalPages}
</p>
</div>
<TasksToolbar
currentSearch={search}
currentStatus={status}
currentPriority={priority}
currentProjectId={projectId}
/>
<TasksDataTable
tasks={tasks}
currentPage={page}
totalPages={totalPages}
pageSize={pageSize}
totalCount={totalCount}
/>
</div>
)
}
▶ Example: DataTable Component
// src/components/tasks/tasks-data-table.tsx
import Link from "next/link"
import { Badge } from "@/components/ui/badge"
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar"
import { Button } from "@/components/ui/button"
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@/components/ui/table"
interface TaskRow {
id: string
title: string
status: string
priority: string
project: { name: string; color: string }
assignee: { name: string; image: string | null } | null
_count: { comments: number; attachments: number }
createdAt: Date
}
interface TasksDataTableProps {
tasks: TaskRow[]
currentPage: number
totalPages: number
pageSize: number
totalCount: number
}
const statusStyles: Record<string, string> = {
TODO: "bg-yellow-100 text-yellow-800",
IN_PROGRESS: "bg-blue-100 text-blue-800",
DONE: "bg-green-100 text-green-800",
}
const priorityStyles: Record<string, string> = {
LOW: "bg-gray-100 text-gray-600",
MEDIUM: "bg-blue-100 text-blue-600",
HIGH: "bg-orange-100 text-orange-600",
URGENT: "bg-red-100 text-red-600",
}
export function TasksDataTable({
tasks,
currentPage,
totalPages,
pageSize,
totalCount,
}: TasksDataTableProps) {
return (
<div className="rounded-lg border">
<Table>
<TableHeader>
<TableRow>
<TableHead className="w-[400px]">Task</TableHead>
<TableHead>Status</TableHead>
<TableHead>Priority</TableHead>
<TableHead>Project</TableHead>
<TableHead>Assignee</TableHead>
<TableHead className="text-right">Comments</TableHead>
<TableHead className="text-right">Date</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{tasks.length === 0 ? (
<TableRow>
<TableCell colSpan={7} className="h-32 text-center text-muted-foreground">
No tasks found matching your filters.
</TableCell>
</TableRow>
) : (
tasks.map((task) => (
<TableRow key={task.id}>
<TableCell>
<Link
href={`/dashboard/tasks/${task.id}`}
className="font-medium hover:underline"
>
{task.title}
</Link>
</TableCell>
<TableCell>
<Badge
variant="secondary"
className={statusStyles[task.status]}
>
{task.status.replace("_", " ")}
</Badge>
</TableCell>
<TableCell>
<Badge
variant="secondary"
className={priorityStyles[task.priority]}
>
{task.priority}
</Badge>
</TableCell>
<TableCell>
<div className="flex items-center gap-2">
<div
className="h-2 w-2 rounded-full"
style={{ backgroundColor: task.project.color }}
/>
<span className="text-sm">{task.project.name}</span>
</div>
</TableCell>
<TableCell>
{task.assignee ? (
<div className="flex items-center gap-2">
<Avatar className="h-6 w-6">
<AvatarImage src={task.assignee.image ?? undefined} />
<AvatarFallback className="text-[10px]">
{task.assignee.name[0]}
</AvatarFallback>
</Avatar>
<span className="text-sm">{task.assignee.name}</span>
</div>
) : (
<span className="text-sm text-muted-foreground">Unassigned</span>
)}
</TableCell>
<TableCell className="text-right">{task._count.comments}</TableCell>
<TableCell className="text-right text-sm text-muted-foreground">
{task.createdAt.toLocaleDateString("en-US", {
month: "short",
day: "numeric",
})}
</TableCell>
</TableRow>
))
)}
</TableBody>
</Table>
<div className="flex items-center justify-between border-t p-4">
<p className="text-sm text-muted-foreground">
Showing {((currentPage - 1) * pageSize) + 1} to{" "}
{Math.min(currentPage * pageSize, totalCount)} of {totalCount} tasks
</p>
<div className="flex items-center gap-2">
<Button
variant="outline"
size="sm"
disabled={currentPage <= 1}
asChild={currentPage > 1}
>
{currentPage > 1 ? (
<Link href={`/dashboard/tasks?page=${currentPage - 1}`}>
Previous
</Link>
) : (
<span>Previous</span>
)}
</Button>
<div className="flex items-center gap-1">
{Array.from({ length: Math.min(totalPages, 5) }, (_, i) => {
const pageNum = i + 1
return (
<Button
key={pageNum}
variant={pageNum === currentPage ? "default" : "outline"}
size="sm"
className="h-8 w-8 p-0"
asChild
>
<Link href={`/dashboard/tasks?page=${pageNum}`}>
{pageNum}
</Link>
</Button>
)
})}
</div>
<Button
variant="outline"
size="sm"
disabled={currentPage >= totalPages}
asChild={currentPage < totalPages}
>
{currentPage < totalPages ? (
<Link href={`/dashboard/tasks?page=${currentPage + 1}`}>
Next
</Link>
) : (
<span>Next</span>
)}
</Button>
</div>
</div>
</div>
)
}
8. Complete Example: Full Task Board Page
// src/app/(dashboard)/dashboard/projects/[id]/page.tsx
import { Suspense } from "react"
import { prisma } from "@/lib/db"
import { auth } from "@/lib/auth"
import { notFound } from "next/navigation"
import { TaskBoard } from "@/components/tasks/task-board"
import { CreateTaskForm } from "@/components/tasks/create-task-form"
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"
import { Badge } from "@/components/ui/badge"
interface ProjectBoardPageProps {
params: Promise<{ id: string }>
}
export default async function ProjectBoardPage({ params }: ProjectBoardPageProps) {
const { id } = await params
const session = await auth()
const project = await prisma.project.findUnique({
where: { id },
include: {
_count: { select: { tasks: true } },
},
})
if (!project) {
notFound()
}
const tasks = await prisma.task.findMany({
where: { projectId: id },
orderBy: { order: "asc" },
include: {
assignee: { select: { name: true, image: true } },
_count: { select: { comments: true, attachments: true } },
},
})
const statusCounts = {
TODO: tasks.filter((t) => t.status === "TODO").length,
IN_PROGRESS: tasks.filter((t) => t.status === "IN_PROGRESS").length,
DONE: tasks.filter((t) => t.status === "DONE").length,
}
return (
<div className="space-y-6">
<div className="flex items-center justify-between">
<div>
<div className="flex items-center gap-3">
<div
className="h-4 w-4 rounded-full"
style={{ backgroundColor: project.color }}
/>
<h1 className="text-2xl font-bold">{project.name}</h1>
<Badge variant="secondary">
{project.status}
</Badge>
</div>
<p className="mt-1 text-sm text-muted-foreground">
{tasks.length} tasks · {statusCounts.TODO} todo ·{" "}
{statusCounts.IN_PROGRESS} in progress · {statusCounts.DONE} done
</p>
</div>
</div>
<Suspense fallback={<div className="h-12 animate-pulse rounded-lg bg-muted" />}>
<CreateTaskForm projectId={project.id} members={[]} />
</Suspense>
<TaskBoard tasks={tasks} />
</div>
)
}
Expected output (when accessing /dashboard/projects/[id] in a browser):
→ Three-Column Kanban Board:TODO (8) | IN PROGRESS (5) | DONE (7)
→ Click "+" Button to Pop Up the Quick Create Form → Enter a title → Enter → The task appears immediately on the TODO column (Optimistic Update)
→ Click on the task card's ▶ button → Move the task to IN PROGRESS
→ Click to Delete → Display a confirmation dialog box → Confirm Deletion → The Task Has Disappeared
→ Drag and drop files into the upload area → Display a progress bar → Attachments Associated with Tasks
→ Search "auth" → URL Parameter Update → DataTable Filter to display matching tasks
❓ FAQ
useOptimistic and regular useState?useOptimistic is a new hook introduced in React 19, specifically designed for the optimistic update pattern used with Server Actions. It updates the UI immediately while the Server Action is executing and automatically rolls back to the actual data if the Server Action fails. Using useState with manual rollbacks requires more boilerplate code and is prone to errors./uploads/ solution is easy to implement but not suitable for production (no CDN, limited storage space, and difficult to back up). TaskFlow uses local upload as the development solution and switches to uploadthing in the production environment.safeParse, which automatically generates type error messages, making it more reliable than manually written if-else validation.useActionState to delete a task instead of directly calling deleteTask?useActionState is a hook designed for Server Actions in React 19. It provides isPending state (to disable the button and prevent duplicate submissions) and action a wrapper function (to automatically handle formData). Compared to a direct call, useActionState is more secure (CSRF protection) and provides more natural UI feedback.revalidateTag and revalidatePath?revalidateTag("tasks") When a cached tag expires, all fetches marked with { next: { tags: ["tasks"] } } are refreshed. revalidatePath("/dashboard/tasks") Refreshes by path. We recommend using the Tag strategy—it offers finer control and isn’t path-dependent. All fetch operations in TaskFlow are tagged with "tasks."📖 Summary
- Server Actions: Use the
"use server"command to execute functions directly on the server, avoiding manual API routes - Zod provides type-safe form validation, which, when combined with Server Actions' error handling, enables a complete validation workflow.
useOptimisticEnables real-time UI updates and automatically rolls back when a Server Action failsuseActionStateManages the status of confirmation pop-ups, providingisPendingand automatic formData wrapping- Three-column Kanban board implements drag-and-drop state switching via the
reorderTaskServer Action - Uploadthing + Local
/uploads/dual-method file upload, supporting images, PDFs, and text - URL search parameters enable true pagination, search, and filtering; DataTable displays complete task information across 7 columns
revalidateTag("tasks")Standardize cache invalidation strategies to ensure real-time UI updates after CRUD operations
📝 Exercises
-
Basic Exercise (⭐): Implement a quick task creation form (Title + projectId) in the TaskFlow project using a Server Action and an optimistic update. Verification: After clicking "Create," the task immediately appears in the TODO column of the kanban board.
-
Advanced Exercise (⭐⭐): Add a "Batch Operation" feature to the DataTable—after selecting multiple rows, perform a batch update of their status (TODO → IN PROGRESS) or delete them in bulk. Use
useOptimisticto implement optimistic updates for batch selections. Write abulkUpdateTasksServer Action to handle array parameters. -
Challenge (⭐⭐⭐): Implement full drag-and-drop sorting (using
@dnd-kit/coreor the native HTML5 Drag & Drop API): Allow task cards to be freely dragged between the three columns; display a placeholder while dragging; callreorderTaskafter releasing the card to update the status and sort order; UseuseOptimisticto provide immediate sorting feedback after dragging; handle drag conflicts (optimistic locking when two users drag the same task at the same time).



