Node.js · Vercel Functions · Drag & Drop API
Kanban Board
Drag-and-drop task board where every column move is persisted through the API, not just moved in the DOM.
Drag a card to another column and the move is sent to the backend and persisted — refresh the page and it stays where you dropped it. Board reads and card writes are handled by a single serverless function so they share the same in-memory store.
Live demo
embed// /api/board and /api/cards are both handled here in one serverless function
// so the card list is a single shared in-memory store — splitting them into
// separate files would put each on its own Lambda with its own memory, and a
// card added via /api/cards would never show up when /api/board is fetched.
const state = require('./_store');
const COLUMNS = [
{ id: 'todo', label: 'To Do' },
{ id: 'doing', label: 'In Progress' },
{ id: 'done', label: 'Done' },
];
const VALID_COLUMNS = COLUMNS.map((c) => c.id);
function getBoard(req, res) {
const board = COLUMNS.map((col) => ({
...col,
cards: state.cards.filter((c) => c.column === col.id),
}));
res.status(200).json(board);
}
function createCard(req, res) {
const title = (req.body && req.body.title || '').toString().trim();
const column = VALID_COLUMNS.includes(req.body && req.body.column) ? req.body.column : 'todo';
if (!title) {
res.status(400).json({ error: 'title is required' });
return;
}
const card = { id: Date.now().toString(), title, column };
state.cards.push(card);
res.status(201).json(card);
}
function updateCard(req, res, id) {
const card = state.cards.find((c) => c.id === id);
if (!card) {
res.status(404).json({ error: 'not found' });
return;
}
if (VALID_COLUMNS.includes(req.body && req.body.column)) {
card.column = req.body.column;
}
res.status(200).json(card);
}
function deleteCard(req, res, id) {
const before = state.cards.length;
state.cards = state.cards.filter((c) => c.id !== id);
if (state.cards.length === before) {
res.status(404).json({ error: 'not found' });
return;
}
res.status(204).end();
}
module.exports = (req, res) => {
const action = req.query['...action'] || [];
const [segment] = Array.isArray(action) ? action : [action];
const id = (req.query.id || '').toString();
if (segment === 'board' && req.method === 'GET') return getBoard(req, res);
if (segment === 'cards' && req.method === 'POST') return createCard(req, res);
if (segment === 'cards' && req.method === 'PATCH') return updateCard(req, res, id);
if (segment === 'cards' && req.method === 'DELETE') return deleteCard(req, res, id);
res.status(405).json({ error: 'method not allowed' });
};
const board = document.getElementById('board');
async function load() {
const res = await fetch('/api/board');
render(await res.json());
}
function render(columns) {
board.innerHTML = '';
for (const col of columns) {
const colEl = document.createElement('div');
colEl.className = 'column';
colEl.dataset.column = col.id;
colEl.innerHTML = `
<h2>${col.label} (${col.cards.length})</h2>
<div class="cards"></div>
<div class="add-row">
<input type="text" placeholder="New card…">
<button>Add</button>
</div>
`;
const cardsEl = colEl.querySelector('.cards');
for (const card of col.cards) {
const cardEl = document.createElement('div');
cardEl.className = 'card-item';
cardEl.draggable = true;
cardEl.dataset.id = card.id;
cardEl.innerHTML = `<span></span><button title="Delete">✕</button>`;
cardEl.querySelector('span').textContent = card.title;
cardEl.querySelector('button').addEventListener('click', () => removeCard(card.id));
cardEl.addEventListener('dragstart', () => cardEl.classList.add('dragging'));
cardEl.addEventListener('dragend', () => cardEl.classList.remove('dragging'));
cardsEl.appendChild(cardEl);
}
colEl.addEventListener('dragover', (e) => { e.preventDefault(); colEl.classList.add('drag-over'); });
colEl.addEventListener('dragleave', () => colEl.classList.remove('drag-over'));
colEl.addEventListener('drop', (e) => {
e.preventDefault();
colEl.classList.remove('drag-over');
const dragging = document.querySelector('.card-item.dragging');
if (dragging) moveCard(dragging.dataset.id, col.id);
});
const input = colEl.querySelector('input');
colEl.querySelector('.add-row button').addEventListener('click', async () => {
const title = input.value.trim();
if (!title) return;
await fetch('/api/cards', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ title, column: col.id }),
});
load();
});
board.appendChild(colEl);
}
}
async function moveCard(id, column) {
await fetch(`/api/cards?id=${encodeURIComponent(id)}`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ column }),
});
load();
}
async function removeCard(id) {
await fetch(`/api/cards?id=${encodeURIComponent(id)}`, { method: 'DELETE' });
load();
}
load();