Skip to content
  • React
  • dnd-kit
  • Kanban
  • Express.js
  • MongoDB
  • MERN

Building a Drag-and-Drop Kanban Board with dnd-kit, Express and MongoDB

By Published 6 min read (or skim, I won't know)

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.

ConcernWhere it lives
Drag and drop@dnd-kit/core and @dnd-kit/sortable
Board stateReact state shaped as { stage: Lead[] }
PersistencePATCH /api/leads/reorder on Express
StorageMongoDB 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.
  • closestCorners works better than the default rectIntersection for 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 status yourself. Mongoose updateOne skips schema validators by default, so the enum will not catch a bad stage.
  • Route order. PATCH /reorder sits above /:id, or Express would treat reorder as 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:

  • bulkWrite is 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 KeyboardSensor with sortableKeyboardCoordinates so the board works without a mouse, and give the grip handle a real label such as "Move Acme deal".

Key takeaways

  • Store a status for the column and an order for the position.
  • Columns are droppables, cards are sortables; that is what makes empty columns work.
  • Move across columns in onDragOver, reorder in onDragEnd.
  • Use DragOverlay for 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.