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
activeViewIdwhen 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()returnsPersistedTableStateSnapshot | nullset()writes the same snapshot typedelete()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:
- change sorting, filters, grouping, and a few presentation settings
- reload the page and confirm they come back
- clear the remembered state through your reset path
- 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, anduserId - 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.