# Next.js Drag and Drop with dnd kit

Next.js drag and drop with dnd kit in a client component: the AdminKit PRO task board, hydration-safe ids and overlay, and mouse, touch and keyboard sorting.

> This feature is only available in **AdminKit PRO**. [Learn more](/pricing/#nextjs).

A task board feels natural when people can pick up a card and drop it where it belongs. The tasks page of [AdminKit for Next.js](/nextjs/) does that with [dnd kit](https://dndkit.com/). You drag cards up and down a column, or across to another one.

Dragging runs in the browser, so the board, `src/components/pages/apps/tasks/task-board.tsx`, is a client component, a file that starts with `"use client"`. It uses dnd kit directly. There is no wrapper in `src/components/plugins/`. The page, `src/app/(dashboard)/pages-tasks/page.tsx`, stays a server component. It exports the metadata and renders `<TaskBoard />`.

AdminKit PRO installs three dnd kit packages:

| Package | Version | Used for |
| --- | --- | --- |
| `@dnd-kit/core` | 6.3.1 | `DndContext`, sensors, `DragOverlay`, `useDroppable` |
| `@dnd-kit/sortable` | 10.0.0 | `SortableContext`, `useSortable`, `arrayMove` |
| `@dnd-kit/utilities` | 3.2.2 | `CSS.Translate.toString()` for the drag transform |

The docs cannot run dnd kit, so the examples here are code only. The [tasks demo](https://nextjs-demo.adminkit.io/pages-tasks) shows the board.

## Example

Start with a list people can reorder. Hooks and drag handlers run in the browser, so the file starts with `"use client"`. The server still renders it: the HTML that Next.js builds has the list in its first order. dnd kit takes over after hydration, when the browser has taken over that HTML.

```tsx title="src/components/sortable-list.tsx"
"use client";

import {
  closestCorners,
  DndContext,
  type DragEndEvent,
  KeyboardSensor,
  MouseSensor,
  TouchSensor,
  useSensor,
  useSensors,
} from "@dnd-kit/core";
import {
  arrayMove,
  SortableContext,
  sortableKeyboardCoordinates,
  useSortable,
  verticalListSortingStrategy,
} from "@dnd-kit/sortable";
import { CSS } from "@dnd-kit/utilities";
import { useState } from "react";
import { cx } from "@/lib/cx";

function SortableItem({ id }: { id: string }) {
  const { attributes, listeners, setNodeRef, transform, transition, isDragging } =
    useSortable({ id });

  return (
    <div
      ref={setNodeRef}
      className={cx("list-group-item cursor-grab", isDragging && "sortable-ghost")}
      style={{ transform: CSS.Translate.toString(transform), transition }}
      {...attributes}
      {...listeners}
    >
      {id}
    </div>
  );
}

export function SortableList({ initialItems }: { initialItems: string[] }) {
  const [items, setItems] = useState(initialItems);
  const sensors = useSensors(
    useSensor(MouseSensor, { activationConstraint: { distance: 4 } }),
    useSensor(TouchSensor, { activationConstraint: { delay: 150, tolerance: 5 } }),
    useSensor(KeyboardSensor, { coordinateGetter: sortableKeyboardCoordinates })
  );

  const onDragEnd = ({ active, over }: DragEndEvent) => {
    if (over && active.id !== over.id) {
      setItems((current) =>
        arrayMove(current, current.indexOf(String(active.id)), current.indexOf(String(over.id)))
      );
    }
  };

  return (
    // A fixed id keeps dnd kit's accessibility ids the same on the server and in the browser
    <DndContext
      id="sortable-list"
      sensors={sensors}
      collisionDetection={closestCorners}
      onDragEnd={onDragEnd}
    >
      <SortableContext items={items} strategy={verticalListSortingStrategy}>
        <div className="list-group">
          {items.map((item) => (
            <SortableItem key={item} id={item} />
          ))}
        </div>
      </SortableContext>
    </DndContext>
  );
}
```

Your page passes the items as plain data, so it stays a server component:

```tsx title="src/app/(dashboard)/checklist/page.tsx"
import { SortableList } from "@/components/sortable-list";
import { pageMetadata } from "@/lib/site";

export const metadata = pageMetadata("/checklist");

export default function Page() {
  return (
    <SortableList
      initialItems={["Write release notes", "Review pull request", "Update dependencies"]}
    />
  );
}
```

`.sortable-ghost` fades the dragged item to 40% opacity, and `.cursor-grab` shows the grab cursor. Both come with AdminKit's Sass. When you add a route such as `/checklist`, add it to `src/data/pages.json` too, where `pageMetadata()` reads it.

## Server rendering and hydration

The HTML from the server and the first render in the browser must match, or React reports a hydration error. Two details in `task-board.tsx` make sure they do:

- `DndContext` has `id="task-board"`. Without an `id`, dnd kit numbers the ids of its screen reader elements (`aria-describedby`) with a counter. That count can differ between the server and the browser.
- The `DragOverlay` goes to `document.body` through `createPortal`, and `document` does not exist on the server. So the board renders the portal only once `useMounted()` from `@/hooks/use-mounted` returns `true`, which happens after hydration:

```tsx
const mounted = useMounted();

// ...and in the JSX of the board:
{mounted &&
  createPortal(
    <DragOverlay dropAnimation={reducedMotion ? null : undefined}>
      {active && tasks[active] && (
        <TaskCard task={tasks[active]} className="sortable-drag mb-0" />
      )}
    </DragOverlay>,
    document.body
  )}
```

## How the task board works

The board keeps the tasks by key and the task keys of each column, a `Record<ColumnId, string[]>`. Both start from `src/mocks/tasks.ts`. That state only lives in the browser. The app builds to static files, so there is no server to save to. To keep the changes, send them to your API with `fetch` from the drag handlers.

| Part | What it does |
| --- | --- |
| `DndContext` | One for the board, with `closestCorners` collision detection |
| Sensors | Mouse after 4px; touch after a 150ms press with 5px tolerance, so swipes still scroll; keyboard with `sortableKeyboardCoordinates` |
| `SortableContext` | One per column: its task keys, `verticalListSortingStrategy` |
| `useDroppable` | Each column's list is a drop target, also when it is empty |
| `useSortable` | Each card; 150ms `ease` transition, none under `prefers-reduced-motion` |
| `DragOverlay` | The card under the pointer (`.sortable-drag`); no drop animation under reduced motion |

When a drag starts, `onDragStart` saves the board. `onDragOver` moves the task into the column it is over. `onDragEnd` reorders within a column with `arrayMove`. If the user cancels, `onDragCancel` restores the saved board.

## Accessibility

- With the keyboard: Tab to a card, Space or Enter to pick it up, arrow keys to move it within or between columns, Space or Enter to drop, Escape to cancel.
- `screenReaderInstructions` explain these keys, and `announcements` read out the task and its column and position at each step.
- The board passes only `tabIndex`, `aria-roledescription` and `aria-describedby` from `useSortable`'s `attributes`, so the cards keep their `<article>` role instead of `role="button"`.
- Under `prefers-reduced-motion`, cards and the overlay move without animation.

## Related

- [React Drag and Drop with dnd kit](/react/docs/plugins/drag-and-drop/): the same board in AdminKit for React (Vite)
- [Bootstrap 5 drag and drop](/bootstrap/docs/plugins/drag-and-drop/): the HTML version, with SortableJS
- [Card](/nextjs/docs/components/card/): the cards of the task board
- [Dropdowns](/nextjs/docs/components/dropdowns/): the column menus of the board
- [Tasks demo](https://nextjs-demo.adminkit.io/pages-tasks)

---

Canonical HTML page: https://adminkit.io/nextjs/docs/plugins/drag-and-drop/

Site overview for agents: https://adminkit.io/llms.txt (expanded: https://adminkit.io/llms-full.txt) · Documentation: https://adminkit.io/bootstrap/docs/ · Questions: support@adminkit.io
