diff --git a/apps/kitchensink-react/src/AppRoutes.tsx b/apps/kitchensink-react/src/AppRoutes.tsx
index 2c98a823a..c4c9b45a5 100644
--- a/apps/kitchensink-react/src/AppRoutes.tsx
+++ b/apps/kitchensink-react/src/AppRoutes.tsx
@@ -14,6 +14,7 @@ import {SearchRoute} from './DocumentCollection/SearchRoute'
import {PresenceRoute} from './Presence/PresenceRoute'
import {ProjectAuthHome} from './ProjectAuthentication/ProjectAuthHome'
import {ProtectedRoute} from './ProtectedRoute'
+import {AssetsRoute} from './routes/AssetsRoute'
import {DashboardContextRoute} from './routes/DashboardContextRoute'
import {DashboardWorkspacesRoute} from './routes/DashboardWorkspacesRoute'
import ExperimentalResourceClientRoute from './routes/ExperimentalResourceClientRoute'
@@ -28,6 +29,10 @@ const documentCollectionRoutes = [
path: 'users',
element: ,
},
+ {
+ path: 'assets',
+ element: ,
+ },
{
path: 'document-list',
element: ,
diff --git a/apps/kitchensink-react/src/routes/AssetsRoute.tsx b/apps/kitchensink-react/src/routes/AssetsRoute.tsx
new file mode 100644
index 000000000..49d330bb2
--- /dev/null
+++ b/apps/kitchensink-react/src/routes/AssetsRoute.tsx
@@ -0,0 +1,305 @@
+import {
+ type AssetDocumentBase,
+ createAssetHandle,
+ useAssets,
+ useDeleteAsset,
+ useLinkMediaLibraryAsset,
+ useUploadAsset,
+} from '@sanity/sdk-react'
+import {Button, Card, Flex, Label, Stack, Text} from '@sanity/ui'
+import {type JSX, useCallback, useEffect, useMemo, useRef, useState} from 'react'
+
+import {DocumentGridLayout} from '../components/DocumentGridLayout/DocumentGridLayout'
+import {PageLayout} from '../components/PageLayout'
+
+function AssetList({
+ assets,
+ onDelete,
+}: {
+ assets: AssetDocumentBase[]
+ onDelete: (id: string) => void
+}) {
+ if (!assets.length)
+ return (
+
+
+ No assets
+
+ Upload a file to get started.
+
+
+
+ )
+
+ return (
+
+ {assets.map((a) => (
+
+
+
+
+ {a._type}
+
+ {a.url ? (
+
+ ) : (
+
+
+ {a.originalFilename ?? a._id}
+
+
+ )}
+
+ {a.originalFilename ?? a._id}
+
+
+
+
+
+
+
+
+
+ ))}
+
+ )
+}
+
+export function AssetsRoute(): JSX.Element {
+ // Query controls
+ const [assetType, setAssetType] = useState<'all' | 'image' | 'file'>('all')
+ const [order, setOrder] = useState('_createdAt desc')
+ const [limit, setLimit] = useState(24)
+ // Bump this to force a re-fetch of assets
+ const [refresh, setRefresh] = useState(0)
+ const [isUploading, setIsUploading] = useState(false)
+ const requeryTimeoutsRef = useRef([])
+ const options = useMemo(
+ () => ({
+ assetType,
+ order,
+ limit,
+ params: {refresh}, // add refresh to params to cache bust
+ projectId: 'vo1ysemo',
+ dataset: 'production',
+ }),
+ [assetType, order, limit, refresh],
+ )
+
+ // Hooks
+ const assets = useAssets(options)
+ const upload = useUploadAsset()
+ const remove = useDeleteAsset()
+ const linkML = useLinkMediaLibraryAsset()
+
+ // Cleanup any scheduled re-queries on unmount
+ useEffect(() => {
+ return () => {
+ requeryTimeoutsRef.current.forEach((id) => clearTimeout(id))
+ requeryTimeoutsRef.current = []
+ }
+ }, [])
+
+ const triggerRequeryBurst = useCallback(() => {
+ setRefresh((r) => r + 1)
+ const t1 = window.setTimeout(() => setRefresh((r) => r + 1), 600)
+ const t2 = window.setTimeout(() => setRefresh((r) => r + 1), 1500)
+ requeryTimeoutsRef.current.push(t1, t2)
+ }, [])
+
+ // Upload handlers
+ const fileInputRef = useRef(null)
+ const onSelectFile = useCallback(
+ async (ev: React.ChangeEvent) => {
+ const f = ev.target.files?.[0]
+ if (!f) return
+ setIsUploading(true)
+ const kind = f.type.startsWith('image/') ? 'image' : 'file'
+ try {
+ if (kind === 'image') {
+ await upload('image', f, {
+ filename: f.name,
+ projectId: options.projectId,
+ dataset: options.dataset,
+ })
+ } else {
+ await upload('file', f, {
+ filename: f.name,
+ projectId: options.projectId,
+ dataset: options.dataset,
+ })
+ }
+ // trigger re-queries so the new asset appears once indexed
+ triggerRequeryBurst()
+ } finally {
+ if (fileInputRef.current) fileInputRef.current.value = ''
+ setIsUploading(false)
+ }
+ },
+ [upload, triggerRequeryBurst, options.projectId, options.dataset],
+ )
+
+ const onDelete = useCallback(
+ async (id: string) => {
+ if (!confirm('Delete this asset?')) return
+ await remove(
+ createAssetHandle({assetId: id, projectId: options.projectId, dataset: options.dataset}),
+ )
+ // re-query after deletion
+ triggerRequeryBurst()
+ },
+ [remove, triggerRequeryBurst, options.projectId, options.dataset],
+ )
+
+ // Media Library link form
+ const [mlAssetId, setMlAssetId] = useState('')
+ const [mlId, setMlId] = useState('')
+ const [mlInstId, setMlInstId] = useState('')
+ const onLinkMl = useCallback(async () => {
+ if (!mlAssetId || !mlId || !mlInstId) return
+ await linkML({assetId: mlAssetId, mediaLibraryId: mlId, assetInstanceId: mlInstId})
+ setMlAssetId('')
+ setMlId('')
+ setMlInstId('')
+ // re-query after linking
+ triggerRequeryBurst()
+ }, [linkML, mlAssetId, mlId, mlInstId, triggerRequeryBurst])
+
+ return (
+
+
+
+
+
+
+
+
+
+
+ setOrder(e.target.value)}
+ placeholder="_createdAt desc"
+ style={{
+ width: '100%',
+ border: '1px solid #ccc',
+ padding: '8px',
+ borderRadius: '4px',
+ }}
+ />
+
+
+
+ setLimit(parseInt(e.target.value || '0', 10))}
+ style={{
+ width: '100%',
+ border: '1px solid #ccc',
+ padding: '8px',
+ borderRadius: '4px',
+ }}
+ />
+
+
+
+
+ {isUploading && (
+
+ Uploading...
+
+ )}
+
+
+
+
+
+
+ Browse
+
+
+
+
+
+
+
+ Link Media Library Asset
+
+
+ setMlAssetId(e.target.value)}
+ />
+ setMlId(e.target.value)}
+ />
+ setMlInstId(e.target.value)}
+ />
+
+
+
+
+
+
+ )
+}
diff --git a/packages/core/package.json b/packages/core/package.json
index fc9533faf..94e2717cd 100644
--- a/packages/core/package.json
+++ b/packages/core/package.json
@@ -61,6 +61,7 @@
"@sanity/comlink": "^3.0.4",
"@sanity/diff-match-patch": "^3.2.0",
"@sanity/diff-patch": "^6.0.0",
+ "@sanity/image-url": "^1.2.0",
"@sanity/json-match": "^1.0.5",
"@sanity/message-protocol": "^0.12.0",
"@sanity/mutate": "^0.12.4",
diff --git a/packages/core/src/_exports/index.ts b/packages/core/src/_exports/index.ts
index e3df1f009..02ef399a5 100644
--- a/packages/core/src/_exports/index.ts
+++ b/packages/core/src/_exports/index.ts
@@ -1,3 +1,4 @@
+/* eslint-disable simple-import-sort/exports */
import {type SanityProject as _SanityProject} from '@sanity/client'
/**
@@ -52,11 +53,13 @@ export {
export {type AuthConfig, type AuthProvider} from '../config/authConfig'
export {
createDatasetHandle,
+ createAssetHandle,
createDocumentHandle,
createDocumentTypeHandle,
createProjectHandle,
} from '../config/handles'
export {
+ type AssetHandle,
type DatasetHandle,
type DocumentHandle,
type DocumentTypeHandle,
@@ -139,6 +142,22 @@ export {
export {getPerspectiveState} from '../releases/getPerspectiveState'
export type {ReleaseDocument} from '../releases/releasesStore'
export {getActiveReleasesState} from '../releases/releasesStore'
+export {
+ type AssetDocumentBase,
+ type AssetKind,
+ type AssetQueryOptions,
+ type ImageAssetId,
+ type LinkMediaLibraryAssetOptions,
+ type UploadAssetOptions,
+ getImageUrlBuilder,
+ deleteAsset,
+ getAssetDownloadUrl,
+ getAssetsState,
+ isImageAssetId,
+ linkMediaLibraryAsset,
+ resolveAssets,
+ uploadAsset,
+} from '../assets/assets'
export {createSanityInstance, type SanityInstance} from '../store/createSanityInstance'
export {type Selector, type StateSource} from '../store/createStateSourceAction'
export {getUsersKey, parseUsersKey} from '../users/reducers'
diff --git a/packages/core/src/assets/assets.test.ts b/packages/core/src/assets/assets.test.ts
new file mode 100644
index 000000000..4f55d9c6e
--- /dev/null
+++ b/packages/core/src/assets/assets.test.ts
@@ -0,0 +1,239 @@
+import {type SanityClient} from '@sanity/client'
+import {of} from 'rxjs'
+import {beforeEach, describe, expect, it, vi} from 'vitest'
+
+import {getClient, getClientState} from '../client/clientStore'
+import {createSanityInstance, type SanityInstance} from '../store/createSanityInstance'
+import {type StateSource} from '../store/createStateSourceAction'
+import {
+ type AssetDocumentBase,
+ deleteAsset,
+ getAssetDownloadUrl,
+ getAssetsState,
+ getImageUrlBuilder,
+ isImageAssetId,
+ linkMediaLibraryAsset,
+ resolveAssets,
+ uploadAsset,
+} from './assets'
+
+vi.mock('../client/clientStore', () => ({
+ getClient: vi.fn(),
+ getClientState: vi.fn(),
+}))
+
+describe('assets', () => {
+ let instance: SanityInstance
+
+ beforeEach(() => {
+ vi.resetAllMocks()
+ instance = createSanityInstance({projectId: 'proj', dataset: 'ds'})
+ })
+
+ it('isImageAssetId validates expected pattern', () => {
+ expect(isImageAssetId('image-abc_123-800x600-jpg')).toBe(true)
+ expect(isImageAssetId('image-someHash-1x2-png')).toBe(true)
+ expect(isImageAssetId('image-abc-800-600-jpg')).toBe(false)
+ expect(isImageAssetId('file-abc-800x600-jpg')).toBe(false)
+ expect(isImageAssetId('image-abc-800x600-JPG')).toBe(false)
+ })
+
+ describe('getImageUrlBuilder', () => {
+ it('builds a CDN URL from a valid asset id', () => {
+ const url = getImageUrlBuilder(instance).image('image-somehash-1024x768-png').url()
+ expect(url).toBe('https://cdn.sanity.io/images/proj/ds/somehash-1024x768.png')
+ })
+
+ it('supports explicit projectId/dataset overrides', () => {
+ const url = getImageUrlBuilder(instance, 'p2', 'd2').image('image-x-10x20-webp').url()
+ expect(url).toBe('https://cdn.sanity.io/images/p2/d2/x-10x20.webp')
+ })
+ })
+
+ describe('getAssetDownloadUrl', () => {
+ it('appends dl param with empty value when filename omitted', () => {
+ expect(getAssetDownloadUrl('https://cdn.sanity.io/file.png')).toBe(
+ 'https://cdn.sanity.io/file.png?dl=',
+ )
+ })
+
+ it('appends dl param with encoded filename', () => {
+ expect(getAssetDownloadUrl('https://cdn.sanity.io/file.png', 'nice name.png')).toBe(
+ 'https://cdn.sanity.io/file.png?dl=nice%20name.png',
+ )
+ })
+
+ it('uses & when URL already has query parameters', () => {
+ expect(getAssetDownloadUrl('https://cdn.sanity.io/file.png?w=100', 'x')).toBe(
+ 'https://cdn.sanity.io/file.png?w=100&dl=x',
+ )
+ })
+ })
+
+ describe('upload/delete asset', () => {
+ it('uploadAsset forwards to client with mapped options and tag', async () => {
+ const body = Buffer.from('data')
+ const uploaded = {_id: 'image-abc-1x1-png', _type: 'sanity.imageAsset'}
+ const upload = vi.fn().mockResolvedValue(uploaded)
+ vi.mocked(getClient).mockReturnValue({
+ assets: {upload},
+ } as unknown as SanityClient)
+
+ const result = await uploadAsset(instance, 'image', body, {
+ filename: 'photo.png',
+ contentType: 'image/png',
+ meta: ['palette'],
+ title: 't',
+ description: 'd',
+ label: 'l',
+ creditLine: 'c',
+ sourceName: 'ext',
+ sourceId: '42',
+ sourceUrl: 'https://example.com/asset/42',
+ })
+
+ expect(upload).toHaveBeenCalledTimes(1)
+ expect(upload).toHaveBeenCalledWith(
+ 'image',
+ body,
+ expect.objectContaining({
+ filename: 'photo.png',
+ contentType: 'image/png',
+ extract: ['palette'],
+ title: 't',
+ description: 'd',
+ label: 'l',
+ creditLine: 'c',
+ tag: 'sdk.upload-asset',
+ source: {
+ name: 'ext',
+ id: '42',
+ url: 'https://example.com/asset/42',
+ },
+ }),
+ )
+ expect(result).toBe(uploaded)
+ })
+
+ it('uploadAsset omits source when incomplete', async () => {
+ const body = Buffer.from('x')
+ const upload = vi.fn().mockResolvedValue({_id: 'file-1', _type: 'sanity.fileAsset'})
+ vi.mocked(getClient).mockReturnValue({assets: {upload}} as unknown as SanityClient)
+
+ await uploadAsset(instance, 'file', body, {sourceName: 'ext'})
+
+ const options = vi.mocked(upload).mock.calls[0][2]
+ expect(options.source).toBeUndefined()
+ })
+
+ it('deleteAsset forwards to client.delete', async () => {
+ const del = vi.fn().mockResolvedValue(undefined)
+ vi.mocked(getClient).mockReturnValue({delete: del} as unknown as SanityClient)
+
+ await deleteAsset(instance, 'image-abc-1x1-png')
+ expect(del).toHaveBeenCalledWith('image-abc-1x1-png')
+ })
+
+ it('deleteAsset supports AssetHandle with explicit project/dataset', async () => {
+ const del = vi.fn().mockResolvedValue(undefined)
+ const mockedClient = {delete: del} as unknown as SanityClient
+ vi.mocked(getClient).mockReturnValue(mockedClient)
+
+ await deleteAsset(instance, {assetId: 'file-xyz', projectId: 'p9', dataset: 'd9'})
+
+ expect(del).toHaveBeenCalledWith('file-xyz')
+ expect(vi.mocked(getClient)).toHaveBeenCalledWith(
+ instance,
+ expect.objectContaining({projectId: 'p9', dataset: 'd9'}),
+ )
+ })
+ })
+
+ describe('linkMediaLibraryAsset', () => {
+ it('throws when projectId/dataset are missing', async () => {
+ const empty = createSanityInstance({})
+ await expect(
+ linkMediaLibraryAsset(empty, {
+ assetId: 'a',
+ mediaLibraryId: 'm',
+ assetInstanceId: 'i',
+ }),
+ ).rejects.toThrow(/projectId and dataset are required/)
+ empty.dispose()
+ })
+
+ it('posts to media-library link endpoint with correct body', async () => {
+ const request = vi.fn().mockResolvedValue({_id: 'linkedAsset', _type: 'sanity.fileAsset'})
+ vi.mocked(getClient).mockReturnValue({request} as unknown as SanityClient)
+
+ const doc = await linkMediaLibraryAsset(instance, {
+ projectId: 'p1',
+ dataset: 'd1',
+ assetId: 'asset-1',
+ mediaLibraryId: 'lib-1',
+ assetInstanceId: 'inst-1',
+ tag: 'custom-tag',
+ })
+
+ expect(request).toHaveBeenCalledWith(
+ expect.objectContaining({
+ uri: '/assets/media-library-link/d1',
+ method: 'POST',
+ tag: 'custom-tag',
+ body: {
+ assetId: 'asset-1',
+ mediaLibraryId: 'lib-1',
+ assetInstanceId: 'inst-1',
+ },
+ }),
+ )
+ expect(doc).toEqual({_id: 'linkedAsset', _type: 'sanity.fileAsset'})
+ })
+ })
+
+ describe('assets integration', () => {
+ it('resolveAssets fetches via client observable when configured', async () => {
+ const data: AssetDocumentBase[] = [{_id: 'image-1', _type: 'sanity.imageAsset', url: 'u'}]
+ const fetch = vi.fn().mockReturnValue(of({result: data, syncTags: []}))
+
+ const fakeClient = {
+ observable: {fetch},
+ live: {events: vi.fn().mockReturnValue(of())},
+ config: () => ({token: undefined}),
+ } as unknown as SanityClient
+
+ vi.mocked(getClientState).mockReturnValue({
+ observable: of(fakeClient),
+ } as StateSource)
+
+ const result = await resolveAssets(instance, {
+ projectId: 'p',
+ dataset: 'd',
+ assetType: 'image',
+ where: 'defined(url)',
+ limit: 1,
+ params: {x: 1},
+ order: '_createdAt desc',
+ })
+
+ expect(fetch).toHaveBeenCalledTimes(1)
+ const [groq, params, options] = vi.mocked(fetch).mock.calls[0]
+ expect(typeof groq).toBe('string')
+ expect(groq).toContain('sanity.imageAsset')
+ expect(groq).toContain('defined(url)')
+ expect(groq).toContain('order(_createdAt desc)')
+ expect(groq).toContain('[0...1]')
+ expect(params).toEqual({x: 1})
+ expect(options).toEqual(expect.objectContaining({tag: 'sdk.assets'}))
+ expect(result).toEqual(data)
+ })
+
+ it('getAssetsState throws without projectId/dataset', () => {
+ const empty = createSanityInstance({})
+ expect(() => getAssetsState(empty, {assetType: 'all'}).getCurrent()).toThrow(
+ /projectId and dataset are required/,
+ )
+ empty.dispose()
+ })
+ })
+})
diff --git a/packages/core/src/assets/assets.ts b/packages/core/src/assets/assets.ts
new file mode 100644
index 000000000..81775a3e3
--- /dev/null
+++ b/packages/core/src/assets/assets.ts
@@ -0,0 +1,398 @@
+import {
+ type AssetMetadataType,
+ type SanityAssetDocument,
+ type SanityImageAssetDocument,
+ type UploadBody,
+} from '@sanity/client'
+import imageUrlBuilder from '@sanity/image-url'
+import {type SanityDocument} from '@sanity/types'
+
+// rxjs no longer used in this module after refactor
+import {getClient} from '../client/clientStore'
+import {type AssetHandle, type DatasetHandle} from '../config/sanityConfig'
+import {getQueryState, resolveQuery} from '../query/queryStore'
+import {type SanityInstance} from '../store/createSanityInstance'
+import {type StateSource} from '../store/createStateSourceAction'
+
+const API_VERSION = 'v2025-10-29'
+const IMAGE_ASSET_ID_PATTERN =
+ /^image-(?[A-Za-z0-9_-]+)-(?\d+x\d+)-(?[a-z]+)$/
+
+/**
+ * Template-literal type for Sanity image asset IDs, eg:
+ * `image--x-`
+ * @public
+ */
+export type ImageAssetId =
+ `image-${string}-${number}x${number}-${Lowercase}`
+
+/**
+ * Runtime validator and type guard for image asset IDs
+ * @public
+ */
+export function isImageAssetId(value: string): value is ImageAssetId {
+ return IMAGE_ASSET_ID_PATTERN.test(value)
+}
+
+/**
+ * Supported asset kinds for uploads
+ * @public
+ */
+export type AssetKind = 'image' | 'file'
+
+/**
+ * Options when uploading an asset
+ *
+ * These map to the underlying Sanity Assets API. Most fields are optional and will be
+ * stored on the created asset document.
+ *
+ * @public
+ */
+export interface UploadAssetOptions {
+ /** Optional filename to associate with the uploaded asset */
+ filename?: string
+ /** Optional MIME type if it cannot be inferred */
+ contentType?: string
+ /** Optional title for the asset */
+ title?: string
+ /** Optional description for the asset */
+ description?: string
+ /** Optional freeform label */
+ label?: string
+ /** Optional credit line */
+ creditLine?: string
+ /** Optional metadata keys to extract on upload (mapped to client `extract`) */
+ meta?: AssetMetadataType[]
+ /** Name of the external source the asset originates from */
+ sourceName?: string
+ /** Identifier of the asset in the external source */
+ sourceId?: string
+ /** URL of the asset in the external source */
+ sourceUrl?: string
+ /** Optional explicit project to upload the asset to */
+ projectId?: string
+ /** Optional explicit dataset to upload the asset to */
+ dataset?: string
+}
+
+/**
+ * Upload an asset to the current project/dataset.
+ *
+ * - Pass `kind` as `'image'` or `'file'`.
+ * - `body` accepts the common upload types (eg. `File`, `Blob`, `Buffer`, `NodeJS.ReadableStream`).
+ * - Returns the created asset document.
+ *
+ * Example (browser):
+ * ```ts
+ * const file = input.files?.[0]!
+ * const image = await uploadAsset(instance, 'image', file, {filename: file.name})
+ * ```
+ *
+ * Example (node):
+ * ```ts
+ * import {createReadStream} from 'node:fs'
+ *
+ * const rs = createReadStream('/path/photo.jpg')
+ * const image = await uploadAsset(instance, 'image', rs, {filename: 'photo.jpg'})
+ * ```
+ *
+ * @public
+ */
+export async function uploadAsset(
+ instance: SanityInstance,
+ kind: 'image',
+ body: UploadBody,
+ options?: UploadAssetOptions,
+): Promise
+/** @public */
+export async function uploadAsset(
+ instance: SanityInstance,
+ kind: 'file',
+ body: UploadBody,
+ options?: UploadAssetOptions,
+): Promise
+/** @public */
+export async function uploadAsset(
+ instance: SanityInstance,
+ kind: AssetKind,
+ body: UploadBody,
+ options?: UploadAssetOptions,
+): Promise {
+ const client = getClient(instance, {
+ apiVersion: API_VERSION,
+ projectId: options?.projectId ?? instance.config.projectId,
+ dataset: options?.dataset ?? instance.config.dataset,
+ useProjectHostname: true,
+ })
+ // Map public options to client upload options
+ const clientOptions = {
+ filename: options?.filename,
+ contentType: options?.contentType,
+ // `meta` maps to client `extract`
+ ...(options?.meta ? {extract: options.meta} : {}),
+ title: options?.title,
+ description: options?.description,
+ label: options?.label,
+ creditLine: options?.creditLine,
+ tag: 'sdk.upload-asset',
+ ...(options?.sourceName && options?.sourceId
+ ? {
+ source: {
+ name: options.sourceName,
+ id: options.sourceId,
+ ...(options?.sourceUrl ? {url: options.sourceUrl} : {}),
+ },
+ }
+ : {}),
+ }
+ const assetType: 'image' | 'file' = kind
+ return await client.assets.upload(assetType, body, clientOptions)
+}
+
+/**
+ * Delete an asset by its asset document ID.
+ * Pass the asset document `_id` (eg. `image-abc123-2000x1200-jpg`).
+ * @public
+ */
+export async function deleteAsset(instance: SanityInstance, assetDocumentId: string): Promise
+/** @public */
+export async function deleteAsset(instance: SanityInstance, handle: AssetHandle): Promise
+/** @public */
+export async function deleteAsset(
+ instance: SanityInstance,
+ idOrHandle: string | AssetHandle,
+): Promise {
+ if (typeof idOrHandle === 'string') {
+ const client = getClient(instance, {apiVersion: API_VERSION})
+ await client.delete(idOrHandle)
+ return
+ }
+
+ const projectId = idOrHandle.projectId ?? instance.config.projectId
+ const dataset = idOrHandle.dataset ?? instance.config.dataset
+ if (!projectId || !dataset) {
+ throw new Error('A projectId and dataset are required to delete an asset.')
+ }
+ const client = getClient(instance, {
+ apiVersion: API_VERSION,
+ projectId,
+ dataset,
+ useProjectHostname: true,
+ })
+ await client.delete(idOrHandle.assetId)
+}
+
+/**
+ * Append a download query parameter to an asset URL so that browsers download the file
+ * using a preferred filename.
+ *
+ * If `filename` is omitted, the CDN uses the original filename (or the asset ID if absent).
+ *
+ * @public
+ */
+export function getAssetDownloadUrl(url: string, filename?: string): string {
+ const param = typeof filename === 'string' && filename.length > 0 ? filename : ''
+ const joiner = url.includes('?') ? '&' : '?'
+ return `${url}${joiner}dl=${encodeURIComponent(param)}`
+}
+
+/**
+ * Returns a configured `@sanity/image-url` builder bound to the instance's project/dataset
+ * (or explicit overrides), for generating image URLs with transformations.
+ *
+ * @public
+ */
+export function getImageUrlBuilder(
+ instance: SanityInstance,
+ projectId?: string,
+ dataset?: string,
+): ReturnType {
+ const resolvedProject = projectId ?? instance.config.projectId
+ const resolvedDataset = dataset ?? instance.config.dataset
+ if (!resolvedProject || !resolvedDataset) {
+ throw new Error('A projectId and dataset are required to build image URLs.')
+ }
+ return imageUrlBuilder({projectId: resolvedProject, dataset: resolvedDataset})
+}
+
+/**
+ * Asset document subset returned by default asset queries
+ * @public
+ */
+export interface AssetDocumentBase {
+ _id: string
+ _type: 'sanity.imageAsset' | 'sanity.fileAsset'
+ url?: string
+ path?: string
+ originalFilename?: string
+ size?: number
+ metadata?: unknown
+}
+
+/**
+ * Options for querying assets
+ * @public
+ */
+export interface AssetQueryOptions<
+ TDataset extends string = string,
+ TProjectId extends string = string,
+> extends DatasetHandle {
+ /** Restrict to a specific asset type. Defaults to both */
+ assetType?: 'image' | 'file' | 'all'
+ /** Additional GROQ filter expression appended with && (...) */
+ where?: string
+ /** GROQ params */
+ params?: Record
+ /** Optional ordering, eg: `_createdAt desc` */
+ order?: string
+ /** Limit number of results */
+ limit?: number
+ /** Optional projection body inside \{...\}. Omit to return full documents */
+ projection?: string
+}
+
+function buildAssetsGroq(
+ options: Omit,
+): string {
+ const typeNames =
+ options.assetType === 'image'
+ ? ['sanity.imageAsset']
+ : options.assetType === 'file'
+ ? ['sanity.fileAsset']
+ : ['sanity.imageAsset', 'sanity.fileAsset']
+
+ const typeFilter = `_type in [${typeNames.map((t) => `"${t}"`).join(', ')}]`
+ const where = options.where ? `${typeFilter} && (${options.where})` : typeFilter
+
+ let groq = `*[${where}]`
+ if (options.order) groq += ` | order(${options.order})`
+ if (typeof options.limit === 'number') groq += `[0...${options.limit}]`
+ if (options.projection) groq += `{${options.projection}}`
+ return groq
+}
+
+/**
+ * Returns a StateSource for an asset query using the centralized query store.
+ *
+ * @public
+ */
+export function getAssetsState(
+ instance: SanityInstance,
+ options: AssetQueryOptions,
+): StateSource {
+ const projectId = options.projectId ?? instance.config.projectId
+ const dataset = options.dataset ?? instance.config.dataset
+ if (!projectId || !dataset) {
+ throw new Error('A projectId and dataset are required to query assets.')
+ }
+
+ const groq = buildAssetsGroq(options)
+ return getQueryState(instance, {
+ query: groq,
+ params: options.params,
+ projectId,
+ dataset,
+ tag: 'sdk.assets',
+ })
+}
+
+/**
+ * Returns a StateSource for an asset query.
+ *
+ * Example:
+ * ```ts
+ * const state = getAssetsState(instance, {assetType: 'image', limit: 50})
+ * state.subscribe(images => {
+ * // render
+ * })
+ * ```
+ *
+ * @public
+ */
+// kept for backward-compat in docs (exported above as function)
+/**
+ * Resolves an asset query one-time (Promise-based).
+ *
+ * Example:
+ * ```ts
+ * const files = await resolveAssets(instance, {assetType: 'file', order: '_createdAt desc'})
+ * ```
+ *
+ * @public
+ */
+export function resolveAssets(
+ instance: SanityInstance,
+ options: AssetQueryOptions,
+): Promise {
+ const projectId = options.projectId ?? instance.config.projectId
+ const dataset = options.dataset ?? instance.config.dataset
+ if (!projectId || !dataset) {
+ throw new Error('A projectId and dataset are required to query assets.')
+ }
+
+ const groq = buildAssetsGroq(options)
+ return resolveQuery(instance, {
+ query: groq,
+ params: options.params,
+ projectId,
+ dataset,
+ tag: 'sdk.assets',
+ })
+}
+
+/**
+ * Options for linking a Media Library asset to a dataset
+ * @public
+ */
+export interface LinkMediaLibraryAssetOptions<
+ TDataset extends string = string,
+ TProjectId extends string = string,
+> extends DatasetHandle {
+ assetId: string
+ mediaLibraryId: string
+ assetInstanceId: string
+ /** Optional request tag */
+ tag?: string
+}
+
+/**
+ * Link a Media Library asset to a local dataset, creating a linked asset document.
+ *
+ * Example:
+ * ```ts
+ * await linkMediaLibraryAsset(instance, {
+ * assetId: 'mlAssetId',
+ * mediaLibraryId: 'mediaLib1',
+ * assetInstanceId: 'mlInst1',
+ * })
+ * ```
+ *
+ * @public
+ */
+export async function linkMediaLibraryAsset(
+ instance: SanityInstance,
+ options: LinkMediaLibraryAssetOptions,
+): Promise {
+ const projectId = options.projectId ?? instance.config.projectId
+ const dataset = options.dataset ?? instance.config.dataset
+ if (!projectId || !dataset) {
+ throw new Error('A projectId and dataset are required to link a Media Library asset.')
+ }
+
+ const client = getClient(instance, {
+ apiVersion: API_VERSION,
+ projectId,
+ dataset,
+ useProjectHostname: true,
+ })
+ return await client.request({
+ uri: `/assets/media-library-link/${dataset}`,
+ method: 'POST',
+ tag: options.tag ?? 'sdk.link-media-library',
+ body: {
+ assetId: options.assetId,
+ mediaLibraryId: options.mediaLibraryId,
+ assetInstanceId: options.assetInstanceId,
+ },
+ })
+}
diff --git a/packages/core/src/config/__tests__/handles.test.ts b/packages/core/src/config/__tests__/handles.test.ts
index 8e194632f..d419ff899 100644
--- a/packages/core/src/config/__tests__/handles.test.ts
+++ b/packages/core/src/config/__tests__/handles.test.ts
@@ -1,6 +1,7 @@
import {describe, expect, it} from 'vitest'
import {
+ createAssetHandle,
createDatasetHandle,
createDocumentHandle,
createDocumentTypeHandle,
@@ -27,4 +28,9 @@ describe('handle creation functions', () => {
const input = {documentType: 'movie', documentId: '123'}
expect(createDocumentHandle(input)).toBe(input)
})
+
+ it('createAssetHandle returns input', () => {
+ const input = {assetId: 'image-abc-1x1-png', projectId: 'p', dataset: 'd'}
+ expect(createAssetHandle(input)).toBe(input)
+ })
})
diff --git a/packages/core/src/config/handles.ts b/packages/core/src/config/handles.ts
index fd988446e..7c85d9176 100644
--- a/packages/core/src/config/handles.ts
+++ b/packages/core/src/config/handles.ts
@@ -1,4 +1,5 @@
import {
+ type AssetHandle,
type DatasetHandle,
type DocumentHandle,
type DocumentTypeHandle,
@@ -65,3 +66,17 @@ export function createDatasetHandle<
>(handle: DatasetHandle): DatasetHandle {
return handle
}
+
+/**
+ * Creates or validates an `AssetHandle` object.
+ * Ensures the provided object conforms to the `AssetHandle` interface.
+ * @param handle - The object containing asset identification properties.
+ * @returns The validated `AssetHandle` object.
+ * @public
+ */
+export function createAssetHandle<
+ TDataset extends string = string,
+ TProjectId extends string = string,
+>(handle: AssetHandle): AssetHandle {
+ return handle
+}
diff --git a/packages/core/src/config/sanityConfig.ts b/packages/core/src/config/sanityConfig.ts
index 7dbd8accd..0bae5119c 100644
--- a/packages/core/src/config/sanityConfig.ts
+++ b/packages/core/src/config/sanityConfig.ts
@@ -63,6 +63,17 @@ export interface DocumentHandle<
documentId: string
}
+/**
+ * Identifies a specific asset within a Sanity project/dataset.
+ * Includes optional `projectId`/`dataset` and the required `assetId` (asset document `_id`).
+ * Useful for cross-instance asset operations without relying on the active ResourceProvider.
+ * @public
+ */
+export interface AssetHandle
+ extends DatasetHandle {
+ assetId: string
+}
+
/**
* Represents the complete configuration for a Sanity SDK instance
* @public
diff --git a/packages/react/src/_exports/sdk-react.ts b/packages/react/src/_exports/sdk-react.ts
index 88c38a770..1bec4857a 100644
--- a/packages/react/src/_exports/sdk-react.ts
+++ b/packages/react/src/_exports/sdk-react.ts
@@ -6,6 +6,10 @@ export {SanityApp, type SanityAppProps} from '../components/SanityApp'
export {SDKProvider, type SDKProviderProps} from '../components/SDKProvider'
export {ComlinkTokenRefreshProvider} from '../context/ComlinkTokenRefresh'
export {ResourceProvider, type ResourceProviderProps} from '../context/ResourceProvider'
+export {useAssets} from '../hooks/assets/useAssets'
+export {useDeleteAsset} from '../hooks/assets/useDeleteAsset'
+export {useLinkMediaLibraryAsset} from '../hooks/assets/useLinkMediaLibraryAsset'
+export {useUploadAsset} from '../hooks/assets/useUploadAsset'
export {useAuthState} from '../hooks/auth/useAuthState'
export {useAuthToken} from '../hooks/auth/useAuthToken'
export {useCurrentUser} from '../hooks/auth/useCurrentUser'
diff --git a/packages/react/src/hooks/assets/useAssets.test.tsx b/packages/react/src/hooks/assets/useAssets.test.tsx
new file mode 100644
index 000000000..2f4dfcd83
--- /dev/null
+++ b/packages/react/src/hooks/assets/useAssets.test.tsx
@@ -0,0 +1,231 @@
+import {type AssetDocumentBase, getAssetsState, resolveAssets, type StateSource} from '@sanity/sdk'
+import {act, render, screen} from '@testing-library/react'
+import {useState} from 'react'
+import {type Observable, Subject} from 'rxjs'
+import {beforeEach, describe, expect, it, vi} from 'vitest'
+
+import {ResourceProvider} from '../../context/ResourceProvider'
+import {useAssets} from './useAssets'
+
+vi.mock('@sanity/sdk', async (importOriginal) => {
+ const original = await importOriginal()
+ return {
+ ...original,
+ getAssetsState: vi.fn(),
+ resolveAssets: vi.fn(),
+ }
+})
+
+describe('useAssets', () => {
+ beforeEach(() => {
+ vi.resetAllMocks()
+ })
+
+ it('renders assets immediately when available', () => {
+ const assets: AssetDocumentBase[] = [
+ {_id: 'image-1', _type: 'sanity.imageAsset', url: 'u1'},
+ {_id: 'file-1', _type: 'sanity.fileAsset', url: 'u2'},
+ ]
+
+ vi.mocked(getAssetsState).mockReturnValue({
+ getCurrent: vi.fn().mockReturnValue(assets),
+ subscribe: vi.fn(),
+ get observable(): Observable {
+ throw new Error('Not implemented')
+ },
+ } as unknown as StateSource)
+
+ function TestComponent() {
+ const data = useAssets({assetType: 'all'})
+ return {data.map((a) => a._id).join(',')}
+ }
+
+ render(
+ Loading...
}>
+
+ ,
+ )
+
+ expect(screen.getByTestId('output').textContent).toBe('image-1,file-1')
+ })
+
+ it('suspends until assets are resolved via Suspense', async () => {
+ const ref = {current: undefined as AssetDocumentBase[] | undefined}
+ const getCurrent = vi.fn(() => ref.current)
+ const storeChanged$ = new Subject()
+
+ vi.mocked(getAssetsState).mockReturnValue({
+ getCurrent,
+ subscribe: vi.fn((cb) => {
+ const sub = storeChanged$.subscribe(cb)
+ return () => sub.unsubscribe()
+ }),
+ get observable(): Observable {
+ throw new Error('Not implemented')
+ },
+ } as unknown as StateSource)
+
+ let resolvePromise: () => void
+ vi.mocked(resolveAssets).mockReturnValue(
+ new Promise((resolve) => {
+ resolvePromise = () => {
+ ref.current = [{_id: 'image-1', _type: 'sanity.imageAsset'}]
+ storeChanged$.next()
+ resolve(ref.current)
+ }
+ }),
+ )
+
+ function TestComponent() {
+ const data = useAssets({assetType: 'image'})
+ return {data.map((a) => a._id).join(',')}
+ }
+
+ render(
+ Loading...}
+ >
+
+ ,
+ )
+
+ expect(screen.getByTestId('fallback')).toBeInTheDocument()
+
+ await act(async () => {
+ resolvePromise()
+ })
+
+ expect(screen.getByTestId('output').textContent).toBe('image-1')
+ })
+
+ it('re-subscribes and updates when options change', async () => {
+ const ref = {current: undefined as AssetDocumentBase[] | undefined}
+ const getCurrent = vi.fn(() => ref.current)
+ const storeChanged$ = new Subject()
+
+ vi.mocked(getAssetsState).mockImplementation((_instance, options) => {
+ if (options?.assetType === 'image') {
+ return {
+ getCurrent: vi.fn().mockReturnValue([{_id: 'image-1', _type: 'sanity.imageAsset'}]),
+ subscribe: vi.fn(),
+ get observable(): Observable {
+ throw new Error('Not implemented')
+ },
+ } as unknown as StateSource
+ }
+
+ return {
+ getCurrent,
+ subscribe: vi.fn((cb) => {
+ const sub = storeChanged$.subscribe(cb)
+ return () => sub.unsubscribe()
+ }),
+ get observable(): Observable {
+ throw new Error('Not implemented')
+ },
+ } as unknown as StateSource
+ })
+
+ let resolvePromise: () => void
+ vi.mocked(resolveAssets).mockReturnValue(
+ new Promise((resolve) => {
+ resolvePromise = () => {
+ ref.current = [{_id: 'file-1', _type: 'sanity.fileAsset'}]
+ storeChanged$.next()
+ resolve(ref.current)
+ }
+ }),
+ )
+
+ function Wrapper() {
+ const [kind, setKind] = useState<'image' | 'file'>('image')
+ const data = useAssets({assetType: kind})
+ return (
+
+
{data.map((a) => a._id).join(',')}
+
+
+ )
+ }
+
+ render(
+ Loading...}>
+
+ ,
+ )
+
+ // Initially for images
+ expect(screen.getByTestId('output').textContent).toBe('image-1')
+
+ // Change to files, trigger suspense and then resolve
+ act(() => {
+ screen.getByTestId('toggle').dispatchEvent(new MouseEvent('click', {bubbles: true}))
+ })
+
+ await act(async () => {
+ resolvePromise()
+ })
+
+ expect(screen.getByTestId('output').textContent).toBe('file-1')
+ })
+
+ it('calls unsubscribe on unmount', () => {
+ const unsubscribe = vi.fn()
+ const subscribe = vi.fn().mockImplementation((_cb: () => void) => {
+ // Return cleanup
+ return unsubscribe
+ })
+
+ vi.mocked(getAssetsState).mockReturnValue({
+ getCurrent: vi.fn().mockReturnValue([]),
+ subscribe,
+ get observable(): Observable {
+ throw new Error('Not implemented')
+ },
+ } as unknown as StateSource)
+
+ function TestComponent() {
+ useAssets()
+ return null
+ }
+
+ const {unmount} = render(
+ Loading...}>
+
+ ,
+ )
+
+ expect(subscribe).toHaveBeenCalled()
+ unmount()
+ expect(unsubscribe).toHaveBeenCalled()
+ })
+
+ it('throws when state source getCurrent throws (error path)', () => {
+ vi.mocked(getAssetsState).mockReturnValue({
+ getCurrent: vi.fn(() => {
+ throw new Error('failed')
+ }),
+ subscribe: vi.fn(),
+ get observable(): Observable {
+ throw new Error('Not implemented')
+ },
+ } as unknown as StateSource)
+
+ function TestComponent() {
+ const data = useAssets()
+ return {data.length}
+ }
+
+ expect(() =>
+ render(
+ Loading...}>
+
+ ,
+ ),
+ ).toThrow('failed')
+ })
+})
diff --git a/packages/react/src/hooks/assets/useAssets.ts b/packages/react/src/hooks/assets/useAssets.ts
new file mode 100644
index 000000000..ad9d14bb5
--- /dev/null
+++ b/packages/react/src/hooks/assets/useAssets.ts
@@ -0,0 +1,41 @@
+import {
+ type AssetDocumentBase,
+ type AssetQueryOptions,
+ getAssetsState,
+ resolveAssets,
+ type SanityInstance,
+ type StateSource,
+} from '@sanity/sdk'
+import {identity} from 'rxjs'
+
+import {createStateSourceHook} from '../helpers/createStateSourceHook'
+
+type UseAssets = (options?: AssetQueryOptions) => AssetDocumentBase[]
+
+/**
+ * @public
+ * Returns assets from your dataset based on flexible query options.
+ *
+ * Examples:
+ * ```tsx
+ * // Images only
+ * const images = useAssets({assetType: 'image', limit: 50})
+ *
+ * // Files ordered by creation
+ * const files = useAssets({assetType: 'file', order: '_createdAt desc'})
+ *
+ * // Filter (GROQ) with params
+ * const results = useAssets({where: 'size > $min', params: {min: 1024}})
+ * ```
+ */
+export const useAssets: UseAssets = createStateSourceHook({
+ getState: (instance: SanityInstance, options?: AssetQueryOptions) =>
+ getAssetsState(instance, (options ?? {}) as AssetQueryOptions) as unknown as StateSource<
+ AssetDocumentBase[]
+ >,
+ shouldSuspend: (instance: SanityInstance, options?: AssetQueryOptions) =>
+ getAssetsState(instance, (options ?? {}) as AssetQueryOptions).getCurrent() === undefined,
+ suspender: (instance: SanityInstance, options?: AssetQueryOptions) =>
+ resolveAssets(instance, (options ?? {}) as AssetQueryOptions),
+ getConfig: identity as (options?: AssetQueryOptions) => AssetQueryOptions | undefined,
+})
diff --git a/packages/react/src/hooks/assets/useDeleteAsset.test.tsx b/packages/react/src/hooks/assets/useDeleteAsset.test.tsx
new file mode 100644
index 000000000..c3c94e3e4
--- /dev/null
+++ b/packages/react/src/hooks/assets/useDeleteAsset.test.tsx
@@ -0,0 +1,91 @@
+import {deleteAsset} from '@sanity/sdk'
+import {renderHook} from '@testing-library/react'
+import {describe, expect, it, vi} from 'vitest'
+
+import {ResourceProvider} from '../../context/ResourceProvider'
+import {useDeleteAsset} from './useDeleteAsset'
+
+vi.mock('@sanity/sdk', async (importOriginal) => {
+ const original = await importOriginal()
+ return {
+ ...original,
+ deleteAsset: vi.fn(),
+ }
+})
+
+describe('useDeleteAsset', () => {
+ it('calls deleteAsset with the asset document id', async () => {
+ vi.mocked(deleteAsset).mockResolvedValue(undefined)
+
+ const {result} = renderHook(() => useDeleteAsset(), {
+ wrapper: ({children}) => (
+
+ {children}
+
+ ),
+ })
+
+ await result.current('image-abc-1x1-png')
+
+ expect(deleteAsset).toHaveBeenCalledWith(
+ expect.objectContaining({config: expect.objectContaining({projectId: 'p'})}),
+ 'image-abc-1x1-png',
+ )
+ })
+
+ it('propagates errors from delete', async () => {
+ vi.mocked(deleteAsset).mockRejectedValue(new Error('nope'))
+
+ const {result} = renderHook(() => useDeleteAsset(), {
+ wrapper: ({children}) => (
+
+ {children}
+
+ ),
+ })
+
+ await expect(result.current('file-1')).rejects.toThrow('nope')
+ })
+
+ it('returns new function when instance changes', () => {
+ const {result, unmount} = renderHook(() => useDeleteAsset(), {
+ wrapper: ({children}) => (
+
+ {children}
+
+ ),
+ })
+
+ const first = result.current
+ unmount()
+
+ const {result: result2} = renderHook(() => useDeleteAsset(), {
+ wrapper: ({children}) => (
+
+ {children}
+
+ ),
+ })
+
+ expect(result2.current).not.toBe(first)
+ })
+
+ it('calls deleteAsset when given an AssetHandle', async () => {
+ vi.mocked(deleteAsset).mockResolvedValue(undefined)
+
+ const {result} = renderHook(() => useDeleteAsset(), {
+ wrapper: ({children}) => (
+
+ {children}
+
+ ),
+ })
+
+ await result.current({assetId: 'image-abc', projectId: 'p2', dataset: 'd2'})
+
+ expect(deleteAsset).toHaveBeenCalledWith(
+ expect.objectContaining({config: expect.objectContaining({projectId: 'p'})}),
+ {assetId: 'image-abc', projectId: 'p2', dataset: 'd2'},
+ )
+ })
+})
diff --git a/packages/react/src/hooks/assets/useDeleteAsset.ts b/packages/react/src/hooks/assets/useDeleteAsset.ts
new file mode 100644
index 000000000..61c1b2046
--- /dev/null
+++ b/packages/react/src/hooks/assets/useDeleteAsset.ts
@@ -0,0 +1,15 @@
+import {deleteAsset} from '@sanity/sdk'
+
+import {createCallbackHook} from '../helpers/createCallbackHook'
+
+/**
+ * @public
+ * Provides a stable callback to delete an asset by its asset document ID.
+ *
+ * Example:
+ * ```tsx
+ * const remove = useDeleteAsset()
+ * await remove('image-abc123-2000x1200-jpg')
+ * ```
+ */
+export const useDeleteAsset = createCallbackHook(deleteAsset)
diff --git a/packages/react/src/hooks/assets/useLinkMediaLibraryAsset.test.tsx b/packages/react/src/hooks/assets/useLinkMediaLibraryAsset.test.tsx
new file mode 100644
index 000000000..4a5380bef
--- /dev/null
+++ b/packages/react/src/hooks/assets/useLinkMediaLibraryAsset.test.tsx
@@ -0,0 +1,83 @@
+import {linkMediaLibraryAsset, type SanityDocument} from '@sanity/sdk'
+import {renderHook} from '@testing-library/react'
+import {describe, expect, it, vi} from 'vitest'
+
+import {ResourceProvider} from '../../context/ResourceProvider'
+import {useLinkMediaLibraryAsset} from './useLinkMediaLibraryAsset'
+
+vi.mock('@sanity/sdk', async (importOriginal) => {
+ const original = await importOriginal()
+ return {
+ ...original,
+ linkMediaLibraryAsset: vi.fn(),
+ }
+})
+
+describe('useLinkMediaLibraryAsset', () => {
+ it('calls linkMediaLibraryAsset with provided options', async () => {
+ vi.mocked(linkMediaLibraryAsset).mockResolvedValue({
+ _id: 'doc',
+ _type: 'any',
+ } as unknown as SanityDocument)
+
+ const {result} = renderHook(() => useLinkMediaLibraryAsset(), {
+ wrapper: ({children}) => (
+
+ {children}
+
+ ),
+ })
+
+ const out = await result.current({
+ assetId: 'a',
+ mediaLibraryId: 'm',
+ assetInstanceId: 'i',
+ tag: 't',
+ })
+
+ expect(out).toEqual({_id: 'doc', _type: 'any'})
+ expect(linkMediaLibraryAsset).toHaveBeenCalledWith(
+ expect.objectContaining({config: expect.objectContaining({projectId: 'p'})}),
+ {assetId: 'a', mediaLibraryId: 'm', assetInstanceId: 'i', tag: 't'},
+ )
+ })
+
+ it('propagates errors from link operation', async () => {
+ vi.mocked(linkMediaLibraryAsset).mockRejectedValue(new Error('fail'))
+
+ const {result} = renderHook(() => useLinkMediaLibraryAsset(), {
+ wrapper: ({children}) => (
+
+ {children}
+
+ ),
+ })
+
+ await expect(
+ result.current({assetId: 'a', mediaLibraryId: 'm', assetInstanceId: 'i'}),
+ ).rejects.toThrow('fail')
+ })
+
+ it('returns new function when instance changes', () => {
+ const {result, unmount} = renderHook(() => useLinkMediaLibraryAsset(), {
+ wrapper: ({children}) => (
+
+ {children}
+
+ ),
+ })
+
+ const first = result.current
+ unmount()
+
+ const {result: result2} = renderHook(() => useLinkMediaLibraryAsset(), {
+ wrapper: ({children}) => (
+
+ {children}
+
+ ),
+ })
+
+ expect(result2.current).not.toBe(first)
+ })
+})
diff --git a/packages/react/src/hooks/assets/useLinkMediaLibraryAsset.ts b/packages/react/src/hooks/assets/useLinkMediaLibraryAsset.ts
new file mode 100644
index 000000000..8bc646048
--- /dev/null
+++ b/packages/react/src/hooks/assets/useLinkMediaLibraryAsset.ts
@@ -0,0 +1,21 @@
+import {linkMediaLibraryAsset, type LinkMediaLibraryAssetOptions} from '@sanity/sdk'
+
+import {createCallbackHook} from '../helpers/createCallbackHook'
+
+/**
+ * @public
+ * Provides a stable callback to link a Media Library asset to your dataset.
+ *
+ * Example:
+ * ```tsx
+ * const linkMlAsset = useLinkMediaLibraryAsset()
+ * await linkMlAsset({
+ * assetId: 'mlAssetId',
+ * mediaLibraryId: 'mediaLib1',
+ * assetInstanceId: 'mlInst1',
+ * })
+ * ```
+ */
+export const useLinkMediaLibraryAsset = createCallbackHook(
+ (instance, options: LinkMediaLibraryAssetOptions) => linkMediaLibraryAsset(instance, options),
+)
diff --git a/packages/react/src/hooks/assets/useUploadAsset.test.tsx b/packages/react/src/hooks/assets/useUploadAsset.test.tsx
new file mode 100644
index 000000000..3c6c32daa
--- /dev/null
+++ b/packages/react/src/hooks/assets/useUploadAsset.test.tsx
@@ -0,0 +1,121 @@
+import {type SanityAssetDocument, type SanityImageAssetDocument} from '@sanity/client'
+import {uploadAsset} from '@sanity/sdk'
+import {renderHook} from '@testing-library/react'
+import {describe, expect, it, vi} from 'vitest'
+
+import {ResourceProvider} from '../../context/ResourceProvider'
+import {useUploadAsset} from './useUploadAsset'
+
+vi.mock('@sanity/sdk', async (importOriginal) => {
+ const original = await importOriginal()
+ return {
+ ...original,
+ uploadAsset: vi.fn(),
+ }
+})
+
+describe('useUploadAsset', () => {
+ it('returns a function and uploads image assets', async () => {
+ const uploaded = {
+ _id: 'image-abc-1x1-png',
+ _type: 'sanity.imageAsset',
+ assetId: 'abc',
+ extension: 'png',
+ size: 1,
+ url: 'u',
+ path: 'p',
+ mimeType: 'image/png',
+ } as unknown as SanityImageAssetDocument
+ vi.mocked(uploadAsset).mockResolvedValue(uploaded)
+
+ const {result} = renderHook(() => useUploadAsset(), {
+ wrapper: ({children}) => (
+
+ {children}
+
+ ),
+ })
+
+ const fn = result.current
+ const body = Buffer.from('x')
+ const res = await fn('image', body, {filename: 'a.png'})
+ expect(res).toBe(uploaded)
+ expect(uploadAsset).toHaveBeenCalledWith(
+ expect.objectContaining({config: expect.objectContaining({projectId: 'p'})}),
+ 'image',
+ body,
+ {filename: 'a.png'},
+ )
+ })
+
+ it('uploads file assets and forwards options', async () => {
+ const uploaded = {
+ _id: 'file-abc',
+ _type: 'sanity.fileAsset',
+ assetId: 'abc',
+ extension: 'pdf',
+ size: 10,
+ url: 'u',
+ path: 'p',
+ mimeType: 'application/pdf',
+ } as unknown as SanityAssetDocument
+ vi.mocked(uploadAsset).mockResolvedValue(uploaded)
+
+ const {result} = renderHook(() => useUploadAsset(), {
+ wrapper: ({children}) => (
+
+ {children}
+
+ ),
+ })
+
+ const out = await result.current('file', Buffer.from('f'), {title: 'T'})
+ expect(out).toBe(uploaded)
+ expect(uploadAsset).toHaveBeenCalledWith(
+ expect.objectContaining({config: expect.objectContaining({projectId: 'p'})}),
+ 'file',
+ expect.any(Buffer),
+ {title: 'T'},
+ )
+ })
+
+ it('propagates errors from upload', async () => {
+ const err = new Error('boom')
+ vi.mocked(uploadAsset).mockRejectedValue(err)
+
+ const {result} = renderHook(() => useUploadAsset(), {
+ wrapper: ({children}) => (
+
+ {children}
+
+ ),
+ })
+
+ await expect(result.current('image', Buffer.from('x'))).rejects.toThrow('boom')
+ })
+
+ it('returns new function when instance changes (cleanup/remount)', async () => {
+ vi.mocked(uploadAsset).mockResolvedValue({} as SanityImageAssetDocument)
+
+ const {result, unmount} = renderHook(() => useUploadAsset(), {
+ wrapper: ({children}) => (
+
+ {children}
+
+ ),
+ })
+
+ const first = result.current
+ unmount()
+
+ const {result: result2} = renderHook(() => useUploadAsset(), {
+ wrapper: ({children}) => (
+
+ {children}
+
+ ),
+ })
+
+ expect(result2.current).not.toBe(first)
+ })
+})
diff --git a/packages/react/src/hooks/assets/useUploadAsset.ts b/packages/react/src/hooks/assets/useUploadAsset.ts
new file mode 100644
index 000000000..73d678a73
--- /dev/null
+++ b/packages/react/src/hooks/assets/useUploadAsset.ts
@@ -0,0 +1,44 @@
+import {
+ type SanityAssetDocument,
+ type SanityImageAssetDocument,
+ type UploadBody,
+} from '@sanity/client'
+import {uploadAsset, type UploadAssetOptions} from '@sanity/sdk'
+import {useCallback} from 'react'
+
+import {useSanityInstance} from '../context/useSanityInstance'
+
+/**
+ * @public
+ * Returns a stable callback for uploading assets.
+ *
+ * Overloads ensure proper return typing based on `kind`.
+ *
+ * @example
+ * ```tsx
+ * const upload = useUploadAsset()
+ * const onDrop = async (file: File) => {
+ * const image = await upload('image', file, {filename: file.name})
+ * }
+ * ```
+ */
+export function useUploadAsset(): {
+ (kind: 'image', body: UploadBody, options?: UploadAssetOptions): Promise
+ (kind: 'file', body: UploadBody, options?: UploadAssetOptions): Promise
+} {
+ const instance = useSanityInstance()
+ return useCallback(
+ (kind: 'image' | 'file', body: UploadBody, options?: UploadAssetOptions) =>
+ kind === 'image'
+ ? uploadAsset(instance, 'image', body, options)
+ : uploadAsset(instance, 'file', body, options),
+ [instance],
+ ) as unknown as {
+ (
+ kind: 'image',
+ body: UploadBody,
+ options?: UploadAssetOptions,
+ ): Promise
+ (kind: 'file', body: UploadBody, options?: UploadAssetOptions): Promise
+ }
+}
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index 54f5661a1..b25e23638 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -353,6 +353,9 @@ importers:
'@sanity/diff-patch':
specifier: ^6.0.0
version: 6.0.0
+ '@sanity/image-url':
+ specifier: ^1.2.0
+ version: 1.2.0
'@sanity/json-match':
specifier: ^1.0.5
version: 1.0.5
@@ -2797,6 +2800,10 @@ packages:
resolution: {integrity: sha512-JHumVRxzzaZAJyOimntdukA9TjjzsJiaiq/uUBdTknMLCNvtM6KQ5OCp6W5fIdY78uyFxtQjz+MPXwK8WBIxWg==}
engines: {node: '>=10.0.0'}
+ '@sanity/image-url@1.2.0':
+ resolution: {integrity: sha512-pYRhti+lDi22it+npWXkEGuYyzbXJLF+d0TYLiyWbKu46JHhYhTDKkp6zmGu4YKF5cXUjT6pnUjFsaf2vlB9nQ==}
+ engines: {node: '>=10.0.0'}
+
'@sanity/import@3.38.2':
resolution: {integrity: sha512-7KUEiksAjr+Ub+xbWbIIrNlfEesmqJcBW+n7zOr65TN8lS9WarTzIClDjbeZ13yYMS9e9FOKIzXVJC8UFwIseA==}
engines: {node: '>=18'}
@@ -10461,6 +10468,8 @@ snapshots:
'@sanity/image-url@1.1.0': {}
+ '@sanity/image-url@1.2.0': {}
+
'@sanity/import@3.38.2(@types/react@19.1.2)':
dependencies:
'@sanity/asset-utils': 2.2.1