diff --git a/apps/kitchensink-react/src/AppRoutes.tsx b/apps/kitchensink-react/src/AppRoutes.tsx
index 2c98a823a..7285d9d0c 100644
--- a/apps/kitchensink-react/src/AppRoutes.tsx
+++ b/apps/kitchensink-react/src/AppRoutes.tsx
@@ -17,6 +17,7 @@ import {ProtectedRoute} from './ProtectedRoute'
import {DashboardContextRoute} from './routes/DashboardContextRoute'
import {DashboardWorkspacesRoute} from './routes/DashboardWorkspacesRoute'
import ExperimentalResourceClientRoute from './routes/ExperimentalResourceClientRoute'
+import {MediaLibraryRoute} from './routes/MediaLibraryRoute'
import {PerspectivesRoute} from './routes/PerspectivesRoute'
import {ProjectsRoute} from './routes/ProjectsRoute'
import {ReleasesRoute} from './routes/releases/ReleasesRoute'
@@ -72,6 +73,10 @@ const documentCollectionRoutes = [
path: 'presence',
element: ,
},
+ {
+ path: 'media-library',
+ element: ,
+ },
]
const dashboardInteractionRoutes = [
diff --git a/apps/kitchensink-react/src/routes/MediaLibraryRoute.tsx b/apps/kitchensink-react/src/routes/MediaLibraryRoute.tsx
new file mode 100644
index 000000000..74a18c670
--- /dev/null
+++ b/apps/kitchensink-react/src/routes/MediaLibraryRoute.tsx
@@ -0,0 +1,76 @@
+import {mediaLibrarySource, useQuery} from '@sanity/sdk-react'
+import {Card, Spinner, Text} from '@sanity/ui'
+import {type JSX, useState} from 'react'
+
+// for now, hardcoded. should be inferred from org later on
+const MEDIA = mediaLibrarySource('mlPGY7BEqt52')
+
+export function MediaLibraryRoute(): JSX.Element {
+ const [query] = useState('*[_type == "sanity.asset"][0...10] | order(_id desc)')
+ const [isLoading] = useState(false)
+
+ const {data, isPending} = useQuery({
+ query,
+ source: MEDIA,
+ })
+
+ return (
+
+
+ Media Library Query Demo
+
+
+
+ This route demonstrates querying against a Sanity media library. The MediaLibraryProvider is
+ automatically created by SanityApp when a media library config is present. The query runs
+ against: https://api.sanity.io/v2025-03-24/media-libraries/mlPGY7BEqt52/query
+
+
+
+
+
+ Current query:
+
+
+ {query}
+
+
+
+
+
+
+
+ Query Results:
+
+ {(isPending || isLoading) && }
+
+
+
+ {JSON.stringify(data, null, 2)}
+
+
+
+ )
+}
diff --git a/packages/core/src/_exports/index.ts b/packages/core/src/_exports/index.ts
index e3df1f009..ca0fcb24f 100644
--- a/packages/core/src/_exports/index.ts
+++ b/packages/core/src/_exports/index.ts
@@ -58,8 +58,11 @@ export {
} from '../config/handles'
export {
type DatasetHandle,
+ datasetSource,
type DocumentHandle,
+ type DocumentSource,
type DocumentTypeHandle,
+ mediaLibrarySource,
type PerspectiveHandle,
type ProjectHandle,
type ReleasePerspective,
diff --git a/packages/core/src/client/clientStore.test.ts b/packages/core/src/client/clientStore.test.ts
index c0b912052..18462dac3 100644
--- a/packages/core/src/client/clientStore.test.ts
+++ b/packages/core/src/client/clientStore.test.ts
@@ -3,6 +3,7 @@ import {Subject} from 'rxjs'
import {beforeEach, describe, expect, it, vi} from 'vitest'
import {getAuthMethodState, getTokenState} from '../auth/authStore'
+import {datasetSource, mediaLibrarySource} from '../config/sanityConfig'
import {createSanityInstance, type SanityInstance} from '../store/createSanityInstance'
import {getClient, getClientState} from './clientStore'
@@ -158,4 +159,120 @@ describe('clientStore', () => {
subscription.unsubscribe()
})
})
+
+ describe('source handling', () => {
+ it('should create client when source is provided', () => {
+ const source = datasetSource('source-project', 'source-dataset')
+ const client = getClient(instance, {apiVersion: '2024-11-12', source})
+
+ expect(vi.mocked(createClient)).toHaveBeenCalledWith(
+ expect.objectContaining({
+ apiVersion: '2024-11-12',
+ source: expect.objectContaining({
+ __sanity_internal_sourceId: {
+ projectId: 'source-project',
+ dataset: 'source-dataset',
+ },
+ }),
+ }),
+ )
+ // Client should be projectless - no projectId/dataset in config
+ expect(client.config()).not.toHaveProperty('projectId')
+ expect(client.config()).not.toHaveProperty('dataset')
+ expect(client.config()).toEqual(
+ expect.objectContaining({
+ source: expect.objectContaining({
+ __sanity_internal_sourceId: {
+ projectId: 'source-project',
+ dataset: 'source-dataset',
+ },
+ }),
+ }),
+ )
+ })
+
+ it('should create resource when source has array sourceId and be projectless', () => {
+ const source = mediaLibrarySource('media-lib-123')
+ const client = getClient(instance, {apiVersion: '2024-11-12', source})
+
+ expect(vi.mocked(createClient)).toHaveBeenCalledWith(
+ expect.objectContaining({
+ '~experimental_resource': {type: 'media-library', id: 'media-lib-123'},
+ 'apiVersion': '2024-11-12',
+ }),
+ )
+ // Client should be projectless - no projectId/dataset in config
+ expect(client.config()).not.toHaveProperty('projectId')
+ expect(client.config()).not.toHaveProperty('dataset')
+ expect(client.config()).toEqual(
+ expect.objectContaining({
+ '~experimental_resource': {type: 'media-library', id: 'media-lib-123'},
+ }),
+ )
+ })
+
+ it('should create projectless client when source is provided, ignoring instance config', () => {
+ const source = datasetSource('source-project', 'source-dataset')
+ const client = getClient(instance, {apiVersion: '2024-11-12', source})
+
+ // Client should be projectless - source takes precedence, instance config is ignored
+ expect(client.config()).not.toHaveProperty('projectId')
+ expect(client.config()).not.toHaveProperty('dataset')
+ expect(client.config()).toEqual(
+ expect.objectContaining({
+ source: expect.objectContaining({
+ __sanity_internal_sourceId: {
+ projectId: 'source-project',
+ dataset: 'source-dataset',
+ },
+ }),
+ }),
+ )
+ })
+
+ it('should warn when both source and explicit projectId/dataset are provided', () => {
+ const consoleSpy = vi.spyOn(console, 'warn').mockImplementation(() => {})
+ const source = datasetSource('source-project', 'source-dataset')
+ const client = getClient(instance, {
+ apiVersion: '2024-11-12',
+ source,
+ projectId: 'explicit-project',
+ dataset: 'explicit-dataset',
+ })
+
+ expect(consoleSpy).toHaveBeenCalledWith(
+ 'Both source and explicit projectId/dataset are provided. The source will be used and projectId/dataset will be ignored.',
+ )
+ // Client should still be projectless despite explicit projectId/dataset
+ expect(client.config()).not.toHaveProperty('projectId')
+ expect(client.config()).not.toHaveProperty('dataset')
+ consoleSpy.mockRestore()
+ })
+
+ it('should create different clients for different sources', () => {
+ const source1 = datasetSource('project-1', 'dataset-1')
+ const source2 = datasetSource('project-2', 'dataset-2')
+ const source3 = mediaLibrarySource('media-lib-1')
+
+ const client1 = getClient(instance, {apiVersion: '2024-11-12', source: source1})
+ const client2 = getClient(instance, {apiVersion: '2024-11-12', source: source2})
+ const client3 = getClient(instance, {apiVersion: '2024-11-12', source: source3})
+
+ expect(client1).not.toBe(client2)
+ expect(client2).not.toBe(client3)
+ expect(client1).not.toBe(client3)
+ expect(vi.mocked(createClient)).toHaveBeenCalledTimes(3)
+ })
+
+ it('should reuse clients with identical source configurations', () => {
+ const source = datasetSource('same-project', 'same-dataset')
+ const options = {apiVersion: '2024-11-12', source}
+
+ const client1 = getClient(instance, options)
+ const client2 = getClient(instance, options)
+
+ expect(client1).toBe(client2)
+ expect(vi.mocked(createClient)).toHaveBeenCalledTimes(1)
+ })
+ })
})
diff --git a/packages/core/src/client/clientStore.ts b/packages/core/src/client/clientStore.ts
index c83b266e0..78092d1d0 100644
--- a/packages/core/src/client/clientStore.ts
+++ b/packages/core/src/client/clientStore.ts
@@ -2,6 +2,7 @@ import {type ClientConfig, createClient, type SanityClient} from '@sanity/client
import {pick} from 'lodash-es'
import {getAuthMethodState, getTokenState} from '../auth/authStore'
+import {type DocumentSource, SOURCE_ID} from '../config/sanityConfig'
import {bindActionGlobally} from '../store/createActionBinder'
import {createStateSourceAction} from '../store/createStateSourceAction'
import {defineStore, type StoreContext} from '../store/defineStore'
@@ -39,6 +40,7 @@ const allowedKeys = Object.keys({
'requestTagPrefix': null,
'useProjectHostname': null,
'~experimental_resource': null,
+ 'source': null,
} satisfies Record) as (keyof ClientOptions)[]
const DEFAULT_CLIENT_CONFIG: ClientConfig = {
@@ -90,6 +92,11 @@ export interface ClientOptions extends Pick({
@@ -156,18 +163,42 @@ export const getClient = bindActionGlobally(
const tokenFromState = state.get().token
const {clients, authMethod} = state.get()
+ const hasSource = !!options.source
+ let sourceId = options.source?.[SOURCE_ID]
+
+ let resource
+ if (Array.isArray(sourceId)) {
+ resource = {type: sourceId[0], id: sourceId[1]}
+ sourceId = undefined
+ }
+
const projectId = options.projectId ?? instance.config.projectId
const dataset = options.dataset ?? instance.config.dataset
const apiHost = options.apiHost ?? instance.config.auth?.apiHost
const effectiveOptions: ClientOptions = {
...DEFAULT_CLIENT_CONFIG,
- ...((options.scope === 'global' || !projectId) && {useProjectHostname: false}),
+ ...((options.scope === 'global' || !projectId || hasSource) && {useProjectHostname: false}),
token: authMethod === 'cookie' ? undefined : (tokenFromState ?? undefined),
...options,
...(projectId && {projectId}),
...(dataset && {dataset}),
...(apiHost && {apiHost}),
+ ...(resource && {'~experimental_resource': resource}),
+ }
+
+ // When a source is provided, don't use projectId/dataset - the client should be "projectless"
+ // The client code itself will ignore the non-source config, so we do this to prevent confusing the user.
+ // (ref: https://github.com/sanity-io/client/blob/5c23f81f5ab93a53f5b22b39845c867988508d84/src/data/dataMethods.ts#L691)
+ if (hasSource) {
+ if (options.projectId || options.dataset) {
+ // eslint-disable-next-line no-console
+ console.warn(
+ 'Both source and explicit projectId/dataset are provided. The source will be used and projectId/dataset will be ignored.',
+ )
+ }
+ delete effectiveOptions.projectId
+ delete effectiveOptions.dataset
}
if (effectiveOptions.token === null || typeof effectiveOptions.token === 'undefined') {
diff --git a/packages/core/src/config/sanityConfig.ts b/packages/core/src/config/sanityConfig.ts
index 7dbd8accd..00c63468f 100644
--- a/packages/core/src/config/sanityConfig.ts
+++ b/packages/core/src/config/sanityConfig.ts
@@ -81,3 +81,34 @@ export interface SanityConfig extends DatasetHandle, PerspectiveHandle {
enabled: boolean
}
}
+
+export const SOURCE_ID = '__sanity_internal_sourceId'
+
+/**
+ * A document source can be used for querying.
+ *
+ * @beta
+ * @see datasetSource Construct a document source for a given projectId and dataset.
+ * @see mediaLibrarySource Construct a document source for a mediaLibraryId.
+ */
+export type DocumentSource = {
+ [SOURCE_ID]: ['media-library', string] | {projectId: string; dataset: string}
+}
+
+/**
+ * Returns a document source for a projectId and dataset.
+ *
+ * @beta
+ */
+export function datasetSource(projectId: string, dataset: string): DocumentSource {
+ return {[SOURCE_ID]: {projectId, dataset}}
+}
+
+/**
+ * Returns a document source for a Media Library.
+ *
+ * @beta
+ */
+export function mediaLibrarySource(id: string): DocumentSource {
+ return {[SOURCE_ID]: ['media-library', id]}
+}
diff --git a/packages/core/src/query/queryStore.ts b/packages/core/src/query/queryStore.ts
index 995bfec2f..5f92b02d9 100644
--- a/packages/core/src/query/queryStore.ts
+++ b/packages/core/src/query/queryStore.ts
@@ -23,9 +23,9 @@ import {
} from 'rxjs'
import {getClientState} from '../client/clientStore'
-import {type DatasetHandle} from '../config/sanityConfig'
+import {type DatasetHandle, type DocumentSource} from '../config/sanityConfig'
import {getPerspectiveState} from '../releases/getPerspectiveState'
-import {bindActionByDataset} from '../store/createActionBinder'
+import {bindActionBySource} from '../store/createActionBinder'
import {type SanityInstance} from '../store/createSanityInstance'
import {
createStateSourceAction,
@@ -62,6 +62,7 @@ export interface QueryOptions<
DatasetHandle {
query: TQuery
params?: Record
+ source?: DocumentSource
}
/**
@@ -160,6 +161,7 @@ const listenForNewSubscribersAndFetch = ({state, instance}: StoreContext {
return _getQueryState(...args)
}
-const _getQueryState = bindActionByDataset(
+const _getQueryState = bindActionBySource(
queryStore,
createStateSourceAction({
selector: ({state, instance}: SelectorContext, options: QueryOptions) => {
@@ -349,7 +352,7 @@ export function resolveQuery(
export function resolveQuery(...args: Parameters): Promise {
return _resolveQuery(...args)
}
-const _resolveQuery = bindActionByDataset(
+const _resolveQuery = bindActionBySource(
queryStore,
({state, instance}, {signal, ...options}: ResolveQueryOptions) => {
const normalized = normalizeOptionsWithPerspective(instance, options)
diff --git a/packages/core/src/store/createActionBinder.ts b/packages/core/src/store/createActionBinder.ts
index 4d505707e..ad3974d03 100644
--- a/packages/core/src/store/createActionBinder.ts
+++ b/packages/core/src/store/createActionBinder.ts
@@ -1,4 +1,4 @@
-import {type SanityConfig} from '../config/sanityConfig'
+import {type DocumentSource, type SanityConfig, SOURCE_ID} from '../config/sanityConfig'
import {type SanityInstance} from './createSanityInstance'
import {createStoreInstance, type StoreInstance} from './createStoreInstance'
import {type StoreState} from './createStoreState'
@@ -43,7 +43,9 @@ export type BoundStoreAction<_TState, TParams extends unknown[], TReturn> = (
* )
* ```
*/
-export function createActionBinder(keyFn: (config: SanityConfig) => string) {
+export function createActionBinder(
+ keyFn: (config: SanityConfig, ...params: TKeyParams) => string,
+) {
const instanceRegistry = new Map>()
const storeRegistry = new Map>()
@@ -54,12 +56,12 @@ export function createActionBinder(keyFn: (config: SanityConfig) => string) {
* @param action - The action to bind
* @returns A function that executes the action with a Sanity instance
*/
- return function bindAction(
+ return function bindAction(
storeDefinition: StoreDefinition,
action: StoreAction,
): BoundStoreAction {
return function boundAction(instance: SanityInstance, ...params: TParams) {
- const keySuffix = keyFn(instance.config)
+ const keySuffix = keyFn(instance.config, ...params)
const compositeKey = storeDefinition.name + (keySuffix ? `:${keySuffix}` : '')
// Get or create instance set for this composite key
@@ -128,13 +130,32 @@ export function createActionBinder(keyFn: (config: SanityConfig) => string) {
* fetchDocument(sanityInstance, 'doc123')
* ```
*/
-export const bindActionByDataset = createActionBinder(({projectId, dataset}) => {
+export const bindActionByDataset = createActionBinder(({projectId, dataset}) => {
if (!projectId || !dataset) {
throw new Error('This API requires a project ID and dataset configured.')
}
return `${projectId}.${dataset}`
})
+/**
+ * Binds an action to a store that's scoped to a specific document source.
+ **/
+export const bindActionBySource = createActionBinder<[{source?: DocumentSource}, ...unknown[]]>(
+ ({projectId, dataset}, {source}) => {
+ if (source) {
+ const id = source[SOURCE_ID]
+ if (!id) throw new Error('Invalid source (missing ID information)')
+ if (Array.isArray(id)) return id.join(':')
+ return `${id.projectId}.${id.dataset}`
+ }
+
+ if (!projectId || !dataset) {
+ throw new Error('This API requires a project ID and dataset configured.')
+ }
+ return `${projectId}.${dataset}`
+ },
+)
+
/**
* Binds an action to a global store that's shared across all Sanity instances
*
@@ -173,4 +194,4 @@ export const bindActionByDataset = createActionBinder(({projectId, dataset}) =>
* getCurrentUser(sanityInstance)
* ```
*/
-export const bindActionGlobally = createActionBinder(() => 'global')
+export const bindActionGlobally = createActionBinder(() => 'global')