react-datatable
Guides

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:

  • list
  • get
  • create
  • update
  • delete
  • share
  • setUserDefault
  • setWorkspaceDefault

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/user
  • PUT /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 access
  • get() returns one hydrated DatatableView
  • create() and update() send the saved-view state payload
  • share() 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 supportedGroupingColumns aligned 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:

  1. save a new named view
  2. reload and confirm it appears in the views UI
  3. load it and confirm filters, grouping, and presentation state replay correctly
  4. set it as a user or workspace default
  5. 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.

On this page