App SDK

Create a document list with the App SDK

Render a live, sortable, searchable list of documents in an App SDK app with useDocuments and useDocumentProjection.

A document list is the starting point for most custom apps: a live, scrollable view of the documents an author works with. The App SDK builds one from two hooks. useDocuments returns lightweight document handles for every document that matches your criteria. useDocumentProjection fetches the fields each list item renders. Both hooks stay in sync with the Content Lake, so the list updates in real time as authors create, edit, and delete documents.

This guide explains how to render a sorted, filterable, searchable list of documents in an App SDK app, with a button that loads more results.

Prerequisites:

  • An App SDK app with projectId and dataset set in the SanityApp config. To create one, see the App SDK quick start.
  • @sanity/sdk-react 3.0.0 or later. The examples on this page were written against 3.10.0.
  • React 19.2 or later (react and react-dom), the minimum that @sanity/sdk-react 3.x supports.
  • A dataset with documents of the type you want to list. The examples use a movie type with title and releaseDate fields. Replace them with your own type and fields.

Fetch document handles with the useDocuments hook

useDocuments returns a data array of document handles. A handle identifies a document (documentId, documentType, projectId, and dataset) but doesn't contain its content. Fetching handles first keeps the list query small, no matter how many fields each document has.

Create a component that fetches handles for every movie document:

import {useDocuments} from '@sanity/sdk-react'

export function MovieList() {
  const {data: movies} = useDocuments({documentType: 'movie'})

  return (
    <ul>
      {movies.map((movie) => (
        <li key={movie.documentId}>{movie.documentId}</li>
      ))}
    </ul>
  )
}

Use documentId as the React key. It's stable across real-time updates, unlike the array index.

useDocuments returns the first 25 matching documents. To change the batch size, or to load more, see Load more documents.

Render each document with the useDocumentProjection hook

A handle alone only shows IDs. To render content, give each list item its own component that calls useDocumentProjection with the handle and a GROQ projection naming the fields it needs.

Add a MovieListItem component to src/MovieList.tsx and render it for each handle:

import {Suspense} from 'react'
import {
  useDocumentProjection,
  useDocuments,
  type DocumentHandle,
} from '@sanity/sdk-react'

interface MovieProjection {
  title?: string
  releaseDate?: string
}

export function MovieList() {
  const {data: movies} = useDocuments({documentType: 'movie'})

  return (
    <ul>
      {movies.map((movie) => (
        <Suspense key={movie.documentId} fallback={<li>Loading…</li>}>
          <MovieListItem {...movie} />
        </Suspense>
      ))}
    </ul>
  )
}

function MovieListItem(props: DocumentHandle) {
  const {data} = useDocumentProjection<MovieProjection>({
    ...props,
    projection: `{title, releaseDate}`,
  })

  return (
    <li>
      {data?.title ?? 'Untitled'}
      {data?.releaseDate && ` (${data.releaseDate.slice(0, 4)})`}
    </li>
  )
}

Three things make this work:

  • Spread the handle (...props) into useDocumentProjection so the hook knows which document, project, and dataset to read from.
  • Wrap each item in Suspense. useDocumentProjection suspends while it fetches. A boundary per item means one slow item shows its own fallback instead of blanking the whole list.
  • Keep one fetching hook per component. MovieList calls useDocuments and MovieListItem calls useDocumentProjection. An update to one document re-renders only its item.

The SDK batches the per-item projection requests, so rendering one component per document doesn't send one network request per document. For the reasoning behind this pattern, see App SDK best practices.

Add the list to your app

useDocuments suspends while it fetches its first results, so render MovieList inside its own Suspense boundary. Without one, React falls back to the boundary inside SanityApp. The whole app then shows its loading state until the list loads.

Replace the contents of src/App.tsx, setting your own project ID and dataset:

import {type SanityConfig} from '@sanity/sdk'
import {SanityApp} from '@sanity/sdk-react'
import {Suspense} from 'react'
import {MovieList} from './MovieList'

const config: SanityConfig[] = [
  {
    projectId: '',
    dataset: '',
  },
]

export default function App() {
  return (
    <SanityApp config={config} fallback={<div>Loading…</div>}>
      <Suspense fallback={<p>Loading movies…</p>}>
        <MovieList />
      </Suspense>
    </SanityApp>
  )
}
  • : The ID of the project that holds your documents. Find it in sanity.io/manage.
  • : The dataset name, such as production.

Start the dev server and open the app in your organization's dashboard:

npm run dev

The app lists the title and release year of up to 25 movie documents. Edit a title in Sanity Studio and the list updates without a reload.

Sort the list

Pass orderings to sort the results. Each entry takes a field and a direction of asc or desc. Later entries break ties in earlier ones.

To sort newest first, then alphabetically by title, replace the useDocuments call in MovieList:

const {data: movies} = useDocuments({
  documentType: 'movie',
  orderings: [
    {field: 'releaseDate', direction: 'desc'},
    {field: 'title', direction: 'asc'},
  ],
})

orderings is marked beta in the SDK, so its shape may change in a minor release.

Filter the list with GROQ

Pass a GROQ filter expression to narrow the results, and pass values into it with params. The SDK combines the filter with documentType, so don't repeat _type == "movie" in the filter.

To list only movies released in or after a given year, replace the useDocuments call in MovieList:

const {data: movies} = useDocuments({
  documentType: 'movie',
  filter: 'releaseDate >= $from',
  params: {from: '2000-01-01'},
})

Always pass user input through params rather than interpolating it into the filter string.

Add a search field

Pass a search string to match documents by text. Store the input value in state in the parent component, and pass it to the component that calls useDocuments:

import {Suspense, useState} from 'react'
import {
  useDocumentProjection,
  useDocuments,
  type DocumentHandle,
} from '@sanity/sdk-react'

interface MovieProjection {
  title?: string
  releaseDate?: string
}

export function MovieList() {
  const [search, setSearch] = useState('')

  return (
    <>
      <input
        type="search"
        placeholder="Search movies"
        value={search}
        onChange={(event) => setSearch(event.currentTarget.value)}
      />
      <Suspense fallback={<p>Loading movies…</p>}>
        <MovieResults search={search} />
      </Suspense>
    </>
  )
}

function MovieResults({search}: {search: string}) {
  const {data: movies, isPending} = useDocuments({
    documentType: 'movie',
    search,
  })

  return (
    <ul style={{opacity: isPending ? 0.5 : 1}}>
      {movies.map((movie) => (
        <Suspense key={movie.documentId} fallback={<li>Loading…</li>}>
          <MovieListItem {...movie} />
        </Suspense>
      ))}
    </ul>
  )
}

function MovieListItem(props: DocumentHandle) {
  const {data} = useDocumentProjection<MovieProjection>({
    ...props,
    projection: `{title, releaseDate}`,
  })

  return (
    <li>
      {data?.title ?? 'Untitled'}
      {data?.releaseDate && ` (${data.releaseDate.slice(0, 4)})`}
    </li>
  )
}

The input sits outside the Suspense boundary that wraps MovieResults, so it stays visible while the list first loads. After that, typing doesn't suspend the list. useDocuments keeps showing the previous results while the new query runs, and sets isPending to true. The example uses isPending to dim the list until the new results arrive.

search runs a full-text match across all of a document's fields, and matches the last word as a prefix. To search a single field, such as the title, use a filter with params instead. The SDK trims the search string, and an empty string returns the unfiltered list. You can combine search with filter, params, and orderings in the same call.

Load more documents

useDocuments returns results in batches. Besides data, it returns:

  • count: The total number of documents that match the query.
  • hasMore: Whether more matching documents are available.
  • loadMore: A function that appends the next batch to data.
  • isPending: Whether a new query or the next batch is loading. data keeps the previous results until it finishes.

Set the batch size with batchSize (default 25), and render a button that calls loadMore while hasMore is true. This is the complete src/MovieList.tsx, building on the example in Add a search field:

import {Suspense, useState} from 'react'
import {
  useDocumentProjection,
  useDocuments,
  type DocumentHandle,
} from '@sanity/sdk-react'

interface MovieProjection {
  title?: string
  releaseDate?: string
}

export function MovieList() {
  const [search, setSearch] = useState('')

  return (
    <>
      <input
        type="search"
        placeholder="Search movies"
        value={search}
        onChange={(event) => setSearch(event.currentTarget.value)}
      />
      <Suspense fallback={<p>Loading movies…</p>}>
        <MovieResults search={search} />
      </Suspense>
    </>
  )
}

function MovieResults({search}: {search: string}) {
  const {data: movies, count, hasMore, isPending, loadMore} = useDocuments({
    documentType: 'movie',
    search,
    batchSize: 20,
  })

  return (
    <>
      <p>
        Showing {movies.length} of {count} movies
      </p>
      <ul style={{opacity: isPending ? 0.5 : 1}}>
        {movies.map((movie) => (
          <Suspense key={movie.documentId} fallback={<li>Loading…</li>}>
            <MovieListItem {...movie} />
          </Suspense>
        ))}
      </ul>
      {hasMore && (
        <button type="button" onClick={loadMore} disabled={isPending}>
          {isPending ? 'Loading…' : 'Load more'}
        </button>
      )}
    </>
  )
}

function MovieListItem(props: DocumentHandle) {
  const {data} = useDocumentProjection<MovieProjection>({
    ...props,
    projection: `{title, releaseDate}`,
  })

  return (
    <li>
      {data?.title ?? 'Untitled'}
      {data?.releaseDate && ` (${data.releaseDate.slice(0, 4)})`}
    </li>
  )
}

Changing search, filter, params, orderings, documentType, or batchSize resets the list to the first batch.

To trigger loadMore automatically as the reader scrolls, call it from an IntersectionObserver attached to an element at the end of the list. To show numbered pages instead of a growing list, use usePaginatedDocuments, which accepts the same filtering, search, and ordering options.

Troubleshoot the document list

The list includes unpublished changes. useDocuments and useDocumentProjection read with the drafts perspective by default. Each document appears with its draft content if a draft exists, and its published content otherwise. To show only published content, pass perspective: 'published' to both hooks. To change the default for the whole app, set perspective on the SanityConfig object.

The list is empty. Check that documentType matches the document's _type value exactly, including case, and that the projectId and dataset in SanityConfig point to the dataset that holds the documents. If you also pass a filter, remove it to confirm whether the filter excludes everything.

The whole app shows its loading state while the list first loads. A component that calls useDocuments or useDocumentProjection has no Suspense boundary of its own, so React falls back to the one inside SanityApp. Wrap each fetching component in Suspense, as shown in Add the list to your app.

Items render Untitled. The projection returned no title, either because the field is empty or because the projection names a field that doesn't exist on the type. Check field names against your schema.

Next steps

  • Open a document from the list in Sanity Studio with useNavigateToStudioDocument, from @sanity/sdk-react/dashboard.
  • Edit list items in place with useDocument and useEditDocument. See Editing documents.
  • Learn how handles move between hooks in Document handles.
  • See every option and return value in the useDocuments reference.

Was this page helpful?

Sanity logosanity.ioAll Systems Operational© Sanity 2026
Change site theme