@basementstudio/sanity-plugin-csv-export
v0.3.0
Published
Portable Sanity Studio plugin for exporting documents as CSV — pick types and fields, preview, download.
Readme
@basementstudio/sanity-plugin-csv-export
A Sanity Studio v5 tool that lets you select document types and fields (via searchable dropdowns), preview results, and download them as a clean RFC-4180 CSV or XML file — all from within the Studio.
Installation
bun add @basementstudio/sanity-plugin-csv-exportpnpm add @basementstudio/sanity-plugin-csv-exportnpm install @basementstudio/sanity-plugin-csv-exportUsage
Add csvExport() to your plugins array in sanity.config.ts (or .js):
import {defineConfig} from 'sanity'
import {csvExport} from '@basementstudio/sanity-plugin-csv-export'
export default defineConfig({
// ...
plugins: [csvExport()],
})A CSV Export entry (with a download icon) will appear in the Studio top navigation.
With options
plugins: [
csvExport({
includedTypes: ['post', 'author', 'company'],
defaultDelimiter: ';',
filenamePrefix: 'acme-export',
}),
],Options
All options are optional.
| Option | Type | Default | Description |
|---|---|---|---|
| includedTypes | string[] \| (ctx: {currentUser}) => string[] \| undefined | all document types | Allowlist of document type names to show in the type picker. The function form receives the current user, so you can mirror role-based Studio structure (return undefined to allow all types). Namespaced system documents (sanity.imageAsset, media.tag, translation.metadata, …) are hidden by default — an explicit allowlist fully controls visibility, so you can deliberately re-add one. |
| excludedTypes | string[] | [] | Document type names to hide. Applied after includedTypes. Useful for hiding internal/system types. |
| serializers | Partial<Record<FieldKind, Serializer>> | — | Override the built-in cell serializer for one or more field kinds. |
| defaultFormat | 'csv' \| 'xml' | 'csv' | Output format pre-selected in the options bar. Users can switch it per export. |
| defaultDelimiter | ',' \| ';' \| '\t' | ',' | Default CSV delimiter pre-selected in the options bar (CSV only). Users can still change it per export. |
| filenamePrefix | string | workspace title (slugified) | Prefix for the downloaded file, used as-is (only path-illegal characters are stripped). Result: <prefix>-<types>-<timestamp>.csv — e.g. accel-company-2026-07-27-1432.csv. When every type is selected the type list collapses to all; more than 3 types are capped with a +N count. |
Matching the Structure navigation
To offer the same document types a user sees in the Structure sidebar, pass a function to includedTypes using the same role logic your structure resolver uses:
csvExport({
includedTypes: ({currentUser}) => {
if (isAdmin(currentUser)) return undefined // all types
const roles = currentUser?.roles?.map((r) => r.name) ?? []
for (const role of roles) {
const allowed = ROLE_ALLOWED_TYPES[role]
if (allowed) return [...allowed]
}
return undefined
},
excludedTypes: ['metadata'], // internal types hidden from the sidebar
})All documents / All fields
Both pickers start with an All documents / All fields toggle. Neither is on by default — the tool starts with no types selected and only _id pre-checked.
Union field list & name collisions
With multiple types selected, the field list is the union across all selected types; fields absent on a given type render as empty cells. If two types declare the same field name with a different shape (e.g. status is a string on one type and a reference on another), each conflicting type gets its own disambiguated column — labeled e.g. Status (Ticket) — so no type's data is exported through another type's projection.
Serializer override example
Note:
slug,reference,image, andfilevalues are flattened inside the GROQ projection (slug.current,ref->title,asset->url), so a serializer override for those kinds receives the already-resolved string, not the raw object — it acts as a string formatter. The same applies toreferenceList, which receives the array of resolved title strings. Overrides forportableText,array,object, andgeopointreceive the raw value.
Override how Portable Text fields are serialized — for example to strip all markup and join paragraphs with a pipe character:
import {csvExport} from '@basementstudio/sanity-plugin-csv-export'
import type {Serializer} from '@basementstudio/sanity-plugin-csv-export'
interface Block {
children?: {text?: unknown}[]
}
const myPortableTextSerializer: Serializer = (value) => {
if (!Array.isArray(value)) return ''
return value
.map((block) => {
const children = (block as Block)?.children ?? []
return children.map((child) => (child?.text == null ? '' : String(child.text))).join('')
})
.filter(Boolean)
.join(' | ')
}
export default defineConfig({
plugins: [
csvExport({
serializers: {
portableText: myPortableTextSerializer,
},
}),
],
})Field types
The plugin resolves complex Sanity field types to flat strings before writing CSV cells:
| Field kind | Serialization |
|---|---|
| primitive | String(value) — covers string, number, boolean, date |
| slug | The .current value (resolved via GROQ projection) |
| reference | Resolved referenced document title (via GROQ -> dereference); multi-target references coalesce across each target's title field. Expand the field in the picker to export other fields of the referenced document instead |
| image | Asset CDN URL (via GROQ asset->url dereference) |
| file | Asset CDN URL |
| portableText | Plain text extracted from block children, paragraphs joined with newline |
| referenceList | Array of references, each dereferenced to its target's title (via GROQ []->), joined with ; |
| array | Items joined with ; — object items render as key: value pairs; arrays of images/files resolve to asset URLs |
| object | key: value pairs, with Sanity bookkeeping keys (_key, _type, …) omitted |
| geopoint | lat,lng coordinate pair |
Arrays never fall back to raw JSON. An array of references reads as Alice; Bob; an array of inline objects reads as (label: Docs, href: /docs); (label: Blog, href: /blog), parenthesized so item boundaries stay visible. A reference the projection could not dereference (a mixed-member array, an unregistered custom type) falls back to the target document id rather than the raw {_ref, _key, _type} object.
Choosing what a reference exports
A reference column exports the referenced document's title, which the plugin
guesses from preview.select.title, then a title field, then name — and
falls back to _id when a type has none of those. That guess is often not the
field you want, and an id is never useful in a spreadsheet.
So reference fields expand in the Fields picker. Each one shows a chevron with a count; opening it lists the fields of the document it points at, and you can tick as many as you need:
☑ Company Name
☑ Partners 3 ⌄
☐ ID
☑ Name
☑ Slug
☐ Portrait
☐ Accel Office 2 ⌄Each ticked field becomes its own column (Partners → Slug), projected in GROQ
rather than post-processed — partners[]->slug.current. Arrays of references
project through every item and join the results with ; , the same as the
resolved-title column.
Searching the picker reaches into nested fields, and a match pulls its parent in
as context, so typing "email" finds Partners → Email without expanding
anything first.
Notes:
- The reference row itself stays selectable and still exports the resolved title, so existing selections and saved configurations keep working.
- Expansion stops one level deep. A reference on the referenced document still
resolves to its own title (
partners[]->basedIn->namereads "London", not an id), it just doesn't expand again — which is what keeps two document types that reference each other from unfolding forever. - When a reference points at several document types, their fields are merged into one set. A field only some targets declare is exported as empty for the others.
Saved configurations
Picking types, fields, and options again for a recurring export gets old fast. The tool has a Saved configurations row at the top: Save as new names the current selection, Update overwrites the loaded one, Delete removes it.
Presets are stored as csvExport.preset documents in the dataset, so they are shared with everyone who has access to it — a colleague can load the same export you set up. The plugin registers the document type itself; no schema change is needed. The type is read-only and hidden from omnisearch, so presets are managed only through the export tool, and its dotted name keeps it out of the tool's own type picker.
Saving needs dataset write permission. Without it, presets still load and run — only the save/update/delete buttons fail, with the reason shown inline.
A preset outlives the schema it was saved against and is shared across roles, so loading one reconciles it against what you can currently export: types you have no access to and fields no longer in the schema are dropped, and the tool tells you what it skipped.
If you use a fully explicit structure in structureTool, presets won't appear in your navigation. With the default structure, filter them out:
structureTool({
structure: (S) =>
S.list()
.title('Content')
.items(S.documentTypeListItems().filter((item) => item.getId() !== 'csvExport.preset')),
})Output formats
Pick the format in the options bar:
- CSV — RFC-4180, UTF-8 BOM, configurable delimiter (
,/;/ tab), optional header row. - XML — one
<document>per row; each cell is<field name="key" label="Label">value</field>with the column identifier in an attribute (so any field key is represented safely) and all values XML-escaped.
The same field selection and serialization (references → title, images → URL, Portable Text → plain text, …) feed both formats.
CSV injection protection
Cell values that would execute as spreadsheet formulas (starting with =, @, or with +/- when not a plain number) are prefixed with a single quote (') so Excel and Google Sheets treat them as literal text (CWE-1236).
Drafts / perspective
The tool offers an Include drafts toggle. When enabled the GROQ query runs with perspective: 'drafts', which returns the draft version of documents where one exists, falling back to the published version. When disabled, only published documents are returned.
How it works
Field discovery is schema-driven: the plugin reads the live Sanity schema via useSchema() and walks each selected document type's field tree to produce a flat list of FieldDescriptor objects (key, path, label, kind). Those descriptors are compiled into a GROQ projection that fetches only the chosen fields, using -> dereferencing for references and images and []-> for arrays of references. Large datasets are fetched in pages using keyset pagination (ordering by _id), inspired by Sanity's Exporting content as CSV guide. The collected rows are encoded client-side as RFC-4180 CSV with a UTF-8 BOM () so the file opens correctly in Excel and Numbers without manual encoding steps.
Developing / previewing the UI
Two ways to see the tool running, depending on whether you need real content.
1. The dev studio in this repo
A throwaway Studio lives at sanity.config.ts with a sample schema in
dev/schema.ts. It imports the plugin from ./src, so edits hot-reload.
bun install
SANITY_STUDIO_PROJECT_ID=<your project id> bun run devSANITY_STUDIO_DATASET defaults to production. Any project you can read works
— the tool only reads documents, so pointing it at real content is safe.
The sample schema exists to exercise the awkward field shapes rather than to
model anything real: a single reference, an array of references, a reference
with two possible target types, a reference on a referenced document, and a
target type with no title, name, or preview title at all — the case that
leaves a column full of ids until you pick a field yourself.
Field discovery reads the studio's schema, so Preview and Download only return rows if the dataset actually holds documents of these sample types. For real rows, use the second option.
2. Linked into a real studio
To see the tool against your own schema and content, link the plugin into a studio that already has both:
# in this repo
bun run link-watch
# in the studio, in another terminal
npm link @basementstudio/sanity-plugin-csv-export # or pnpm/yarn link
npm run devlink-watch rebuilds on save, so the linked studio picks changes up as you go.
This is the way to check behaviour that depends on your content — how a
reference resolves, what a preset restores, how big an export gets.
Requirements
- Sanity Studio v5+
- React 19+
License
MIT © Basement Studio
