Build a Trello-style team Kanban board with Replit in 1-2 hours. You'll create an Express API with PostgreSQL (Drizzle ORM) for workspaces, boards, columns, tasks, and comments, plus a React drag-and-drop frontend with optimistic updates. Replit Agent scaffolds the full app from one prompt. Deploy on Autoscale.
What you're building
Team task management is one of the most commonly needed tools for small businesses and startups. Existing solutions like Trello or Linear are great but expensive as teams grow and inflexible for custom workflows. Building your own means you control the data, the UI, and the pricing.
Replit Agent generates the entire Express + PostgreSQL backend and React frontend from a single prompt. You get a working Kanban board with drag-and-drop in minutes. The main engineering challenge — optimistic drag-and-drop that rolls back on server errors — is handled by the pattern in this guide.
The architecture uses a workspace model where each team has a workspace with member roles. Every board, column, and task query joins through workspace_members to enforce access control. Tasks use fractional position indexing: when a card is moved between two others, its new position is set to `(prev + next) / 2`, avoiding the need to update every subsequent card's position on each move.
Final result
A full team Kanban board with workspaces, role-based membership, drag-and-drop task cards with priority indicators and assignees, WIP limits, task comments, and markdown descriptions — all deployed on Replit.
Tech stack
Prerequisites
- A Replit account (Free tier is sufficient)
- Basic understanding of what APIs and databases do (no coding experience needed)
- An idea of the workflow stages your team uses (e.g. To Do, In Progress, Review, Done)
- Optional: a list of team members who'll use the board
Build steps
Scaffold the Kanban app with Replit Agent
Open Replit and use the Agent prompt below. Agent will generate the full Express server, Drizzle schema for workspaces, boards, columns, tasks, and comments, plus a React frontend with a Kanban board layout. This single prompt produces a working skeleton you can build on.
1// Paste this into Replit Agent:2// Build a team Kanban task management app with Express and PostgreSQL using Drizzle ORM.3// Schema:4// workspaces (id serial PK, name text, owner_id text, created_at),5// workspace_members (id serial, workspace_id int references workspaces, user_id text,6// role text default member enum owner/admin/member, joined_at, UNIQUE workspace_id+user_id),7// boards (id serial, workspace_id int references workspaces, name text, position int default 0),8// columns (id serial, board_id int references boards, name text,9// position int not null, wip_limit int),10// tasks (id serial, column_id int references columns, title text, description text,11// assignee_id text, priority text default medium enum low/medium/high/critical,12// labels text[], due_date timestamp, position numeric not null,13// created_by text, created_at, updated_at),14// task_comments (id serial, task_id int references tasks, author_id text, body text, created_at).15// Routes: GET /api/workspaces, POST /api/workspaces,16// GET /api/boards/:id (full board with columns and tasks joined),17// POST /api/boards/:id/columns, PATCH /api/columns/:id/position,18// POST /api/columns/:id/tasks, PATCH /api/tasks/:id,19// PATCH /api/tasks/:id/move (update column_id and position using fractional indexing),20// GET /api/tasks/:id/comments, POST /api/tasks/:id/comments.21// React frontend: Kanban board with vertical column lanes, task cards with assignee avatar,22// priority color dot (critical=red, high=orange, medium=blue, low=gray), label tags, due date.23// Draggable cards between columns using drag events. On drop, PATCH /api/tasks/:id/move.24// Task detail modal with markdown description, comment thread, and edit form.25// Optimistic drag-and-drop: update UI immediately, rollback if PATCH fails.26// Use Replit Auth for user identification. Bind server to 0.0.0.0.Pro tip: Tell Agent the specific column names you want in the default board (e.g. 'Backlog, To Do, In Progress, Review, Done'). It will seed these columns automatically when a new board is created.
Expected result: Agent creates the full project. The preview shows a working Kanban board with at least three columns.
Implement the full board query with a single join
The board detail route should return all columns and their tasks in one database query — not N+1 separate queries. Using Drizzle's join or a raw SQL query with JSON aggregation avoids the slow 'query columns, then query each column's tasks' pattern that beginners often write.
1// server/routes/boards.js2const express = require('express');3const { db } = require('../db');4const { boards, columns, tasks } = require('../schema');5const { eq, asc } = require('drizzle-orm');67const router = express.Router();89router.get('/api/boards/:id', async (req, res) => {10 const boardId = parseInt(req.params.id);1112 // Single query using JSON aggregation for efficiency13 const result = await db.execute(14 `SELECT15 b.id, b.name,16 COALESCE(json_agg(17 json_build_object(18 'id', c.id,19 'name', c.name,20 'position', c.position,21 'wip_limit', c.wip_limit,22 'tasks', (23 SELECT COALESCE(json_agg(24 json_build_object(25 'id', t.id, 'title', t.title,26 'priority', t.priority, 'labels', t.labels,27 'assignee_id', t.assignee_id,28 'due_date', t.due_date, 'position', t.position29 ) ORDER BY t.position ASC30 ), '[]')31 FROM tasks t WHERE t.column_id = c.id32 )33 ) ORDER BY c.position ASC34 ), '[]') AS columns35 FROM boards b36 LEFT JOIN columns c ON c.board_id = b.id37 WHERE b.id = $138 GROUP BY b.id`,39 [boardId]40 );4142 if (result.rows.length === 0) {43 return res.status(404).json({ error: 'Board not found' });44 }4546 res.json(result.rows[0]);47});4849module.exports = router;Pro tip: Open Drizzle Studio from the Database tool to verify the data structure coming back from this query before wiring up the frontend.
Build the task move route with fractional position indexing
When a card is dragged between two cards, its new position is `(prevCardPosition + nextCardPosition) / 2`. This avoids updating all subsequent cards on every move. The move route validates the WIP limit of the target column before allowing the transfer.
1// server/routes/tasks.js — PATCH /api/tasks/:id/move2const express = require('express');3const { db } = require('../db');4const { tasks, columns } = require('../schema');5const { eq, count } = require('drizzle-orm');67const router = express.Router();89router.patch('/api/tasks/:id/move', express.json(), async (req, res) => {10 const taskId = parseInt(req.params.id);11 const { targetColumnId, prevPosition, nextPosition } = req.body;1213 // Calculate new fractional position14 const prev = prevPosition ?? 0;15 const next = nextPosition ?? prev + 2000;16 const newPosition = (prev + next) / 2;1718 // Check WIP limit on target column19 const [col] = await db.select()20 .from(columns)21 .where(eq(columns.id, targetColumnId))22 .limit(1);2324 if (col?.wipLimit) {25 const [{ value: taskCount }] = await db26 .select({ value: count() })27 .from(tasks)28 .where(eq(tasks.columnId, targetColumnId));2930 if (Number(taskCount) >= col.wipLimit) {31 return res.status(409).json({32 error: `WIP limit of ${col.wipLimit} reached for '${col.name}'`,33 code: 'WIP_LIMIT_EXCEEDED',34 });35 }36 }3738 await db.update(tasks)39 .set({40 columnId: targetColumnId,41 position: newPosition,42 updatedAt: new Date(),43 })44 .where(eq(tasks.id, taskId));4546 const [updated] = await db.select().from(tasks).where(eq(tasks.id, taskId)).limit(1);47 res.json(updated);48});4950module.exports = router;Expected result: Dragging a card updates its column and position immediately in the UI, and the change persists after page refresh. WIP limit violations return a 409 with an error message.
Implement optimistic drag-and-drop in React
Optimistic UI updates the board state immediately when a card is dropped, then sends the PATCH request in the background. If the server returns an error (e.g. WIP limit exceeded), the UI rolls back to the original state. This makes the board feel instant even on slow connections.
1// React pseudo-code for optimistic drag-and-drop2// In your Board component:34const [columns, setColumns] = React.useState(initialColumns);56async function handleCardDrop(cardId, targetColumnId, prevPos, nextPos) {7 // Save original state for rollback8 const originalColumns = JSON.parse(JSON.stringify(columns));910 // Optimistic update — move card in local state immediately11 const newPos = ((prevPos ?? 0) + (nextPos ?? (prevPos ?? 0) + 2000)) / 2;12 setColumns(prev => {13 const next = prev.map(col => ({14 ...col,15 tasks: col.tasks.filter(t => t.id !== cardId),16 }));17 const targetCol = next.find(c => c.id === targetColumnId);18 if (targetCol) {19 targetCol.tasks.push({ ...getTask(cardId, originalColumns), position: newPos, columnId: targetColumnId });20 targetCol.tasks.sort((a, b) => a.position - b.position);21 }22 return next;23 });2425 // Send to server26 try {27 const res = await fetch(`/api/tasks/${cardId}/move`, {28 method: 'PATCH',29 headers: { 'Content-Type': 'application/json' },30 body: JSON.stringify({ targetColumnId, prevPosition: prevPos, nextPosition: nextPos }),31 });3233 if (!res.ok) {34 const err = await res.json();35 alert(err.error); // e.g. 'WIP limit of 3 reached'36 setColumns(originalColumns); // rollback37 }38 } catch {39 setColumns(originalColumns); // rollback on network error40 }41}Pro tip: Use the HTML5 Drag and Drop API (draggable, onDragStart, onDragOver, onDrop) for the simplest implementation. For a polished experience, ask Agent to integrate @dnd-kit/core instead.
Add workspace access control and deploy
Every query that reads or writes board, column, or task data should verify the requesting user is a workspace member. An Express middleware handles this with a single join through workspace_members. Deploy on Autoscale — task boards have moderate daytime traffic that scales to zero overnight.
1// server/middleware/requireWorkspaceMember.js2const { db } = require('../db');3const { workspaceMembers } = require('../schema');4const { and, eq } = require('drizzle-orm');56async function requireWorkspaceMember(req, res, next) {7 const workspaceId = parseInt(req.params.workspaceId || req.body.workspaceId);8 const userId = req.user.id;910 if (!workspaceId) return res.status(400).json({ error: 'workspaceId required' });1112 const [member] = await db.select()13 .from(workspaceMembers)14 .where(15 and(16 eq(workspaceMembers.workspaceId, workspaceId),17 eq(workspaceMembers.userId, userId)18 )19 )20 .limit(1);2122 if (!member) {23 return res.status(403).json({ error: 'Not a workspace member' });24 }2526 req.workspaceMember = member; // role available for admin checks27 next();28}2930module.exports = { requireWorkspaceMember };3132// server/index.js — deployment config check:33// const PORT = process.env.PORT || 3000;34// app.listen(PORT, '0.0.0.0', () => console.log('Kanban server running'));Expected result: The app is live at your *.replit.app URL. Team members can be invited to workspaces, boards are isolated per workspace, and cards drag smoothly between columns.
Complete code
1const express = require('express');2const { db } = require('../db');3const { tasks, columns, taskComments } = require('../schema');4const { eq, count, asc } = require('drizzle-orm');56const router = express.Router();78// PATCH /api/tasks/:id — update any task field9router.patch('/api/tasks/:id', express.json(), async (req, res) => {10 const taskId = parseInt(req.params.id);11 const { title, description, assigneeId, priority, labels, dueDate } = req.body;1213 const [updated] = await db14 .update(tasks)15 .set({16 ...(title !== undefined && { title }),17 ...(description !== undefined && { description }),18 ...(assigneeId !== undefined && { assigneeId }),19 ...(priority !== undefined && { priority }),20 ...(labels !== undefined && { labels }),21 ...(dueDate !== undefined && { dueDate: dueDate ? new Date(dueDate) : null }),22 updatedAt: new Date(),23 })24 .where(eq(tasks.id, taskId))25 .returning();2627 res.json(updated);28});2930// PATCH /api/tasks/:id/move — drag-and-drop with fractional position + WIP limit check31router.patch('/api/tasks/:id/move', express.json(), async (req, res) => {32 const taskId = parseInt(req.params.id);33 const { targetColumnId, prevPosition, nextPosition } = req.body;3435 const prev = prevPosition ?? 0;36 const next = nextPosition ?? prev + 2000;37 const newPosition = (prev + next) / 2;3839 const [col] = await db.select().from(columns)40 .where(eq(columns.id, targetColumnId)).limit(1);4142 if (col?.wipLimit) {43 const [{ value: taskCount }] = await db44 .select({ value: count() })45 .from(tasks)46 .where(eq(tasks.columnId, targetColumnId));4748 if (Number(taskCount) >= col.wipLimit) {49 return res.status(409).json({50 error: `WIP limit of ${col.wipLimit} reached for column '${col.name}'`,51 code: 'WIP_LIMIT_EXCEEDED',52 });53 }54 }5556 const [updated] = await db57 .update(tasks)58 .set({ columnId: targetColumnId, position: newPosition, updatedAt: new Date() })59 .where(eq(tasks.id, taskId))60 .returning();Customization ideas
Email notifications for task assignments
When a task's assignee_id is set or changed, send an email via SendGrid to notify the assigned person. Include the task title, board name, and a direct link. Store the SendGrid API key in Replit Secrets (lock icon).
Time tracking per task
Add a `time_entries` table with task_id, user_id, started_at, and ended_at. Add start/stop timer buttons to the task detail modal. Show total time logged on the task card for team accountability.
Board templates
Add a `board_templates` table with predefined column configurations (e.g. 'Software Sprint: Backlog, To Do, In Progress, Review, Done'). When creating a new board, let users pick a template to auto-populate columns with names and WIP limits.
Task archiving and search
Add a `is_archived` boolean to tasks. Archived tasks are hidden from the Kanban board but searchable via a `GET /api/tasks/search?q=keyword` route using PostgreSQL full-text search on the title and description fields.
Common pitfalls
Pitfall: Board load is slow because of N+1 queries
How to avoid: Use the single JSON aggregation query from step 2 that fetches the entire board — columns and tasks — in one database call.
Pitfall: Card positions collide or become equal after many moves
How to avoid: When the gap falls below a threshold (e.g. 0.001), rebalance the column by assigning integer positions 1000, 2000, 3000... to all cards in order. Run this in the move route when needed.
Pitfall: Workspace members can access other workspaces' data
How to avoid: Add a join through workspace_members in every sensitive query, or use the requireWorkspaceMember middleware on all board/task routes.
Best practices
- Use the JSON aggregation query for the board endpoint — it returns all columns and tasks in one round trip instead of N+1 queries
- Use fractional position indexing for card ordering — it avoids updating all subsequent cards on every drag operation
- Validate WIP limits server-side on the move route, not just in the frontend — users can bypass frontend validation
- Implement optimistic drag-and-drop with explicit rollback — update the UI immediately on drop, then revert if the server returns an error
- Store avatar URLs in your profiles table rather than fetching from an external service — reduces latency on board load
- Add the DB retry wrapper from server/lib/retryDb.js to the board load route — if no one views the board for 5+ minutes, the first request reconnects to PostgreSQL
- Use Drizzle Studio (open from the Database tool) to inspect task and column data during development without writing extra queries
AI prompts to try
Copy these prompts to build this project faster.
I'm building a Kanban task management app with Express.js and PostgreSQL using Drizzle ORM on Replit. My schema has workspaces, boards, columns, tasks, and workspace_members tables. Help me write a PostgreSQL query using JSON aggregation that returns a complete board in a single query: the board metadata, all its columns ordered by position, and each column's tasks ordered by their fractional position field. Also explain how fractional position indexing works and when to rebalance positions.
Add a task search and filter sidebar to my Kanban board. Implement GET /api/boards/:id/tasks/search with query params: q (text search on title and description), assignee_id, priority (array), labels (array), due_before (date), due_after (date). Use PostgreSQL ILIKE for text search and array overlap operator for labels. In the React frontend, add a filter panel on the right side of the board with inputs for each filter type. When filters are active, dim task cards that don't match and highlight those that do.
Frequently asked questions
Can I build a Kanban board without any coding experience?
Yes. Replit Agent generates the full Express API, Drizzle schema, and React frontend from the prompt in step 1. You'll need to follow the steps to configure the board data and test the drag-and-drop, but you don't need to write code from scratch.
What plan does Replit require for this app?
The Free tier is sufficient for development and moderate team use. The Autoscale deployment on the Free tier handles up to a few hundred concurrent users. Upgrade to Replit Core if you need more compute or storage for larger teams.
How does the WIP (Work In Progress) limit work?
Each column has an optional wip_limit integer. When a card is dragged to that column and the current task count equals the limit, the server returns 409 and the UI rolls back the drag. This enforces lean workflow principles by preventing too many tasks from piling up in one stage.
Can multiple team members drag cards at the same time?
Yes — each drag is an independent PATCH request that updates the specific card's column_id and position. There's no lock or conflict detection by default. If two users move the same card simultaneously, the last write wins. For collaborative real-time boards, add polling every 10 seconds to refresh the board state.
Should I use Autoscale or Reserved VM for this app?
Autoscale is the right choice for team task boards. Traffic is predictable (during business hours) and scales to zero overnight, reducing costs. Reserved VM ($6-20/month) only makes sense if you add WebSocket-based real-time collaboration.
How do I add real-time updates so all team members see card moves instantly?
The simplest approach is polling: have each board page call GET /api/boards/:id every 10-15 seconds and merge any server-side changes into the local state. For true real-time, use Server-Sent Events (SSE) with a PostgreSQL LISTEN/NOTIFY trigger on the tasks table — but this requires Reserved VM since SSE connections can't survive Autoscale scale-to-zero.
Can RapidDev help me build a custom project management tool?
Yes. RapidDev has built 600+ apps including project management tools with Gantt charts, Slack integrations, and custom reporting. Book a free consultation at rapidevelopers.com.
How do I invite team members to a workspace?
Generate a unique invite link (store a UUID token in workspace_invites table). Share the link with team members. When they click it and log in via Replit Auth, the accept-invite route inserts them into workspace_members with the 'member' role.
Talk to an Expert
Our team has built 600+ apps. Get personalized help with your project.
Book a free consultation