sanity-plugin-external-source
A generic picker for any external API in Sanity Studio.
By Max Shastsel
Install command
npm i sanity-plugin-external-sourcesanity-plugin-external-source
A generic visual picker for any external API in Sanity Studio.
Give editors a native-feeling dialog with preview tiles, search, filters and pagination for data that lives outside Sanity β a PIM, a DAM, an internal REST service, a public API. You write a small adapter (one fetch function); the plugin does the rest.
- π§© Adapter pattern β the plugin knows nothing about your API
- πΌ Visual picker dialog β tiles or rows, with image fallbacks, loading skeletons and empty states
- π Search + declarative filters β select, multi-select, toggle, text and date-range, rendered for you
- π Pagination is just a generator β
yieldpages from anasync *fetch; the plugin handles "Load more", dedup and cancellation - π Multiple sources β register several adapters and get source tabs
- π§Ύ Snapshot storage β source, id, title, image and raw JSON payload stored on the document, queryable with GROQ
- β»οΈ Refresh β re-sync the stored snapshot from the source with one click
- π¨ Custom renderers β override tiles and the preview card per adapter, keep the plugin's UX plumbing
Built exclusively with @sanity/ui, so it looks and feels native in the Studio.
Installation
npm install sanity-plugin-external-sourceCompatible with Sanity Studio v3.78+, v4, v5 and v6 (Node 20.19+).
Quick start
// sanity.config.ts
import {defineConfig} from 'sanity'
import {externalSource} from 'sanity-plugin-external-source'
export default defineConfig({
// ...
plugins: [externalSource()]
})Register the schema type once via the plugin; adapters live on each field:
import {defineField} from 'sanity'
import {createRestAdapter} from 'sanity-plugin-external-source'
const productsAdapter = createRestAdapter({
name: 'products',
title: 'Products',
buildUrl: ctx => `https://api.example.com/products?q=${encodeURIComponent(ctx.query)}`,
nextUrl: json => json.nextPageUrl ?? null, // omit for APIs without pagination
selectItems: json => json.items,
mapItem: raw => ({
id: String(raw.id),
title: raw.name,
subtitle: raw.sku,
imageUrl: raw.thumbnail,
raw
})
})
defineField({
name: 'featuredProduct',
title: 'Featured product',
type: 'external.item',
options: {
adapters: [productsAdapter] // one field, one source
}
})Fields can mix multiple adapters and get source tabs in the picker:
defineField({
name: 'anyExternal',
type: 'external.item',
options: {adapters: [productsAdapter, mediaAdapter, pimAdapter]}
})Different fields on the same document can use completely different adapter lists β pickers are scoped per field.
Editors get a preview card with Select / Replace, Refresh and Clear actions, and a full-width picker dialog.
What gets stored
Selecting an item snapshots it into the document:
{
_type: 'external.item', // configurable via `typeName`
source: 'products', // adapter name
externalId: '8231',
title: 'Aurora Desk 100',
subtitle: 'SKU-1000',
imageUrl: 'https://β¦/thumb.png',
payload: '{"id":8231,β¦}', // JSON.stringify of the raw record (disable with storePayload: false)
syncedAt: '2026-07-03T12:00:00.000Z'
}All fields are declared in the schema, so GROQ works as expected:
*[_type == "page" && featuredProduct.source == "products"]{
title,
"productId": featuredProduct.externalId,
"productImage": featuredProduct.imageUrl,
"syncedAt": featuredProduct.syncedAt
}
payloadis a JSON string (GROQ has no JSON parsing) β parse it in your frontend when you need the full record.
Writing an adapter
An adapter is a plain object with one loading function. fetch comes in two forms:
asyncfunction returning all items β for APIs where one request is enough.async *generator yielding pages β each Load more click pulls one more page. Pagination state is just your local variables; the plugin never sees it.
Here is a complete, working generator adapter for the public PokeAPI:
import {defineAdapter} from 'sanity-plugin-external-source'
const API = 'https://pokeapi.co/api/v2'
export const pokemonAdapter = defineAdapter({
name: 'pokemon', // stored in documents as `source` β keep it stable!
title: 'PokΓ©mon', // tab label
async *fetch({query, signal}) {
let url = `${API}/pokemon?limit=24&q=${encodeURIComponent(query)}`
while (url) {
const res = await fetch(url, {signal}) // signal: stale runs get aborted
if (!res.ok) throw new Error(`PokeAPI: HTTP ${res.status}`) // shown inline in the dialog
const json = await res.json()
const items = json.results.map(r => {
const id = r.url.replace(/\/+$/, '').split('/').pop()
return {
id,
title: r.name,
imageUrl: `https://raw.githubusercontent.com/PokeAPI/sprites/master/sprites/pokemon/other/official-artwork/${id}.png`,
raw: r
}
})
if (!json.next) return items // `return` the last page β "Load more" hides immediately
yield items // one page per "Load more" click
url = json.next
}
},
// Optional: enables the "Refresh" button on the input
async resolve(id) {
const res = await fetch(`${API}/pokemon/${id}`)
if (res.status === 404) return null // item gone upstream
if (!res.ok) throw new Error(`PokeAPI: HTTP ${res.status}`)
const p = await res.json()
return {
id: String(p.id),
title: p.name,
imageUrl: p.sprites.other['official-artwork'].front_default,
raw: p
}
}
})The contract in one picture:
βββββββββββββββββββββββββββββββββββββββββββββββββββββ
plugin βββΆ β fetch({query, filters, signal}) β βββΆ your API
β async fn β resolve with all items β
β async gen β yield one page per "Load more" β
βββββββββββββββββββββββββββββββββββββββββββββββββββββCalled once per query/filter combination; changing either aborts the previous run (signal) and starts a fresh one.
Adapter fields:
| Field | Required | Purpose |
|---|---|---|
name | β | Machine name, stored as source in documents |
title | β | Tab label |
fetch(ctx) | β | Load items β all at once, or page by page |
resolve(id) | β | Re-fetch one item; enables Refresh |
filters | β | Declarative filter bar (see below) |
icon | β | Tab icon (@sanity/icons works great) |
defaultLayout | β | 'grid' (default) or 'list' |
disableSearch | β | Hide the search box for APIs without search |
searchPlaceholder | β | Placeholder for the search box |
renderTile(props) | β | Custom tile content (see below) |
renderPreview(props) | β | Custom preview card on the input |
Items must extend this shape (defineAdapter<MyItem>() gives you typed context everywhere):
interface ExternalItem {
id: string // stable unique id β stored as externalId
title?: string
subtitle?: string
imageUrl?: string
raw?: unknown // stored as the JSON payload (falls back to the item itself)
}Pagination recipes
There is no cursor plumbing β pagination state is ordinary local variables inside your generator:
// 1) No pagination β return everything (or the top N)
async fetch({query, signal}) {
const res = await fetch(searchUrl(query), {signal})
return mapItems(await res.json())
}
// 2) Offsets / page numbers β a loop counter
async *fetch({query, signal}) {
for (let offset = 0; ; offset += LIMIT) {
const json = await getJson(searchUrl(query, offset), signal)
const items = json.results.map(mapItem)
if (offset + LIMIT >= json.count) return items
yield items
}
}
// 3) Cursor tokens / next-page URLs β a mutable variable
async *fetch({query, signal}) {
let url = firstUrl(query)
while (true) {
const json = await getJson(url, signal)
if (!json.next) return mapItems(json)
yield mapItems(json)
url = json.next
}
}UI behavior:
- Each Load more click pulls one
yielded page and appends it. Duplicate ids are dropped, so overlapping pages are safe. returnthe last page (instead ofyielding it) when your API tells you it's the last β the Load more button hides immediately. If you only everyield, the button hides one click later (the click that discovers the end).- Changing the query, any filter, or the source tab aborts the running fetch (
ctx.signal) and starts a fresh one. - A thrown error shows inline with Retry. Retry restarts your
fetch; items already on screen stay put and re-walked pages are skipped by the id-dedup.
Filters
Declare filters; the plugin renders them (and collapses them into a "Filters" popover in narrow containers). Current values arrive verbatim in ctx.filters β mapping them to API params is your adapter's job.
filters: [
{kind: 'select', name: 'category', title: 'Category', multiple: true,
options: [{value: 'kitchen', title: 'Kitchen'}, {value: 'outdoors', title: 'Outdoors'}]},
{kind: 'toggle', name: 'inStock', title: 'In stock only'},
{kind: 'text', name: 'sku', title: 'SKU contains', placeholder: 'SKU-10β¦'},
{kind: 'daterange', name: 'created', title: 'Created'}
]| Kind | Control | Value in ctx.filters |
|---|---|---|
select | Dropdown with an "All" option | string |
select + multiple | Checkbox popover | string[] |
toggle | Switch | true (absent when off) |
text | Debounced text input | string |
daterange | Two native date inputs | {from?: 'YYYY-MM-DD', to?: 'YYYY-MM-DD'} |
Unset filters are absent from ctx.filters. Active filters show a count badge and a Clear filters action; every filter change refetches from page one.
Custom stored shape
By default, selecting an item snapshots externalId, title, subtitle, imageUrl (plus the framework fields _type, source, syncedAt, and a payload string of the raw record). If you want a different shape β e.g. store only the id, or add custom fields β set mapValue on the adapter:
{name: 'products', ...,
// Store only the id β framework fields (_type, source, syncedAt) still added.
mapValue: item => ({externalId: item.id})}{name: 'articles', ...,
// Store a custom subset β headline, canonical url, published date.
mapValue: item => ({
externalId: item.id,
headline: item.title,
url: item.raw.canonicalUrl,
publishedAt: item.raw.publishedAt
})}The keys you return replace the default item-derived fields. If you add fields that aren't declared in the plugin's schema (the built-in type has source, externalId, title, subtitle, imageUrl, payload, syncedAt), define your own type via typeName so those fields survive round-trip through the Studio form.
Combine with storePayload: false (plugin-level) to skip the JSON payload string entirely:
externalSource({storePayload: false})Custom tiles and previews
renderTile replaces a tile's content β the plugin keeps ownership of the grid/list layout, click & keyboard handling, and selection state:
import {Badge, Box, Flex, Stack, Text} from '@sanity/ui'
renderTile({item, selected, query}) {
return (
<Flex align="center" gap={3}>
<Box flex={1}>
<Stack space={2}>
<Text size={1} weight={selected ? 'bold' : 'medium'}>{item.title}</Text>
<Text size={0} muted>{item.raw.sku}</Text>
</Stack>
</Box>
<Badge tone={item.raw.inStock ? 'positive' : 'critical'}>
{item.raw.inStock ? 'In stock' : 'Out of stock'}
</Badge>
</Flex>
)
}renderPreview({value}) does the same for the selected-value card on the input; it receives the stored ExternalItemValue (parse value.payload for the full record).
Tip: build custom renderers with @sanity/ui primitives so they inherit the Studio theme (including dark mode) for free.
Grid β list layout
Editors can toggle between a tile grid and rows β handy for data without imagery (e.g. financial records). The choice is persisted per adapter in localStorage. Set the initial layout with defaultLayout: 'list'.
createRestAdapter
For plain JSON REST APIs you usually don't need to write fetch yourself:
createRestAdapter({
name: 'articles',
title: 'Articles',
buildUrl: ctx => {
// First page only β the factory follows `nextUrl` from there.
const url = new URL('https://api.example.com/articles')
if (ctx.query) url.searchParams.set('q', ctx.query)
if (typeof ctx.filters.section === 'string') url.searchParams.set('section', ctx.filters.section)
return url
},
nextUrl: json => json.nextPageUrl ?? null, // null = no more pages; omit for single-page APIs
headers: () => ({Authorization: `Bearer ${myToken}`}), // static object or (async) factory
selectItems: json => json.data.hits,
mapItem: raw => ({id: String(raw.id), title: raw.headline, imageUrl: raw.thumb, raw}),
resolveUrl: id => `https://api.example.com/articles/${id}`, // enables Refresh; 404 β "item gone"
mapResolved: json => json.data
})Everything is explicit β the factory never guesses at your response shape.
| Option | Required | Purpose |
|---|---|---|
buildUrl(ctx) | β | URL of the first page |
selectItems(json) | β | Pick the records array out of the response body |
mapItem(raw) | β | Map one raw record to an item |
nextUrl(json, ctx) | β | URL of the next page (null = end). Omit for single-page APIs |
headers | β | Static headers or an (async) per-request factory |
resolveUrl(id) | β | Enables Refresh |
mapResolved(json) | β | Unwrap the resolve response (default: identity) |
filters, icon, defaultLayout, disableSearch, searchPlaceholder, mapValue, renderTile, renderPreview | β | Passed through |
Non-OK responses throw HTTP <status> <statusText> and surface inline in the dialog; resolve treats 404 as "item no longer exists".
CORS and authentication
Adapters run in the browser, inside the Studio:
- The API must send CORS headers allowing your Studio origin (
http://localhost:3333during development). - Never ship long-lived secrets to the browser. For authenticated APIs, deploy a tiny serverless proxy (Cloudflare Worker, Vercel/Netlify function) that holds the credential and forwards requests; point
buildUrlat the proxy. - For editor-provided, per-studio secrets (API keys entered in the Studio UI), the established pattern is
@sanity/studio-secretsβ store the key with it, read it in your adapter'sheadersfactory.
Plugin options
externalSource({
typeName: 'external.item', // optional: rename the schema type
storePayload: true // optional: set false to skip storing the raw JSON
})Custom typeName example β a dedicated product.ref type:
externalSource({typeName: 'product.ref'})
// β¦
defineField({
name: 'product',
type: 'product.ref',
options: {adapters: [productsAdapter]}
})Field options
options on any external.item field:
| Key | Purpose |
|---|---|
adapters | Required. Adapters this field can pick from. |
dialog.mode | 'fullscreen' (default) or 'centered'. |
dialog.width | For mode: 'centered', the @sanity/ui width scale (0β4, default 1). |
Dialog mode
The picker opens fullscreen by default. If you'd rather have a classic centered modal for a particular field, set options.dialog on the field:
defineField({
name: 'featuredProduct',
type: 'external.item',
options: {dialog: {mode: 'centered'}} // or 'fullscreen'
})For mode: 'centered' you can also set width (the @sanity/ui theme-container index, 0β4; default 1):
options: {dialog: {mode: 'centered', width: 3}}Both modes close on Escape or the β button. Grid tiles are keyboard accessible (Tab to focus, Enter/Space to select). With more than one adapter, tabs at the top switch sources.
Editor UX details
- Tiles are keyboard accessible:
Tabto a tile,Enter/Spaceto select,Escapecloses the dialog. - Fetch/resolve errors render as inline critical cards (with Retry) β the form never crashes.
readOnlyfields disable Select/Replace, Refresh and Clear.- If a stored value references an adapter that is no longer registered, the input shows a warning and still lets you Clear or Replace it.
Development
This repo ships a dev studio wired to three demo adapters (a hand-written PokeAPI generator with client-side search, an offline in-memory "Products" API demonstrating all filter kinds and custom renderers, and a createRestAdapter next-URL demo):
npm install
npm run dev # starts the dev studio in ./dev
npm run build # plugin-kit verify + pkg-utils buildLicense
MIT Β© Max Shastsel