- React
- dnd-kit
- Kanban
- Express.js
- MongoDB
- MERN
Building a Drag-and-Drop Kanban Board with dnd-kit, Express and MongoDB
A drag-and-drop Kanban board in React needs three pieces: dnd-kit droppable columns with sortable cards, a drag handler that moves cards between columns while dragging and reorders them on drop, and one backend endpoint that saves each card's column and position to MongoDB.
What we are building
Atlass CRM has a deal pipeline with five stages: New, Qualified, Proposal, Won and Lost. Every lead is a card. Drag it to another column and its stage changes; drop it higher in a column and it moves up the list. Reload the page and everything is where you left it.
| Concern | Where it lives |
|---|---|
| Drag and drop | @dnd-kit/core and @dnd-kit/sortable |
| Board state | React state shaped as { stage: Lead[] } |
| Persistence | PATCH /api/leads/reorder on Express |
| Storage | MongoDB status and order fields on each lead |
Step 1: Store the column and the position
Each lead keeps its stage and its position inside that stage. Nothing else is needed:
export const LEAD_STATUSES = ["New", "Qualified", "Proposal", "Won", "Lost"];
const leadSchema = new mongoose.Schema(
{
owner: { type: mongoose.Schema.Types.ObjectId, ref: "User", required: true, index: true },
name: { type: String, required: true, trim: true },
value: { type: Number, default: 0, min: 0 },
status: { type: String, enum: LEAD_STATUSES, default: "New", index: true },
order: { type: Number, default: 0 },
},
{ timestamps: true },
);
The list endpoint sorts by order first, so the board comes back in the saved order:
const leads = await Lead.find({ owner: req.user._id }).sort({ order: 1, createdAt: -1 });
Step 2: Turn a flat list into a board
The API returns one array. The board wants one array per column:
const PIPELINE_STAGES = ["New", "Qualified", "Proposal", "Won", "Lost"];
const toBoard = (leads) => {
const board = Object.fromEntries(PIPELINE_STAGES.map((s) => [s, []]));
for (const l of leads) (board[l.status] || board.New).push(l);
return board;
};
The || board.New fallback means a lead with an unknown status still shows up somewhere instead of vanishing.
Step 3: Columns are droppable, cards are sortable
Each column registers itself as a droppable with its stage name as the id. Inside it, a SortableContext makes the cards reorderable:
function Column({ stage, leads }) {
const { setNodeRef, isOver } = useDroppable({ id: stage });
return (
<div ref={setNodeRef} className={cn("min-h-[60vh] rounded-3xl p-3", isOver && "bg-brand-50/60")}>
<SortableContext items={leads.map((l) => l._id)} strategy={verticalListSortingStrategy}>
{leads.map((lead) => <SortableCard key={lead._id} lead={lead} />)}
</SortableContext>
{leads.length === 0 && <p>Drop leads here</p>}
</div>
);
}
The droppable matters for empty columns. With no cards there is nothing to hover over, so without it you could never drop into "Won" for the first time.
Each card uses useSortable, and the drag listeners go on a grip handle rather than the whole card, so buttons on the card still click normally:
function SortableCard({ lead }) {
const { attributes, listeners, setNodeRef, transform, transition, isDragging } =
useSortable({ id: lead._id });
return (
<div
ref={setNodeRef}
style={{ transform: CSS.Transform.toString(transform), transition }}
className={cn(isDragging && "opacity-40")}
>
<LeadCard lead={lead} dragHandle={{ attributes, listeners }} />
</div>
);
}
Step 4: Wire up the DndContext
const sensors = useSensors(
useSensor(PointerSensor, { activationConstraint: { distance: 6 } }),
);
const snapshot = useRef(null); // board before the drag, for rollback
<DndContext
sensors={sensors}
collisionDetection={closestCorners}
onDragStart={({ active }) => {
snapshot.current = board;
setActiveId(active.id);
}}
onDragOver={handleDragOver}
onDragEnd={handleDragEnd}
onDragCancel={() => setActiveId(null)}
>
{PIPELINE_STAGES.map((stage) => <Column key={stage} stage={stage} leads={board[stage]} />)}
<DragOverlay>{activeLead ? <LeadCard lead={activeLead} overlay /> : null}</DragOverlay>
</DndContext>
Two choices worth copying:
activationConstraint: { distance: 6 }means a drag only starts after the pointer moves 6px. A plain click stays a click.closestCornersworks better than the defaultrectIntersectionfor stacked columns, because it picks the nearest card even when the dragged card overlaps two.
Step 5: Move between columns while dragging
Both droppables (columns) and sortables (cards) can be the over target, so the first job is to find which column an id belongs to:
const findContainer = (id) => {
if (id in board) return id; // a column id
return PIPELINE_STAGES.find((s) => board[s].some((l) => l._id === id));
};
onDragOver fires continuously. When the card crosses into another column, move it there right away, so the cards in the target column open up a gap under the pointer:
const handleDragOver = ({ active, over }) => {
if (!over) return;
const from = findContainer(active.id);
const to = findContainer(over.id);
if (!from || !to || from === to) return;
setBoard((prev) => {
const fromItems = prev[from].filter((l) => l._id !== active.id);
const moved = { ...prev[from].find((l) => l._id === active.id), status: to };
const toItems = [...prev[to]];
const overIdx = toItems.findIndex((l) => l._id === over.id);
toItems.splice(overIdx === -1 ? toItems.length : overIdx, 0, moved);
return { ...prev, [from]: fromItems, [to]: toItems };
});
};
Hovering a card inserts above it; hovering the empty part of a column appends to the end.
Step 6: Reorder on drop, then save
By the time onDragEnd runs, the card is already in the right column. All that is left is the final position inside it, which arrayMove handles:
const handleDragEnd = ({ active, over }) => {
setActiveId(null);
if (!over) return;
const stage = findContainer(over.id);
if (!stage) return;
const items = board[stage];
const oldIdx = items.findIndex((l) => l._id === active.id);
const newIdx = items.findIndex((l) => l._id === over.id);
const next = {
...board,
[stage]: oldIdx !== -1 && newIdx !== -1 ? arrayMove(items, oldIdx, newIdx) : items,
};
setBoard(next);
const updates = PIPELINE_STAGES.flatMap((s) =>
next[s].map((l, order) => ({ id: l._id, status: s, order })),
);
leadsApi.reorder(updates).catch(() => {
setBoard(snapshot.current);
toast.error("Could not save pipeline");
});
};
snapshot.current is the board saved during onDragStart. If the save fails, the board snaps back instead of showing an order the server never accepted.
The payload is simply "every card's stage and index". That keeps the server dumb and the client the single source of truth for order.
Step 7: One Express endpoint saves the whole board
router.use(protect);
router.patch("/reorder", reorderLeads); // must be registered before "/:id"
export const reorderLeads = asyncHandler(async (req, res) => {
const { updates } = req.body;
if (!Array.isArray(updates)) throw new ApiError(400, "updates must be an array");
if (updates.some((u) => !LEAD_STATUSES.includes(u.status))) throw new ApiError(400, "Invalid status");
await Lead.bulkWrite(
updates.map((u) => ({
updateOne: {
filter: { _id: u.id, owner: req.user._id },
update: { $set: { status: u.status, order: u.order } },
},
})),
);
res.json({ success: true });
});
Three details matter:
- Filter by
owner. The client sends ids; the server must make sure they belong to the signed-in user. - Validate
statusyourself. MongooseupdateOneskips schema validators by default, so theenumwill not catch a bad stage. - Route order.
PATCH /reordersits above/:id, or Express would treatreorderas a lead id.
What I would change next
The first version of Atlass CRM saved updates with one updateOne per lead inside Promise.all, and called the API from inside a setState updater. Both work, but:
bulkWriteis one round trip instead of one query per card, which adds up on a board with hundreds of deals.- Side effects do not belong in state updaters. React Strict Mode runs updaters twice in development, which sends the save request twice. Compute the next board, set it, then call the API, as shown above.
- Send only the columns that changed. A drag touches at most two columns; the other three do not need rewriting.
- Add a
KeyboardSensorwithsortableKeyboardCoordinatesso the board works without a mouse, and give the grip handle a real label such as "Move Acme deal".
Key takeaways
- Store a
statusfor the column and anorderfor the position. - Columns are droppables, cards are sortables; that is what makes empty columns work.
- Move across columns in
onDragOver, reorder inonDragEnd. - Use
DragOverlayfor a smooth, unclipped preview. - Save the whole board through one owner-scoped
bulkWrite, and roll back on failure.
Want a pipeline, dashboard or internal tool like this? I build them as a freelance MERN stack developer.
Frequently asked questions
How do you build a Kanban board in React?
Group items into an object keyed by column, render each column as a dnd-kit droppable with a SortableContext inside, make each card sortable with useSortable, move cards between columns in onDragOver, reorder them in onDragEnd, and save the new column and position of every card to the backend.
Is dnd-kit better than react-beautiful-dnd?
For new projects, yes. react-beautiful-dnd is no longer maintained, while dnd-kit is actively maintained, lightweight, works with React 18 and 19, and supports pointer, touch and keyboard sensors.
How do you save Kanban card order in MongoDB?
Store a status field for the column and a numeric order field for the position. After a drop, send the status and order of the affected cards to one endpoint and apply them with a single bulkWrite, filtered by the current user, then sort queries by order.
Why use DragOverlay in dnd-kit?
DragOverlay renders the dragged card outside the scrolling columns, so it is not clipped by overflow, does not jump when the source list re-renders, and can be styled differently, for example lifted and slightly rotated.