← All projects

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();