
How to Use Sanity With Astro
To use Sanity with Astro, install the official integration with npx astro add @sanity/astro, configure it in astro.config.mjs with your projectId, dataset, an apiVersion date and useCdn, and add a type reference for @sanity/astro/module to src/env.d.ts. You can then import sanityClient from the sanity:client virtual module in any component's frontmatter and fetch content with GROQ. Render rich text with @portabletext/astro, build image URLs with @sanity/image-url, and optionally embed Sanity Studio at a route like /admin with @astrojs/react.
Astro and Sanity suit each other well. Astro turns content into fast, mostly static HTML, and Sanity gives editors a structured, real-time editing environment with a query language that returns exactly the shape your components need. This guide covers the integration itself: installation, configuration and environment variables, querying, typing results, Portable Text, images, embedding the Studio, the experimental content loader and when you need server rendering for Visual Editing. For a full step-by-step project, see building a static site with Astro and Sanity.
What the Integration Gives You
@sanity/astro is Sanity's official Astro integration. It provides:
- sanity:client: a virtual module exporting a configured
@sanity/clientinstance, available in frontmatter, endpoints and middleware. - Embedded Studio: a Studio mounted at a route of your Astro site, using React.
- Visual Editing components: overlays for click-to-edit previews in the Presentation tool.
- sanityLoader: an experimental Content Layer loader that turns a GROQ query into an Astro content collection.
- Named clients: several clients for different projects or datasets, accessed with
getClient().
You don't strictly need it. Any Sanity project can be queried from Astro with @sanity/client or plain fetch(), as the general guide to using a headless CMS with Astro shows. The integration simply saves you from wiring the client, types and Studio together yourself.
Install the Integration
In an existing Astro project, run:
npx astro add @sanity/astro @astrojs/react
@astrojs/react is only needed if you plan to embed the Studio or use the Visual Editing component. For a site that only reads content, npx astro add @sanity/astro is enough. If you don't have a Sanity project yet, create one first with npm create sanity@latest, as described in setting up your first Sanity project.
To install everything manually instead, including what the embedded Studio needs:
npm install @sanity/astro @sanity/client @astrojs/react sanity react react-dom react-is styled-components
npm install -D @types/react @types/react-dom @types/react-is
Configure the Client
The integration accepts the same options as @sanity/client, plus a few of its own. Read the values from environment variables so you can change datasets per environment. Astro config files can't use import.meta.env, so load the variables with Vite's loadEnv:
// astro.config.mjs
import { defineConfig } from 'astro/config'
import sanity from '@sanity/astro'
import { loadEnv } from 'vite'
const { PUBLIC_SANITY_PROJECT_ID, PUBLIC_SANITY_DATASET } = loadEnv(
process.env.NODE_ENV ?? 'development',
process.cwd(),
'',
)
export default defineConfig({
integrations: [
sanity({
projectId: PUBLIC_SANITY_PROJECT_ID,
dataset: PUBLIC_SANITY_DATASET,
apiVersion: '2025-02-19',
useCdn: false,
}),
],
})
# .env
PUBLIC_SANITY_PROJECT_ID=abc123xy
PUBLIC_SANITY_DATASET=production
The project ID and dataset aren't secrets, so a PUBLIC_ prefix is fine. Tokens are a different matter: never give them the prefix, and never put them in the integration's default client config, because the default client can be shipped to the browser.
Choose useCdn by Rendering Mode
| Rendering | useCdn | Reason |
|---|---|---|
Static build (output: 'static') | false | Queries run once at build time, so you want the freshest data |
| Server-rendered, published content | true | The API CDN is faster under repeated requests |
| Server-rendered drafts or previews | false | Drafts aren't served from the CDN |
The trade-offs are explained further in the guide to the Sanity CDN vs the live API.
Add the Types
So TypeScript understands sanity:client, add a reference to src/env.d.ts:
// src/env.d.ts
/// <reference types="astro/client" />
/// <reference types="@sanity/astro/module" />
Alternatively, set injectTypes: true in the integration options and Astro generates the declarations for you during astro dev, astro build or astro sync. Restart your editor's TypeScript server after either change.
Query Content With GROQ
Import the client in a component's frontmatter and fetch. In a static build this runs once at build time; in server mode it runs per request:
---
// src/pages/index.astro
import { sanityClient } from 'sanity:client'
const posts = await sanityClient.fetch(`*[_type == "post" && defined(slug.current)]
| order(publishedAt desc)[0...10]{
_id,
title,
"slug": slug.current,
publishedAt
}`)
---
<h1>Latest posts</h1>
<ul>
{posts.map((post) => (
<li>
<a href={`/posts/${post.slug}/`}>{post.title}</a>
</li>
))}
</ul>
Pass dynamic values as parameters rather than building strings, for example sanityClient.fetch(query, { slug }). The GROQ parameters guide explains why. For the query syntax itself, keep the GROQ cheat sheet to hand.
Organise Queries in One Place
As the site grows, move queries into a module and wrap them in defineQuery from the groq package, so Sanity TypeGen can generate result types:
// src/lib/queries.ts
import { defineQuery } from 'groq'
export const POSTS_QUERY = defineQuery(`*[_type == "post" && defined(slug.current)]
| order(publishedAt desc){ _id, title, "slug": slug.current, publishedAt }`)
export const POST_QUERY = defineQuery(`*[_type == "post" && slug.current == $slug][0]{
title,
publishedAt,
mainImage,
body
}`)
Because sanityClient is a regular SanityClient, sanityClient.fetch(POSTS_QUERY) picks up generated types automatically once TypeGen has run. Without TypeGen, pass a generic such as sanityClient.fetch<Post[]>(query).
Render Portable Text
Sanity stores rich text as Portable Text, a JSON format. In Astro, render it with @portabletext/astro, the Sanity-maintained fork of the earlier astro-portabletext package:
npm install @portabletext/astro
---
// src/components/PortableText.astro
import { PortableText as PortableTextInternal } from '@portabletext/astro'
import SanityImage from './SanityImage.astro'
interface Props {
value: any[]
}
const { value } = Astro.props
const components = {
type: {
image: SanityImage,
},
}
---
<PortableTextInternal value={value} components={components} />
The defaults cover paragraphs, headings, lists, bold, italics and links. Custom block types, like code blocks or embeds, are mapped by _type in components.type, and annotations in components.mark. Each component receives the node as Astro.props.node. If you're new to the format, what is Portable Text explains the structure you're rendering.
Display Sanity Images
Sanity serves images from its own CDN with on-demand transforms, so build URLs with @sanity/image-url rather than processing them again with Astro's image pipeline:
// src/lib/image.ts
import { createImageUrlBuilder, type SanityImageSource } from '@sanity/image-url'
import { sanityClient } from 'sanity:client'
const builder = createImageUrlBuilder(sanityClient)
export function urlFor(source: SanityImageSource) {
return builder.image(source)
}
---
// src/components/SanityImage.astro
import { urlFor } from '../lib/image'
interface Props {
node: { asset: { _ref: string }; alt?: string }
width?: number
}
const { node, width = 800 } = Astro.props
const src = urlFor(node).width(width).auto('format').url()
const srcset = [400, 800, 1200]
.map((w) => `${urlFor(node).width(w).auto('format').url()} ${w}w`)
.join(', ')
---
<img src={src} srcset={srcset} sizes="(min-width: 800px) 800px, 100vw" alt={node.alt ?? ''} loading="lazy" />
Naming the prop node lets the same component work both inside Portable Text and on its own. auto('format') serves AVIF or WebP when the browser supports them. If you do pass Sanity URLs to Astro's <Image />, add cdn.sanity.io to image.domains, but expect double processing. The image URL builder guide covers crops, hotspots and fit modes.
Embed Sanity Studio
The integration can mount the Studio on a route of your Astro site. Add studioBasePath and the React integration:
// astro.config.mjs
import { defineConfig } from 'astro/config'
import react from '@astrojs/react'
import sanity from '@sanity/astro'
export default defineConfig({
integrations: [
sanity({
projectId: 'abc123xy',
dataset: 'production',
apiVersion: '2025-02-19',
useCdn: false,
studioBasePath: '/admin',
}),
react(),
],
})
Then create sanity.config.ts in the project root:
// sanity.config.ts
import { defineConfig } from 'sanity'
import { structureTool } from 'sanity/structure'
import { visionTool } from '@sanity/vision'
import { schemaTypes } from './schemaTypes'
export default defineConfig({
name: 'default',
title: 'My Astro Site',
projectId: 'abc123xy',
dataset: 'production',
plugins: [structureTool(), visionTool()],
schema: { types: schemaTypes },
})
Finally, allow the origins the Studio runs on, with credentials:
npx sanity cors add http://localhost:4321 --credentials
npx sanity cors add https://www.example.com --credentials
Routing depends on your output mode. With output: 'static', the integration defaults to hash routing, so the Studio is one prerendered page at /admin and Studio URLs look like /admin#/structure/post. With a server adapter, it uses normal browser paths. Set studioRouterHistory to 'hash' or 'browser' to choose explicitly. Many teams still prefer a standalone Studio; the guide to deploying Sanity Studio compares the options.
Load Sanity Into Content Collections
On Astro 5 and later, the integration ships an experimental loader that feeds a GROQ query into the Content Layer, so getCollection() and getEntry() work for Sanity documents just like local Markdown:
// src/content.config.ts
import { defineCollection } from 'astro:content'
import { z } from 'astro/zod'
import { sanityLoader } from '@sanity/astro/loader'
import { sanityClient } from 'sanity:client'
const posts = defineCollection({
loader: sanityLoader<{ _id: string; slug: string }>({
client: sanityClient,
query: `*[_type == "post" && defined(slug.current)]{ _id, title, "slug": slug.current, publishedAt }`,
id: (post) => post.slug,
}),
schema: z.object({
_id: z.string(),
title: z.string(),
slug: z.string(),
publishedAt: z.string().optional(),
}),
})
export const collections = { posts }
Because it's experimental, its options may change in a minor release, so pin your version if you depend on it. Fetching directly with sanityClient remains the stable approach. The static site guide compares the two in a real project.
Visual Editing Needs Server Rendering
Sanity's Visual Editing works in Astro, but only on server-rendered pages, because drafts and stega-encoded content must be fetched per request with a token. The usual approach is:
- Add an adapter, such as
@astrojs/vercelor@astrojs/node. - Keep most pages static and opt preview routes out with
export const prerender = false, or switch tooutput: 'server'if most pages need previews. - Render
<VisualEditing />from@sanity/astro/visual-editingin your layout, enabled by an environment variable. - Fetch with a helper that uses the drafts perspective, a read token and stega when Visual Editing is enabled.
- Add the Presentation tool to the Studio.
The token belongs in a server-only variable such as SANITY_API_READ_TOKEN, passed per request rather than set on the default client. Astro's static vs on-demand rendering guide explains the rendering modes these steps rely on.
FAQ: Sanity With Astro
No. You can query Sanity with @sanity/client or fetch from any Astro project. The integration adds a preconfigured client in sanity:client, types, an embedded Studio route, Visual Editing components and an experimental content loader, which saves setup work.
For most blogs, marketing sites and documentation, build time is best: pages are static and fast, and Sanity isn't called by visitors. Use server rendering for previews, Visual Editing, personalisation or content that must update without a rebuild.
Create a build or deploy hook on your hosting platform and call it from a Sanity webhook when documents are published. Each publish then triggers a fresh build that fetches the latest content.
Use @portabletext/astro. It's maintained under the Portable Text organisation as a fork of astro-portabletext, which Sanity previously recommended, and it renders standard blocks while letting you map custom types and marks to Astro components.
You can, by adding cdn.sanity.io to image.domains, but Sanity's CDN already resizes and converts formats. Passing those URLs through Astro's pipeline processes them twice, so building URLs with @sanity/image-url is usually the better choice.
With browser history routing, Studio sub-paths need a server-rendered catch-all route, which requires an adapter. On a fully static site, use hash routing, which is the default for static output, so the Studio lives on a single page.
Conclusion
Using Sanity with Astro starts with one command and a few lines of config. The @sanity/astro integration gives you a ready-made sanityClient from sanity:client, which you call in frontmatter with GROQ queries, while @portabletext/astro and @sanity/image-url handle rich text and images. Set useCdn to match your rendering mode, keep tokens out of public variables and the default client, and add CORS origins for anything that runs in the browser.
From there, you choose how far to go. A static site with webhook-triggered builds is fast and simple, an embedded Studio keeps editing on your own domain, and server-rendered preview routes unlock Visual Editing. To see the pieces working together, follow the guide to building a static site with Astro and Sanity.


