react-datatable
Guides

Server persisted state endpoint

Use this guide when the table should remember one user's last working state across reloads, browsers, or devices.

This page is about automatic state persistence. If users need named presets they can manage and share, use Server saved views endpoint.

Decide what this endpoint stores

Current-view persistence is quiet, personal memory.

It usually stores:

  • column visibility, order, and widths
  • sticky columns and display toggles
  • sorting
  • grouping and grouped expansion
  • global search and column filters
  • filter mode
  • activeViewId when the user explicitly chose a saved view

It should not store temporary action state like row selection or open dialogs.

Start with the persisted snapshot type

The adapter reads and writes PersistedTableStateSnapshot values.

import {
  buildPersistedTableStateSnapshot,
  replayPersistedTableState,
} from "react-datatable-server"

The useful part is the full stored shape:

interface PersistedTableStateSnapshot {
  version: 1
  query: {
    filters: ColumnFilter[]
    sorting: SortingState
    globalFilter: string
    grouping: {
      columns: string[]
      showEmptyGroups: boolean
      expansion: {
        defaultExpanded: boolean
        overrides: Record<string, boolean>
      }
    } | null
    filterMode: "AND" | "OR"
  }
  presentation: {
    showColumnHeaders: boolean
    stickyColumnsCount: number
    showHorizontalLines: boolean
    showVerticalLines: boolean
    showOrderingBadge: boolean
    columnOrder: string[]
    columnVisibility: Record<string, boolean>
    columnWidths: Record<string, number>
    activeViewId: string | null
  }
}

That means persisted state stores both backend-facing query state and the presentation state needed to restore the table.

Use buildPersistedTableStateSnapshot() before saving, and replayPersistedTableState() after loading.

For the surrounding contracts, see TypeScript types.

Create the Postgres table with Drizzle

For a multi-tenant SaaS app, a practical schema is one row per table, workspace, and user.

That gives each user-facing table surface one remembered state.

import { jsonb, pgTable, text, timestamp, uniqueIndex } from "drizzle-orm/pg-core"
import type { PersistedTableStateSnapshot } from "react-datatable/persistence"

export const datatableState = pgTable(
  "datatable_state",
  {
    id: text("id").primaryKey(),
    tableKey: text("table_key").notNull(),
    workspaceId: text("workspace_id").notNull(),
    userId: text("user_id").notNull(),
    snapshot: jsonb("snapshot").$type<PersistedTableStateSnapshot>().notNull(),
    updatedAt: timestamp("updated_at", { withTimezone: true }).notNull().defaultNow(),
    createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(),
  },
  (table) => ({
    scopeIdx: uniqueIndex("datatable_state_scope_idx").on(
      table.tableKey,
      table.workspaceId,
      table.userId
    ),
  })
)

That unique scope is the important part. It prevents one table surface from overwriting another.

Add a small storage service

Keep the persistence logic in one backend module.

import { and, eq } from "drizzle-orm"
import { datatableState } from "@/db/schema"
import type { PersistedTableStateSnapshot } from "react-datatable/persistence"

export async function getDatatableState(args: {
  db: Database
  tableKey: string
  workspaceId: string
  userId: string
}) {
  return args.db.query.datatableState.findFirst({
    where: and(
      eq(datatableState.tableKey, args.tableKey),
      eq(datatableState.workspaceId, args.workspaceId),
      eq(datatableState.userId, args.userId)
    ),
  })
}

export async function upsertDatatableState(args: {
  db: Database
  id: string
  tableKey: string
  workspaceId: string
  userId: string
  snapshot: PersistedTableStateSnapshot
}) {
  await args.db
    .insert(datatableState)
    .values({
      id: args.id,
      tableKey: args.tableKey,
      workspaceId: args.workspaceId,
      userId: args.userId,
      snapshot: args.snapshot,
    })
    .onConflictDoUpdate({
      target: [datatableState.tableKey, datatableState.workspaceId, datatableState.userId],
      set: {
        snapshot: args.snapshot,
        updatedAt: new Date(),
      },
    })
}

export async function deleteDatatableState(args: {
  db: Database
  tableKey: string
  workspaceId: string
  userId: string
}) {
  await args.db
    .delete(datatableState)
    .where(
      and(
        eq(datatableState.tableKey, args.tableKey),
        eq(datatableState.workspaceId, args.workspaceId),
        eq(datatableState.userId, args.userId)
      )
    )
}

Add the GET endpoint

The load endpoint should either return a saved snapshot or 404.

import { NextRequest, NextResponse } from "next/server"
import { getDatatableState } from "@/server/datatable/state-store"
import { requireWorkspaceAccess } from "@/server/auth"

export async function GET(request: NextRequest) {
  const session = await requireWorkspaceAccess(request)
  const tableKey = request.nextUrl.searchParams.get("tableKey")

  if (!tableKey) {
    return NextResponse.json({ error: "tableKey is required" }, { status: 400 })
  }

  const record = await getDatatableState({
    db: session.db,
    tableKey,
    workspaceId: session.workspaceId,
    userId: session.userId,
  })

  if (!record) {
    return NextResponse.json({ error: "Not found" }, { status: 404 })
  }

  return NextResponse.json(record.snapshot)
}

Add the PUT and DELETE endpoints

Write state snapshots on save, and remove the row on reset.

The request body still uses PersistedTableState. The server converts it to PersistedTableStateSnapshot before writing.

import { NextRequest, NextResponse } from "next/server"
import { createId } from "@paralleldrive/cuid2"
import { buildPersistedTableStateSnapshot } from "react-datatable-server"
import type { PersistedTableState } from "react-datatable/persistence"
import { deleteDatatableState, upsertDatatableState } from "@/server/datatable/state-store"
import { requireWorkspaceAccess } from "@/server/auth"

export async function PUT(request: NextRequest) {
  const session = await requireWorkspaceAccess(request)
  const body = (await request.json()) as {
    tableKey: string
    state: PersistedTableState
  }

  const snapshot = buildPersistedTableStateSnapshot(body.state)

  await upsertDatatableState({
    db: session.db,
    id: createId(),
    tableKey: body.tableKey,
    workspaceId: session.workspaceId,
    userId: session.userId,
    snapshot,
  })

  return NextResponse.json({ ok: true })
}

export async function DELETE(request: NextRequest) {
  const session = await requireWorkspaceAccess(request)
  const body = (await request.json()) as { tableKey: string }

  await deleteDatatableState({
    db: session.db,
    tableKey: body.tableKey,
    workspaceId: session.workspaceId,
    userId: session.userId,
  })

  return NextResponse.json({ ok: true })
}

Wire the frontend table-state adapter

Now connect persistState.adapter to those endpoints.

The contract is:

  • get() returns PersistedTableStateSnapshot | null
  • set() writes the same snapshot type
  • delete() clears the saved row for that table scope
import type {
  PersistedTableStateSnapshot,
  TableStateAdapter,
} from "react-datatable/persistence"
import { replayPersistedTableState } from "react-datatable-server"

export const tableStateServerAdapter: TableStateAdapter = {
  async get(config) {
    const url = new URL("/api/datatable-state", window.location.origin)
    url.searchParams.set("tableKey", config.tableKey)

    const res = await fetch(url)
    if (res.status === 404) return null
    if (!res.ok) throw new Error("Failed to load datatable state")

    const snapshot = (await res.json()) as PersistedTableStateSnapshot
    return replayPersistedTableState(snapshot)
  },

  async set(config, state) {
    const res = await fetch("/api/datatable-state", {
      method: "PUT",
      headers: { "content-type": "application/json" },
      body: JSON.stringify({
        tableKey: config.tableKey,
        state,
      }),
    })

    if (!res.ok) throw new Error("Failed to save datatable state")
  },

  async delete(config) {
    const res = await fetch("/api/datatable-state", {
      method: "DELETE",
      headers: { "content-type": "application/json" },
      body: JSON.stringify({
        tableKey: config.tableKey,
      }),
    })

    if (!res.ok) throw new Error("Failed to delete datatable state")
  },
}

The important part is consistency:

  • API reads return the persisted-state snapshot
  • API writes persist the same snapshot shape
  • UI code calls replayPersistedTableState() before handing the value back to the table

Connect it to Datatable

<Datatable
  tableKey="customers"
  columns={columns}
  data={rows}
  getRowId={(row) => row.id}
  persistState={{
    adapter: tableStateServerAdapter,
    debounceMs: 1500,
  }}
/>

Use a stable tableKey. It is the top-level product boundary for remembered state.

Keep precedence predictable

Persistence restores one person's last saved state, but it does not win over a specific URL state on the first load. That keeps shared or bookmarked table URLs reliable while still letting the table remember the user's last working state afterward.

For the full state-loading details, see State Lifecycle.

That is why current-view persistence and saved views can coexist cleanly when both use the same underlying query and presentation model.

Verify the persistent state endpoint before you move on

Run one full user-path check before you ship it:

  1. change sorting, filters, grouping, and a few presentation settings
  2. reload the page and confirm they come back
  3. clear the remembered state through your reset path
  4. reload again and confirm the table falls back to defaults or saved-view precedence

Before you ship it, confirm that:

  • the row is scoped by tableKey, workspaceId, and userId
  • new writes use the persisted-state snapshot
  • grouped expansion survives save and reload
  • save failures surface somewhere useful
  • deleting the record really resets the remembered state

For the persistence types behind this guide, see TypeScript types.

On this page