> For AI agents: the complete Sanity documentation index is available at [https://www.sanity.io/docs/llms.txt](https://www.sanity.io/docs/llms.txt).

# 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](https://www.sanity.io/docs/app-sdk/sdk-quickstart).
- `@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](https://www.sanity.io/docs/app-sdk/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:

**src/MovieList.tsx**

```tsx
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](https://www.sanity.io/docs/app-sdk/create-a-document-list).

## 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:

**src/MovieList.tsx**

```tsx
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](https://www.sanity.io/docs/app-sdk/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:

**src/App.tsx**

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

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

export default function App() {
  return (
    <SanityApp config={config} fallback={<div>Loading…</div>}>
      <Suspense fallback={<p>Loading movies…</p>}>
        <MovieList />
      </Suspense>
    </SanityApp>
  )
}
```

- `YOUR_PROJECT_ID`: The ID of the project that holds your documents. Find it in [sanity.io/manage](https://www.sanity.io/manage).
- `YOUR_DATASET`: The dataset name, such as `production`.

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

**npm**

```shell
npm run dev
```

**pnpm**

```shell
pnpm run dev
```

**yarn**

```shell
yarn run dev
```

**bun**

```shell
bun 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`:

**src/MovieList.tsx**

```tsx
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`:

**src/MovieList.tsx**

```tsx
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`:

**src/MovieList.tsx**

```tsx
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](https://www.sanity.io/docs/app-sdk/create-a-document-list):

**src/MovieList.tsx**

```tsx
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](https://www.sanity.io/docs/app-sdk/create-a-document-list).

**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](https://www.sanity.io/docs/app-sdk/editing-documents).
- Learn how handles move between hooks in [Document handles](https://www.sanity.io/docs/app-sdk/document-handles).
- See every option and return value in the [useDocuments reference](https://reference.sanity.io/_sanity/sdk-react/exports/useDocuments/).

