Next.js: Projeto Final: CRUD & Server Actions

Última atualização: 2026-08-26

O gerenciamento de tarefas está no coração de uma plataforma SaaS — criar, editar, excluir, arrastar e soltar para reordenar, fazer upload de arquivos, pesquisar e filtrar. Um sistema CRUD abrangente permite que os usuários gerenciem fluxos de trabalho de forma eficiente.

1. O Que Você Vai Aprender



2. Uma História Real de um Gerente de Projeto

(1) Ponto de Dor: Uma equipe de 50 pessoas desperdiça 3 horas por dia em gerenciamento de tarefas

Bob é gerente de projeto na Acme Corp, que gerencia 5 projetos e mais de 200 tarefas. A equipe gasta uma quantidade significativa de tempo todos os dias: criando tarefas (preenchendo formulários de 10 campos repetidamente), arrastando e soltando para atualizar status (como colar Post-its em um quadro branco), pedindo arquivos aos colegas (trocando-os no WeChat) e procurando tarefas (rolando planilhas do Excel). Alice calculou que, em uma equipe de 50 pessoas, cada pessoa desperdiça 36 minutos por dia em gerenciamento ineficiente de tarefas, o que equivale a uma perda mensal de produtividade de 600 horas.

(2) Server Actions + Atualizações Otimistas + Uma Solução para Drag-and-Drop

Implemente CRUD sem refresh usando Server Actions + useOptimistic para feedback instantâneo + mudança de estado por arrastar e soltar.

TSX
// Uma linha de Server Action completa a criação da tarefa + Atualização de Cache
<form action={createTask}>
  <input name="title" required />
  <input name="projectId" type="hidden" value={project.id} />
  <button type="submit">Criar Tarefa</button>
</form>

(3) Resultados

Cenário Antes da Otimização Após Otimização Melhoria de Eficiência
Criar Tarefa 3 minutos (formulário completo) 15 segundos (criação rápida) 12x
Ordenação por Arrastar e Soltar Quadro Branco Manuscrito Clicar na Coluna de Status Instantâneo
Compartilhamento de Arquivos Enviar Arquivos pelo WeChat Upload via Uploadthing 5x
Pesquisar Tarefa Navegando no Excel Pesquisa em tempo real na DataTable 20x
Produtividade Mensal 600 horas desperdiçadas ~50 horas desperdiçadas Redução de 92%


3. Server Actions para CRUD de Tarefas

(1) Arquitetura Operacional

100%
graph TB
    subgraph "Server Actions"
        A[createTask] --> B[Validar com Zod]
        B --> C[Criar com Prisma]
        C --> D[revalidateTag]
        D --> E[redirect]
    end

    subgraph "Client"
        F[TaskForm] -->|action| A
        G[useOptimistic] -->|UI instantânea| F
        H[useActionState] -->|confirmar| J[deleteTask]
    end

    subgraph "Cache"
        D --> K[revalidateTag<br/>"tasks"]
        K --> L[Atualizar UI]
    end

    style A fill:#cce5ff
    style G fill:#d4edda
    style K fill:#ffeeba

(2) Comparação de Operações de Tarefa

Operação Server Action Validação Política de Cache Feedback da UI
createTask "use server" Zod revalidateTag("tasks") useOptimistic
updateTask "use server" Zod parcial revalidateTag("tasks") Atualização Otimista
deleteTask "use server" Verificação de Confirmação revalidateTag("tasks") useActionState
reorderTask "use server" Verificar Número revalidateTag("tasks") Reordenar Imediatamente

▶ Exemplo: Definição de Server Actions

Saída:

TEXT 📖 Somente leitura
Diagrama: createTask; Validar com Zod; Criar com Prisma; revalidateTag; redirect; TaskForm.
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, "Título é obrigatório").max(200, "Título muito longo"),
  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("Não autorizado")
  }

  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: "Falha na validação",
    }
  }

  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("Não autorizado")
  }

  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: "Falha na validação",
    }
  }

  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: "Não autorizado" }
  }

  const taskId = formData.get("id") as string

  if (!taskId) {
    return { message: "ID da tarefa é obrigatório" }
  }

  const task = await prisma.task.findUnique({
    where: { id: taskId },
    select: { creatorId: true, projectId: true },
  })

  if (!task) {
    return { message: "Tarefa não encontrada" }
  }

  if (task.creatorId !== session.user.id) {
    return { message: "Apenas o criador pode excluir esta tarefa" }
  }

  await prisma.task.delete({ where: { id: taskId } })

  revalidateTag("tasks")
  revalidateTag(`project-${task.projectId}`)

  return { message: "Tarefa excluída com sucesso" }
}

export async function reorderTask(taskId: string, newStatus: TaskStatus, newOrder: number) {
  const session = await auth()
  if (!session?.user?.id) {
    throw new Error("Não autorizado")
  }

  await prisma.task.update({
    where: { id: taskId },
    data: { status: newStatus, order: newOrder },
  })

  revalidateTag("tasks")
}

Saída:

TEXT 📖 Somente leitura
Aceita campos do formulário: title, description, projectId, assigneeId, priority.
Valida a entrada com o schema Zod antes do processamento.
Grava no banco de dados e invalida as tags de cache relevantes.


4. Atualizações Otimistas e Pop-ups de Confirmação

(1) Três Modos de UI

100%
graph LR
    subgraph "Atualização Otimista"
        A1[Usuário clica] --> B1[Atualização instantânea da UI]
        B1 --> C1[Server Action]
        C1 --> D1{Rollback?}
        D1 -->|Sucesso| E1[Manter]
        D1 -->|Erro| F1[Reverter]
    end

    subgraph "Action State"
        A2[Usuário clica em excluir] --> B2[Mostrar diálogo de confirmação]
        B2 --> C2[Se confirmado<br/>chamar deleteTask]
        C2 --> D2[Mostrar mensagem de resultado]
    end

    subgraph "Tradicional"
        A3[Enviar formulário] --> B3[Spinner de carregamento]
        B3 --> C3[Server Action]
        C3 --> D3[revalidateTag + refresh]
    end

    style B1 fill:#d4edda
    style B2 fill:#ffeeba
    style B3 fill:#f8d7da

▶ Exemplo: useOptimistic — Atualizações Otimistas

Saída:

TEXT 📖 Somente leitura
Diagrama ilustra a arquitetura/fluxo descrito.
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">
          Nenhuma tarefa
        </div>
      ) : (
        <div className="space-y-2">
          {optimisticTasks.map((task) => (
            <TaskCard
              key={task.id}
              task={task}
              onStatusChange={onStatusChange}
            />
          ))}
        </div>
      )}
    </div>
  )
}

Saída:

TEXT 📖 Somente leitura
Renderiza a UI do componente ▶ Exemplo: useOptimistic — Atualizações Otimistas conforme descrito na seção.

▶ Exemplo: useActionState para Confirmar Exclusão

Saída:

TEXT 📖 Somente leitura
O componente renderiza a UI descrita no navegador.
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>Excluir Tarefa</DialogTitle>
            <DialogDescription>
              Tem certeza de que deseja excluir "{taskTitle}"? Esta ação não pode ser desfeita.
            </DialogDescription>
          </DialogHeader>
          {state.message && state.message !== "Tarefa excluída com sucesso" && (
            <div className="my-4 rounded-md bg-destructive/10 p-3 text-sm text-destructive">
              {state.message}
            </div>
          )}
          {state.message === "Tarefa excluída com sucesso" && (
            <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)}
            >
              Cancelar
            </Button>
            <Button
              type="submit"
              variant="destructive"
              disabled={isPending}
            >
              {isPending ? "Excluindo..." : "Excluir"}
            </Button>
          </DialogFooter>
        </form>
      </DialogContent>
    </Dialog>
  )
}

Saída:

TEXT 📖 Somente leitura
Formulário interativo com gerenciamento de estado client-side.
Conteúdo visível: Excluir Tarefa


5. Ordenação por Arrastar e Soltar (Kanban de Três Colunas)

(1) Estrutura Kanban

100%
graph LR
    subgraph "Quadro de Tarefas"
        A["A Fazer<br/>(Coluna)"] -->|Arrastar| B["Em Andamento<br/>(Coluna)"]
        B -->|Arrastar| C["Concluído<br/>(Coluna)"]
        C -->|Arrastar de volta| B
        B -->|Arrastar de volta| A
    end

    style A fill:#fef3c7
    style B fill:#dbeafe
    style C fill:#d1fae5

▶ Exemplo: Layout de três colunas para um quadro de tarefas

Saída:

TEXT 📖 Somente leitura
Diagrama da estrutura de layout e template no diretório app.
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("Falha ao reordenar tarefa:", 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>
  )
}

Saída:

TEXT 📖 Somente leitura
Renderiza a UI do componente ▶ Exemplo: Layout de três colunas para um quadro de tarefas conforme descrito na seção.

▶ Exemplo: Componente Task Card

Saída:

TEXT 📖 Somente leitura
O componente renderiza a UI descrita no navegador.
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("pt-BR", {
                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>
  )
}

Saída:

TEXT 📖 Somente leitura
Renderiza a UI do componente ▶ Exemplo: Componente Task Card conforme descrito na seção.


6. Upload de Arquivos (uploadthing)

(1) Arquitetura de Upload

100%
sequenceDiagram
    participant C as Client
    participant U as UploadThing
    participant S as Server Action
    participant DB as Banco de Dados

    C->>U: Fazer upload do arquivo (multipart)
    U-->>C: Retornar fileUrl + metadados
    C->>S: Server Action (formData com fileUrl)
    S->>DB: Criar registro Attachment
    DB-->>S: Attachment criado
    S->>S: revalidateTag("tasks")
    S-->>C: Sucesso

▶ Exemplo: Configuração do UploadThing

Saída:

TEXT 📖 Somente leitura
Diagrama de sequência mostrando o fluxo de mensagens entre os participantes.
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("Não autorizado")
      return { userId: session.user.id }
    })
    .onUploadComplete(async ({ metadata, file }) => {
      return { uploadedBy: metadata.userId, fileUrl: file.url }
    }),
} satisfies FileRouter

export type OurFileRouter = typeof ourFileRouter

Saída:

TEXT 📖 Somente leitura
Define tipo(s) TypeScript: FileRouter, 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,
})


7. Pesquisa, Filtragem e Paginação

(1) Estrutura de Parâmetros de Busca URL

100%
graph LR
    A[Parâmetros de Busca URL] --> B[Server Component]
    B --> C[Consulta Prisma]
    C --> D[Resultados Filtrados]
    D --> E[shadcn/ui DataTable]
    E -->|Interação do usuário| F[Atualizar parâmetros URL]
    F -->|Navegação Next.js| A

    style A fill:#cce5ff
    style E fill:#d4edda
    style F fill:#ffeeba


8. Exemplo Completo: Página Completa do Quadro de Tarefas

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} tarefas · {statusCounts.TODO} a fazer ·{" "}
            {statusCounts.IN_PROGRESS} em andamento · {statusCounts.DONE} concluídas
          </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>
  )
}

Saída esperada (ao acessar /dashboard/projects/[id] no navegador):

TEXT 📖 Somente leitura
→ Quadro Kanban de Três Colunas: A FAZER (8) | EM ANDAMENTO (5) | CONCLUÍDO (7)
→ Clique no botão "+" para abrir o Formulário de Criação Rápida → Digite um título → Enter → A tarefa aparece imediatamente na coluna A FAZER (Atualização Otimista)
→ Clique no botão ▶ do cartão da tarefa → Mover a tarefa para EM ANDAMENTO
→ Clique para Excluir → Exibir uma caixa de diálogo de confirmação → Confirmar Exclusão → A Tarefa Desapareceu
→ Arraste e solte arquivos na área de upload → Exibir uma barra de progresso → Anexos Associados às Tarefas
→ Pesquisar "auth" → Atualização do Parâmetro URL → DataTable Filtra para exibir tarefas correspondentes

❓ Perguntas Frequentes

P: Como escolher entre Server Actions e API Routes? R: Use Server Actions para envios de formulários e atualizações de cache (menos código, type-safe, sem necessidade de endpoints de API). Use API Routes para integrações de terceiros, webhooks e APIs mobile. Server Actions são a forma recomendada de lidar com formulários no Next.js, enquanto API Routes são usadas para clientes não-navegador.

P: Qual é a diferença entre useOptimistic e useState comum? R: useOptimistic é um novo hook introduzido no React 19, projetado especificamente para o padrão de atualização otimista usado com Server Actions. Ele atualiza a UI imediatamente enquanto a Server Action está sendo executada e reverte automaticamente para os dados reais se a Server Action falhar. Usar useState com rollbacks manuais requer mais código boilerplate e é propenso a erros.

P: Qual é melhor, uploadthing ou upload local de arquivos? R: uploadthing fornece CDN, otimização de imagens e validação de segurança, sendo adequado para ambientes de produção. A solução local /uploads/ é fácil de implementar, mas não é adequada para produção (sem CDN, espaço de armazenamento limitado e difícil de fazer backup). O TaskFlow usa upload local como solução de desenvolvimento e muda para uploadthing no ambiente de produção.

P: Por que a paginação por parâmetros de busca URL não usa paginação client-side? R: A paginação por parâmetros de busca URL é "paginação verdadeira" (paginação server-side), que carrega apenas os dados da página atual e é adequada para grandes conjuntos de dados. A paginação client-side carrega todos os dados de uma vez, resultando em tempos de carregamento inicial lentos e desperdício de largura de banda. Como o TaskFlow pode conter dezenas de milhares de tarefas, a paginação server-side é a única opção viável.

P: Qual é o papel da validação Zod na Server Action? R: Ela fornece uma dupla camada de proteção — a validação no navegador (HTML5 required + validação front-end) previne erros acidentais, enquanto a validação Zod server-side previne requisições maliciosas. O Zod também oferece safeParse type-safe, que gera automaticamente mensagens de erro de tipo, sendo mais confiável do que validação if-else escrita manualmente.

P: Por que usar useActionState para excluir uma tarefa em vez de chamar deleteTask diretamente? R: useActionState é um hook projetado para Server Actions no React 19. Ele fornece estado isPending (para desabilitar o botão e evitar envios duplicados) e uma função wrapper action (para manipular automaticamente formData). Comparado a uma chamada direta, useActionState é mais seguro (proteção CSRF) e fornece feedback de UI mais natural.


📖 Resumo

📝 Exercícios

  1. Exercício Básico (⭐): Implemente um formulário de criação rápida de tarefas (Título + projectId) no projeto TaskFlow usando uma Server Action e uma atualização otimista. Verificação: Após clicar em "Criar", a tarefa aparece imediatamente na coluna A FAZER do quadro kanban.

  2. Exercício Avançado (⭐⭐): Adicione um recurso de "Operação em Lote" à DataTable — após selecionar várias linhas, realize uma atualização em lote de seus status (A FAZER → EM ANDAMENTO) ou exclua-as em massa. Use useOptimistic para implementar atualizações otimistas para seleções em lote. Escreva uma Server Action bulkUpdateTasks para lidar com parâmetros de array.

  3. Desafio (⭐⭐⭐): Implemente ordenação completa por arrastar e soltar (usando @dnd-kit/core ou a API nativa HTML5 Drag & Drop): Permita que os cartões de tarefa sejam livremente arrastados entre as três colunas; exiba um placeholder durante o arrasto; chame reorderTask após soltar o cartão para atualizar o status e a ordem de classificação; use useOptimistic para fornecer feedback imediato de ordenação após o arrasto; lide com conflitos de arrasto (bloqueio otimista quando dois usuários arrastam a mesma tarefa ao mesmo tempo).

Web-Tutorial.com

Equipe Técnica Web-Tutorial

Uma plataforma de tutoriais mantida por diversos desenvolvedores. Cada tutorial é escrito e revisado por profissionais da área correspondente. Trabalhamos para manter nosso conteúdo preciso e confiável — se encontrar algum problema, avise-nos.

100%