SDK-native document tables for Sanity apps built on @sanity/sdk-react.
This package sits on top of @sanity-labs/react-table-kit and adds:
- Sanity SDK-backed data fetching and pagination
- automatic GROQ projection generation from your columns
- explicit filter definitions with URL-backed state
- server-aware grouping for paginated tables
- inline editing, inline create, and release-aware staging
- Sanity-specific cells for references, users, document status, tasks, comments, and Studio links
Use @sanity-labs/react-table-kit when you only need UI primitives and already have table data.
Use @sanity-labs/sdk-table-kit when your app already uses @sanity/sdk-react and you want the
table to fetch and act on Sanity documents directly.
pnpm add @sanity-labs/react-table-kit @sanity-labs/sdk-table-kitPeer dependencies for the SDK layer:
@sanity/icons@sanity/sdk@sanity/sdk-react@sanity/types@sanity/uireactreact-dom
You will also need styled-components because it is a peer dependency of
@sanity-labs/react-table-kit.
import {
SanityDocumentTable,
column,
filter,
} from "@sanity-labs/sdk-table-kit";
const articleFilters = [
filter.search({
label: "Search",
fields: ["title", { path: "author->name", label: "Author name" }],
}),
filter.string({
field: "status",
label: "Status",
options: [
{ label: "Draft", value: "draft" },
{ label: "Published", value: "published" },
],
}),
];
export function ArticlesTable() {
return (
<SanityDocumentTable
documentType="article"
pageSize={25}
defaultSort={{ field: "_updatedAt", direction: "desc" }}
filters={articleFilters}
columns={[
column.string({ field: "title", searchable: true, edit: true }),
column.reference({
field: "author",
header: "Author",
referenceType: "person",
preview: {
select: {
name: "name",
image: "image.asset",
},
prepare: ({ name, image }) => ({
title: String(name ?? ""),
media: image,
}),
},
}),
column.badge({
field: "status",
colorMap: {
draft: "caution",
published: "positive",
},
}),
column.updatedAt(),
column.openInStudio(),
]}
/>
);
}SanityDocumentTablegives you an all-in-one table with data fetching, sorting, pagination, filter UI, bulk actions, and optional release-aware staging.columnmerges the basereact-table-kitcolumn helpers with SDK-specific helpers likereference(),user(),documentStatus(), andtasks().filter,useFilterUrlState, anduseFilterPresetsare re-exported from@sanity-labs/react-table-kitso the same filter model works across both packages.useSanityTableData()is the lower-level SDK data adapter.useSanityDocumentTable()returns ready-to-spreadtablePropsandpaginationPropsif you want a custom layout.- Comments, tasks, references, releases, and document-status cells are available as composable exports when you need something more custom than the default table.
SDK-specific exports:
SanityDocumentTablecolumnuseSanityTableData()useSanityDocumentTable()PaginationControlsAddonDataProvideruseSDKEditHandler()ReferenceCellReferenceEditPopoverPreviewCellUserCellOpenInStudioCellDocumentStatusCellSharedCommentsPanelTaskSummaryEditorView
Re-exported from @sanity-labs/react-table-kit:
DocumentTablefilteruseFilterUrlState()useFilterPresets()- all filter and table types exported from the shared table kit barrel
The table has a larger surface area than the quick-start example shows. These are the main props most apps reach for first:
<SanityDocumentTable
documentType="article"
// String for one type, or string[] when you want to query across multiple types.
filter='status != "archived"'
// Optional raw GROQ predicate appended to the base type filter.
params={{ market: "us" }}
// Params used by the raw `filter` prop and merged with compiled filter params.
filters={filters}
// Explicit filter definitions rendered above the table and compiled into GROQ.
filterState={filterState}
// Optional shared URL-backed filter state from `useFilterUrlState(filters)`.
columns={columns}
// Column defs from `column.*()` or compatible `ColumnDef`s.
pageSize={25}
// Enables server-backed pagination for the single-document-type flow.
pageSizeOptions={[25, 50, 100]}
onPageSizeChange={(nextPageSize) => console.log(nextPageSize)}
defaultSort={{ field: "_updatedAt", direction: "desc" }}
// Default server sort when pagination is enabled.
projection="{ _id, _type, title }"
// Optional escape hatch when you do not want the projection generated from columns.
emptyMessage="No articles found"
stripedRows
onRowClick={(row) => console.log(row)}
bulkActions={(selection) => <MyBulkActions selection={selection} />}
onSelectionChange={(selectedRows) => console.log(selectedRows)}
createDocument
// `true` uses defaults, or pass `{buttonText, initialValues}` for custom behavior.
releases
// Adds release-aware UI, release header state, and version-aware staging behavior.
computedFilters={computedFilters}
// Named filters that other UI surfaces can activate, such as stat cards.
reorderable
columnOrder={columnOrder}
onColumnOrderChange={setColumnOrder}
/>documentTypeacceptsstring | string[].- Use a single
documentTypewhen you want the built-in paginated document-table flow. Usestring[]when you want one query-backed table across multiple document types. pageSizechanges how data is loaded. In the documented single-type flow it enables SDK pagination; without it, the table falls back to query mode.- When paginated mode is active, grouping is server-aware. The current group key is stored in the
groupByURL param and the SDK prefixes the active group ordering ahead of the current sort. paramsare merged with compiled filter params. The internal document-type params still win, so avoid relying on your owndocTypeordocTypeskeys.columnsare not only presentation config. Theirfieldvalues also drive the generated GROQ projection unless you provideprojectionyourself.bulkActionsare additive. The table can still inject built-in publish and release actions when those features are enabled.createDocumentsupports eithertrueor an object config for custom button text and initial values.releasesdoes not filter the table or change the table read perspective. It keeps the normal row set visible and treats the selected release as the staging target for edits and release actions.
SanityDocumentTable and useSanityDocumentTable() automatically wire server-backed grouping for
paginated tables. Mark columns as groupable: true, and the table will expose them in the group-by
UI while useSanityTableData() keeps the active group key in URL state and injects the matching
ordering into the SDK query.
For display-oriented columns, you can separate the visible group label from the backend ordering field:
column.custom({
field: 'status',
header: 'Status',
groupable: true,
groupValue: (rawValue) => statusLabels[String(rawValue ?? '')] ?? String(rawValue ?? ''),
groupField: 'coalesce(status, "draft")',
})
column.reference({
field: 'section',
header: 'Section',
referenceType: 'section',
preview,
groupable: true,
groupField: 'section->title',
})Use groupValue when group headers should show a prepared or friendly label. Use groupField when
the server should group by a different path or GROQ expression than the rendered cell value.
The SDK column namespace contains every base helper from @sanity-labs/react-table-kit plus
Sanity-specific helpers.
Most helpers build on a common shape like this:
type CommonColumnOptions = {
header?: string;
icon?: React.ComponentType<React.SVGProps<SVGSVGElement>>;
filterable?: boolean;
groupable?: boolean;
searchable?: boolean;
flex?: number;
width?: number;
};
type RoleOptions = {
visibleTo?: string[];
editableBy?: string[];
};
type CommentOptions =
| true
| {
fieldPath?: string;
fieldLabel?: string;
};field can be a simple field, a dot path, or a GROQ expression:
"title";
"web.dueDate";
'coalesce(status, "draft")';The SDK layer automatically turns non-simple fields into projection aliases for rendering while preserving the real edit path for patches.
Checkbox selection column.
column.select(config?: {width?: number} & RoleOptions)Generic text column.
column.string({
...common,
...roles,
field: string,
sortable?: boolean,
edit?: true | {onSave: (document, newValue: string) => void},
comments?: CommentOptions,
})If you omit header, the helper derives a neutral label from the field when possible
(for example title -> Title, authorName -> Author Name, web.dueDate -> Due Date).
Deprecated compatibility preset for the common title field. Equivalent to
column.string({field: 'title', header: 'Title'}), while still allowing field overrides for
older call sites.
Document type column backed by _type.
column.type({
...common,
...roles,
sortable?: boolean,
edit?: true | {options: EditOption[]; onSave: (document, newValue: string) => void},
})Last-updated column backed by _updatedAt.
column.updatedAt({
...common,
...roles,
sortable?: boolean,
edit?: true | {
onSave: (document, newValue: string) => void
toneByDateRange?: boolean
},
})Advanced escape hatch for columns that need computed projections or custom rendering, sorting, grouping, filtering, or editing behavior.
column.custom({
field: string,
...advancedOptions,
})Prefer column.string(), column.badge(), column.date(), or column.reference() when they fit
the data shape. See Advanced: column.custom() below for the full prop reference and example.
Badge-rendered categorical column.
column.badge({
...common,
...roles,
field: string,
colorMap?: Record<string, BadgeTone | {tone: BadgeTone; label: string}>,
sortable?: boolean,
edit?: true | {options: EditOption[]; onSave: (document, newValue: string) => void},
})Date column with optional overdue or date-range tone behavior.
column.date({
...common,
...roles,
field: string,
sortable?: boolean,
showOverdue?: boolean,
toneByDateRange?: boolean,
edit?: true | {
onSave: (document, newValue: string) => void
toneByDateRange?: boolean
},
filterMode?: 'exact' | 'range',
comments?: CommentOptions,
})Boolean column rendered as a toggle/checkbox style cell.
column.boolean({
...common,
...roles,
field: string,
sortable?: boolean,
edit?: true | {onSave: (document, newValue: boolean) => void},
})Action column that opens the current row in Sanity Studio.
column.openInStudio(config?: {width?: number; header?: string})Reference column with Sanity Studio-style preview config and optional inline editing.
column.reference({
field: string,
header: string,
referenceType: string,
preview: {
select: Record<string, string>,
prepare: (selection) => ({
title?: string,
subtitle?: string,
media?: unknown,
}),
},
sortable?: boolean,
sortField?: string,
filterable?: boolean,
groupable?: boolean,
groupField?: string,
width?: number,
edit?: boolean,
placeholder?: string,
comments?: CommentOptions,
})Use sortField when the server should sort by a different field than the rendered preview title.
Use groupField when server-backed grouping should use a different path or expression than the
prepared preview title.
Document preview cell powered by the SDK preview APIs.
column.preview(config?: {header?: string; width?: number})Resolves a stored user ID into an avatar/name cell.
column.user({
field: string,
header: string,
showName?: boolean | 'first',
width?: number,
comments?: CommentOptions,
})Publish-state cell that matches Studio document status semantics.
column.documentStatus(config?: {width?: number; header?: string})Task summary cell for document-scoped tasks.
column.tasks(config?: {header?: string; width?: number})Use column.custom() when a built-in helper does not model the column cleanly.
Reach for it when:
- the rendered value comes from a computed GROQ projection
- the cell needs custom JSX
- sorting should compare a derived label rather than the raw value
- group headers should show a friendly label
- server-backed grouping should use a different backend field or GROQ expression
- filtering needs a custom predicate
- editing needs the full
ColumnEditConfigsurface
Prefer a built-in helper when:
- the value is plain text and maps cleanly to one field:
column.string() - the value is categorical and should render as a badge:
column.badge() - the value is a date or datetime:
column.date() - the value is a Sanity reference preview:
column.reference()
column.custom({
// identity and data
field: string,
projection?: string,
// presentation
header?: string,
icon?: React.ComponentType<React.SVGProps<SVGSVGElement>>,
filterable?: boolean,
groupable?: boolean,
searchable?: boolean,
flex?: number,
width?: number,
// rendering and table behavior
cell?: (value, row) => React.ReactNode,
sortable?: boolean,
sortValue?: (rawValue, row) => string | number,
groupValue?: (rawValue, row) => string,
groupField?: string,
filterFn?: (row, filterValue) => boolean,
filterMode?: 'exact' | 'range',
// editing, access, and comments
edit?: ColumnEditConfig,
visibleTo?: string[],
editableBy?: string[],
comments?: CommentOptions,
})Identity and data:
fieldis required. It gives the column a stable key and names the row value used by the cell. When you also provideprojection,fieldis usually a simple alias such asworkflowStageorenteredStageAt.projectionis SDK-only. Use it when the rendered value should come from a custom GROQ expression instead of reading directly fromfield.
Presentation:
header,icon,flex, andwidthcontrol how the column is labeled and laid out.filterable,groupable, andsearchabledecide whether the column participates in those table features.
Rendering and table behavior:
cellrenders the value with custom JSX.sortableenables or disables sorting for the column.sortValuetransforms the raw value before sorting. Use it when the visible label differs from the stored value.groupValuetransforms the raw value into the visible group label.groupFieldis the backend field or GROQ expression to use for server-backed grouping when that should differ from the rendered cell value.filterFnlets you provide a custom client-side predicate for the column.filterModechooses how built-in filtering should interpret values: exact match or range.
Editing, access, and comments:
editaccepts the fullColumnEditConfig, socolumn.custom()can opt intotext,select,date, or fully custom editing flows.visibleTolimits which role slugs can see the column.editableBylimits which role slugs can edit the column.commentsenables field-scoped comments. Usetruefor the default field path and label, or an object to overridefieldPathandfieldLabel.
import {Badge} from '@sanity/ui'
import {column} from '@sanity-labs/sdk-table-kit'
const STAGE_LABELS: Record<string, string> = {
draft: 'Draft & Edit',
ideation: 'Ideation',
scheduled: 'Scheduled',
}
export const workflowStageColumn = column.custom({
field: 'workflowStage',
header: 'Workflow stage',
projection: 'coalesce(status, "draft")',
groupable: true,
filterable: true,
searchable: true,
cell: (value) => {
const stage = String(value ?? 'draft')
return <Badge tone={stage === 'scheduled' ? 'positive' : 'primary'}>{STAGE_LABELS[stage]}</Badge>
},
sortValue: (rawValue) => STAGE_LABELS[String(rawValue ?? 'draft')] ?? 'Draft & Edit',
groupValue: (rawValue) => STAGE_LABELS[String(rawValue ?? 'draft')] ?? 'Draft & Edit',
groupField: 'coalesce(status, "draft")',
})Why this uses column.custom():
fieldis a stable alias for a computed value rather than a real document field.projectionfetches that computed value from GROQ.cellrenders the value as a badge instead of plain text.sortValueandgroupValuekeep sorting and group labels aligned with the displayed stage label.groupFieldtells server-backed grouping which backend expression to use.
sdk-table-kit re-exports the filter model from @sanity-labs/react-table-kit, then compiles the
active filter values into GROQ before calling the SDK.
Most apps start with these builders:
filter.string({
field: 'status',
label: 'Status',
operator?: 'is' | 'in',
options?: Array<{label: string; value: string}>,
})
filter.date({
field: 'plannedPublishDate',
label: 'Planned publish date',
operator?: 'is' | 'before' | 'after' | 'range',
granularity?: 'date' | 'datetime',
includeTime?: boolean,
})
filter.number({
field: 'priority',
label: 'Priority',
operator?: 'is' | 'gt' | 'gte' | 'lt' | 'lte' | 'range',
options?: Array<{label: string; value: number}>,
})
filter.boolean({
field: 'featured',
label: 'Featured',
})
filter.reference({
field: 'author',
label: 'Author',
referenceType: 'person',
relation?: 'single' | 'array',
preview?: {
select: Record<string, string>,
prepare: (selection) => PreviewValue,
},
options?: {
source?: 'documents',
searchable?: boolean,
pageSize?: number,
filter?: string,
params?: Record<string, unknown>,
sort?: {field: string; direction: 'asc' | 'desc'},
},
})
filter.search({
label: 'Search',
fields: ['title', {path: 'author->name', label: 'Author name'}],
mode?: 'contains' | 'match',
placeholder?: string,
debounceMs?: number,
})
filter.custom({
key: 'myFilter',
label: 'My filter',
control: 'select',
valueType: 'string',
serialize: (value) => value,
deserialize: (value) => value,
formatChip: (value) => String(value),
component?: (props) => React.ReactNode,
clientPredicate?: (row, value) => boolean,
query?: {
toGroq: (value, context) => ({groq: '...', params: {...}}),
toCountGroq?: (value, context) => ({groq: '...', params: {...}}),
},
})All filter builders share the same base metadata:
{
key?: string
label: string
operator?: ...
defaultValue?: ...
hidden?: boolean
toInitialValue?: (value) => unknown
}filter.search() fields are source query paths, not projected row aliases, so paths like
author->name or section->title are valid.
import { filter, useFilterUrlState } from "@sanity-labs/sdk-table-kit";
const filters = [
filter.search({ label: "Search", fields: ["title"] }),
filter.string({ field: "status", label: "Status" }),
];
const filterState = useFilterUrlState(filters);Pass filterState when another surface should share the same filter source of truth. If you omit
it, SanityDocumentTable creates an internal URL-backed state for the same filter definitions.
useFilterPresets() is useful when stat cards or shortcut buttons should write named filter values
into that shared state.
<SanityDocumentTable
documentType="article"
filters={filters}
filterState={filterState}
columns={columns}
/>Internally, the table:
- reads the current committed values from
filterState - runs
compileFilters(filters, {documentType, values, params}) - produces a GROQ fragment plus GROQ params for every active filter
- merges that compiled fragment with the low-level
filterprop - sends the final query to
usePaginatedDocuments()oruseQuery()
At a high level, the built-in filter kinds compile like this:
- string: equality or
inchecks - boolean: equality checks
- number: equality, comparison, or range checks
- date:
dateTime(...)comparisons or ranges - reference:
_refchecks for single references, or array-reference predicates whenrelation: 'array' - search:
matchqueries across one or more fields - custom: whatever your
query.toGroq()function returns
These two props are related, but they are not the same:
filtersis the high-level filter UI contract. The table renders controls for these and compiles their active values into GROQ.filteris the low-level raw GROQ predicate string.
If you pass both, the table combines them with &&.
If you use task or comment surfaces, wrap the relevant part of your app with AddonDataProvider.
Pass users={SanityUser[]} when your app shell already has the project members loaded:
<AddonDataProvider users={usersFromAppShell}>
<SanityDocumentTable {...props} />
</AddonDataProvider>That prevents task popovers from needing to suspend while resolving users internally. Omitting
users still works, but task/comment UI may show a loading state until users resolve.
Use the lower-level APIs when you want a custom layout:
useSanityTableData()gives you rawdata,loading,pagination, andsorting.useSanityTableData()also exposesgroupingso custom layouts can keep the group-by UI in sync with the server-backed query state.useSanityDocumentTable()gives you ready-to-spreadtablePropsforDocumentTableandpaginationPropsforPaginationControls.
pnpm installpnpm buildpnpm typecheckpnpm lintpnpm test
