Detailed view of Ruby on Rails code highlighting software development intricacies.

react-grid-layout in Next.js 16: Draggable, Resizable Dashboard

· 9 min read

To build a customizable dashboard in the Next.js 16 App Router, load the user's saved layout in a Server Component and pass it to a client component. That component renders react-grid-layout 2.3, and only after it has measured its container. That one decision avoids most hydration errors. The other decisions in this tutorial (saving through a Server Action, putting Motion on an inner element, deferring removal until the exit animation finishes) handle the rest of the usual problems.

We'll build it in five steps, and each step leaves you with something that runs. The versions are react-grid-layout 2.3.0 and Motion 14.3.0.

Install the packages and know what changed in v2

1npm install react-grid-layout motion

If you've used react-grid-layout before, v2 is a TypeScript rewrite with a different API. The WidthProvider HOC is gone from the main entry point. The README now requires a width prop and recommends getting it from the useContainerWidth hook. That hook returns width, containerRef and a mounted flag. Old v1 code still works if you import from react-grid-layout/legacy. Most blog posts and Stack Overflow answers about Next.js still show the v1 WidthProvider(Responsive) pattern, so check which API a snippet uses before you copy it.

Step 1: Define the dashboard shape and a per-user store

Keep the widget list and the layouts as separate pieces of state. Widgets describe what to render. Layouts describe where each widget sits at each breakpoint, and the two are linked by the layout item's i key.

1// lib/dashboard.ts
2import type { ResponsiveLayouts } from "react-grid-layout";
3
4export const WIDGET_TYPES = ["revenue", "signups", "errors", "notes"] as const;
5export type WidgetType = (typeof WIDGET_TYPES)[number];
6
7export type Widget = { id: string; type: WidgetType };
8export type DashboardState = { widgets: Widget[]; layouts: ResponsiveLayouts };
9
10export const BREAKPOINTS = { lg: 1200, md: 996, sm: 768, xs: 480, xxs: 0 };
11export const COLS = { lg: 12, md: 10, sm: 6, xs: 4, xxs: 2 };
12
13// Only define `lg`. The responsive grid derives the other breakpoints
14// from the closest larger layout the first time it needs them.
15export const DEFAULT_DASHBOARD: DashboardState = {
16  widgets: [
17    { id: "revenue-1", type: "revenue" },
18    { id: "signups-1", type: "signups" },
19    { id: "errors-1", type: "errors" },
20  ],
21  layouts: {
22    lg: [
23      { i: "revenue-1", x: 0, y: 0, w: 6, h: 4 },
24      { i: "signups-1", x: 6, y: 0, w: 6, h: 4 },
25      { i: "errors-1", x: 0, y: 4, w: 12, h: 3 },
26    ],
27  },
28};

Next, the persistence layer. To keep this runnable without a database, it writes one JSON file per user. Swap the two fs calls for your ORM when you move it into a real app.

1// app/dashboard/actions.ts
2"use server";
3
4import { cookies } from "next/headers";
5import { mkdir, readFile, writeFile } from "node:fs/promises";
6import path from "node:path";
7import { DEFAULT_DASHBOARD, type DashboardState } from "@/lib/dashboard";
8
9const DIR = path.join(process.cwd(), ".data", "dashboards");
10
11async function getUserId() {
12  // Replace with your auth session lookup. Reading cookies also makes
13  // the page dynamic, so it is never prerendered with one user's layout.
14  const store = await cookies();
15  const raw = store.get("user-id")?.value ?? "demo-user";
16  return raw.replace(/[^a-zA-Z0-9_-]/g, "");
17}
18
19export async function loadDashboard(): Promise<DashboardState> {
20  const file = path.join(DIR, `${await getUserId()}.json`);
21  try {
22    return JSON.parse(await readFile(file, "utf8")) as DashboardState;
23  } catch {
24    return DEFAULT_DASHBOARD;
25  }
26}
27
28export async function saveDashboard(state: DashboardState) {
29  if (!Array.isArray(state?.widgets) || typeof state.layouts !== "object") {
30    throw new Error("Invalid dashboard payload");
31  }
32  await mkdir(DIR, { recursive: true });
33  const file = path.join(DIR, `${await getUserId()}.json`);
34  await writeFile(file, JSON.stringify(state));
35}

Two details here trip people up. First, cookies() is async in current Next.js, so you have to await it. Second, a page that uses no request-time API can be prerendered at build time. Then every visitor gets the same frozen layout, and you'll waste an hour trying to work out why saves don't seem to stick. Reading a cookie or session prevents that. Also keep in mind that Server Actions are public POST endpoints, so validate the payload and never accept a user ID from the client.

Step 2: Load the layout on the server

The page is a plain async Server Component:

1// app/dashboard/page.tsx
2import { loadDashboard } from "./actions";
3import Dashboard from "./dashboard";
4
5export default async function DashboardPage() {
6  const initial = await loadDashboard();
7  return (
8    <main style={{ padding: 24 }}>
9      <Dashboard initial={initial} />
10    </main>
11  );
12}

This is why the layout lives on the server and not in localStorage. If the client component read localStorage during its first render, the server HTML (default layout) and the client's first render (saved layout) would differ, and React would report a hydration mismatch. When the server passes in the saved layout, both renders start from the same data. If you really need localStorage, for example for anonymous users, read it in a useEffect and accept a brief flash of the default layout.

Step 3: Render the responsive grid only after measuring

Here's the client component with drag, resize and breakpoints, but without saving or animations yet:

1// app/dashboard/dashboard.tsx (first version)
2"use client";
3
4import { useState } from "react";
5import {
6  ResponsiveGridLayout,
7  useContainerWidth,
8  type Layout,
9  type ResponsiveLayouts,
10} from "react-grid-layout";
11import "react-grid-layout/css/styles.css";
12import "react-resizable/css/styles.css";
13import { BREAKPOINTS, COLS, type DashboardState } from "@/lib/dashboard";
14
15export default function Dashboard({ initial }: { initial: DashboardState }) {
16  const { width, containerRef, mounted } = useContainerWidth();
17  const [widgets] = useState(initial.widgets);
18  const [layouts, setLayouts] = useState<ResponsiveLayouts>(initial.layouts);
19
20  return (
21    <div ref={containerRef}>
22      {mounted ? (
23        <ResponsiveGridLayout
24          width={width}
25          layouts={layouts}
26          breakpoints={BREAKPOINTS}
27          cols={COLS}
28          gridConfig={{ rowHeight: 60, margin: [16, 16] }}
29          dragConfig={{ handle: ".widget-handle", cancel: ".widget-remove" }}
30          onLayoutChange={(_layout: Layout, all: ResponsiveLayouts) =>
31            setLayouts(all)
32          }
33        >
34          {widgets.map((w) => (
35            <div key={w.id} className="widget-shell">
36              <header className="widget-handle">{w.type}</header>
37            </div>
38          ))}
39        </ResponsiveGridLayout>
40      ) : (
41        <div style={{ height: 480 }} aria-busy="true" />
42      )}
43    </div>
44  );
45}

A few things in here are deliberate.

"use client" doesn't mean "client only." Client components still render on the server. Without a measured width, the grid would compute pixel positions for some guessed width, and the layout would jump once it hydrated. Gating on mounted means the server and the client's first render both output the same placeholder. The grid renders only after useContainerWidth has measured the real container. Give the placeholder roughly the height of the dashboard so the page doesn't shift.

Advertisement

Import both stylesheets. If items stack on top of each other or the resize handles don't appear, a missing CSS import is usually the cause. In the App Router you can import global CSS from a client component.

Use a drag handle. Without handle, the whole card is draggable, which makes text selection, inputs and charts inside widgets frustrating to use. The cancel selector covers the remove button we add in Step 5. Without it, pressing a button inside the handle starts a drag and the click never fires. v2 groups these options into gridConfig, dragConfig and resizeConfig objects. If you're coming from v1's flat rowHeight and draggableHandle props, the README's Hooks and config sections list every field.

Keep breakpoint names consistent. The keys in breakpoints, cols and layouts have to match. Only define lg at first and let the grid derive md through xxs. onLayoutChange reports every layout it knows about, so derived breakpoints get added to your state as the user visits them. Once a user adjusts the sm layout on their phone, that layout is saved separately from lg.

Step 4: Persist layout changes without hammering the server

onLayoutChange fires a lot. It runs once when the grid mounts, again on breakpoint changes, and after every drag and resize. Debounce the save and skip the first effect run, so loading the page doesn't immediately write the same data back.

Add this inside the component and change const [widgets] = useState(...) to const [widgets, setWidgets] = useState(...):

1import { useEffect, useRef, useState } from "react";
2import { saveDashboard } from "./actions";
3
4// inside Dashboard():
5const isFirstRun = useRef(true);
6
7useEffect(() => {
8  if (isFirstRun.current) {
9    isFirstRun.current = false;
10    return;
11  }
12  const timer = setTimeout(() => {
13    saveDashboard({ widgets, layouts }).catch(console.error);
14  }, 600);
15  return () => clearTimeout(timer);
16}, [widgets, layouts]);

If you'd rather save only on deliberate user actions, use onDragStop and onResizeStop. That avoids saves caused by window resizes, but you'll also need to save on add and remove. For most dashboards, the debounced effect is simpler and good enough.

Step 5: Add and remove widgets with Motion animations

This is where Motion comes in, along with two traps.

Trap one: don't make the grid item a motion.div. react-grid-layout positions each direct child by writing a CSS transform to its style. Motion also animates transform when you use scale, x or y. If both write to the same element, one overwrites the other and items end up in the wrong place. Keep the grid item as a plain div and animate a motion.div inside it.

Trap two: AnimatePresence can't wrap grid children. The grid has to own its direct children, and a widget that's removed from state also disappears from the layout straight away. The reliable approach is to mark a widget as exiting, play the exit animation, and remove it from state in onAnimationComplete. The Motion animation docs cover the initial, animate and transition props used here.

1import { motion } from "motion/react";
2import { WIDGET_TYPES, type WidgetType } from "@/lib/dashboard";
3
4// inside Dashboard(), alongside the other state:
5const [exiting, setExiting] = useState<Set<string>>(() => new Set());
6const initialIds = useRef(new Set(initial.widgets.map((w) => w.id)));
7
8function addWidget(type: WidgetType) {
9  const id = `${type}-${crypto.randomUUID().slice(0, 8)}`;
10  setWidgets((prev) => [...prev, { id, type }]);
11  setLayouts((prev) =>
12    Object.fromEntries(
13      Object.entries(prev).map(([bp, layout]) => [
14        bp,
15        [
16          ...(layout ?? []),
17          {
18            i: id,
19            x: 0,
20            y: Infinity, // place at the bottom; compaction fixes the value
21            w: Math.min(4, COLS[bp as keyof typeof COLS] ?? 4),
22            h: 3,
23          },
24        ],
25      ]),
26    ),
27  );
28}
29
30function finishRemoval(id: string) {
31  setWidgets((prev) => prev.filter((w) => w.id !== id));
32  setLayouts((prev) =>
33    Object.fromEntries(
34      Object.entries(prev).map(([bp, layout]) => [
35        bp,
36        (layout ?? []).filter((item) => item.i !== id),
37      ]),
38    ),
39  );
40  setExiting((prev) => {
41    const next = new Set(prev);
42    next.delete(id);
43    return next;
44  });
45}
46
47// toolbar, rendered above the grid container:
48<div style={{ display: "flex", gap: 8, marginBottom: 16 }}>
49  {WIDGET_TYPES.map((t) => (
50    <button key={t} onClick={() => addWidget(t)}>+ {t}</button>
51  ))}
52</div>
53
54// replace the children of <ResponsiveGridLayout>:
55{widgets.map((w) => {
56  const isExiting = exiting.has(w.id);
57  return (
58    <div key={w.id} className="widget-shell">
59      <motion.div
60        style={{ height: "100%", background: "#fff", borderRadius: 8 }}
61        initial={initialIds.current.has(w.id) ? false : { opacity: 0, scale: 0.85 }}
62        animate={isExiting ? { opacity: 0, scale: 0.85 } : { opacity: 1, scale: 1 }}
63        transition={{ duration: 0.2, ease: "easeOut" }}
64        onAnimationComplete={() => {
65          if (isExiting) finishRemoval(w.id);
66        }}
67      >
68        <header
69          className="widget-handle"
70          style={{ display: "flex", justifyContent: "space-between", cursor: "move" }}
71        >
72          {w.type}
73          <button
74            className="widget-remove"
75            disabled={isExiting}
76            onClick={() => setExiting((prev) => new Set(prev).add(w.id))}
77          >
78            ×
79          </button>
80        </header>
81      </motion.div>
82    </div>
83  );
84})}

Some details worth understanding:

  • initial={false} for widgets that were there on page load. Without it, every card would fade in each time the page loads, which gets annoying fast. Only widgets added in this session get the enter animation.
  • Add the new item to every existing breakpoint layout. If you only add it to the current breakpoint, the grid has to make up a position when the user resizes the window, and the result is usually a 1×1 box. The code maps over the breakpoints already in state. That's important: if you created an md entry that held only the new item, the grid would stop deriving md from lg, and every other widget would lose its position there.
  • y: Infinity is the standard way to say "put it at the bottom." The vertical compactor moves the item up to the first free row, and the next onLayoutChange replaces Infinity with a real number before the debounced save runs. JSON.stringify would turn Infinity into null, which is one more reason the debounce matters.
  • The gap closes after the animation, not during it. While a card fades out, its grid cell stays reserved. Once finishRemoval runs, the compactor moves the cards below it up. The grid's built-in CSS transitions animate that movement, so you don't need extra Motion code to make it look smooth.

If you want enter animations without the extra state, you could drop Motion and use a CSS @keyframes animation on .widget-shell > *. You'd lose the exit animation, though, and the exit is the part users notice most. Removing a widget with no transition and watching the cards below jump up is the main reason to add Motion here at all.

Where to take it next

The structure handles a few extensions well. To give each widget type its own minimum size, add minW and minH to the layout items you create in addWidget. The grid enforces them during resize. To make a widget fixed, set static: true on its layout item. For a "reset layout" button, call setWidgets(DEFAULT_DASHBOARD.widgets) and setLayouts(DEFAULT_DASHBOARD.layouts), and the debounced effect saves the result. If you add a database, store widgets and layouts together in one JSON column. They're always read and written together, and normalizing grid coordinates into rows is extra work you won't get anything back for.

Advertisement

Share this page

Article by Marcus Rodriguez

Marcus Rodriguez is the ReactLibraries.com byline for Next.js, server components, data fetching and full-stack TypeScript. Articles under this byline are written with AI assistance from current documentation, changelogs and release notes, and are reviewed by the ReactLibraries.com editor before publishing.

Related Content

Continue learning with these related articles