Server saved views endpoint
Use this guide when users need named table presets they can create on purpose, reopen later, and optionally share with a workspace.
This page is about saved views. If you only need automatic last-state restore for one user, use Server persisted state endpoint.
Start with the saved view model
A saved view is more than a state blob.
It combines:
- a durable table-state payload
- a name
- scope metadata
- sharing metadata
- default flags
The saved view payload itself is DatatableView["state"].
import {
buildDatatableViewState,
replaySavedViewState,
} from "react-datatable-server"The stored state shape is:
interface DatatableViewState {
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 keeps saved views aligned with the same query and presentation model used by live requests and current-view persistence.
For the surrounding saved-view contracts, see TypeScript types.
Create the Postgres table with Drizzle
A straightforward schema keeps the view metadata and the saved state together.
A saved-view row needs two kinds of fields:
- identity and sharing metadata such as
id,name,isShared, and default flags - the saved-view state payload in
snapshot
import { boolean, jsonb, pgTable, text, timestamp, uniqueIndex } from "drizzle-orm/pg-core"
import type { DatatableView } from "react-datatable/persistence"
export const datatableViews = pgTable(
"datatable_views",
{
id: text("id").primaryKey(),
tableKey: text("table_key").notNull(),
workspaceId: text("workspace_id").notNull(),
ownerUserId: text("owner_user_id").notNull(),
name: text("name").notNull(),
isShared: boolean("is_shared").notNull().default(false),
isUserDefault: boolean("is_user_default").notNull().default(false),
isWorkspaceDefault: boolean("is_workspace_default").notNull().default(false),
snapshot: jsonb("snapshot").$type<DatatableView["state"]>().notNull(),
createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(),
updatedAt: timestamp("updated_at", { withTimezone: true }).notNull().defaultNow(),
},
(table) => ({
scopeIdx: uniqueIndex("datatable_views_scope_idx").on(
table.tableKey,
table.workspaceId,
table.ownerUserId,
table.name
),
})
)This schema works well when:
- private views belong to one user
- shared views stay inside one workspace
- defaults are stored on the view row itself
If your product needs richer ACLs, split sharing into a separate table.
Define the API surface first
A backend saved-views adapter usually needs these actions:
listgetcreateupdatedeletesharesetUserDefaultsetWorkspaceDefault
That is the product surface the UI expects.
A minimum DatatableView payload returned from your backend should look like this:
{
id: "view_123",
name: "Open enterprise deals",
state: {
version: 1,
query: { ... },
presentation: { ... },
},
isShared: true,
isUserDefault: false,
isWorkspaceDefault: true,
createdBy: "user_123",
createdAt: new Date(),
updatedAt: new Date(),
workspaceId: "ws_123",
}If your response omits those fields, the built-in views UI will not have enough information to render ownership, sharing, or default state correctly.
Add a small store module
Keep the SQL behind one service layer.
import { and, asc, eq, or } from "drizzle-orm"
import { datatableViews } from "@/db/schema"
export async function listDatatableViews(args: {
db: Database
tableKey: string
workspaceId: string
userId: string
}) {
return args.db.query.datatableViews.findMany({
where: and(
eq(datatableViews.tableKey, args.tableKey),
eq(datatableViews.workspaceId, args.workspaceId),
or(
eq(datatableViews.ownerUserId, args.userId),
eq(datatableViews.isShared, true)
)
),
orderBy: [asc(datatableViews.name)],
})
}For create() and update(), build the saved-view state payload with buildDatatableViewState() before it hits the database.
Add the list and get endpoints
These two endpoints load the available views and one specific view.
import { NextRequest, NextResponse } from "next/server"
import { getDatatableView, listDatatableViews } from "@/server/datatable/view-store"
import { requireWorkspaceAccess } from "@/server/auth"
export async function GET(request: NextRequest) {
const session = await requireWorkspaceAccess(request)
const tableKey = request.nextUrl.searchParams.get("tableKey")
const viewId = request.nextUrl.searchParams.get("viewId")
if (!tableKey) {
return NextResponse.json({ error: "tableKey is required" }, { status: 400 })
}
if (viewId) {
const view = await getDatatableView({
db: session.db,
viewId,
tableKey,
workspaceId: session.workspaceId,
userId: session.userId,
})
if (!view) {
return NextResponse.json({ error: "Not found" }, { status: 404 })
}
return NextResponse.json(view)
}
const views = await listDatatableViews({
db: session.db,
tableKey,
workspaceId: session.workspaceId,
userId: session.userId,
})
return NextResponse.json(views)
}Add the create and update endpoints
Create and update should be explicit about the saved state they persist.
import { NextRequest, NextResponse } from "next/server"
import { createId } from "@paralleldrive/cuid2"
import { buildDatatableViewState } from "react-datatable-server"
import type { DatatableView } from "react-datatable/persistence"
import { createDatatableView, updateDatatableView } from "@/server/datatable/view-store"
import { requireWorkspaceAccess } from "@/server/auth"
export async function POST(request: NextRequest) {
const session = await requireWorkspaceAccess(request)
const body = (await request.json()) as {
tableKey: string
view: DatatableView
}
const created = await createDatatableView({
db: session.db,
id: createId(),
tableKey: body.tableKey,
workspaceId: session.workspaceId,
ownerUserId: session.userId,
name: body.view.name,
isShared: false,
snapshot: buildDatatableViewState(body.view.state),
})
return NextResponse.json(created)
}
export async function PATCH(request: NextRequest) {
const session = await requireWorkspaceAccess(request)
const body = (await request.json()) as {
tableKey: string
viewId: string
updates: Partial<DatatableView>
}
const updated = await updateDatatableView({
db: session.db,
tableKey: body.tableKey,
workspaceId: session.workspaceId,
userId: session.userId,
viewId: body.viewId,
updates: {
...body.updates,
state: body.updates.state
? buildDatatableViewState(body.updates.state)
: undefined,
},
})
return NextResponse.json(updated)
}Add delete, share, and default endpoints
Saved views need a few lifecycle actions beyond CRUD.
Delete
Allow the owner to delete a private view or an unshared view they control.
export async function DELETE(request: NextRequest, { params }: { params: { viewId: string } }) {
const session = await requireWorkspaceAccess(request)
const body = (await request.json()) as { tableKey: string }
await deleteDatatableView({
db: session.db,
tableKey: body.tableKey,
workspaceId: session.workspaceId,
userId: session.userId,
viewId: params.viewId,
})
return NextResponse.json({ ok: true })
}Share
Turn isShared on through a dedicated endpoint instead of treating it like a silent field toggle. Sharing changes who can see the view.
export async function POST(request: NextRequest, { params }: { params: { viewId: string } }) {
const session = await requireWorkspaceAccess(request)
const body = (await request.json()) as { tableKey: string }
const shared = await shareDatatableView({
db: session.db,
tableKey: body.tableKey,
workspaceId: session.workspaceId,
userId: session.userId,
viewId: params.viewId,
})
return NextResponse.json(shared)
}Set defaults
A practical pattern is:
PUT /api/datatable-views/defaults/userPUT /api/datatable-views/defaults/workspace
When one view becomes the new default, clear the old default inside the same scope.
export async function PUT(request: NextRequest) {
const session = await requireWorkspaceAccess(request)
const body = (await request.json()) as {
tableKey: string
viewId: string
}
await setUserDefaultDatatableView({
db: session.db,
tableKey: body.tableKey,
workspaceId: session.workspaceId,
userId: session.userId,
viewId: body.viewId,
})
return NextResponse.json({ ok: true })
}Wire the frontend saved-views adapter
Once the endpoints exist, connect them through DatatableViewAdapter.
The adapter contract is more than plain CRUD:
list()returns every view the current user can accessget()returns one hydratedDatatableViewcreate()andupdate()send the saved-view state payloadshare()and the default setters expose the extra UI actions
import type { DatatableView, DatatableViewAdapter } from "react-datatable/persistence"
import { buildDatatableViewState, replaySavedViewState } from "react-datatable-server"
export const datatableViewServerAdapter: DatatableViewAdapter = {
async list(config) {
const url = new URL("/api/datatable-views", window.location.origin)
url.searchParams.set("tableKey", config.tableKey)
const res = await fetch(url)
if (!res.ok) throw new Error("Failed to list views")
const views = (await res.json()) as DatatableView[]
return views.map((view) => ({
...view,
state: replaySavedViewState(view.state),
}))
},
async get(config, viewId) {
const url = new URL("/api/datatable-views", window.location.origin)
url.searchParams.set("tableKey", config.tableKey)
url.searchParams.set("viewId", viewId)
const res = await fetch(url)
if (res.status === 404) return null
if (!res.ok) throw new Error("Failed to load view")
const view = (await res.json()) as DatatableView
return {
...view,
state: replaySavedViewState(view.state),
}
},
async create(config, view) {
const res = await fetch("/api/datatable-views", {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({
tableKey: config.tableKey,
view: {
...view,
state: buildDatatableViewState(view.state),
},
}),
})
if (!res.ok) throw new Error("Failed to create view")
return res.json()
},
async update(config, viewId, updates) {
const res = await fetch("/api/datatable-views", {
method: "PATCH",
headers: { "content-type": "application/json" },
body: JSON.stringify({
tableKey: config.tableKey,
viewId,
updates,
}),
})
if (!res.ok) throw new Error("Failed to update view")
return res.json()
},
async delete(config, viewId) {
const res = await fetch(`/api/datatable-views/${viewId}`, {
method: "DELETE",
headers: { "content-type": "application/json" },
body: JSON.stringify({ tableKey: config.tableKey }),
})
if (!res.ok) throw new Error("Failed to delete view")
},
async share(config, viewId) {
const res = await fetch(`/api/datatable-views/${viewId}/share`, {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({ tableKey: config.tableKey }),
})
if (!res.ok) throw new Error("Failed to share view")
return res.json()
},
async setUserDefault(config, viewId) {
const res = await fetch("/api/datatable-views/defaults/user", {
method: "PUT",
headers: { "content-type": "application/json" },
body: JSON.stringify({ tableKey: config.tableKey, viewId }),
})
if (!res.ok) throw new Error("Failed to set user default")
},
async setWorkspaceDefault(config, viewId) {
const res = await fetch("/api/datatable-views/defaults/workspace", {
method: "PUT",
headers: { "content-type": "application/json" },
body: JSON.stringify({ tableKey: config.tableKey, viewId }),
})
if (!res.ok) throw new Error("Failed to set workspace default")
},
}Connect it to Datatable
<Datatable
tableKey="customers"
columns={columns}
data={rows}
getRowId={(row) => row.id}
views={{
adapter: datatableViewServerAdapter,
}}
toolbar={{
views: true,
filterButton: true,
quickSearch: true,
displayOptions: true,
}}
/>Keep saved views aligned with backend capabilities
Saved views should not keep requesting states the backend can no longer run.
When columns are removed or renamed:
- migrate existing saved-view payloads if possible
- reject stale filters or grouping clearly if not
- keep
supportedGroupingColumnsaligned with the backend query builder
That matters even more when views store grouped expansion state.
Verify the saved views endpoint before you move on
Run one end-to-end view flow before you ship it:
- save a new named view
- reload and confirm it appears in the views UI
- load it and confirm filters, grouping, and presentation state replay correctly
- set it as a user or workspace default
- reload again and confirm default precedence works
Before you ship it, confirm that:
- new writes use the direct saved-view state payload
- private and shared views respect workspace scope
- user-default and workspace-default actions clear older defaults in the same scope
- the adapter replays saved state correctly after load
- the UI only exposes sharing or default actions your adapter actually supports
- stale view payloads fail clearly instead of producing partial behavior
For the persistence and saved-view contracts behind this guide, see TypeScript types.