404 Not Found

404 Not Found


nginx

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


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.

TSX
// 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

100%
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

TYPESCRIPT
// 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

100%
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

TSX
// 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

TSX
// 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

100%
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

TSX
// 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

TSX
// 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

100%
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

TYPESCRIPT
// 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
TYPESCRIPT
// 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

TSX
// 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

TYPESCRIPT
// 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

100%
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

TSX
// 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

TSX
// 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

TSX
// 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):

TEXT
→ 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

Q How do I choose between Server Actions and API Routes?
A Use Server Actions for form submissions and cache refreshes (less code, type-safe, no API endpoints required). Use API Routes for third-party integrations, webhooks, and mobile APIs. Server Actions are the recommended way to handle forms in Next.js, while API Routes are used for non-browser clients.
Q What is the difference between useOptimistic and regular useState?
A 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.
Q Which is better, uploadthing or local file upload?
A uploadthing provides a CDN, image optimization, and security validation, making it suitable for production environments. The local /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.
Q Why doesn’t URL search parameter pagination use client-side pagination?
A URL search parameter pagination is “true pagination” (server-side pagination), which loads only the data for the current page and is suitable for large datasets. Client-side pagination loads all data at once, resulting in slow initial loading times and wasted bandwidth. Since TaskFlow may contain tens of thousands of tasks, server-side pagination is the only viable option.
Q What is the role of Zod validation in Server Action?
A It provides a double layer of protection—browser-side validation (HTML5 required + front-end validation) prevents accidental errors, while server-side Zod validation prevents malicious requests. Zod also offers type-safe safeParse, which automatically generates type error messages, making it more reliable than manually written if-else validation.
Q Why use useActionState to delete a task instead of directly calling deleteTask?
A 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.
Q What is the difference between revalidateTag and revalidatePath?
A 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

📝 Exercises

  1. 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.

  2. 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 useOptimistic to implement optimistic updates for batch selections. Write a bulkUpdateTasks Server Action to handle array parameters.

  3. Challenge (⭐⭐⭐): Implement full drag-and-drop sorting (using @dnd-kit/core or the native HTML5 Drag & Drop API): Allow task cards to be freely dragged between the three columns; display a placeholder while dragging; call reorderTask after releasing the card to update the status and sort order; Use useOptimistic to provide immediate sorting feedback after dragging; handle drag conflicts (optimistic locking when two users drag the same task at the same time).

Web-Tutorial.com

Web-Tutorial Tech Team

A team of developers maintaining programming tutorials. Each tutorial is written and reviewed by developers with expertise in that field. We work to keep our content accurate and reliable — if you spot an issue, please let us know.

100%

🙏 帮我们做得更好

我们是刚上线的编程教程站,几个人的小团队,精力有限。页面虽经检查,难免还有疏漏——链接失效、排版错乱、内容有误、语言生硬……

如果您发现了,麻烦告诉我们,我们会在收到反馈后第一时间进行修复,再次感谢您的光临 🙏