Skip to main content

Full-stack practical project

In this chapter, you integrate all the knowledge you have learned so far and build a project management app (Trello-like) with React + NestJS + Prisma.

What you learn in this chapter

  • Building a full-stack app combining React + NestJS + Prisma
  • Implementing drag-and-drop with @dnd-kit
  • Efficient server-state management leveraging TanStack Query
  • Practical project design and component splitting
info

In this chapter, you use React / TypeScript / Tailwind CSS / state management (Zustand / TanStack Query) / React Router / authentication / NestJS + Prisma covered in this book in combination. Separately from the Chapter 3 project used in your learning so far, you create a new full-stack project. While building an actually working application, you experience how each technology works together. After it is complete, take on the "advanced challenges."

Project overview

Feature list

FeatureDescription
User authenticationRegistration, login, logout
Project managementCreate, edit, delete
Board managementManage boards (lists) within a project
Card managementManage task cards within a board
Drag-and-dropReorder cards and boards

Tech stack

Frontend:

  • React + TypeScript
  • Vite
  • Tailwind CSS
  • Zustand (auth state)
  • TanStack Query (server state)
  • React Router
  • @dnd-kit (drag-and-drop)
Why choose @dnd-kit

There are multiple libraries for implementing drag-and-drop in React, but @dnd-kit was chosen for the following reasons: (1) accessibility support out of the box, (2) full TypeScript support, (3) lightweight with good performance, (4) high customizability. The previously popular react-beautiful-dnd had a disclaimer of reduced maintenance added to its README in October 2021, was deprecated on npm in October 2024, and the repository was subsequently archived, so @dnd-kit is recommended for new projects.

Backend:

  • NestJS
  • Prisma + PostgreSQL
  • JWT authentication

Frontend setup

# Create the project
npm create vite@latest project-app -- --template react-ts
cd project-app

# Install dependencies
npm install zustand @tanstack/react-query axios react-router
npm install @dnd-kit/core @dnd-kit/sortable @dnd-kit/utilities
npm install clsx
npm install tailwindcss @tailwindcss/vite
npm install -D @types/node

For Tailwind CSS v4, add import tailwindcss from '@tailwindcss/vite' to vite.config.ts and register tailwindcss() in plugins. Then, writing @import "tailwindcss"; at the top of src/index.css completes the setup. For setup details, see Chapter 17, Tailwind CSS.

Project structure

project-app/
├── src/
│ ├── components/
│ │ ├── layout/
│ │ │ ├── Header.tsx
│ │ │ └── Layout.tsx
│ │ ├── auth/
│ │ │ ├── LoginForm.tsx
│ │ │ ├── RegisterForm.tsx
│ │ │ └── PrivateRoute.tsx
│ │ ├── projects/
│ │ │ ├── ProjectCard.tsx
│ │ │ ├── ProjectForm.tsx
│ │ │ └── ProjectList.tsx
│ │ └── board/
│ │ ├── Board.tsx
│ │ ├── BoardColumn.tsx
│ │ ├── Card.tsx
│ │ └── CardForm.tsx
│ ├── hooks/
│ │ ├── useAuth.ts
│ │ ├── useProjects.ts
│ │ └── useBoards.ts
│ ├── stores/
│ │ └── authStore.ts
│ ├── lib/
│ │ └── api.ts
│ ├── types/
│ │ └── index.ts
│ ├── pages/
│ │ ├── LoginPage.tsx
│ │ ├── RegisterPage.tsx
│ │ ├── DashboardPage.tsx
│ │ └── ProjectPage.tsx
│ ├── App.tsx
│ └── main.tsx
├── .env
└── package.json
warning

The board and card APIs this chapter's frontend calls (POST /projects/:id/boards, POST /boards/:id/cards, PATCH /cards/:id/position, etc.) are not implemented in Chapter 28. Create the Boards and Cards modules yourself, using the same pattern as Chapter 28's Projects module (controller / service / DTO), before proceeding to this chapter. This itself is the comprehensive exercise of Chapter 28.

Entry point and routing

In main.tsx, you configure TanStack Query's QueryClientProvider and React Router's BrowserRouter.

// src/main.tsx
import { StrictMode } from 'react'
import { createRoot } from 'react-dom/client'
import { BrowserRouter } from 'react-router'
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
import App from './App'
import './index.css'

const queryClient = new QueryClient()

createRoot(document.getElementById('root')!).render(
<StrictMode>
<QueryClientProvider client={queryClient}>
<BrowserRouter>
<App />
</BrowserRouter>
</QueryClientProvider>
</StrictMode>
)

App.tsx, the authentication-related code (AuthProvider / PrivateRoute / login/registration forms / stores/authStore.ts), and src/lib/api.ts (the Axios interceptor) are reused from those created in Chapter 27. For the route definitions, add /projects/:projectId for the project detail page.

// src/App.tsx (add to Chapter 27's Routes)
<Route
path="/projects/:projectId"
element={
<PrivateRoute>
<ProjectPage />
</PrivateRoute>
}
/>

Type definitions

// src/types/index.ts
export type User = {
id: string
email: string
name: string
}

export type Project = {
id: string
name: string
description: string | null
ownerId: string
owner: User
boards?: Board[] // included only in the detail API
_count?: { boards: number } // the list API returns only the board count
createdAt: string
updatedAt: string
}

export type Board = {
id: string
name: string
position: number
projectId: string
cards: Card[]
createdAt: string
updatedAt: string
}

export type Card = {
id: string
title: string
description: string | null
position: number
boardId: string
createdAt: string
updatedAt: string
}

The project list page

// src/pages/DashboardPage.tsx
import { useState } from 'react'
import { Link } from 'react-router'
import { useProjects, useCreateProject } from '../hooks/useProjects'
import { ProjectCard } from '../components/projects/ProjectCard'
import { ProjectForm } from '../components/projects/ProjectForm'

export function DashboardPage() {
const [isFormOpen, setIsFormOpen] = useState(false)
const { data: projects, isLoading } = useProjects()
const createProject = useCreateProject()

const handleOpenForm = () => {
setIsFormOpen(true)
}

const handleCloseForm = () => {
setIsFormOpen(false)
}

const handleCreate = (data: { name: string; description?: string }) => {
createProject.mutate(data, {
onSuccess: () => setIsFormOpen(false)
})
}

if (isLoading) {
return (
<div className="flex justify-center py-12">
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-blue-500" />
</div>
)
}

return (
<div className="max-w-6xl mx-auto px-4 py-8">
<div className="flex justify-between items-center mb-8">
<h1 className="text-2xl font-bold">Projects</h1>
<button
onClick={handleOpenForm}
className="px-4 py-2 bg-blue-500 text-white rounded-lg hover:bg-blue-600"
>
New project
</button>
</div>

{projects?.length === 0 ? (
<div className="text-center py-12 text-gray-500">
<p>There are no projects</p>
<p className="text-sm mt-2">Create one from "New project"</p>
</div>
) : (
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
{projects?.map((project) => (
<Link key={project.id} to={`/projects/${project.id}`}>
<ProjectCard project={project} />
</Link>
))}
</div>
)}

{isFormOpen && (
<ProjectForm
onSubmit={handleCreate}
onClose={handleCloseForm}
isLoading={createProject.isPending}
/>
)}
</div>
)
}

The project creation form

A modal form for creating a new project, called from DashboardPage.

// src/components/projects/ProjectForm.tsx
import { useState } from 'react'

type ProjectFormProps = {
onSubmit: (data: { name: string; description?: string }) => void
onClose: () => void
isLoading: boolean
}

export function ProjectForm({ onSubmit, onClose, isLoading }: ProjectFormProps) {
const [name, setName] = useState('')
const [description, setDescription] = useState('')

const handleNameChange = (e: React.ChangeEvent<HTMLInputElement>) => {
setName(e.target.value)
}

const handleDescriptionChange = (e: React.ChangeEvent<HTMLTextAreaElement>) => {
setDescription(e.target.value)
}

const handleSubmit = (e: React.FormEvent) => {
e.preventDefault()
if (!name.trim()) return

onSubmit({
name: name.trim(),
description: description.trim() || undefined
})
}

return (
<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50">
<div className="bg-white rounded-lg shadow-xl w-full max-w-md p-6">
<div className="flex justify-between items-center mb-4">
<h2 className="text-xl font-bold">New project</h2>
<button onClick={onClose} className="text-gray-500 hover:text-gray-700">

</button>
</div>

<form onSubmit={handleSubmit} className="space-y-4">
<div>
<label className="block text-sm font-medium mb-1">Project name</label>
<input
type="text"
value={name}
onChange={handleNameChange}
autoFocus
className="w-full px-3 py-2 border rounded"
/>
</div>

<div>
<label className="block text-sm font-medium mb-1">Description (optional)</label>
<textarea
value={description}
onChange={handleDescriptionChange}
rows={3}
className="w-full px-3 py-2 border rounded"
/>
</div>

<div className="flex justify-end gap-2">
<button
type="button"
onClick={onClose}
className="px-4 py-2 text-gray-600 hover:text-gray-800"
>
Cancel
</button>
<button
type="submit"
disabled={isLoading}
className="px-4 py-2 bg-blue-500 text-white rounded hover:bg-blue-600 disabled:opacity-50"
>
Create
</button>
</div>
</form>
</div>
</div>
)
}

The project card

// src/components/projects/ProjectCard.tsx
import { Project } from '../../types'

type ProjectCardProps = {
project: Project
}

export function ProjectCard({ project }: ProjectCardProps) {
return (
<div className="bg-white rounded-lg shadow p-6 hover:shadow-lg transition-shadow">
<h3 className="text-lg font-semibold mb-2">{project.name}</h3>
{project.description && (
<p className="text-gray-600 text-sm mb-4 line-clamp-2">
{project.description}
</p>
)}
<div className="flex items-center justify-between text-sm text-gray-500">
<span>{project._count?.boards ?? project.boards?.length ?? 0} boards</span>
<span>{new Date(project.createdAt).toLocaleDateString('en-US')}</span>
</div>
</div>
)
}

The project detail page (Kanban board)

// src/pages/ProjectPage.tsx
import { useState, useEffect } from 'react'
import { useParams } from 'react-router'
import {
DndContext,
DragOverlay,
closestCorners,
PointerSensor,
useSensor,
useSensors,
DragStartEvent,
DragEndEvent,
DragOverEvent
} from '@dnd-kit/core'
import { arrayMove } from '@dnd-kit/sortable'
import { useProject } from '../hooks/useProjects'
import { useCreateBoard, useUpdateCardPosition } from '../hooks/useBoards'
import { BoardColumn } from '../components/board/BoardColumn'
import { Card } from '../components/board/Card' // the Card component
import { Card as CardType, Board } from '../types' // the Card type (avoid the name collision)

export function ProjectPage() {
const { projectId } = useParams<{ projectId: string }>()
const { data: project, isLoading } = useProject(projectId!)
const updateCardPosition = useUpdateCardPosition()

const [activeCard, setActiveCard] = useState<CardType | null>(null)
const [boards, setBoards] = useState<Board[]>([])

// Update the boards when the project data changes
useEffect(() => {
if (project?.boards) {
setBoards(project.boards)
}
}, [project])

const sensors = useSensors(
useSensor(PointerSensor, {
activationConstraint: {
distance: 8
}
})
)

const handleDragStart = (event: DragStartEvent) => {
const { active } = event
const activeData = active.data.current

if (activeData?.type === 'card') {
setActiveCard(activeData.card)
}
}

const handleDragOver = (event: DragOverEvent) => {
const { active, over } = event

if (!over) return

const activeData = active.data.current
const overData = over.data.current

if (activeData?.type !== 'card') return

const activeCardId = active.id as string
const activeCard = activeData.card as CardType
const activeBoardId = activeCard.boardId

// Move the card to a different board
let overBoardId: string

if (overData?.type === 'card') {
overBoardId = (overData.card as CardType).boardId
} else if (overData?.type === 'board') {
overBoardId = over.id as string
} else {
return
}

if (activeBoardId === overBoardId) return

setBoards((prevBoards) => {
const activeBoard = prevBoards.find((b) => b.id === activeBoardId)
const overBoard = prevBoards.find((b) => b.id === overBoardId)

if (!activeBoard || !overBoard) return prevBoards

const activeCardIndex = activeBoard.cards.findIndex(
(c) => c.id === activeCardId
)
const movedCard = activeBoard.cards[activeCardIndex]
if (!movedCard) return prevBoards

let overCardIndex = overBoard.cards.length

if (overData?.type === 'card') {
overCardIndex = overBoard.cards.findIndex(
(c) => c.id === (over.id as string)
)
}

// Do not mutate state directly; create new cards arrays and update immutably
return prevBoards.map((board) => {
if (board.id === activeBoardId) {
return {
...board,
cards: board.cards.filter((_, i) => i !== activeCardIndex)
}
}
if (board.id === overBoardId) {
const newCards = [...board.cards]
newCards.splice(overCardIndex, 0, { ...movedCard, boardId: overBoardId })
return { ...board, cards: newCards }
}
return board
})
})
}

const handleDragEnd = (event: DragEndEvent) => {
const { active, over } = event

setActiveCard(null)

if (!over) return

const activeData = active.data.current
const overData = over.data.current

if (activeData?.type === 'card' && overData?.type === 'card') {
const activeCard = activeData.card as CardType
const overCard = overData.card as CardType

if (activeCard.boardId === overCard.boardId) {
// Reorder within the same board
const board = boards.find((b) => b.id === activeCard.boardId)
if (!board) return

const oldIndex = board.cards.findIndex((c) => c.id === activeCard.id)
const newIndex = board.cards.findIndex((c) => c.id === overCard.id)

// Send the update to the server
// (keep the updater function pure. Calling it inside causes a double PATCH send under StrictMode's double execution)
updateCardPosition.mutate({
cardId: activeCard.id,
boardId: activeCard.boardId,
position: newIndex
})

// Do not mutate state directly; replace only the target board's cards with a new array
setBoards((prevBoards) =>
prevBoards.map((b) =>
b.id === activeCard.boardId
? { ...b, cards: arrayMove(b.cards, oldIndex, newIndex) }
: b
)
)
} else {
// Move to a different board (already handled in handleDragOver)
updateCardPosition.mutate({
cardId: activeCard.id,
boardId: overCard.boardId,
position: 0
})
}
}
}

if (isLoading) {
return (
<div className="flex justify-center py-12">
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-blue-500" />
</div>
)
}

if (!project) {
return <div className="text-center py-12">Project not found</div>
}

return (
<div className="h-full overflow-hidden">
<div className="px-4 py-4 border-b">
<h1 className="text-xl font-bold">{project.name}</h1>
{project.description && (
<p className="text-gray-600 text-sm mt-1">{project.description}</p>
)}
</div>

<div className="h-[calc(100vh-120px)] overflow-x-auto p-4">
<DndContext
sensors={sensors}
collisionDetection={closestCorners}
onDragStart={handleDragStart}
onDragOver={handleDragOver}
onDragEnd={handleDragEnd}
>
<div className="flex gap-4 h-full">
{boards.map((board) => (
<BoardColumn
key={board.id}
board={board}
projectId={project.id}
/>
))}

<AddBoardButton projectId={project.id} />
</div>

<DragOverlay>
{activeCard && <Card card={activeCard} isDragging />}
</DragOverlay>
</DndContext>
</div>
</div>
)
}

function AddBoardButton({ projectId }: { projectId: string }) {
const [isAdding, setIsAdding] = useState(false)
const [name, setName] = useState('')
const { mutate: createBoard, isPending } = useCreateBoard()

const handleNameChange = (e: React.ChangeEvent<HTMLInputElement>) => {
setName(e.target.value)
}

const handleStartAdding = () => {
setIsAdding(true)
}

const handleCancel = () => {
setIsAdding(false)
}

const handleSubmit = (e: React.FormEvent) => {
e.preventDefault()
if (!name.trim()) return

createBoard(
{ projectId, name: name.trim() },
{
onSuccess: () => {
setName('')
setIsAdding(false)
}
}
)
}

if (isAdding) {
return (
<div className="w-72 shrink-0 bg-gray-100 rounded-lg p-3">
<form onSubmit={handleSubmit}>
<input
type="text"
value={name}
onChange={handleNameChange}
placeholder="Enter a board name..."
autoFocus
className="w-full px-3 py-2 rounded border focus:outline-hidden focus:ring-2 focus:ring-blue-500"
/>
<div className="flex gap-2 mt-2">
<button
type="submit"
disabled={isPending}
className="px-3 py-1.5 bg-blue-500 text-white rounded hover:bg-blue-600 disabled:opacity-50"
>
Add
</button>
<button
type="button"
onClick={handleCancel}
className="px-3 py-1.5 text-gray-600 hover:text-gray-800"
>
Cancel
</button>
</div>
</form>
</div>
)
}

return (
<button
onClick={handleStartAdding}
className="w-72 shrink-0 h-12 bg-gray-200 hover:bg-gray-300 rounded-lg
flex items-center justify-center text-gray-600 transition-colors"
>
+ Add a board
</button>
)
}

The board column

// src/components/board/BoardColumn.tsx
import { useState } from 'react'
import { useDroppable } from '@dnd-kit/core'
import { SortableContext, verticalListSortingStrategy } from '@dnd-kit/sortable'
import { Board } from '../../types'
import { SortableCard } from './SortableCard'
import { useCreateCard, useDeleteBoard } from '../../hooks/useBoards'

type BoardColumnProps = {
board: Board
projectId: string
}

export function BoardColumn({ board, projectId }: BoardColumnProps) {
const [isAddingCard, setIsAddingCard] = useState(false)
const [cardTitle, setCardTitle] = useState('')

const { setNodeRef } = useDroppable({
id: board.id,
data: { type: 'board', board }
})

const createCard = useCreateCard()
const deleteBoard = useDeleteBoard()

const handleCardTitleChange = (e: React.ChangeEvent<HTMLTextAreaElement>) => {
setCardTitle(e.target.value)
}

const handleStartAddingCard = () => {
setIsAddingCard(true)
}

const handleCancelAddCard = () => {
setIsAddingCard(false)
setCardTitle('')
}

const handleAddCard = (e: React.FormEvent) => {
e.preventDefault()
if (!cardTitle.trim()) return

createCard.mutate(
{
boardId: board.id,
title: cardTitle.trim()
},
{
onSuccess: () => {
setCardTitle('')
setIsAddingCard(false)
}
}
)
}

const handleDeleteBoard = () => {
if (window.confirm('Delete this board?')) {
deleteBoard.mutate({ projectId, boardId: board.id })
}
}

return (
<div
ref={setNodeRef}
className="w-72 shrink-0 bg-gray-100 rounded-lg flex flex-col max-h-full"
>
{/* Header */}
<div className="p-3 font-semibold flex items-center justify-between">
<span>{board.name}</span>
<div className="flex items-center gap-1">
<span className="text-sm text-gray-500">{board.cards.length}</span>
<button
onClick={handleDeleteBoard}
className="p-1 text-gray-400 hover:text-red-500"
>
×
</button>
</div>
</div>

{/* Card list */}
<div className="flex-1 overflow-y-auto px-3 pb-3 space-y-2">
<SortableContext
items={board.cards.map((c) => c.id)}
strategy={verticalListSortingStrategy}
>
{board.cards.map((card) => (
<SortableCard key={card.id} card={card} />
))}
</SortableContext>

{/* Card add form */}
{isAddingCard ? (
<form onSubmit={handleAddCard} className="space-y-2">
<textarea
value={cardTitle}
onChange={handleCardTitleChange}
placeholder="Enter a card title..."
autoFocus
rows={2}
className="w-full px-3 py-2 rounded border resize-none focus:outline-hidden focus:ring-2 focus:ring-blue-500"
/>
<div className="flex gap-2">
<button
type="submit"
disabled={createCard.isPending}
className="px-3 py-1.5 bg-blue-500 text-white rounded text-sm hover:bg-blue-600 disabled:opacity-50"
>
Add
</button>
<button
type="button"
onClick={handleCancelAddCard}
className="px-3 py-1.5 text-gray-600 hover:text-gray-800 text-sm"
>
Cancel
</button>
</div>
</form>
) : (
<button
onClick={handleStartAddingCard}
className="w-full py-2 text-sm text-gray-500 hover:text-gray-700 hover:bg-gray-200 rounded transition-colors"
>
+ Add a card
</button>
)}
</div>
</div>
)
}

The sortable card

// src/components/board/SortableCard.tsx
import { useSortable } from '@dnd-kit/sortable'
import { CSS } from '@dnd-kit/utilities'
import { Card as CardType } from '../../types'
import { Card } from './Card'

type SortableCardProps = {
card: CardType
}

export function SortableCard({ card }: SortableCardProps) {
const {
attributes,
listeners,
setNodeRef,
transform,
transition,
isDragging
} = useSortable({
id: card.id,
data: { type: 'card', card }
})

const style = {
transform: CSS.Transform.toString(transform),
transition
}

return (
<div ref={setNodeRef} style={style} {...attributes} {...listeners}>
<Card card={card} isDragging={isDragging} />
</div>
)
}
// src/components/board/Card.tsx
import { Card as CardType } from '../../types'
import clsx from 'clsx'

type CardProps = {
card: CardType
isDragging?: boolean
}

export function Card({ card, isDragging }: CardProps) {
return (
<div
className={clsx(
'bg-white p-3 rounded shadow-sm border border-gray-200 cursor-grab',
'hover:shadow-md transition-shadow',
isDragging && 'opacity-50 shadow-lg'
)}
>
<p className="text-sm">{card.title}</p>
{card.description && (
<p className="text-xs text-gray-500 mt-1 line-clamp-2">
{card.description}
</p>
)}
</div>
)
}

API hooks

// src/hooks/useProjects.ts
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
import api from '../lib/api'
import { Project } from '../types'

export function useProjects() {
return useQuery<Project[]>({
queryKey: ['projects'],
queryFn: async () => {
const { data } = await api.get('/projects')
return data
}
})
}

export function useProject(id: string) {
return useQuery<Project>({
queryKey: ['projects', id],
queryFn: async () => {
const { data } = await api.get(`/projects/${id}`)
return data
}
})
}

export function useCreateProject() {
const queryClient = useQueryClient()

return useMutation({
mutationFn: async (data: { name: string; description?: string }) => {
const { data: project } = await api.post('/projects', data)
return project
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['projects'] })
}
})
}

export function useUpdateProject() {
const queryClient = useQueryClient()

return useMutation({
mutationFn: async ({
id,
data
}: {
id: string
data: { name?: string; description?: string }
}) => {
const { data: project } = await api.patch(`/projects/${id}`, data)
return project
},
onSuccess: (_, variables) => {
queryClient.invalidateQueries({ queryKey: ['projects', variables.id] })
queryClient.invalidateQueries({ queryKey: ['projects'] })
}
})
}

export function useDeleteProject() {
const queryClient = useQueryClient()

return useMutation({
mutationFn: async (id: string) => {
await api.delete(`/projects/${id}`)
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['projects'] })
}
})
}
// src/hooks/useBoards.ts
import { useMutation, useQueryClient } from '@tanstack/react-query'
import api from '../lib/api'

export function useCreateBoard() {
const queryClient = useQueryClient()

return useMutation({
mutationFn: async ({ projectId, name }: { projectId: string; name: string }) => {
const { data } = await api.post(`/projects/${projectId}/boards`, { name })
return data
},
onSuccess: (_, variables) => {
queryClient.invalidateQueries({ queryKey: ['projects', variables.projectId] })
}
})
}

export function useDeleteBoard() {
const queryClient = useQueryClient()

return useMutation({
mutationFn: async ({
projectId,
boardId
}: {
projectId: string
boardId: string
}) => {
await api.delete(`/projects/${projectId}/boards/${boardId}`)
},
onSuccess: (_, variables) => {
queryClient.invalidateQueries({ queryKey: ['projects', variables.projectId] })
}
})
}

export function useCreateCard() {
const queryClient = useQueryClient()

return useMutation({
mutationFn: async ({
boardId,
title,
description
}: {
boardId: string
title: string
description?: string
}) => {
const { data } = await api.post(`/boards/${boardId}/cards`, {
title,
description
})
return data
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['projects'] })
}
})
}

export function useUpdateCardPosition() {
const queryClient = useQueryClient()

return useMutation({
mutationFn: async ({
cardId,
boardId,
position
}: {
cardId: string
boardId: string
position: number
}) => {
const { data } = await api.patch(`/cards/${cardId}/position`, {
boardId,
position
})
return data
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['projects'] })
}
})
}

Startup and verification

Setting environment variables

Set the URL of the API the frontend connects to in .env.

# project-app/.env
VITE_API_URL="http://localhost:3000/api"

Because Chapter 28 sets app.setGlobalPrefix('api'), do not forget the trailing /api (if you forget it, all API requests return 404).

Backend

cd project-api
npm run start:dev

Frontend

cd project-app
npm run dev

Verification

  1. Access http://localhost:5173
  2. Register a user
  3. Create a project
  4. Add a board
  5. Add a card
  6. Reorder with drag-and-drop

Advanced challenges

Ideas to further extend this project:

FeatureDescription
Inviting team membersShare a project among multiple users
Card detail modalDescription, due date, labels, attachments
Real-time syncSync across multiple tabs/users with WebSocket
Search and filterA search feature for cards/projects
Activity logRecord and display the change history
Dark modeTheme switching

Try it

Once the full-stack project is complete, take on the following challenges.

  1. Implement a card detail modal: when a card is clicked, display a detail modal and add features for editing the description, setting a due date, and deletion. Implement the modal using React Portal.

  2. Add a search and filter feature: make it possible to do keyword search on the project list page and within a board. Use useDeferredValue so that typing does not freeze even while entering (unlike debounce, this is not a time delay but a mechanism that lowers the rendering priority).

  3. Implement an activity log: record the operation history such as card creation, movement, and deletion, and make it displayable on the project detail page. Add an Activity model on the backend.

Hint
  1. Render the modal into document.body using createPortal. The process of closing on a click outside the modal uses the propagation of the onClick event. Implement the card update API with PATCH /cards/:id.

  2. By including the search keyword in TanStack Query's useQuery queryKey, it is automatically refetched when the keyword changes. Passing the value deferred with useDeferredValue to the queryKey improves UX.

  3. Define an Activity model in Prisma with action (create/move/delete), targetType (card/board), targetId, userId, and createdAt. Using a NestJS interceptor, you can automatically record a log on each operation.

Answer and explanation

Challenge 1: Implement a card detail modal

To handle the due date, first add a dueDate field to Card. Add it to both the Prisma schema and the type definition, and run a migration.

// prisma/schema.prisma (add to model Card)
dueDate DateTime? @map("due_date")
npx prisma migrate dev --name add-card-due-date
// src/types/index.ts (add to the Card type)
dueDate: string | null

Now you can read and write the due date from CardModal.

// src/components/board/CardModal.tsx
import { createPortal } from 'react-dom'
import { useState } from 'react'
import { useMutation, useQueryClient } from '@tanstack/react-query'
import api from '../../lib/api'
import { Card } from '../../types'

type CardModalProps = {
card: Card
onClose: () => void
}

export function CardModal({ card, onClose }: CardModalProps) {
const [title, setTitle] = useState(card.title)
const [description, setDescription] = useState(card.description || '')
const [dueDate, setDueDate] = useState(card.dueDate?.split('T')[0] || '')
const queryClient = useQueryClient()

const updateCard = useMutation({
mutationFn: async (data: Partial<Card>) => {
const { data: result } = await api.patch(`/cards/${card.id}`, data)
return result
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['projects'] })
onClose()
}
})

const deleteCard = useMutation({
mutationFn: async () => {
await api.delete(`/cards/${card.id}`)
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['projects'] })
onClose()
}
})

const handleSave = () => {
updateCard.mutate({
title,
description: description || null,
dueDate: dueDate ? new Date(dueDate).toISOString() : null
})
}

const handleDelete = () => {
if (window.confirm('Delete this card?')) {
deleteCard.mutate()
}
}

// Close on a click outside the modal
const handleBackdropClick = (e: React.MouseEvent) => {
if (e.target === e.currentTarget) {
onClose()
}
}

return createPortal(
<div
className="fixed inset-0 bg-black/50 flex items-center justify-center z-50"
onClick={handleBackdropClick}
>
<div className="bg-white rounded-lg shadow-xl w-full max-w-lg p-6">
<div className="flex justify-between items-start mb-4">
<h2 className="text-xl font-bold">Card details</h2>
<button onClick={onClose} className="text-gray-500 hover:text-gray-700">

</button>
</div>

<div className="space-y-4">
<div>
<label className="block text-sm font-medium mb-1">Title</label>
<input
type="text"
value={title}
onChange={(e) => setTitle(e.target.value)}
className="w-full px-3 py-2 border rounded"
/>
</div>

<div>
<label className="block text-sm font-medium mb-1">Description</label>
<textarea
value={description}
onChange={(e) => setDescription(e.target.value)}
rows={4}
className="w-full px-3 py-2 border rounded"
/>
</div>

<div>
<label className="block text-sm font-medium mb-1">Due date</label>
<input
type="date"
value={dueDate}
onChange={(e) => setDueDate(e.target.value)}
className="w-full px-3 py-2 border rounded"
/>
</div>
</div>

<div className="flex justify-between mt-6">
<button
onClick={handleDelete}
className="px-4 py-2 text-red-500 hover:text-red-700"
>
Delete
</button>
<div className="space-x-2">
<button onClick={onClose} className="px-4 py-2 text-gray-600">
Cancel
</button>
<button
onClick={handleSave}
disabled={updateCard.isPending}
className="px-4 py-2 bg-blue-500 text-white rounded hover:bg-blue-600"
>
Save
</button>
</div>
</div>
</div>
</div>,
document.body
)
}

Using createPortal, you can render the modal outside the component tree (directly under document.body). This lets you display the modal without being affected by a parent element's overflow: hidden or the like.


Challenge 2: Add a search and filter feature

// src/hooks/useSearchCards.ts
import { useDeferredValue, useState } from 'react'
import { useQuery } from '@tanstack/react-query'
import api from '../lib/api'
import { Card } from '../types'

// The search API is assumed to return the name of the board it belongs to as well
type CardSearchResult = Card & { board: { name: string } }

export function useSearchCards(projectId: string) {
const [searchTerm, setSearchTerm] = useState('')
const deferredSearchTerm = useDeferredValue(searchTerm)

const { data: searchResults, isLoading } = useQuery<CardSearchResult[] | null>({
queryKey: ['cards', 'search', projectId, deferredSearchTerm],
queryFn: async () => {
if (!deferredSearchTerm.trim()) return null
const { data } = await api.get(`/projects/${projectId}/cards/search`, {
params: { q: deferredSearchTerm }
})
return data
},
enabled: deferredSearchTerm.trim().length > 0
})

return {
searchTerm,
setSearchTerm,
searchResults,
isLoading,
isStale: searchTerm !== deferredSearchTerm
}
}
// src/components/board/SearchBar.tsx
import { useSearchCards } from '../../hooks/useSearchCards'

type SearchBarProps = {
projectId: string
}

export function SearchBar({ projectId }: SearchBarProps) {
const { searchTerm, setSearchTerm, searchResults, isLoading, isStale } =
useSearchCards(projectId)

return (
<div className="relative">
<input
type="text"
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
placeholder="Search cards..."
className="w-64 px-4 py-2 border rounded-lg"
/>
{isStale && (
<span className="absolute right-3 top-2.5 text-gray-400">...</span>
)}

{searchResults && searchResults.length > 0 && (
<div className="absolute top-full left-0 right-0 mt-1 bg-white border rounded-lg shadow-lg max-h-64 overflow-y-auto">
{searchResults.map((card) => (
<div key={card.id} className="p-3 hover:bg-gray-50 cursor-pointer">
<p className="font-medium">{card.title}</p>
<p className="text-sm text-gray-500">{card.board.name}</p>
</div>
))}
</div>
)}
</div>
)
}

Using useDeferredValue, you defer the UI update for changes to the search keyword, preventing stutter while typing. With isStale (searchTerm !== deferredSearchTerm), you determine whether a search is in progress and display a loading indicator.


Challenge 3: Implement an activity log

// prisma/schema.prisma (add a model)
model Activity {
id String @id @default(uuid())
action String // create, update, move, delete
targetType String @map("target_type") // card, board, project
targetId String @map("target_id")
targetName String @map("target_name")
projectId String @map("project_id")
project Project @relation(fields: [projectId], references: [id], onDelete: Cascade)
userId String @map("user_id")
user User @relation(fields: [userId], references: [id])
createdAt DateTime @default(now()) @map("created_at")

@@map("activities")
}

Relations require definitions on both sides. Add the following to the Project model and the User model as well.

// add to model Project
activities Activity[]

// add to model User
activities Activity[]
// src/activities/activities.service.ts (NestJS)
import { Injectable } from '@nestjs/common'
import { PrismaService } from '../prisma/prisma.service'

@Injectable()
export class ActivitiesService {
constructor(private prisma: PrismaService) {}

async create(data: {
action: string
targetType: string
targetId: string
targetName: string
projectId: string
userId: string
}) {
return this.prisma.activity.create({ data })
}

async findByProject(projectId: string, limit = 20) {
return this.prisma.activity.findMany({
where: { projectId },
include: {
user: {
select: { id: true, name: true }
}
},
orderBy: { createdAt: 'desc' },
take: limit
})
}
}
// Frontend: src/components/ActivityLog.tsx
import { useQuery } from '@tanstack/react-query'
import api from '../lib/api'

type Activity = {
id: string
action: string
targetType: string
targetName: string
createdAt: string
user: { id: string; name: string }
}

type ActivityLogProps = {
projectId: string
}

export function ActivityLog({ projectId }: ActivityLogProps) {
const { data: activities } = useQuery<Activity[]>({
queryKey: ['activities', projectId],
queryFn: async () => {
const { data } = await api.get(`/projects/${projectId}/activities`)
return data
}
})

const getActionText = (action: string, targetType: string, targetName: string) => {
const actions: Record<string, string> = {
create: 'created',
update: 'updated',
move: 'moved',
delete: 'deleted'
}
const types: Record<string, string> = {
card: 'card',
board: 'board'
}
return `${actions[action]} the ${types[targetType]} "${targetName}"`
}

return (
<div className="bg-white rounded-lg shadow p-4">
<h3 className="font-bold mb-4">Activity</h3>
<ul className="space-y-3">
{activities?.map((activity) => (
<li key={activity.id} className="text-sm">
<span className="font-medium">{activity.user.name}</span>
<span className="text-gray-600 ml-1">
{' '}{getActionText(activity.action, activity.targetType, activity.targetName)}
</span>
<span className="text-gray-400 text-xs ml-2">
{new Date(activity.createdAt).toLocaleString('en-US')}
</span>
</li>
))}
</ul>
</div>
)
}

The activity log is recorded by calling ActivitiesService.create when cards or boards are operated on. Using a NestJS interceptor, you can reduce explicit calls in each controller. On the frontend, it is common to display it in a sidebar of the project detail page.

Summary

In this chapter, you built a Kanban board app as a full-stack project.

  • How to implement drag-and-drop using @dnd-kit
  • The API integration pattern that integrates the frontend and backend
  • UX improvement with TanStack Query's optimistic updates
  • Practical component splitting and project structure design

Closing of this book

info

Thank you for reading this far. The content covered in this book is a tech stack widely used in modern web development. Technology evolves daily, so keep following the updates to the official documentation, and deepen your understanding by actually building apps.

Through this book, you have acquired the following modern full-stack development skills.

Frontend

  • React fundamentals: JSX, components, Props, State
  • Hooks: useState, useEffect, useRef, useContext, useReducer, custom hooks
  • TypeScript: type-safe React development
  • Styling: Tailwind CSS, CSS design
  • State management: Zustand, Context API
  • Data fetching: TanStack Query
  • Routing: React Router
  • Optimization: memoization, performance improvement
  • Authentication: JWT, secure token management

Backend

  • NestJS: module structure, dependency injection, decorators
  • Prisma: type-safe ORM, migrations
  • Authentication: JWT, refresh tokens, HttpOnly Cookie
  • API design: RESTful, validation

Full-stack integration

  • SPA authentication: secure token management and refresh
  • API integration: Axios interceptors, error handling
  • Practical project: building a project management app

Building on this knowledge, take on even more practical application development!

You have finished. As next steps, we recommend the following.