Skip to main content
RapidDev - Software Development Agency

How to Build a Task Management App with Replit

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'll build

  • Multi-workspace support with owner/admin/member roles and workspace-isolated data
  • Boards with customizable columns (To Do, In Progress, Done) and optional WIP limits
  • Task cards with title, description, assignee, priority (critical/high/medium/low), labels, and due dates
  • Drag-and-drop between columns with optimistic UI updates and server-side rollback on error
  • Task detail modal with markdown-rendered description and threaded comment section
  • Fractional position indexing for card ordering without rewriting all positions on every move
  • Full board API that returns all columns and tasks in one join query
Book a free consultation
4.9Clutch rating
600+Happy partners
17+Countries served
190+Team members
Intermediate13 min read1-2 hoursReplit FreeApril 2026RapidDev Engineering Team
TL;DR

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

ReplitIDE & Hosting
ExpressBackend Framework
PostgreSQLDatabase
Drizzle ORMDatabase ORM
Replit AuthAuth
ReactFrontend

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

1

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.

prompt.txt
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.

2

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.

server/routes/boards.js
1// server/routes/boards.js
2const express = require('express');
3const { db } = require('../db');
4const { boards, columns, tasks } = require('../schema');
5const { eq, asc } = require('drizzle-orm');
6
7const router = express.Router();
8
9router.get('/api/boards/:id', async (req, res) => {
10 const boardId = parseInt(req.params.id);
11
12 // Single query using JSON aggregation for efficiency
13 const result = await db.execute(
14 `SELECT
15 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.position
29 ) ORDER BY t.position ASC
30 ), '[]')
31 FROM tasks t WHERE t.column_id = c.id
32 )
33 ) ORDER BY c.position ASC
34 ), '[]') AS columns
35 FROM boards b
36 LEFT JOIN columns c ON c.board_id = b.id
37 WHERE b.id = $1
38 GROUP BY b.id`,
39 [boardId]
40 );
41
42 if (result.rows.length === 0) {
43 return res.status(404).json({ error: 'Board not found' });
44 }
45
46 res.json(result.rows[0]);
47});
48
49module.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.

3

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.

server/routes/tasks.js
1// server/routes/tasks.js — PATCH /api/tasks/:id/move
2const express = require('express');
3const { db } = require('../db');
4const { tasks, columns } = require('../schema');
5const { eq, count } = require('drizzle-orm');
6
7const router = express.Router();
8
9router.patch('/api/tasks/:id/move', express.json(), async (req, res) => {
10 const taskId = parseInt(req.params.id);
11 const { targetColumnId, prevPosition, nextPosition } = req.body;
12
13 // Calculate new fractional position
14 const prev = prevPosition ?? 0;
15 const next = nextPosition ?? prev + 2000;
16 const newPosition = (prev + next) / 2;
17
18 // Check WIP limit on target column
19 const [col] = await db.select()
20 .from(columns)
21 .where(eq(columns.id, targetColumnId))
22 .limit(1);
23
24 if (col?.wipLimit) {
25 const [{ value: taskCount }] = await db
26 .select({ value: count() })
27 .from(tasks)
28 .where(eq(tasks.columnId, targetColumnId));
29
30 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 }
37
38 await db.update(tasks)
39 .set({
40 columnId: targetColumnId,
41 position: newPosition,
42 updatedAt: new Date(),
43 })
44 .where(eq(tasks.id, taskId));
45
46 const [updated] = await db.select().from(tasks).where(eq(tasks.id, taskId)).limit(1);
47 res.json(updated);
48});
49
50module.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.

4

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.

src/components/Board.jsx
1// React pseudo-code for optimistic drag-and-drop
2// In your Board component:
3
4const [columns, setColumns] = React.useState(initialColumns);
5
6async function handleCardDrop(cardId, targetColumnId, prevPos, nextPos) {
7 // Save original state for rollback
8 const originalColumns = JSON.parse(JSON.stringify(columns));
9
10 // Optimistic update — move card in local state immediately
11 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 });
24
25 // Send to server
26 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 });
32
33 if (!res.ok) {
34 const err = await res.json();
35 alert(err.error); // e.g. 'WIP limit of 3 reached'
36 setColumns(originalColumns); // rollback
37 }
38 } catch {
39 setColumns(originalColumns); // rollback on network error
40 }
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.

5

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.

server/middleware/requireWorkspaceMember.js
1// server/middleware/requireWorkspaceMember.js
2const { db } = require('../db');
3const { workspaceMembers } = require('../schema');
4const { and, eq } = require('drizzle-orm');
5
6async function requireWorkspaceMember(req, res, next) {
7 const workspaceId = parseInt(req.params.workspaceId || req.body.workspaceId);
8 const userId = req.user.id;
9
10 if (!workspaceId) return res.status(400).json({ error: 'workspaceId required' });
11
12 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);
21
22 if (!member) {
23 return res.status(403).json({ error: 'Not a workspace member' });
24 }
25
26 req.workspaceMember = member; // role available for admin checks
27 next();
28}
29
30module.exports = { requireWorkspaceMember };
31
32// 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

server/routes/tasks.js
1const express = require('express');
2const { db } = require('../db');
3const { tasks, columns, taskComments } = require('../schema');
4const { eq, count, asc } = require('drizzle-orm');
5
6const router = express.Router();
7
8// PATCH /api/tasks/:id — update any task field
9router.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;
12
13 const [updated] = await db
14 .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();
26
27 res.json(updated);
28});
29
30// PATCH /api/tasks/:id/move — drag-and-drop with fractional position + WIP limit check
31router.patch('/api/tasks/:id/move', express.json(), async (req, res) => {
32 const taskId = parseInt(req.params.id);
33 const { targetColumnId, prevPosition, nextPosition } = req.body;
34
35 const prev = prevPosition ?? 0;
36 const next = nextPosition ?? prev + 2000;
37 const newPosition = (prev + next) / 2;
38
39 const [col] = await db.select().from(columns)
40 .where(eq(columns.id, targetColumnId)).limit(1);
41
42 if (col?.wipLimit) {
43 const [{ value: taskCount }] = await db
44 .select({ value: count() })
45 .from(tasks)
46 .where(eq(tasks.columnId, targetColumnId));
47
48 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 }
55
56 const [updated] = await db
57 .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.

ChatGPT Prompt

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.

Build Prompt

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.

RapidDev

Talk to an Expert

Our team has built 600+ apps. Get personalized help with your project.

Book a free consultation

Need help building your app?

Our experts have built 600+ apps and can accelerate your development. Book a free consultation — no strings attached.

Book a free consultation

We put the rapid in RapidDev

Need a dedicated strategic tech and growth partner? Discover what RapidDev can do for your business! Book a call with our team to schedule a free, no-obligation consultation. We'll discuss your project and provide a custom quote at no cost.