# Course: Build content apps with Sanity App SDK
https://www.sanity.io/learn/course/build-content-apps-with-sanity-app-sdk

Building fast, real-time content authoring applications has never been simpler. Create a feedback processing application with user assignment, AI analysis and more.

---

## Navigation

**Track:** [Mastering content operations](https://www.sanity.io/learn/track/sanity-developer-essentials) · [View as markdown](https://www.sanity.io/learn/track/sanity-developer-essentials.md)

## Contents

1. [Building content apps](https://www.sanity.io/learn/course/build-content-apps-with-sanity-app-sdk/building-content-apps) · [markdown](https://www.sanity.io/learn/course/build-content-apps-with-sanity-app-sdk/building-content-apps.md)
2. [Create a new Project and Studio](https://www.sanity.io/learn/course/build-content-apps-with-sanity-app-sdk/create-a-new-project-and-studio) · [markdown](https://www.sanity.io/learn/course/build-content-apps-with-sanity-app-sdk/create-a-new-project-and-studio.md)
3. [Quickstart a new App SDK app ](https://www.sanity.io/learn/course/build-content-apps-with-sanity-app-sdk/quickstart-a-new-app-sdk-app) · [markdown](https://www.sanity.io/learn/course/build-content-apps-with-sanity-app-sdk/quickstart-a-new-app-sdk-app.md)
4. [useDocuments](https://www.sanity.io/learn/course/build-content-apps-with-sanity-app-sdk/use-documents) · [markdown](https://www.sanity.io/learn/course/build-content-apps-with-sanity-app-sdk/use-documents.md)
5. [useDocumentProjection](https://www.sanity.io/learn/course/build-content-apps-with-sanity-app-sdk/use-document-projection) · [markdown](https://www.sanity.io/learn/course/build-content-apps-with-sanity-app-sdk/use-document-projection.md)
6. [useDocument](https://www.sanity.io/learn/course/build-content-apps-with-sanity-app-sdk/use-document) · [markdown](https://www.sanity.io/learn/course/build-content-apps-with-sanity-app-sdk/use-document.md)
7. [useEditDocument](https://www.sanity.io/learn/course/build-content-apps-with-sanity-app-sdk/use-edit-document) · [markdown](https://www.sanity.io/learn/course/build-content-apps-with-sanity-app-sdk/use-edit-document.md)
8. [useApplyDocumentActions](https://www.sanity.io/learn/course/build-content-apps-with-sanity-app-sdk/use-apply-document-actions) · [markdown](https://www.sanity.io/learn/course/build-content-apps-with-sanity-app-sdk/use-apply-document-actions.md)
9. [useDocumentEvent](https://www.sanity.io/learn/course/build-content-apps-with-sanity-app-sdk/use-document-event) · [markdown](https://www.sanity.io/learn/course/build-content-apps-with-sanity-app-sdk/use-document-event.md)
10. [useUsers](https://www.sanity.io/learn/course/build-content-apps-with-sanity-app-sdk/use-users) · [markdown](https://www.sanity.io/learn/course/build-content-apps-with-sanity-app-sdk/use-users.md)
11. [useUser](https://www.sanity.io/learn/course/build-content-apps-with-sanity-app-sdk/use-user) · [markdown](https://www.sanity.io/learn/course/build-content-apps-with-sanity-app-sdk/use-user.md)
12. [useNavigateToStudioDocument](https://www.sanity.io/learn/course/build-content-apps-with-sanity-app-sdk/use-navigate-to-studio-document) · [markdown](https://www.sanity.io/learn/course/build-content-apps-with-sanity-app-sdk/use-navigate-to-studio-document.md)
13. [useClient](https://www.sanity.io/learn/course/build-content-apps-with-sanity-app-sdk/use-client) · [markdown](https://www.sanity.io/learn/course/build-content-apps-with-sanity-app-sdk/use-client.md)
14. [Deployment and finishing touches](https://www.sanity.io/learn/course/build-content-apps-with-sanity-app-sdk/deployment-and-finishing-touches) · [markdown](https://www.sanity.io/learn/course/build-content-apps-with-sanity-app-sdk/deployment-and-finishing-touches.md)
15. [SDK Quiz](https://www.sanity.io/learn/course/build-content-apps-with-sanity-app-sdk/sdk-quiz) · [markdown](https://www.sanity.io/learn/course/build-content-apps-with-sanity-app-sdk/sdk-quiz.md)

---

## Lesson 1: Building content apps
https://www.sanity.io/learn/course/build-content-apps-with-sanity-app-sdk/building-content-apps

A true content operating system provides more than one way to author content. Build powerful, fit-for-purpose applications faster than ever before.

> [Video: Building content apps](https://www.sanity.io/learn/course/build-content-apps-with-sanity-app-sdk/building-content-apps)

The Sanity App SDK is a collection of utilities for building content applications backed by the Content Lake. It is headless by design, so you can use whatever front-end framework you like. In the React package, almost all of its functionality is provided by React Hooks. 



It is the fastest way to rapidly build task-specific applications for authors and editor teams to perform content operations.



## Why build content apps?



Sanity Studio is a powerful and nearly infinitely customizable admin panel for creating and editing content. However, given its flexibility, it can become so complex that it becomes difficult for authors with a specific task in mind, especially one that needs to be done repetitively, to perform it efficiently.



So, while Sanity Studio may be the default experience for all of your content operations, the Sanity App SDK provides a way to build novel applications with a specific job in mind.



> When all you have is a hammer, everything looks like a nail



Sanity Studio is a hammer, but not all content operations are nails. Sanity App SDK is a scalpel. And this is a tortured metaphor.



For example, imagine a content author at a media publication who needs to process feedback. They could click through a Studio to find what they need, or you could build an application to do what they need faster and better.



And that's what you'll build in this course.



## Prerequisites



This course expects that you have a reasonable understanding of the Command Line, PNPM, React, TypeScript, and Sanity. You will not need to be an expert in any of these, but this course is written with the expectation that this is not your first time encountering these words.



If you would prefer to get a top-to-bottom look at how to work with *Sanity, the platform*, you may be better served by the Day One with Sanity course.



> [!TIP]
> Take [Day one content operations](https://www.sanity.io/learn/course/day-one-with-sanity-studio)



## What you'll build



![An application with a list of feedback items on the left and a document editor on the right.](https://cdn.sanity.io/images/3do82whm/next/40e32fb51ae5fa89b6ed1ad41a822947bd83e523-2240x1480.png)

You'll be building a single-purpose application for processing feedback. You might imagine this feedback is received from a comments form or email account. Your content authors need a more efficient way to see which feedback:



- is pending approval to proceed in the workflow

- should be marked as spam and dismissed

- should be deleted

- and to mark the sentiment of any feedback as positive, neutral or negative.


Let's begin in the next lesson by first creating a new Sanity project and Studio.



---

## Lesson 2: Create a new Project and Studio
https://www.sanity.io/learn/course/build-content-apps-with-sanity-app-sdk/create-a-new-project-and-studio

Get setup with a fresh hosted backend for your content, and the traditional administration panel for Sanity.

> [Video: Create a new Project and Studio](https://www.sanity.io/learn/course/build-content-apps-with-sanity-app-sdk/create-a-new-project-and-studio)

Content written to Sanity is stored within the Content Lake, not in Sanity Studio. The schema types you configure are made available in Sanity Studio, and it writes content of that shape to the Content Lake.



You can think of the Sanity Studio as a *"window into the Content Lake."*



## So I don't need a Studio to build an SDK App?



Nope!



You can write data of any shape (that conforms to JSON) to the Content Lake.



But having a Studio makes it easier to reason about our *complete* universe of content if we have one configured. It's also where you'd configure TypeScript types. So you'll start this course by creating a new Sanity project and Sanity Studio—you just won't need to edit any content there.



## Create a new project



You can create a new free project and initialize a new Sanity Studio from one command using the Sanity CLI. If you do not yet have a Sanity account, you can create one during this process.



You may like to create a parent folder to contain the projects you work on in this course, as your Sanity Studio and App SDK app will live side-by-side.



Once you have a Studio and App setup, your folder structure should look like this:



```text
feedback-course
├── studio
└── app-feedback
```

- [ ] **Run** the following command in your terminal to create a new Sanity project and Sanity Studio.


```sh:Terminal
pnpm dlx create-sanity@latest --template blog --create-project "Feedback Processor" --dataset production --typescript --output-path studio
```

- [ ] **Run** the following from inside of the `/studio` directory


```sh:Terminal
pnpm run dev
```

Open [http://localhost:3333](http://localhost:3333) in your browser and log in. You should now see the Sanity Studio dashboard interface with Post, Category and Author schema types already configured.



## Add "Feedback" schema types



The schema types that appear in the Structure tool of Sanity Studio are defined in the project's configuration files. You'll need to create a new file to add "Feedback" type documents as an option in the Studio and a TypeScript type for your App.



- [ ] **Create** a new file for Feedback type documents


```typescript:studio/schemaTypes/feedbackType.ts
import {defineField, defineType} from 'sanity'

export const feedbackType = defineType({
  name: 'feedback',
  title: 'Feedback',
  type: 'document',
  fields: [
    defineField({
      name: 'content',
      type: 'text',
    }),
    defineField({
      name: 'author',
      type: 'string',
    }),
    defineField({
      name: 'email',
      type: 'string',
    }),
    defineField({
      name: 'sentiment',
      type: 'string',
      options: {list: ['positive', 'neutral', 'negative'], layout: 'radio'},
    }),
    defineField({
      name: 'status',
      type: 'string',
      options: {list: ['pending', 'approved', 'spam'], layout: 'radio'},
    }),
    defineField({
      name: 'assignee',
      type: 'string',
    }),
    defineField({
      name: 'notes',
      type: 'text',
    }),
  ],
  preview: {
    select: {
      title: 'content',
      subtitle: 'author',
    },
  },
})
```

**Update** the schema types index file to include the feedback schema type.



```typescript:studio/schemaTypes/index.ts
import blockContent from './blockContent'
import category from './category'
import post from './post'
import author from './author'
import {feedbackType} from './feedbackType'

export const schemaTypes = [post, author, category, blockContent, feedbackType] 
```

You should now see an option in your Structure tool to list and create Feedback type documents.



## Import seed data



It is easier to build front ends when you have content, and so some has been prepared for you already.



- [ ] **Download** `feedback-seed.ndjson` 

- [ ] **Run** the following from the terminal, inside the `studio` folder to import 20 example Feedback type documents.


```sh:Terminal
pnpm dlx sanity dataset import feedback-seed.ndjson production
```

Open your Studio to confirm these documents are now visible, there's a mix of spam comments and none of them have been marked to indicate their "sentiment," that's the job of our new app!



Let's start building it next.



---

## Lesson 3: Quickstart a new App SDK app 
https://www.sanity.io/learn/course/build-content-apps-with-sanity-app-sdk/quickstart-a-new-app-sdk-app

Start a new App SDK app in seconds from the command line using the Sanity UI template.

> [Video: Quickstart a new App SDK app ](https://www.sanity.io/learn/course/build-content-apps-with-sanity-app-sdk/quickstart-a-new-app-sdk-app)

You've now got content to work with and a Sanity Studio, it's time to start building your app.



- [ ] **Run** the following from the terminal in the root directory (above the `studio` directory)


```sh:Terminal
# in the parent directory
pnpm dlx sanity@latest init --template app-sanity-ui --typescript --output-path app-feedback
```

You may be prompted to select an Organization, select your personal organization, as that's where the project you created in the previous lesson was created.



You should now have these two adjacent folders.



```
feedback-course
├── app-feedback
└── studio
```

## Why Sanity UI?



The command you just ran uses the Sanity UI app template. This includes the required context and packages for the same front end library used in other Sanity applications like Sanity Studio, Media Library and Dashboard.



You're free to use the front-end library of your choice in Sanity App SDK applications. For this course, however, you'll use Sanity UI so that the application you build shares visual harmony with the rest of the dashboard experience.



> [!TIP]
> [Sanity UI](https://www.sanity.io/ui) has its own documentation site with implementation details


> [!TIP]
> See the [App SDK docs](https://www.sanity.io/learn/docs/app-sdk/sanity-ui-sdk) for examples on how to install other styling libraries



## Running two apps



By default, SDK Apps use the same port number (`3333`) as the Studio. To run the Studio and your applications simultaneously, you can update `sanity.cli.ts` of either one. Let's change the default port of the Studio.



- [ ] **Update** the Sanity CLI config of the Sanity Studio


```typescript:studio/sanity.cli.ts
import {defineCliConfig} from 'sanity/cli'

export default defineCliConfig({
  server: {
    port: 3334,
  },
  // ...all other settings
}) 
```

Restart your Studio's development server, you'll get a new development URL. 



Open the Studio in your browser and be asked to create a new CORS origin. 



You can follow the instructions in the browser, or create a new origin using Sanity CLI with the following command run from inside your `studio` folder.



```sh:Terminal
# in /studio
pnpm dlx sanity@latest cors add http://localhost:3334 --allow 
```

- [ ] **Run** the following inside the `app-feedback` folder to start the app's development server.


```sh
# in /app-feedback
pnpm run dev 
```

> [!WARNING]
> If you get an error about a mismatched Organization ID, you may have selected a different Organization to the one in which the project was created. Update `app-feedback/sanity.cli.ts` to use the correct Organization ID.



You'll see a URL in the terminal to open the App running from within the Sanity Dashboard.



Dashboard is the default "home screen" where authors can move between deployed Studios and other applications—such as the one you're building right now. The Dashboard also provides authentication to your app.



In a large enough organization, you may have many teams of authors working between or across multiple projects all served by deployed Sanity Studio instances or Apps of all shapes and sizes. 



Content operations are not the job of a one-size-fits-nobody CMS!



## Targeting your project(s)



While this app will only target one project, an App SDK app can target multiple (worth knowing: Sanity Studio can't do this).



The entry point for your application is `App.tsx`, you can see this defined in `sanity.cli.ts`.



- [ ] **Update** the main `App.tsx` file with the details found in your  `studio/sanity.config.ts` file.


```typescript:app-feedback/src/App.tsx
const sanityConfigs: SanityConfig[] = [
  {
    projectId: 'REPLACE-WITH-YOUR-PROJECT-ID',
    dataset: 'production',
  },
] 
```

There is an `ExampleComponent` already loaded as the main child of the application. Let's replace this with our first use of the App SDK's React hooks to query for a list of documents.



---

## Lesson 4: useDocuments
https://www.sanity.io/learn/course/build-content-apps-with-sanity-app-sdk/use-documents

Performant querying for a live-updating list of documents has never been simpler.

> [Video: useDocuments](https://www.sanity.io/learn/course/build-content-apps-with-sanity-app-sdk/use-documents)

Maybe the most basic thing we can do when creating an application is query and render a list of documents. In this lesson, you'll do it using the `useDocuments` hook. 



Before we do, let's interrogate this decision.



## Why not use client.fetch?



[Sanity Client](https://github.com/sanity-io/client) is the primary way JavaScript applications are built to interact with Sanity's APIs. The App SDK is a wrapper of Sanity Client for building apps and solves much of the UI complexity that comes with working with a lower-level client.



For example, when you query documents with `client.fetch`, the list of documents will not update in real-time as documents change. You might also unknowingly fetch 10,000 documents. Edits made to these documents will not automatically create per-render optimistic updates in the UI.



All complexity and performance concerns are bundled up in the App SDK's React hooks, making it simpler and faster for us to build better content applications.



## Why not useQuery?



The App SDK provides the useQuery hook, which takes a GROQ query. We could use this hook for all our data fetching. However, many other hooks in the App SDK for React require "document handles" to be passed in as parameters.



These can be created ad hoc from values in a document. Still, it's simpler to retrieve document handles in a parent component and pass them down to child components, which perform fetches or actions using those handles.



### When you might want useQuery



There are some patterns where it makes more sense to use `useQuery` instead of `useDocuments`, such as when a parent component needs to know specific values of each document. For example, a component may need to know which day an event is on to render it into a calendar or the geolocation of an event to plot it on a map. 



In these instances, you may be better off fetching with `useQuery` documents and their values in the parent component. Just be aware that this can lead to overfetching.



## What are document handles?



A [document handle](https://www.sanity.io/learn/docs/app-sdk/document-handles) contains at least two and up to four useful pieces of information about a document to identify its type and origin. By keeping the data returned by fetches of documents smaller, we maintain focus on improved performance in our applications.



```json
{
  "dataset": "production",
  "documentId": "116d2c7a-d1de-4d00-9a88-8ac65ceaad10",
  "documentType": "feedback",
  "projectId": "xe385msc"
} 
```

## tl;dr it's about more, smaller fetches



In Server-Side Rendered (SSR) web application frontends, you have likely formed a habit of fetching **everything** your web page needed in one huge GROQ query using `client.fetch`. 



This isn't necessary in a Single Page Application (SPA).



The happy path for App SDK apps is to filter for specific documents using `useDocuments` and pass down the returned document handles to components which individually fetch, edit and take actions on documents.



Any concerns about caching, real-time and optimistic updates are all taken care of by the App SDK.



## Let's finally fetch something



- [ ] **Create** a new component called `Feedback`, which will be the parent component of all our UI.


```tsx:app-feedback/src/Feedback.tsx
import { Suspense, useState } from "react"
import { DocumentHandle } from "@sanity/sdk-react"
import { Card, Flex, Grid, Spinner } from "@sanity/ui"
import { styled } from "styled-components"

import { FeedbackList } from "./FeedbackList"

const ScreenHeightCard = styled(Card)`
  height: 100vh;
  overflow: scroll;
`

export function Feedback() {
  const [selectedFeedback, setSelectedFeedback] =
    useState<DocumentHandle | null>(null)

  return (
    <Grid columns={5}>
      <ScreenHeightCard columnStart={1} columnEnd={3}>
        <Suspense fallback={<Loading />}>
          <FeedbackList
            setSelectedFeedback={setSelectedFeedback}
            selectedFeedback={selectedFeedback}
          />
        </Suspense>
      </ScreenHeightCard>
      <ScreenHeightCard borderLeft columnStart={3} columnEnd={6}>
        {/* TODO: Add <FeedbackEdit /> form */}
      </ScreenHeightCard>
    </Grid>
  )
}

function Loading() {
  return (
    <Flex justify="center" align="center" width="fill" height="fill">
      <Spinner />
    </Flex>
  )
}
```

> [!NOTE]
> Don't forget your `Suspense` boundaries. The App SDK React Hooks use Suspense for data fetching. This means any component which uses one of these hooks could cause a re-render further up the component tree. Since this `Feedback` component will be rendering both the `FeedbackList` and `FeedbackEdit` form components, without being wrapped in `Suspense` an update in one component would force both to re-render. 


> [!TIP]
> **Read more** about [`Suspense` in the React documentation](https://react.dev/reference/react/Suspense)


- [ ] **Create** another component to query for the feedback documents.


```tsx:app-feedback/src/FeedbackList.tsx
import { type DocumentHandle, useDocuments } from "@sanity/sdk-react"
import { Stack, Button } from "@sanity/ui"

type FeedbackListProps = {
  selectedFeedback: DocumentHandle | null
  setSelectedFeedback: (feedback: DocumentHandle | null) => void
}

export function FeedbackList({
  selectedFeedback,
  setSelectedFeedback,
}: FeedbackListProps) {
  const { data, hasMore, loadMore } = useDocuments({
    documentType: "feedback",
  })

  return (
    <Stack space={2} padding={5}>
      {data?.map((feedback) => (
        <pre key={feedback.documentId}>{JSON.stringify(feedback, null, 2)}</pre>
      ))}
      {hasMore && <Button onClick={loadMore} text="Load more" />}
    </Stack>
  )
}
```

Lastly, you'll need to load the Feedback component into the main App. 



- [ ] **Update** `App.tsx` to replace `ExampleComponent` with `Feedback`


```tsx:feedback-app/src/App.tsx
import {Feedback} from "./Feedback"

export default function App() {
  // ...sanityConfigs, Loading

  return (
    <SanityUI>
      <SanityApp config={sanityConfigs} fallback={<Loading />}>
        <Feedback />
      </SanityApp>
    </SanityUI>
  )
}
```

In your application you should now see a list of document handles rendered into the UI.



![Application showing a list of JSON objects](https://cdn.sanity.io/images/3do82whm/next/349b181d054a399467f27797733cbf9d451b2b89-2240x1480.png)

So you now have an application that can query documents, but not any useful information about them. Let's fix that in the next lesson.



---

## Lesson 5: useDocumentProjection
https://www.sanity.io/learn/course/build-content-apps-with-sanity-app-sdk/use-document-projection

Pick just the content you need from individual documents, and only when a component is rendered in view.

> [Video: useDocumentProjection](https://www.sanity.io/learn/course/build-content-apps-with-sanity-app-sdk/use-document-projection)

We have a list of document handles, but we need more information about each document. Let's create a component that uses these handles to fetch more values from each document.



- [ ] **Create** a new component to visualize the value of the `status` field in a document throughout our application.


```tsx:app-feedback/src/StatusBadge.tsx
import { Badge } from "@sanity/ui"

type StatusBadgeProps = {
  status?: string
  fontSize?: number
}

export function StatusBadge({
  status = "PENDING",
  fontSize = 2,
}: StatusBadgeProps) {
  return (
    <Badge
      tone={
        status === "approved"
          ? "positive"
          : status === "spam"
          ? "caution"
          : "default"
      }
      padding={2}
      fontSize={fontSize}
    >
      {status.toUpperCase()}
    </Badge>
  )
}
```

- [ ] **Create** a component to retrieve and display values from a document by its handle


```tsx:app-feedback/src/FeedbackPreview.tsx
import { useRef } from "react"
import { DocumentHandle, useDocumentProjection } from "@sanity/sdk-react"
import { Box, Stack, Text } from "@sanity/ui"

import { StatusBadge } from "./StatusBadge"

type FeedbackPreviewData = {
  _createdAt: string
  content: string | null
  author: string | null
  email: string | null
  status: string
}

export function FeedbackPreview(props: DocumentHandle) {
  const previewRef = useRef<HTMLDivElement>(null)
  const { data, isPending } = useDocumentProjection<FeedbackPreviewData>({
    ...props,
    ref: previewRef,
    projection: `{
      _createdAt,
      content,
      author,
      email,
      "status": coalesce(status, "PENDING")
    }`,
  })

  const showPlaceholder = isPending && !data

  return (
    <Stack ref={previewRef} space={3}>
      <Text size={2} weight="semibold" textOverflow="ellipsis">
        {showPlaceholder ? "..." : data.author}
      </Text>
      <Text muted size={1} textOverflow="ellipsis">
        {showPlaceholder
          ? "..."
          : data.email + " " + data._createdAt.split("T")[0]}
      </Text>
      <Text size={2} textOverflow="ellipsis">
        {showPlaceholder ? "..." : data.content}
      </Text>
      <Box>
        <StatusBadge status={data.status} fontSize={1} />
      </Box>
    </Stack>
  )
}
```

There's a few key things to look at in this component.



- `useDocumentProjection` receives the passed-in document handle as props, and then declares a GROQ "projection" which retrieves values from the document.

- The `ref` being passed into the hook is attached to the outermost Stack component. This will ensure that the content returned by this projection is only queried when the component is rendered and visible on the page. Another small but important performance win!

> [!NOTE]
> Throughout this course we're manually creating Types. This is because TypeGen support for App SDK currently uses experimental packages and may change in future. See the documentation for the most current implementation method.


> [!TIP]
> See [App SDK and TypeGen](https://www.sanity.io/learn/app-sdk/sdk-typegen) in the documentation



## Update the feedback list



Now you have a component to fetch individual documents, let's update the feedback list component to use it.



- [ ] **Update** the `FeedbackList` component to render the `FeedbackPreview` component


```tsx:app-feedback/src/FeedbackList.tsx
import { Suspense } from "react"
import { type DocumentHandle, useDocuments } from "@sanity/sdk-react"
import { Stack, Button, Spinner } from "@sanity/ui"

import { FeedbackPreview } from "./FeedbackPreview"

type FeedbackListProps = {
  selectedFeedback: DocumentHandle | null
  setSelectedFeedback: (feedback: DocumentHandle | null) => void
}

export function FeedbackList({
  selectedFeedback,
  setSelectedFeedback,
}: FeedbackListProps) {
  const { data, hasMore, loadMore } = useDocuments({
    documentType: "feedback",
  })

  return (
    <Stack space={2} padding={5}>
      {data?.map((feedback) => {
        const isSelected = selectedFeedback?.documentId === feedback.documentId

        return (
          <Button
            key={feedback.documentId}
            onClick={() => setSelectedFeedback(feedback)}
            mode={isSelected ? "ghost" : "bleed"}
            tone={isSelected ? "primary" : undefined}
          >
            <Suspense fallback={<Spinner />}>
              <FeedbackPreview {...feedback} />
            </Suspense>
          </Button>
        )
      })}
      {hasMore && <Button onClick={loadMore} text="Load more" />}
    </Stack>
  )
}
```

You should now have the feedback items rendered as a list of buttons. Most importantly you'll see values from each document in each button. And if any other author makes changes to these documents, you'll see those values update live!



![Application rendering a list of documents](https://cdn.sanity.io/images/3do82whm/next/e2132df769d4f4b14d78073cd5e21d63e5f52dbc-2240x1480.png)

You can click to select them, they just won't do anything yet. In the next lesson you can start to building a form to edit each feedback document.



---

## Lesson 6: useDocument
https://www.sanity.io/learn/course/build-content-apps-with-sanity-app-sdk/use-document

Fetch content with real-time and optimistic updates when edits are made—locally or remotely.

> [Video: useDocument](https://www.sanity.io/learn/course/build-content-apps-with-sanity-app-sdk/use-document)

This hook is similar to `useDocumentProjection` in the previous lesson. However `useDocument` will sync with both local and remote changes to the document. Because of this it can be more memory intensive, this hook should be used sparingly.



That is why in this course you've used `useDocumentProjection` for the document list— which could eventually render 100's of documents—while only using `useDocument` for the one document rendered in the editing form. 



- [ ] **Create** a new component to query for the entire document from its handle.


```tsx:app-feedback/src/FeedbackEdit.tsx
import { DocumentHandle, useDocument } from "@sanity/sdk-react"
import { Card, Flex, Stack, Text, Container } from "@sanity/ui"

import { StatusBadge } from "./StatusBadge"

type FeedbackEditProps = {
  selectedFeedback: DocumentHandle
}

export function FeedbackEdit({ selectedFeedback }: FeedbackEditProps) {
  const { data } = useDocument({ ...selectedFeedback })

  if (!data) {
    return null
  }

  // Ensure type safety for all fields
  const author = typeof data.author === "string" ? data.author : ""
  const email = typeof data.email === "string" ? data.email : ""
  const content = typeof data.content === "string" ? data.content : ""
  const createdAt =
    typeof data._createdAt === "string" ? data._createdAt.split("T")[0] : ""
  const status = typeof data.status === "string" ? data.status : "pending"
  const sentiment = typeof data.sentiment === "string" ? data.sentiment : ""
  const notes = typeof data.notes === "string" ? data.notes : ""
  const assignee = typeof data.assignee === "string" ? data.assignee : ""

  return (
    <Container width={1}>
      <Card padding={[0, 0, 4, 5]}>
        <Card padding={[0, 0, 4, 5]} radius={3} shadow={[0, 0, 2]}>
          <Stack space={5}>
            <Flex align="center" justify="space-between">
              <Stack space={3}>
                <Text size={3} weight="semibold">
                  {author}
                </Text>
                <Text size={1} muted>
                  {email} {createdAt}
                </Text>
              </Stack>
              <StatusBadge status={status} fontSize={2} />
            </Flex>

            <Stack space={3}>
              <Card padding={4} radius={2} tone="transparent">
                <Text size={3}>{content}</Text>
              </Card>
            </Stack>

            {/* In the next lessons... */}
            {/* Sentiment, Notes, Assignee, Actions */}
          </Stack>
        </Card>
      </Card>
    </Container>
  )
}
```

- [ ] **Update** the parent Feedback component to render the editing form


```tsx:app-feedback/src/Feedback.tsx
import { Suspense, useState } from "react"
import { DocumentHandle } from "@sanity/sdk-react"
import { Card, Flex, Grid, Spinner } from "@sanity/ui"
import { styled } from "styled-components"

import { FeedbackList } from "./FeedbackList"
import { FeedbackEdit } from "./FeedbackEdit"

const ScreenHeightCard = styled(Card)`
  height: 100vh;
  overflow: scroll;
`

export function Feedback() {
  const [selectedFeedback, setSelectedFeedback] =
    useState<DocumentHandle | null>(null)

  return (
    <Grid columns={5}>
      <ScreenHeightCard columnStart={1} columnEnd={3}>
        <Suspense fallback={<Loading />}>
          <FeedbackList
            setSelectedFeedback={setSelectedFeedback}
            selectedFeedback={selectedFeedback}
          />
        </Suspense>
      </ScreenHeightCard>
      <ScreenHeightCard borderLeft columnStart={3} columnEnd={6}>
        <Suspense fallback={<Loading />}>
          {selectedFeedback ? (
            <FeedbackEdit selectedFeedback={selectedFeedback} />
          ) : null}
        </Suspense>
      </ScreenHeightCard>
    </Grid>
  )
}

function Loading() {
  return (
    <Flex justify="center" align="center" width="fill" height="fill">
      <Spinner />
    </Flex>
  )
}
```

You should now be able to click each document in the list, and open the editing form on the right. The values in this form have a real-time subscription to changes in the Content Lake, as well as an in-memory optimistic cache to render any edits as they happen.



It's time to start editing documents with the App SDK.



---

## Lesson 7: useEditDocument
https://www.sanity.io/learn/course/build-content-apps-with-sanity-app-sdk/use-edit-document

Edit values in documents with all user interface and versioning complexity extracted away.

> [Video: useEditDocument](https://www.sanity.io/learn/course/build-content-apps-with-sanity-app-sdk/use-edit-document)

You've seen how simple it is to create performant, real-time lists of documents. Prepare to be amazed at how simple it is to edit them.



While the API in this lesson may look simple, what it's doing under the hood is anything but. Edits are optimistically written to an in-browser cache. When editing a published document, a new draft is immediately invoked, behavior which the Sanity Studio performs but has been difficult to replicate with Sanity Client alone.



The fetch from `useDocument` in the previous lesson provided us with real-time document values, now you can create form components which will update those values. 



## Editing with a radio input



First let's create a control to update the value of the `sentiment` field with a selection of radio buttons.



- [ ] **Create** a new component with a list of available values


```tsx:app-feedback/src/Sentiment.tsx
import { DocumentHandle, useEditDocument } from "@sanity/sdk-react"
import { Radio, Text, Inline, Stack } from "@sanity/ui"

type SentimentProps = {
  value: string
  handle: DocumentHandle
}

const SENTIMENTS = ["Positive", "Neutral", "Negative"]

export function Sentiment({ value, handle }: SentimentProps) {
  const editSentiment = useEditDocument({ ...handle, path: "sentiment" })

  return (
    <Stack space={3}>
      <Text weight="medium">Sentiment</Text>
      <Inline space={3}>
        {SENTIMENTS.map((sentiment) => (
          <Inline key={sentiment} as="label" space={1} htmlFor={sentiment}>
            <Radio
              id={sentiment}
              checked={value === sentiment.toLowerCase()}
              onChange={(e) => editSentiment(e.currentTarget.value)}
              name="sentiment"
              value={sentiment.toLowerCase()}
            />
            <Text>{sentiment}</Text>
          </Inline>
        ))}
      </Inline>
    </Stack>
  )
}
```

Notice how this component uses `useEditDocument` to only modify a specific path in the document. This means any value passed into the `editSentiment` function will be automatically written to that path in the document.



> [!NOTE]
> In many React applications you are encouraged to write and track changes to local state, perhaps with a `useState` hook. The real-time nature of Sanity Studio and the App SDK encourages you to always write directly to—and render responses from—the Content Lake. App SDK is doing work under the hood to make this optimistic and fast.


- [ ] **Update** the `FeedbackEdit` component to render the `Sentiment` component


```tsx:app-feedback/src/FeedbackEdit.tsx
{/* In the next lessons... */}
<Sentiment value={sentiment} handle={selectedFeedback} />
```

You can now click the radio buttons on any selected document, and the edits will take place in real time.



Open the same document side-by-side in your app and Sanity Studio to see how the changes are reflected to both documents. As well as noting how drafts are automatically invoked when making edits to published documents.



## Editing with a text input



The `notes` field in our feedback schema is for authors to add some helpful details for other team members to read.



- [ ] **Create** a new component to edit `notes`


```tsx:app-feedback/src/Notes.tsx
import { type DocumentHandle, useEditDocument } from "@sanity/sdk-react"
import { Stack, Text, TextArea } from "@sanity/ui"

type NotesProps = {
  value: string
  handle: DocumentHandle
}

export function Notes({ value, handle }: NotesProps) {
  const editNotes = useEditDocument({ ...handle, path: "notes" })

  return (
    <Stack space={3}>
      <Text weight="medium">Reviewer Notes</Text>
      <TextArea
        value={value}
        onChange={(e) => editNotes(e.currentTarget.value)}
        placeholder="Add your notes about this feedback..."
        rows={3}
      />
    </Stack>
  )
}
```

This component is quite similar to the `Sentiment` component before, where `editDocument` is configured with a pre-set path, and the TextArea input writes changes to it.



- [ ] **Update** `FeedbackEdit` to include the `Notes` component


```tsx:app-feedback/src/FeedbackEdit.tsx
<Notes value={notes} handle={selectedFeedback} />
```

Once again, try writing into the text field, and watch the same document in Sanity Studio update almost immediately after.



![Application with a document editing form](https://cdn.sanity.io/images/3do82whm/next/bc0277250ac384bbac6f3604338cb714d535a4e9-2240x1480.png)

You're now able to make edits to documents, but they're all left in a draft state. So our authors can't commit their changes. 



You'll setup document actions in the next lesson to finish the work.



---

## Lesson 8: useApplyDocumentActions
https://www.sanity.io/learn/course/build-content-apps-with-sanity-app-sdk/use-apply-document-actions

Perform actions on documents to end—or begin—the content lifecycle

> [Video: useApplyDocumentActions](https://www.sanity.io/learn/course/build-content-apps-with-sanity-app-sdk/use-apply-document-actions)

Document Actions are primarily used to modify the "version" of an entire document—to publish a draft document, to discard the current draft, or to delete the document.



In our app it may be useful to delete feedback that we don't want to keep (this is different from spam where we may want to keep it as a record of a sender for who all future submissions should be blocked).



## Delete document action



- [ ] **Create** a new component to hold all our document actions.


```tsx:app-feedback/src/Actions.tsx
import {
  deleteDocument,
  type DocumentHandle,
  useApplyDocumentActions,
} from "@sanity/sdk-react"
import { Button, Flex } from "@sanity/ui"

type ActionsProps = {
  handle: DocumentHandle
}

export function Actions({ handle }: ActionsProps) {
  const apply = useApplyDocumentActions()
  const handleDelete = () => apply(deleteDocument(handle))

  return (
    <Flex gap={1} direction={["column", "column", "column", "row"]}>
      <Button
        mode="ghost"
        tone="critical"
        text="Delete"
        onClick={handleDelete}
      />
    </Flex>
  )
}
```

- [ ] **Update** the `FeedbackEdit` component to render it


```tsx:app-feedback/src/FeedbackEdit.tsx
<Flex
  justify="flex-end"
  direction={["column-reverse", "column-reverse", "row"]}
  gap={2}
>
  <Actions handle={selectedFeedback} />
</Flex>
```

You'll add some more buttons to this component in latter lessons, that's why we're wrapping it with a `Flex` component now.



You can now click the "delete" button to delete a document. This action is immediate and the document is removed from the Content Lake.



Remember, you can re-import the seed data if you want to re-populate the feedback list!



You may notice a *small* bug into the UI—the currently selected document no longer exists and displays for a few moments before the list is updated. We'll improve this in a future lesson. 



## Edit and publish in one function



The intention of these buttons is to perform a final action before moving onto the next piece of feedback. If we only edited the `status` field to set the value as "approved" or "spam," the resulting document would still be in a draft version.



What we need is a function that will both edit the field value of the document and publish it.



- [ ] **Update** the Actions component to include buttons to mark as spam and approve


```tsx:app-feedback/src/Actions.tsx
import {
  deleteDocument,
  type DocumentHandle,
  publishDocument,
  useApplyDocumentActions,
  useEditDocument,
} from "@sanity/sdk-react"
import { Button, Flex } from "@sanity/ui"

type ActionsProps = {
  handle: DocumentHandle
}

export function Actions({ handle }: ActionsProps) {
  const apply = useApplyDocumentActions()

  const editStatus = useEditDocument({ ...handle, path: "status" })
  const handleDelete = () => apply(deleteDocument(handle))
  const handleMarkAsSpam = () => {
    editStatus("spam")
    apply(publishDocument(handle))
  }
  const handleApprove = () => {
    editStatus("approved")
    apply(publishDocument(handle))
  }

  return (
    <Flex gap={1} direction={["column", "column", "row"]}>
      <Button
        mode="ghost"
        tone="critical"
        text="Delete"
        onClick={handleDelete}
      />
      <Button
        mode="ghost"
        tone="caution"
        text="Mark as Spam"
        onClick={handleMarkAsSpam}
      />
      <Button
        mode="ghost"
        tone="positive"
        text="Approve"
        onClick={handleApprove}
      />
    </Flex>
  )
}
```

> [!NOTE]
> Invoking `editDocument` is how you can perform bulk editing operations across several documents at the same time.



You'll see these two new actions will invoke both `editDocument` to set the new `status` value and apply the `publishDocument` action. 



![Application with document editing form and three buttons](https://cdn.sanity.io/images/3do82whm/next/c673546cb61d49f6173a8bcce2efecfbf7b8f20d-2240x1480.png)

If you click one of these buttons with the Studio open to the same document, you may only briefly see a draft document with the updated `status` value before it is immediately published.



Now that your app is performing significant actions on documents, some additional feedback in the UI will help users. Let's add notifications in the next lesson.



---

## Lesson 9: useDocumentEvent
https://www.sanity.io/learn/course/build-content-apps-with-sanity-app-sdk/use-document-event

Listen to changes to content in your application and trigger events in the user interface.

> [Video: useDocumentEvent](https://www.sanity.io/learn/course/build-content-apps-with-sanity-app-sdk/use-document-event)

User feedback is extremely important in our applications. Especially when users are taking actions as significant as publishing or deleting documents. 



Sanity UI comes with hooks to pop up toast notifications. We'll configure that when events happen in this lesson.



Sanity UI exports a `ToastProvider` which should already be included inside the SanityUI component.



- [ ] **Confirm** `SanityUI.tsx` includes the `ToastProvider`


```tsx:app-feedback/src/SanityUI.tsx
import {ThemeProvider, ToastProvider} from '@sanity/ui'
import {buildTheme} from '@sanity/ui/theme'
import {createGlobalStyle} from 'styled-components'

const theme = buildTheme()

const GlobalStyle = createGlobalStyle`
  html, body {
    margin: 0;
    padding: 0;
  }
`

export function SanityUI({children}: {children: React.ReactNode}) {
  return (
    <>
      <GlobalStyle />
      <ThemeProvider theme={theme}>
        <ToastProvider>{children}</ToastProvider>
      </ThemeProvider>
    </>
  )
}
```

Now you can create a new component to listen to document events as they happen, and when required, pop up toast notifications in the UI.



- [ ] **Create** a new `FeedbackEvents` component to fire toast notifications as events happen


```tsx:app-feedback/src/FeedbackEvents.tsx
  import { DocumentEvent, useDocumentEvent } from "@sanity/sdk-react"
  import { useToast } from "@sanity/ui"
  
  export function FeedbackEvents() {
    const toast = useToast()
    const onEvent = (documentEvent: DocumentEvent) => {
      if (documentEvent.type === "published") {
        toast.push({
          title: "Feedback processed",
          status: "success",
        })
      } else if (documentEvent.type === "deleted") {
        toast.push({
          title: "Feedback deleted",
          status: "error",
        })
      }
    }
  
    useDocumentEvent({ onEvent })
  
    return null
  }
```

This component doesn't render any UI and so can be rendered anywhere inside the `SanityApp` provider.



- [ ] **Update** `App.tsx` to include the `FeedbackEvents` component


```tsx:app-feedback/src/App.tsx
import { FeedbackEvents } from "./FeedbackEvents"

export function App() {
  // ...sanityConfigs, Loading

  return (
    <SanityUI>
      <SanityApp config={sanityConfigs} fallback={<Loading />}>
        <Feedback />
        <FeedbackEvents />
      </SanityApp>
    </SanityUI>
  )
}

export default App
```

Now you can click **Approve**, **Mark as Spam** or **Delete** on any document and see a toast notification to show you've completed an action.



![Image](https://cdn.sanity.io/images/3do82whm/next/6e39291af02cdcdd47592a7a5688ae8e63795284-2240x1480.png)

At this point, you could consider our app to be "feature complete." Authors are able to set the sentiment of a piece of feedback, add some notes and take a final action on the document.



But we can go deeper!



---

## Lesson 10: useUsers
https://www.sanity.io/learn/course/build-content-apps-with-sanity-app-sdk/use-users

Render an interactive list of Sanity project users to assign to documents.

> [Video: useUsers](https://www.sanity.io/learn/course/build-content-apps-with-sanity-app-sdk/use-users)

Storing user data against documents can be useful for instances such as user "assignment." You can add another document editing control to display all users in a project as a clickable list to set a user ID as a value in a document.



- [ ] **Create** a new component `Assignee` to query for project users and render their avatars.


```tsx:app-feedback/src/Assignee.tsx
import { DocumentHandle, useEditDocument, useUsers } from "@sanity/sdk-react"
import { Inline, Avatar, Stack, Text, Button } from "@sanity/ui"

type AssigneeProps = {
  value: string
  handle: DocumentHandle
}

export function Assignee({ value, handle }: AssigneeProps) {
  const { data: users } = useUsers()
  const editAssignee = useEditDocument({ ...handle, path: "assignee" })

  return (
    <Stack space={3}>
      <Text weight="medium">Assignee</Text>
      <Inline space={1}>
        {users?.map((user) => (
          <Button
            key={user.sanityUserId}
            onClick={() => editAssignee(user.sanityUserId)}
            padding={0}
            mode="bleed"
          >
            <Avatar
              status={value === user.sanityUserId ? "online" : "inactive"}
              size={2}
              src={user.profile?.imageUrl}
            />
          </Button>
        ))}
      </Inline>
    </Stack>
  )
}
```

- [ ] **Update **the `FeedbackEdit` component to include it


```tsx:app-feedback/src/FeedbackEdit.tsx
<Assignee value={assignee} handle={selectedFeedback} /> 
```

There is another hook to quickly retrieve the details of the currently logged in user. We can use it to filter the documents returned in `useDocuments`. Let's put it to work in the next lesson.



---

## Lesson 11: useUser
https://www.sanity.io/learn/course/build-content-apps-with-sanity-app-sdk/use-user

Filter the queried list of documents based on the current user and other selections.

> [Video: useUser](https://www.sanity.io/learn/course/build-content-apps-with-sanity-app-sdk/use-user)

Now that feedback documents can be marked as assigned to specific users, it would be useful to filter the feedback list of documents to just those the current user is responsible for.



The `useDocuments` hook you setup initially in `FeedbackList` only has a `documentType` option set:



```groq
documentType: 'feedback'
```

However, this hook can also take `filter` and `params` options which may be dynamically updated by the application. Let's add some UI elements which will dynamically filter the list of returned documents.



- [ ] **Create** a new component to dynamically filter documents by `status`


```tsx:app-feedback/src/StatusSelector.tsx
import { Button, Grid } from "@sanity/ui"

type StatusSelectorProps = {
  status: string
  setStatus: (nextStatus: string) => void
}

const STATUSES = ["All", "Pending", "Spam", "Approved"]

export function StatusSelector({ status, setStatus }: StatusSelectorProps) {
  return (
    <Grid columns={[2, 2, 2, 4]} gap={1}>
      {STATUSES.map((statusOption) => (
        <Button
          key={statusOption}
          mode={statusOption.toLowerCase() === status ? "default" : "ghost"}
          onClick={() => setStatus(statusOption.toLowerCase())}
          text={statusOption}
        />
      ))}
    </Grid>
  )
}
```

- [ ] **Create** another document to toggle an additional filter for the `assignee` field.


```tsx:app-feedback/src/OnlyMine.tsx
import { Switch, Inline, Text, Card } from "@sanity/ui"
import { useCurrentUser } from "@sanity/sdk-react"
import { Dispatch, SetStateAction } from "react"

type OnlyMineProps = {
  userId: string | null
  setUserId: Dispatch<SetStateAction<string | null>>
}

export function OnlyMine({ userId, setUserId }: OnlyMineProps) {
  const currentUser = useCurrentUser()

  return (
    <Card border padding={2}>
      <Inline space={2}>
        <Text size={1} as="label" htmlFor="only-mine">
          Only mine
        </Text>
        <Switch
          id="only-mine"
          disabled={!currentUser}
          checked={userId === currentUser?.id}
          onClick={() => {
            if (currentUser) {
              setUserId((currentId) =>
                currentId === currentUser.id ? null : currentUser.id
              )
            }
          }}
        />
      </Inline>
    </Card>
  )
}
```

Now you'll need to import these into the `FeedbackList` and set a `filter` that will conditionally use the `params`.



- [ ] **Update** the `FeedbackList` component


```tsx:app-feedback/src/FeedbackList.tsx
import { Suspense, useState } from "react"
import { type DocumentHandle, useDocuments } from "@sanity/sdk-react"
import { Stack, Button, Spinner } from "@sanity/ui"

import { FeedbackPreview } from "./FeedbackPreview"
import { StatusSelector } from "./StatusSelector"
import { OnlyMine } from "./OnlyMine"

type FeedbackListProps = {
  selectedFeedback: DocumentHandle | null
  setSelectedFeedback: (feedback: DocumentHandle | null) => void
}

export function FeedbackList({
  selectedFeedback,
  setSelectedFeedback,
}: FeedbackListProps) {
  const [userId, setUserId] = useState<string | null>(null)
  const [status, setStatus] = useState("all")

  const { data, hasMore, loadMore } = useDocuments({
    documentType: "feedback",
    filter: `
      select(defined($userId) => assignee == $userId, true)
      && select(
        $status == "pending" => !defined(status) || status == "pending",
        $status == "spam" => status == $status,
        $status == "approved" => status == $status,
        true
      )
    `,
    params: { userId, status },
    orderings: [{ field: "_createdAt", direction: "desc" }],
    batchSize: 10,
  })

  return (
    <Stack space={2} padding={5}>
      <StatusSelector status={status} setStatus={setStatus} />
      <OnlyMine userId={userId} setUserId={setUserId} />
      {data?.map((feedback) => {
        const isSelected = selectedFeedback?.documentId === feedback.documentId

        return (
          <Button
            key={feedback.documentId}
            onClick={() => setSelectedFeedback(feedback)}
            mode={isSelected ? "ghost" : "bleed"}
            tone={isSelected ? "primary" : undefined}
          >
            <Suspense fallback={<Spinner />}>
              <FeedbackPreview {...feedback} />
            </Suspense>
          </Button>
        )
      })}
      {hasMore && <Button onClick={loadMore} text="Load more" />}
    </Stack>
  )
}
```

You should now be able to click the buttons to filter based on user assignment or document status. Our app's really useful now!



![Image](https://cdn.sanity.io/images/3do82whm/next/d47f968c0190e7ae48962caeedf3414256b80884-2240x1480.png)

## Conditional GROQ params



The GROQ filter we wrote is a bit gnarly! The `select()` function is used here to only filter by a param value if it is not `null`.



First it uses `defined()` to check if `$userId` is not `null`. If not, it will only find documents where the `assignee` field matches `$userId`. If it is `null`, the value of the assignee field is not used as part of the filter.



It also applies selective filtering looking at the value of the `status` field—first checking for documents without that value (or the value of "pending"), then only showing "spam" or "approved" documents if that's what the current filter matches. Lastly, it just returns everything regardless of the `status` field.



We can go *further*. Let's link your app and the Studio more closely together.



---

## Lesson 12: useNavigateToStudioDocument
https://www.sanity.io/learn/course/build-content-apps-with-sanity-app-sdk/use-navigate-to-studio-document

Bridge the gap between your application and Sanity Studio with an automatic link.

> [Video: useNavigateToStudioDocument](https://www.sanity.io/learn/course/build-content-apps-with-sanity-app-sdk/use-navigate-to-studio-document)

It may benefit your application to include links from any document to the Studio, as your Studio is probably still the source of truth for all your content.



Fortunately, the App SDK provides a hook to automatically generate a link from a document to the correct Studio.



## Deploy the Studio



You will need to deploy your Studio first to make this work, as links from your app's development server won't open in your local running Studio.



- [ ] **Run** the following command in the `/studio` folder to deploy your Studio and schema


```sh
# in the /studio folder
npx sanity@latest deploy 
```

Follow the prompts, once deployed you should see your Studio as an option on the left hand side of the Dashboard.



Let's proceed!



## Composing suspenseful components



In the example below, the Suspense boundary is exported from the component file itself. This is a useful pattern to unify the `fallback` and child components to remove any layout shift.



Instead of a loading spinner, the fallback prop is a disabled version of the same button rendered by the child component when loading is complete.



You may like to consider implementing other suspenseful components in the same way, so that your logic of what renders before and after loading is colocated in a single file.



## Navigate to Studio



- [ ] **Create** a new component for a button which will open documents in the Studio.


```tsx:app-feedback/src/OpenInStudio.tsx
import { Suspense } from "react"
import {
  type DocumentHandle,
  useNavigateToStudioDocument,
} from "@sanity/sdk-react"
import { Button } from "@sanity/ui"

const BUTTON_TEXT = "Open in Studio"

type OpenInStudioProps = {
  handle: DocumentHandle
}

export function OpenInStudio({ handle }: OpenInStudioProps) {
  return (
    <Suspense fallback={<OpenInStudioFallback />}>
      <OpenInStudioButton handle={handle} />
    </Suspense>
  )
}

function OpenInStudioFallback() {
  return <Button text={BUTTON_TEXT} disabled />
}

function OpenInStudioButton({ handle }: OpenInStudioProps) {
  const { navigateToStudioDocument } = useNavigateToStudioDocument(handle)

  return <Button onClick={navigateToStudioDocument} text={BUTTON_TEXT} />
}
```

- [ ] **Update** the `FeedbackEdit` component to add this new button alongside your actions


```tsx:app-feedback/src/FeedbackEdit.tsx
<Flex
  justify="space-between"
  direction={['column-reverse', 'column-reverse', 'row']}
  gap={2}
>
  <OpenInStudio handle={selectedFeedback} />
  <Actions handle={selectedFeedback} />
</Flex> 
```

You should now be able to go directly from any selected feedback document in your app, to that same document in your Sanity Studio.



Your app currently uses the most common hooks in the App SDK for React, but there's one more, do-anything hook we can put to work.



---

## Lesson 13: useClient
https://www.sanity.io/learn/course/build-content-apps-with-sanity-app-sdk/use-client

"Break glass in case of emergency" access to the all-powerful Sanity Client.

> [Video: useClient](https://www.sanity.io/learn/course/build-content-apps-with-sanity-app-sdk/use-client)

If you've done Sanity development before App SDK, it's remarkable to think how complex of an application you've used without ever needing to access the Sanity Client directly.



Thankfully, however, if we have needs that the App SDK doesn't provide, we can always reach out and grab the Sanity Client to take control of our own destiny.



Currently, the sentiment editor is not very intelligent. It requires a human to read the feedback and spend mental cycles processing whether it is positive, neutral, or negative.



This is no longer a job for humans; this is a job for AI. Sanity Client contains AI Agent Actions which are just the tools we need.



## Deploying schema



In the previous lesson you deployed the Studio. This should have also deployed your Studio's schema types—which are required by Agent Actions.



You can list your deployed schemas from the command line:



```sh
# inside the /studio folder
npx sanity@latest schema list 
```

You should see at least one schema deployment. If not, try deploying now.



```sh
# inside the /studio folder
npx sanity@latest schema
```

## Calling the agent action



You'll now update the `Sentiment` component almost entirely. Instead of the value being edited by a user selection, clicking a button will hand the work off to the Agent Action.



Note: In reality this might be better performed in a Sanity Function so it is automated, instead of waiting for user action.



- [ ] **Update** the `Sentiment` component to call an Agent Action.


```tsx:app-feedback/src/Sentiment.tsx
import { DocumentHandle, useClient } from "@sanity/sdk-react"
import { Text, Inline, Stack, Button } from "@sanity/ui"
import { useToast } from "@sanity/ui"
type SentimentProps = {
  feedback: string
  value: string
  handle: DocumentHandle
}

function titleCase(str: string) {
  return str.replace(
    /\w\S*/g,
    (txt) => txt.charAt(0).toUpperCase() + txt.slice(1)
  )
}

const SCHEMA_ID = "_.schemas.default"

export function Sentiment({ feedback, value, handle }: SentimentProps) {
  const client = useClient({ apiVersion: "vX" })
  const toast = useToast()

  function assessSentiment() {
    client.agent.action
      .generate({
        targetDocument: {
          operation: "edit",
          _id: handle.documentId,
        },
        instruction: `
      You are a helpful assistant that analyzes customer feedback and determines the sentiment of the feedback.
      The sentiment can be one of the following: "positive", "neutral", "negative",
      Analyze the following feedback and determine the sentiment: 
      $feedback
      `,
        instructionParams: {
          feedback: {
            type: "constant",
            value: feedback,
          },
        },
        target: {
          path: "sentiment",
        },
        schemaId: SCHEMA_ID,
      })
      .then((result) => {
        toast.push({
          title: "Sentiment assessed",
          description: result.text,
          status: "success",
        })
      })
      .catch((error) => {
        toast.push({
          title: "Error assessing sentiment",
          description: error.message,
          status: "error",
        })
      })
  }

  return (
    <Stack space={3}>
      <Text weight="medium">Sentiment</Text>
      <Inline space={3}>
        <Button mode="ghost" onClick={assessSentiment} text="Assess" />
        <Text>{value ? titleCase(value) : ""}</Text>
      </Inline>
    </Stack>
  )
}
```

You'll also need to pass down the content of the feedback into this component, so it can be used as a parameter in the Agent Action.



- [ ] **Update **the `FeedbackEdit` component to pass feedback down to `Sentiment` as a prop


```tsx:app-feedback/src/FeedbackEdit.tsx
<Sentiment
  value={sentiment}
  handle={selectedFeedback}
  feedback={content}
/>
```

Now with any feedback document open you can click the "Assess" button and save yourself the decision making fatigue of determining user sentiment.



Your app is now feature complete! Let's deploy it to the world.



---

## Lesson 14: Deployment and finishing touches
https://www.sanity.io/learn/course/build-content-apps-with-sanity-app-sdk/deployment-and-finishing-touches

You have a working app. It's time to share it with your authoring team and tidy up some rough edges.

> [Video: Deployment and finishing touches](https://www.sanity.io/learn/course/build-content-apps-with-sanity-app-sdk/deployment-and-finishing-touches)

## Deploy your app



You can deploy your custom application at any time. Just like your Sanity Studio, it can be deployed from the command line.



```sh
# in /app-feedback
pnpm dlx sanity deploy
```

The first time you deploy your application, you’ll be prompted for a title. Once deployed, your app receives a unique ID which should be added to your app's `sanity.cli.ts` file for smoother future deployments.



- [ ] **Update** your `sanity.cli.ts` file once your app is deployed with its `ID`


```typescript:app-feedback/sanity.cli.ts
import { defineCliConfig } from "sanity/cli"

export default defineCliConfig({
  // ...all other settings
  deployment: {
    appId: "YOUR_APP_ID",
  },
})

```

You should now see your Feedback application in the left hand column of your Sanity dashboard.



## Avoid repetitive loading spinners



Using Suspense throughout the application has given us a way to render loading spinners while data is fetched. This is great at first, but you may notice some annoying behavior when clicking through multiple documents in the feedback list. 



The spinner appears almost every time you change documents. Even though the responses for these documents are cached, this occasional disappearance and re-rendering of the editing form is visual noise that we could do without.



Fortunately, React gives us a function called `startTransition`, which we can use to prevent more than one loading spinner when we change the selected document.



- [ ] **Update** the `Feedback` component to use `startTransition`


```tsx:app-feedback/src/Feedback.tsx
import { startTransition, Suspense, useState } from "react"
import { DocumentHandle } from "@sanity/sdk-react"
import { Card, Flex, Grid, Spinner } from "@sanity/ui"
import { styled } from "styled-components"

import { FeedbackList } from "./FeedbackList"
import { FeedbackEdit } from "./FeedbackEdit"

const ScreenHeightCard = styled(Card)`
  height: 100vh;
  overflow: scroll;
`

export function Feedback() {
  const [selectedFeedback, setSelectedFeedback] =
    useState<DocumentHandle | null>(null)
  const updateSelectedFeedback = (handle: DocumentHandle | null) =>
    startTransition(() => setSelectedFeedback(handle))

  return (
    <Grid columns={5}>
      <ScreenHeightCard columnStart={1} columnEnd={3}>
        <Suspense fallback={<Loading />}>
          <FeedbackList
            setSelectedFeedback={updateSelectedFeedback}
            selectedFeedback={selectedFeedback}
          />
        </Suspense>
      </ScreenHeightCard>
      <ScreenHeightCard borderLeft columnStart={3} columnEnd={6}>
        <Suspense fallback={<Loading />}>
          {selectedFeedback ? (
            <FeedbackEdit selectedFeedback={selectedFeedback} />
          ) : null}
        </Suspense>
      </ScreenHeightCard>
    </Grid>
  )
}

function Loading() {
  return (
    <Flex justify="center" align="center" width="fill" height="fill">
      <Spinner />
    </Flex>
  )
}
```

Now, when we click through documents in the list, the previously selected document should remain visible until the next document has finished loading.



It is possible to get a pending state from the `useTransition` hook. [Take a look in the React documentation](https://react.dev/reference/react/Suspense#indicating-that-a-transition-is-happening) for more details.



## Show optimistic updates in the document list



For performance reasons, we have used `useDocumentProjection` in the document list and `useDocument` in the editing form.



You may notice this creates a small discrepancy when changing the status of a document. It happens immediately in the editing form but takes a second to update in the document list.



Since we only need to see optimistic updates on the currently selected document—as that is the one being edited—we could create an optimistic preview component to use in the document list only for the currently selected document. 



- [ ] **Create** a new optimistic preview component for the document list


```tsx:app-feedback/src/FeedbackPreviewSelected.tsx
import { useRef } from "react"
import { DocumentHandle, useDocument } from "@sanity/sdk-react"
import { Box, Stack, Text } from "@sanity/ui"

import { StatusBadge } from "./StatusBadge"

type FeedbackPreviewData = {
  _createdAt: string
  content: string | null
  author: string | null
  email: string | null
  status: string | null
}

export function FeedbackPreviewSelected(props: DocumentHandle) {
  const previewRef = useRef<HTMLDivElement>(null)
  const { data } = useDocument<FeedbackPreviewData>({ ...props })

  const author = typeof data?.author === "string" ? data.author : "..."
  const email = typeof data?.email === "string" ? data.email : "..."
  const content = typeof data?.content === "string" ? data.content : "..."
  const createdAt =
    typeof data?._createdAt === "string" ? data._createdAt.split("T")[0] : "..."
  const status = typeof data?.status === "string" ? data.status : "PENDING"

  return (
    <Stack ref={previewRef} space={3}>
      <Text size={2} weight="semibold" textOverflow="ellipsis">
        {author}
      </Text>
      <Text muted size={1} textOverflow="ellipsis">
        {email} {createdAt}
      </Text>
      <Text size={2} textOverflow="ellipsis">
        {content}
      </Text>
      <Box>
        <StatusBadge status={status} fontSize={1} />
      </Box>
    </Stack>
  )
}
```

- [ ] **Update** the FeedbackList component to selectively render the correct component


```tsx:app-feedback/src/FeedbackList.tsx
{isSelected ? (
  <FeedbackPreviewSelected {...feedback} />
) : (
  <FeedbackPreview {...feedback} />
)}
```

If you change a Feedback item from "Approved" to "Spam" now it should be reflected immediately in the selected document preview.



Much better! Let's test what you've learned in the final lesson.



---

## Lesson 15: SDK Quiz
https://www.sanity.io/learn/course/build-content-apps-with-sanity-app-sdk/sdk-quiz

Let's put everything you've learned to the test!

> [Video: SDK Quiz](https://www.sanity.io/learn/course/build-content-apps-with-sanity-app-sdk/sdk-quiz)

Now you've built a fully featured content application, let's see what you've learned about some of the hooks you've put to work.



> **Question:** useDocuments is preferable to client.fetch because...
>
> 1. It fetches faster
> 2. Built-in batching and real-time updates **[correct]**
> 3. Hooks are the only way to fetch data in React
> 4. You can't be trusted with client.fetch

> **Question:** useDocumentProjection is a hook for
>
> 1. Fetching multiple documents
> 2. Fetching document values **[correct]**
> 3. Fetching future values
> 4. Fetching user data

> **Question:** useDocument should be used sparingly because
>
> 1. It costs extra
> 2. It resolves both local and remote states of the document **[correct]**
> 3. It's slower
> 4. It's not always correct

> **Question:** useEditDocument creates better UI than client.patch because
>
> 1. It's faster
> 2. It handles versions **[correct]**
> 3. It handles webhooks
> 4. It's cheaper

> **Question:** useApplyDocumentActions provides a way to perform actions, like
>
> 1. Trigger a webhook
> 2. Publish a document **[correct]**
> 3. Update your billing
> 4. Delete a user

> **Question:** useDocumentEvent listens to
>
> 1. Mutations to documents **[correct]**
> 2. Webhooks firing
> 3. User log-ins
> 4. Your computer's microphone

> **Question:** useNavigateToStudioDocument
>
> 1. Is an incredibly specific name for a hook **[correct]**
> 2. Is a mysterious name for a hook

---

## Related Resources

- [Track overview](https://www.sanity.io/learn/track/sanity-developer-essentials.md)
- [All courses and lessons](https://www.sanity.io/learn/sitemap.md)
- [Complete content for LLMs](https://www.sanity.io/learn/llms-full.txt)
