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
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
| Feature | Description |
|---|---|
| User authentication | Registration, login, logout |
| Project management | Create, edit, delete |
| Board management | Manage boards (lists) within a project |
| Card management | Manage task cards within a board |
| Drag-and-drop | Reorder 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
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
- Access http://localhost:5173
- Register a user
- Create a project
- Add a board
- Add a card
- Reorder with drag-and-drop
Advanced challenges
Ideas to further extend this project:
| Feature | Description |
|---|---|
| Inviting team members | Share a project among multiple users |
| Card detail modal | Description, due date, labels, attachments |
| Real-time sync | Sync across multiple tabs/users with WebSocket |
| Search and filter | A search feature for cards/projects |
| Activity log | Record and display the change history |
| Dark mode | Theme switching |
Try it
Once the full-stack project is complete, take on the following challenges.
-
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.
-
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).
-
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
-
Render the modal into
document.bodyusingcreatePortal. The process of closing on a click outside the modal uses the propagation of theonClickevent. Implement the card update API withPATCH /cards/:id. -
By including the search keyword in TanStack Query's
useQueryqueryKey, it is automatically refetched when the keyword changes. Passing the value deferred withuseDeferredValueto the queryKey improves UX. -
Define an
Activitymodel in Prisma withaction(create/move/delete),targetType(card/board),targetId,userId, andcreatedAt. 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
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!
What to read next
You have finished. As next steps, we recommend the following.
- TypeScript Guide — learn TypeScript systematically from the language spec to practice (all 21 chapters)
- Clean Code (TypeScript version) — learn the best practices of design standards
- Reinvent Notes Docs — explore other topics (Markdown syntax / Getting Started, etc.)