Everything *[NYC] 2026: see what we announced. →
Skip to content

The official Sanity integration for Astro

Featured Tool
Official(made by Sanity team)

Integrate content from Sanity on Astro websites

By Rune Botten & Cody Olsen and 1 others

Install command

npx astro add @sanity/astro @astrojs/react

The Official Sanity integration for Astro

This integration enables the Sanity Client in your Astro project and lets you embed Sanity Studio on a route. Astro is an all-in-one web framework that supports a range of UI languages and can be deployed in most places.

Installation

In your Astro project, run the following command to install the Sanity integration:

npx astro add @sanity/astro @astrojs/react

Note: @astrojs/react is only needed if you plan to embed a Sanity Studio in your project.

Manual installation of dependencies

npm install @astrojs/react @sanity/astro @sanity/client sanity @types/react-dom @types/react-is @types/react react-dom react-is react styled-components

Types for sanity:client and sanity:studio

This integration leverages Vite.js' virtual modules with Astro's naming convention (e.g. astro:assets). Add this line to the env.d.ts file in the src folder of your project:

/// <reference types="astro/client" />
/// <reference types="@sanity/astro/module" />

On Astro 4.14 and later you can let the integration register the declarations instead, by setting injectTypes: true in the integration options. They then land in .astro/integrations/_sanity_astro/types.d.ts after astro dev, astro build or astro sync through Astro's injectTypes, and the env.d.ts line can go. The option is off by default; keeping the manual reference alongside it is harmless. Setting the clients option also adds the reference on Astro 4.14 and later (see Typed queries per client).

sanity({
  projectId: '<YOUR-PROJECT-ID>',
  dataset: '<YOUR-DATASET-NAME>',
  injectTypes: true,
})

You might have to restart the TS Server running in your code editor to get it to resolve types after updating this file. The easiest way to do this is to restart the application.

Usage

Setting up the Sanity client

Configure the integration in your astro.config.mjs file. The configuration options and methods are the same as for @sanity/client:

import sanity from '@sanity/astro'
import {defineConfig} from 'astro/config'

// https://astro.build/config
export default defineConfig({
  integrations: [
    sanity({
      projectId: '<YOUR-PROJECT-ID>',
      dataset: '<YOUR-DATASET-NAME>',
      // Set useCdn to false if you're building statically.
      useCdn: false,
      // Optional: log server-side Sanity client requests.
      // Modes: 'dev' | 'build' | 'always'
      logClientRequests: 'dev',
    }),
  ],
})

This enables the use of sanityClient in your template files. For example:

---
// /blog/index.astro
import { sanityClient } from "sanity:client";

const posts = await sanityClient.fetch(`*[_type == "post" && defined(slug)] | order(publishedAt desc)`);
---

<h1>Blog</h1>
<ul>
  {posts.map((post) => (
    <li>
      <a href={'/posts/' + post.slug.current} class="post-link">
        {post.title}
      </a>
    </li>
  ))}
</ul>

Check out this guide for a more elaborate introduction to how to integrate content from Sanity into Astro. You can also look in the examples folder in this repository for complete implementation examples.

To log server-side requests made with sanity:client, set logClientRequests in your integration config:

  • logClientRequests: 'dev' logs during development
  • logClientRequests: 'build' logs during static builds
  • logClientRequests: 'always' logs during both development and builds

If omitted, request logging is disabled.

Loading Sanity documents into content collections

On Astro 5 and later, sanityLoader feeds a GROQ query into the Content Layer, so getCollection() and getEntry() work the same way for Sanity documents as for local Markdown. The loader is experimental: it is opt-in through its own @sanity/astro/loader entrypoint and its options may change in a minor release. Pass the client from sanity:client so the loader reuses your integration config:

// src/content.config.ts
import {sanityLoader} from '@sanity/astro/loader'
import {defineCollection} from 'astro:content'
import {z} from 'astro/zod'
import {sanityClient} from 'sanity:client'

const movies = defineCollection({
  loader: sanityLoader<{_id: string; slug: string}>({
    client: sanityClient,
    query: `*[_type == "movie" && defined(slug.current)]{_id, title, "slug": slug.current, releaseDate}`,
    // Entry ids default to `_id`. Use the slug when pages are addressed by it.
    id: (movie) => movie.slug,
  }),
  schema: z.object({
    _id: z.string(),
    title: z.string(),
    slug: z.string(),
    releaseDate: z.string().optional(),
  }),
})

export const collections = {movies}
---
// src/pages/movies/[slug].astro
import {getCollection} from 'astro:content'

export async function getStaticPaths() {
  const movies = await getCollection('movies')
  return movies.map((movie) => ({params: {slug: movie.id}, props: {movie}}))
}

const {movie} = Astro.props
---

<h1>{movie.data.title}</h1>

Every load fetches the whole query result, writes each document with a content digest so unchanged entries are skipped, and removes entries that left the result, so edits and deletions in Sanity show up on the next build or dev-server restart. Two documents mapping to the same entry id (for example a draft and its published document when the query uses the raw or drafts perspective) make the loader throw, naming both _ids, rather than silently dropping one.

Multiple clients

To read from more than one project or dataset, configure named clients with the clients option. Each entry takes the same options as @sanity/client. clients.default is the client exported as sanityClient, so existing code keeps working:

import sanity from '@sanity/astro'
import {defineConfig} from 'astro/config'

export default defineConfig({
  integrations: [
    sanity({
      clients: {
        default: {
          projectId: '<YOUR-PROJECT-ID>',
          dataset: 'marketing',
          useCdn: false,
        },
        community: {
          projectId: '<COMMUNITY-PROJECT-ID>',
          dataset: 'community',
          useCdn: true,
        },
      },
    }),
  ],
})

Top-level client options (projectId, dataset, …) still work and configure the default client. They can be combined with clients as long as clients has no default entry of its own.

Every client is available through getClient(name), and sanityClient stays the default one:

---
import {getClient, sanityClient} from 'sanity:client'

const posts = await sanityClient.fetch(`*[_type == "post"]`)
const members = await getClient('community').fetch(`*[_type == "member"]`)
---

Calling getClient with a name that is not configured throws, and the generated types reject it at compile time as well. To pass names around, type them with the ClientName type exported from sanity:client.

Named clients that are configured with a token or headers are only available in server code (frontmatter, endpoints, middleware). In the browser, getClient(name) returns a stand-in for them that throws on first use, so the credentials never end up in a client bundle. presentationClient is that same stand-in when it points at such a client. The default client is shipped to the browser as before, so keep its token out of the integration config and pass it per request instead, like the loadQuery helper below does.

The presentation client

Mark the client your Presentation and Visual Editing code reads from with presentation: true; without the flag the default client is used. The flag only decides which client the presentationClient export returns, the client itself is configured like any other, so give it the stega.studioUrl from Enable Stega:

sanity({
  studioBasePath: '/admin',
  clients: {
    default: {projectId: '<YOUR-PROJECT-ID>', dataset: 'marketing'},
    community: {
      projectId: '<COMMUNITY-PROJECT-ID>',
      dataset: 'community',
      presentation: true,
      stega: {studioUrl: '/admin'},
    },
  },
})

Read from presentationClient in the helpers that feed Presentation, so they follow the flag instead of hardcoding a name. In the loadQuery helper from Enable Stega that is a one-line change:

// load-query.ts
import {presentationClient} from 'sanity:client'

// ...

const {result, resultSourceMap} = await presentationClient.fetch<QueryResponse>(
  query,
  params ?? {},
  {
    filterResponse: false,
    perspective,
    resultSourceMap: visualEditingEnabled ? 'withKeyArraySelector' : false,
    stega: visualEditingEnabled,
    ...(visualEditingEnabled ? {token} : {}),
    useCdn: !visualEditingEnabled,
  },
)

Only one client can set presentation: true.

Typed queries per client

When the clients option is set (Astro 4.14 or later), the integration writes the configured client names to .astro/integrations/_sanity_astro/clients.d.ts whenever Astro syncs types (astro dev, astro build, astro check, astro sync). That types getClient(name) and rejects unknown names. Combine it with Sanity TypeGen and the typegen.resources option (beta, @sanity/cli 8.16.0 or later, which bundles @sanity/codegen 8.2.0 or later) in sanity.cli.ts to get query result types per project and dataset:

// sanity.cli.ts
import {defineCliConfig} from 'sanity/cli'

export default defineCliConfig({
  typegen: {
    resources: [
      {
        projectId: '<YOUR-PROJECT-ID>',
        dataset: 'marketing',
        schema: './schema.json',
        path: './src/marketing/**/*.{ts,tsx,astro}',
        generates: './src/sanity/marketing.types.ts',
      },
      {
        projectId: '<COMMUNITY-PROJECT-ID>',
        dataset: 'community',
        path: './src/community/**/*.{ts,tsx,astro}',
        generates: './src/sanity/community.types.ts',
      },
    ],
  },
})

Each generated file augments the global SanityQueriesByResource registry under its "projectId.dataset" key. The integration maps every client to its key, so getClient('community').fetch(MEMBERS_QUERY) returns the community result type while sanityClient.fetch(POSTS_QUERY) returns the marketing one:

import {defineQuery} from 'groq'

const POSTS_QUERY = defineQuery(`*[_type == "post"]`)
const MEMBERS_QUERY = defineQuery(`*[_type == "member"]`)

TypeGen only picks up queries tagged with groq or wrapped in defineQuery, and fetch matches results by the query's literal type, which defineQuery keeps and the groq tag widens to string. Use defineQuery for queries fetched through a typed client: a groq-tagged query, or an inline string TypeGen never saw, resolves to any. Things to know:

  • Run npx sanity typegen generate after changing queries, or keep npx sanity typegen generate --watch running (@sanity/cli 8.22.0 or later; older versions reject --watch together with typegen.resources). sanity dev's built-in typegen does not support typegen.resources yet.
  • Give each resource its own path glob. Every query in the matched files is typed against that resource's schema, so overlapping globs type the same query against both schemas.
  • A resource without schema uses the schema deployed for that project and dataset, so npx sanity schema deploy has to have run for it.
  • projectId and dataset have to match the client config exactly. A mismatch is not an error, the client just stays an untyped SanityClient.
  • With typegen.resources, overloadClientMethods defaults to false, so the flat SanityQueries registry from single-resource typegen is not written. Typed clients resolve results through SanityQueriesByResource only, a leftover flat registry has no effect on them. A query imported from a .groq file (see Typing query results) brings its own types, which win over the registry.

Without typegen.resources, every client is a regular SanityClient.

Typing query results

sanityClient is a regular SanityClient, so sanityClient.fetch resolves the types Sanity TypeGen generates for defineQuery queries, and takes an explicit generic, sanityClient.fetch<Post[]>(query), for everything else.

With @sanity/client 7.28.0 or later on v7, or 8.11.0 or later on v8, it also infers the result and checks the params of a query you import from a .groq file through @sanity-labs/groq-content-mapper. No generic, no generated registry. Both the mapper and the client's support for it are experimental, and the mapper needs TypeScript 7.1 (currently typescript@beta). Its README covers the requirements.

npm install -D @sanity-labs/groq-content-mapper typescript@beta
npx sanity schema extract --path ./schema.json # the default path

Astro builds with Vite, so the mapper's Vite plugin loads .groq files as strings:

// astro.config.mjs
import sanity from '@sanity/astro'
import groqFiles from '@sanity-labs/groq-content-mapper/vite'
import {defineConfig} from 'astro/config'

export default defineConfig({
  integrations: [sanity({projectId: '<YOUR-PROJECT-ID>', dataset: '<YOUR-DATASET-NAME>'})],
  vite: {plugins: [groqFiles()]},
})

The tsconfig points the mapper at the schema sanity schema extract writes:

// tsconfig.json
{
  "extends": "astro/tsconfigs/strict",
  "contentMappers": [
    {
      "package": "@sanity-labs/groq-content-mapper",
      "extensions": [".groq"],
      "options": {
        "schema": "./schema.json",
        // Only when you query more than one project or dataset, one schema per folder:
        // "schemas": [{"include": "src/queries/blog", "schema": "./schemas/blog.json"}],
      },
    },
  ],
}

Then write the query in its own file and import it:

// src/queries/postBySlug.groq
*[_type == "post" && slug.current == $slug][0]{title, publishedAt}
// src/lib/posts.ts
import {sanityClient} from 'sanity:client'

import postBySlug from '../queries/postBySlug.groq' with {type: 'text'}

export async function getPost(slug: string) {
  return sanityClient.fetch(postBySlug, {slug})
  //     ^? Promise<{title: string | null; publishedAt: string | null} | null>, and `slug` is checked
}

The types only exist when TypeScript runs the mapper, so type-check with tsc --noEmit --runExternalCode. astro check runs on TypeScript 5 or 6, which have no content mappers, so keep .groq imports in .ts modules like the one above rather than in a component's frontmatter.

sanityLoader reads the same types. Pass a .groq import as its query and it checks params and types the id callback, no generic needed:

// src/content.config.ts
import {sanityLoader} from '@sanity/astro/loader'
import {defineCollection} from 'astro:content'
import {sanityClient} from 'sanity:client'

import moviesByGenre from './queries/moviesByGenre.groq' with {type: 'text'}

const movies = defineCollection({
  loader: sanityLoader({
    client: sanityClient,
    query: moviesByGenre,
    params: {genre: 'drama'},
    id: (movie) => movie.slug, // `movie` is typed from the query's projection
  }),
})

If you wrap sanityClient.fetch in a helper of your own, such as the loadQuery in Enabling Visual Editing, type it with the client's ClientReturn and ClientParams so the inferred types pass through. Plain strings, explicit generics and TypeGen queries keep resolving as before:

import type {ClientParams, ClientReturn} from '@sanity/client'
import {sanityClient} from 'sanity:client'

export async function sanityFetch<const Q extends string>(
  query: Q,
  params?: ClientParams<Q>,
): Promise<ClientReturn<Q, unknown>> {
  return sanityClient.fetch(query, params ?? {})
}

Embedding Sanity Studio on a route

Sanity Studio is a customizable content workspace where you can edit your content. It‘s a Single Page Application that you can keep in its own repository, together with your Astro project as a monorepo, or embedded in your website.

To initialize a Studio in a dedicated folder, you can run npm create sanity@latest and follow the instructions.

This integration lets you embed a Sanity Studio on a route in your Astro project. To enable it:

  1. Create a new file in your project root called sanity.config.ts (or .js)
  2. Add the following code, and add your projectId and dataset to it:
// sanity.config.ts
import {defineConfig} from 'sanity'
import {structureTool} from 'sanity/structure'

export default defineConfig({
  name: 'project-name',
  title: 'Project Name',
  projectId: '<YOUR-PROJECT-ID>',
  dataset: '<YOUR-DATASET-NAME>',
  plugins: [structureTool()],
  schema: {
    types: [/* your content types here*/],
  },
})

You can use this configuration file to install plugins, add a schema with document types, add customizations etc. Note that the Studio will be using Astro‘s development server which is built on top of Vite.

  1. Add the following to your astro.config.mjs:
    • studioBasePath: '/admin': The route/path for where you want to access your studio
    • Import the React integration for Astro, and add it to the integrations array.
// astro.config.mjs
import sanity from '@sanity/astro'
import {defineConfig} from 'astro/config'
import react from '@astrojs/react'

export default defineConfig({
  integrations: [
    sanity({
      projectId: '3do82whm',
      dataset: 'next',
      // Set useCdn to false if you're building statically.
      useCdn: false,
      // Access the Studio on your.url/admin
      studioBasePath: '/admin',
    }),
    react(),
  ],
})
  1. You have to enable CORS origins for authenticated requests for the domains you're running your website project on. The Studio should automatically detect and let you add this when you access the Studio on a new URL. Typically you need to add your local development server URL and your production URL to the CORS origin settings. It's important that you only enable CORS for authenticated requests on domains that you control.

Workspaces in embedded Studio

Sanity workspaces are supported by exporting an array from defineConfig (see Studio Workspaces).

When Studio is embedded through @sanity/astro, the integration owns workspace basePath values so all workspaces stay mounted under your configured studioBasePath route:

  • In browser-history mode (studioRouterHistory omitted or 'browser'), workspaces are mounted as:
    • single workspace: /<studioBasePath> (for example /admin)
    • multiple workspaces: /<studioBasePath>/<workspace-name> for every workspace
  • In hash-history mode (studioRouterHistory: 'hash'), workspaces are mounted inside the hash router:
    • single workspace: #/
    • multiple workspaces: #/<workspace-name> for every workspace
  • If studioRouterHistory is omitted, the integration defaults to hash history for Astro output: 'static', and browser history for server output.

If you are also using Visual Editing stega, set stega.studioUrl to your Studio route path (for example '/admin') and avoid appending a manual hash suffix.

Rendering rich text and block content with Portable Text

Sanity uses an open specification for rich text and block content called Portable Text. Portable Text stores content from the editor as JSON (and not HTML or Markdown). This makes it platform/framework agnostic, and also queryable (for example, you can query for blog posts that have more than 4 TypeScript code blocks).

While it's possible to loop over the JSON structure manually, we recommend using a Portable Text library to do the heavy lifting. It will automatically render the default editor configuration to HTML. If you do customizations like adding custom block types, then you need to map those to a component in your front end.

We recommend using astro-portabletext to render your PortableText fields in Astro. See an example of this in apps/example/src/components/PortableText.astro, including using custom components to render custom blocks and annotations.

---
import {PortableText as PortableTextInternal} from "astro-portabletext"
import CallToActionBox from "./CallToActionBox.astro";
import Code from "./Code.astro";
import SanityImage from "./SanityImage.astro";
import YouTube from "./YouTube.astro";
import InternalLink from "./InternalLink.astro";

const components = {
  type: {
    callToActionBox: CallToActionBox,
    code: Code,
    image: SanityImage,
    youtube: YouTube,
  },
  mark: {
    internalLink: InternalLink
  }
};

---

<PortableTextInternal value={Astro.props.value} components={components} />

Presenting images

Sanity comes with a native asset pipeline for your images and files. It has on-demand transforms, automatic optimization for browsers that supports webp, and serves images from a global CDN network. When you upload images to Sanity, it will also automatically analyze the image and add a metadata document with information like dimensions, color palette, generate blurhash, and LQIP strings.

We recommend using @sanity/image-url to help you generate URLs for presenting Sanity images in your Astro app. See an example of this in apps/example/src/components/SanityImage.astro

You can also use community-contributed integrations like astro-sanity-picture to integrate images from Sanity into your website.

Enabling Visual Editing

To enable Visual Editing, you need to:

  1. Enable Overlays using the VisualEditing component
  2. Add the Presentation tool to the Studio
  3. Enable Stega

Please note that Visual Editing only works for server-side rendered pages. This means you probably want to configure your Astro project something like this:

import vercel from '@astrojs/vercel'

// astro.config.mjs
export default defineConfig({
  integrations: [
    sanity({
      useCdn: true,
      // ...
    }),
    // ...
  ],
  output: 'server',
  adapter: vercel(),
})

1. Enable Overlays using the VisualEditing component

Add VisualEditing from @sanity/astro/visual-editing in your "page shell" layout:

---
import {VisualEditing} from '@sanity/astro/visual-editing'

export type props = {
  title: string
}
const {title} = Astro.props
const visualEditingEnabled = import.meta.env.PUBLIC_SANITY_VISUAL_EDITING_ENABLED == 'true'
---

<html lang="en">
  <head>
    <meta charset="utf-8" />
    <link rel="icon" type="image/svg+xml" href="/favicon.svg" />
    <meta name="viewport" content="width=device-width" />
    <meta name="generator" content={Astro.generator} />
    <title>{title}</title>
  </head>
  <body>
    <slot />
    <VisualEditing enabled={visualEditingEnabled} zIndex={1000} />
    <!--                                          ^optional -->
  </body>
</html>

VisualEditing is needed to render Overlays. It's a React component under the hood, so you'll need the React integration for Astro if you don't already use that at this point.

VisualEditing takes these props:

  • enabled: so you can control whether or not visual editing is enabled depending on your environment.
  • zIndex (optional): allows you to change the z-index of overlay elements.
  • keepStegaOnCopy (optional): by default, Visual Editing strips stega from the clipboard on copy. Pass keepStegaOnCopy to opt out and leave stega in copied text.
  • experimental_refreshStrategy (optional, experimental, opt-in): how the preview updates when the Studio reports a change. Unset (the default) reloads the page, as it always has. See Customizing how the preview refreshes.

In the example above, enabled is controlled using an environment variable:

// .env.local
PUBLIC_SANITY_VISUAL_EDITING_ENABLED="true"

Customizing how the preview refreshes

By default VisualEditing reloads the whole page with window.location.reload() whenever the Studio reports a change. The experimental experimental_refreshStrategy prop picks another strategy. It's accepted by both the .astro VisualEditing component and VisualEditingComponent, and it's ignored when you pass your own refresh:

StrategyWhat happens on a changeTrade-offs
'reload' (default)The page reloads.Always correct for whatever the server renders. A reload discards the follow-up refresh Visual Editing runs about a second after a change, so text typed in the Studio while the page was reloading can be missing until the next edit.
{type: 'reload', followUpDelayMs: 1200}The page reloads, then reloads once more followUpDelayMs after it is back.Catches text typed during the first reload, at the cost of a second reload per change. Only change-triggered reloads arm it, never ordinary navigations or the manual refresh button, and the follow-up does not re-arm itself. Needs sessionStorage.
<VisualEditing
  enabled={visualEditingEnabled}
  experimental_refreshStrategy={{type: 'reload', followUpDelayMs: 1200}}
/>

If you'd rather refresh another way, for example re-fetching data and swapping the DOM in place, pass your own refresh function.

refresh is a function, and Astro can't serialize functions through to a client component, so it isn't accepted on the .astro VisualEditing component. Instead, import the React component from @sanity/astro/visual-editing/component and render it as a client:only island:

// src/components/VisualEditing.tsx
import {VisualEditingComponent} from '@sanity/astro/visual-editing/component'

export default function VisualEditing() {
  return (
    <VisualEditingComponent
      refresh={(payload) => {
        // Return a promise so the overlay shows a loading state until it resolves.
        return new Promise((resolve) => {
          // ...your custom refresh here, then:
          resolve()
        })
      }}
      onSuspiciousStega={(reports) => {
        for (const report of reports) {
          console.warn(`Stega found in ${report.kind}`, report)
        }
      }}
    />
  )
}
---
// src/layouts/Layout.astro
import VisualEditing from '../components/VisualEditing'
const visualEditingEnabled = import.meta.env.PUBLIC_SANITY_VISUAL_EDITING_ENABLED === 'true'
---

<body>
  <slot />
  {visualEditingEnabled && <VisualEditing client:only="react" />}
</body>

The payload tells you why the refresh fired: payload.source is 'manual' when the editor clicks refresh in Presentation, or 'mutation' when a document changes.

VisualEditingComponent accepts the full VisualEditingOptions type from @sanity/visual-editing/react and forwards it unchanged, plus this integration's own experimental_refreshStrategy (see above), which is not forwarded. The one prop of <VisualEditing /> it does not take is portal, which is always on. Beyond refresh, the ones worth knowing about are:

  • history: take over URL syncing. When omitted, the built-in adapter keeps Presentation's URL bar in sync with the preview.
  • onSuspiciousStega: opt-in reporting when stega appears in unsafe placements (class, href, <head>, scripts, and similar).
  • onPerspectiveChange / onVariantChange: keep server-side fetching on the same perspective and variant as the Studio driving the preview.
  • components (alpha): a resolver for custom overlay UI, such as an insert menu on an array field.
  • plugins (alpha): an array of overlay plugin definitions.

refresh falls back to window.location.reload() and history to the built-in adapter when you don't pass them; everything else is passed straight through. The alpha options follow @sanity/visual-editing's stability policy rather than this package's: they can change in a minor release of that dependency without a corresponding @sanity/astro release, so pin it if you rely on them.

The parameter types for these callbacks (HistoryAdapter, HistoryAdapterNavigate, HistoryRefresh, HistoryUpdate, OverlayComponentResolver, OverlayPluginDefinition, SuspiciousStegaReport, and VisualEditingOptions itself) are re-exported from @sanity/astro/visual-editing/component, along with RefreshStrategy and VisualEditingComponentProps (VisualEditingOptions plus experimental_refreshStrategy).

2. Add the Presentation tool to the Studio

Follow the instructions on how to configure the Presentation tool.

3. Enable Stega

If you already run Studio on an Astro route, then you can set the stega.studioUrl to the same relative path:

export default defineConfig({
  integrations: [
    sanity({
      studioBasePath: '/admin',
      stega: {
        studioUrl: '/admin',
      },
    }),
  ],
})

Now, all you need is a loadQuery helper function akin to this one:

// load-query.ts
import {type QueryParams} from 'sanity'
import {sanityClient} from 'sanity:client'

const visualEditingEnabled = import.meta.env.PUBLIC_SANITY_VISUAL_EDITING_ENABLED === 'true'
const token = import.meta.env.SANITY_API_READ_TOKEN

export async function loadQuery<QueryResponse>({
  query,
  params,
}: {
  query: string
  params?: QueryParams
}) {
  if (visualEditingEnabled && !token) {
    throw new Error(
      'The `SANITY_API_READ_TOKEN` environment variable is required during Visual Editing.',
    )
  }

  const perspective = visualEditingEnabled ? 'drafts' : 'published'

  const {result, resultSourceMap} = await sanityClient.fetch<QueryResponse>(query, params ?? {}, {
    filterResponse: false,
    perspective,
    resultSourceMap: visualEditingEnabled ? 'withKeyArraySelector' : false,
    stega: visualEditingEnabled,
    ...(visualEditingEnabled ? {token} : {}),
    useCdn: !visualEditingEnabled,
  })

  return {
    data: result,
    sourceMap: resultSourceMap,
    perspective,
  }
}

You'll notice that we rely on a "read token" which is required in order to enable stega encoding and for authentication when Sanity Studio is live previewing your application.

  1. Go to https://sanity.io/manage and select your project.
  2. Click on the 🔌 API tab.
  3. Click on + Add API token.
  4. Name it "SANITY_API_READ_TOKEN" and set Permissions to Viewer and hit Save.
  5. Copy the token and add it to your .env.local file: SANITY_API_READ_TOKEN="<paste your token here>"

Now, you can query and interact with stega-enabled data using the visual editing overlays:

// some.astro file
import {loadQuery} from '../load-query'

const {data: movies} = await loadQuery<Array<{title: string}>>({
  query: `*[_type == 'movie']`,
})

Resources

Related contributions