
Building a Static Site With Astro and Sanity
To build a static site with Astro and Sanity, keep Astro's default output: 'static', configure @sanity/astro with useCdn: false, and fetch content at build time in getStaticPaths() and page frontmatter using GROQ. Each Sanity document becomes a prerendered HTML page, so visitors never call Sanity's API, and the site can be hosted on any static host. To keep it up to date, create a deploy hook on your host and call it from a Sanity webhook whenever editors publish, so every change triggers a fresh build.
Static generation is the simplest and most robust way to run a Sanity-powered site. Pages are fast because they're plain HTML, hosting is cheap, and a Sanity outage can't take your site down. The price is that changes appear after a rebuild rather than instantly. This guide builds a complete static site from scratch: Studio schemas, a typed data layer, list, detail, category and paginated pages, images, SEO tags, a sitemap and automatic rebuilds. If you want an overview of the integration's features first, read how to use Sanity with Astro.
The Plan
The finished project has two folders, one for the Studio and one for the Astro site:
my-site/
studio/ # Sanity Studio (npm create sanity@latest)
schemaTypes/
web/ # Astro site (npm create astro@latest)
astro.config.mjs
src/
env.d.ts
layouts/Base.astro
lib/sanity.ts
lib/image.ts
components/
PortableText.astro
SanityImage.astro
PostCard.astro
pages/
index.astro
blog/[...page].astro
posts/[slug].astro
categories/[slug].astro
And these routes, all generated at build time:
| Route | Built from |
|---|---|
/ | The six latest posts |
/blog/, /blog/2/, ... | All posts, ten per page |
/posts/[slug]/ | One page per post |
/categories/[slug]/ | One page per category |
Step 1: Create the Studio and Schemas
Create a Studio in its own folder:
npm create sanity@latest -- --dataset production --template clean --typescript --output-path studio
Add two document types. The post references categories, has an image with alt text and a Portable Text body:
// studio/schemaTypes/post.ts
import { defineArrayMember, defineField, defineType } from 'sanity'
export const post = defineType({
name: 'post',
title: 'Post',
type: 'document',
fields: [
defineField({ name: 'title', type: 'string', validation: (rule) => rule.required() }),
defineField({
name: 'slug',
type: 'slug',
options: { source: 'title' },
validation: (rule) => rule.required(),
}),
defineField({ name: 'excerpt', type: 'text', rows: 3 }),
defineField({ name: 'publishedAt', type: 'datetime' }),
defineField({
name: 'mainImage',
type: 'image',
options: { hotspot: true },
fields: [defineField({ name: 'alt', type: 'string', validation: (rule) => rule.required() })],
}),
defineField({
name: 'categories',
type: 'array',
of: [defineArrayMember({ type: 'reference', to: [{ type: 'category' }] })],
}),
defineField({
name: 'body',
type: 'array',
of: [defineArrayMember({ type: 'block' }), defineArrayMember({ type: 'image', options: { hotspot: true } })],
}),
],
})
// studio/schemaTypes/category.ts
import { defineField, defineType } from 'sanity'
export const category = defineType({
name: 'category',
title: 'Category',
type: 'document',
fields: [
defineField({ name: 'title', type: 'string', validation: (rule) => rule.required() }),
defineField({
name: 'slug',
type: 'slug',
options: { source: 'title' },
validation: (rule) => rule.required(),
}),
],
})
Export both from schemaTypes/index.ts, run npm run dev in studio/ and create a few posts and categories. Requiring alt text on the main image keeps the site accessible, a habit covered in adding alt text requirements to Sanity images.
Step 2: Create the Astro Site
npm create astro@latest web -- --template minimal
cd web
npx astro add @sanity/astro
npm install @portabletext/astro @sanity/image-url
Configure the integration for static output. useCdn: false matters here: queries run once per build, so you want the freshest data rather than a cached response:
// web/astro.config.mjs
import { defineConfig } from 'astro/config'
import sanity from '@sanity/astro'
import sitemap from '@astrojs/sitemap'
export default defineConfig({
site: 'https://www.example.com',
trailingSlash: 'always',
integrations: [
sanity({
projectId: 'abc123xy',
dataset: 'production',
apiVersion: '2025-02-19',
useCdn: false,
}),
sitemap(),
],
})
Install the sitemap integration with npx astro add sitemap. Then add the module types:
// web/src/env.d.ts
/// <reference types="astro/client" />
/// <reference types="@sanity/astro/module" />
There's no adapter and no output setting. Astro's default is static, which is exactly what we want.
Step 3: Build a Data Layer
Keep every query in one module with small, named functions. Pages then read like plain function calls, and the GROQ is easy to review in one place:
// web/src/lib/sanity.ts
import type { PortableTextBlock } from '@portabletext/types'
import type { SanityImageSource } from '@sanity/image-url'
import { sanityClient } from 'sanity:client'
export type PostCard = {
_id: string
title: string
slug: string
excerpt: string | null
publishedAt: string | null
mainImage: (SanityImageSource & { alt?: string }) | null
}
export type Post = PostCard & {
body: PortableTextBlock[] | null
categories: { title: string; slug: string }[] | null
}
const cardFields = `_id, title, "slug": slug.current, excerpt, publishedAt, mainImage`
export function getAllPosts() {
return sanityClient.fetch<PostCard[]>(
`*[_type == "post" && defined(slug.current)] | order(publishedAt desc){ ${cardFields} }`,
)
}
export function getPost(slug: string) {
return sanityClient.fetch<Post | null>(
`*[_type == "post" && slug.current == $slug][0]{
${cardFields},
body,
"categories": categories[]->{ title, "slug": slug.current }
}`,
{ slug },
)
}
export function getCategoriesWithPosts() {
return sanityClient.fetch<{ title: string; slug: string; posts: PostCard[] }[]>(
`*[_type == "category" && defined(slug.current)]{
title,
"slug": slug.current,
"posts": *[_type == "post" && references(^._id)] | order(publishedAt desc){ ${cardFields} }
}`,
)
}
The category query uses references(^._id) to collect the posts pointing at each category, which is explained in dereferencing documents in GROQ. Install @portabletext/types as a dev dependency for the PortableTextBlock type, or generate all of these types automatically with Sanity TypeGen.
Step 4: Images and Portable Text Components
Create the image URL builder and a reusable image component:
// web/src/lib/image.ts
import { createImageUrlBuilder, type SanityImageSource } from '@sanity/image-url'
import { sanityClient } from 'sanity:client'
const builder = createImageUrlBuilder(sanityClient)
export const urlFor = (source: SanityImageSource) => builder.image(source)
---
// web/src/components/SanityImage.astro
import type { SanityImageSource } from '@sanity/image-url'
import { urlFor } from '../lib/image'
interface Props {
node: SanityImageSource & { alt?: string }
width?: number
height?: number
eager?: boolean
}
const { node, width = 800, height, eager = false } = Astro.props
const img = (w: number) => {
const b = urlFor(node).width(w).auto('format')
return height ? b.height(Math.round((height / width) * w)).url() : b.url()
}
---
<img
src={img(width)}
srcset={[width / 2, width, width * 2].map((w) => `${img(w)} ${w}w`).join(', ')}
sizes={`(min-width: ${width}px) ${width}px, 100vw`}
alt={node.alt ?? ''}
width={width}
height={height}
loading={eager ? 'eager' : 'lazy'}
/>
The image is resized and converted to AVIF or WebP by Sanity's CDN, so the build doesn't process images at all, which keeps builds quick. For more on this, see optimising Sanity images for performance.
Map Portable Text images to the same component:
---
// web/src/components/PortableText.astro
import type { PortableTextBlock } from '@portabletext/types'
import { PortableText as PortableTextInternal } from '@portabletext/astro'
import SanityImage from './SanityImage.astro'
interface Props {
value: PortableTextBlock[]
}
const { value } = Astro.props
---
<PortableTextInternal value={value} components={{ type: { image: SanityImage } }} />
Step 5: A Base Layout With SEO Tags
Every page shares one layout that sets the title, description, canonical URL and Open Graph tags:
---
// web/src/layouts/Base.astro
interface Props {
title: string
description?: string | null
image?: string
}
const { title, description, image } = Astro.props
const canonical = new URL(Astro.url.pathname, Astro.site)
---
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>{title}</title>
{description && <meta name="description" content={description} />}
<link rel="canonical" href={canonical} />
<meta property="og:title" content={title} />
{description && <meta property="og:description" content={description} />}
{image && <meta property="og:image" content={image} />}
</head>
<body>
<header><a href="/">My Site</a> · <a href="/blog/">Blog</a></header>
<main><slot /></main>
</body>
</html>
If editors need control over these values, add dedicated SEO fields to your schema, as described in managing SEO metadata in Sanity.
Step 6: Generate the Post Pages
getStaticPaths() tells Astro which pages to build. Fetch all posts once, and pass each post as a prop so the page doesn't need a second query:
---
// web/src/pages/posts/[slug].astro
import type { GetStaticPaths } from 'astro'
import Base from '../../layouts/Base.astro'
import PortableText from '../../components/PortableText.astro'
import SanityImage from '../../components/SanityImage.astro'
import { getAllPosts, getPost } from '../../lib/sanity'
import { urlFor } from '../../lib/image'
export const getStaticPaths = (async () => {
const posts = await getAllPosts()
return Promise.all(
posts.map(async ({ slug }) => ({
params: { slug },
props: { post: await getPost(slug) },
})),
)
}) satisfies GetStaticPaths
const { post } = Astro.props
if (!post) return Astro.redirect('/404')
const ogImage = post.mainImage ? urlFor(post.mainImage).width(1200).height(630).url() : undefined
---
<Base title={post.title} description={post.excerpt} image={ogImage}>
<article>
<h1>{post.title}</h1>
{post.publishedAt && (
<time datetime={post.publishedAt}>{new Date(post.publishedAt).toLocaleDateString('en-GB')}</time>
)}
{post.mainImage && <SanityImage node={post.mainImage} width={1200} height={675} eager />}
{post.body && <PortableText value={post.body} />}
{post.categories && (
<ul>
{post.categories.map((c) => <li><a href={`/categories/${c.slug}/`}>{c.title}</a></li>)}
</ul>
)}
</article>
</Base>
For a site with hundreds of posts, replace the per-post getPost calls with one query that returns full posts, so the build makes a single request. The guide to dynamic routes with getStaticPaths covers the Astro side in more detail.
Step 7: Home, Paginated Blog and Category Pages
A small card component keeps list pages consistent:
---
// web/src/components/PostCard.astro
import type { PostCard } from '../lib/sanity'
import SanityImage from './SanityImage.astro'
const { post } = Astro.props as { post: PostCard }
---
<article>
{post.mainImage && <SanityImage node={post.mainImage} width={600} height={338} />}
<h2><a href={`/posts/${post.slug}/`}>{post.title}</a></h2>
{post.excerpt && <p>{post.excerpt}</p>}
</article>
Astro's paginate() helper splits the posts into pages for you:
---
// web/src/pages/blog/[...page].astro
import type { GetStaticPaths, Page } from 'astro'
import Base from '../../layouts/Base.astro'
import PostCard from '../../components/PostCard.astro'
import { getAllPosts, type PostCard as Card } from '../../lib/sanity'
export const getStaticPaths = (async ({ paginate }) => {
const posts = await getAllPosts()
return paginate(posts, { pageSize: 10 })
}) satisfies GetStaticPaths
const { page } = Astro.props as { page: Page<Card> }
---
<Base title={`Blog, page ${page.currentPage}`}>
{page.data.map((post) => <PostCard post={post} />)}
<nav>
{page.url.prev && <a href={page.url.prev}>Newer posts</a>}
{page.url.next && <a href={page.url.next}>Older posts</a>}
</nav>
</Base>
This builds /blog/, /blog/2/ and so on. Because the whole list is fetched once at build time, you don't need GROQ slicing here; pagination with GROQ matters more for server-rendered pages.
Category pages follow the same pattern, using the query that already includes each category's posts:
---
// web/src/pages/categories/[slug].astro
import Base from '../../layouts/Base.astro'
import PostCard from '../../components/PostCard.astro'
import { getCategoriesWithPosts } from '../../lib/sanity'
export async function getStaticPaths() {
const categories = await getCategoriesWithPosts()
return categories.map((category) => ({ params: { slug: category.slug }, props: { category } }))
}
const { category } = Astro.props
---
<Base title={category.title}>
<h1>{category.title}</h1>
{category.posts.map((post) => <PostCard post={post} />)}
</Base>
The home page simply calls getAllPosts() and renders the first six with PostCard.
Step 8: Build and Check
Run a production build:
npm run build
npx astro preview
Astro prints each generated route. Check the output for:
- Every expected slug: a missing page usually means a post without a slug or an unpublished draft. Static builds only see published content.
- sitemap-index.xml in dist/: generated by
@astrojs/sitemapfrom thesitevalue. - Image URLs pointing at cdn.sanity.io: no local image processing should appear in the build log.
Step 9: Rebuild When Editors Publish
A static site only changes when it's rebuilt, so connect publishing to your host's build:
- Create a deploy hook: Netlify, Vercel and Cloudflare Pages all let you create a build or deploy hook URL in the project settings. A POST request to it starts a new build.
- Create a Sanity webhook: at sanity.io/manage, go to API > Webhooks and add a webhook with the deploy hook URL, triggered on create, update and delete.
- Filter it: set the filter to
_type in ["post", "category"]so unrelated documents don't trigger builds. - Test it: publish a change and watch a build start on your host.
Builds usually take somewhere between seconds and a few minutes, depending on the site's size and the host. If editors publish in bursts, many hosts queue or cancel superseded builds automatically. The guide to triggering site rebuilds with Sanity webhooks covers debouncing and other refinements.
When Static Isn't Enough
Static output is the right default, but some needs require server rendering:
- Draft previews and Visual Editing: these need content fetched per request with a token.
- Personalisation or search results: anything that depends on the visitor.
- Very frequent updates: if rebuilds can't keep up with editors.
You don't have to give up static pages to get them. Add an adapter and opt only the routes that need it out of prerendering with export const prerender = false, keeping the rest of the site static. The Sanity with Astro guide outlines the Visual Editing setup.
FAQ: Static Sites With Astro and Sanity
Queries only run during the build, so the API CDN's speed advantage doesn't matter, and its cache could return slightly stale content just after a publish. Querying the live API guarantees each build sees the latest published content.
Not on its static pages, which only contain what was published at build time. For previews, add an adapter and server-rendered preview routes that fetch drafts with a token, and keep the public pages static.
It depends on the number of pages, the queries and your host. Small sites often rebuild in under a minute. Keeping queries to a few large requests and letting Sanity's CDN handle image resizing keeps build times down.
Both work. Fetching with sanityClient in getStaticPaths is stable and simple. The experimental sanityLoader from @sanity/astro/loader lets you use getCollection and Zod schemas, but its options may change, so pin the version if you adopt it.
Any static host works, including Netlify, Vercel, Cloudflare Pages and GitHub Pages. Choose one that offers deploy hooks, so Sanity webhooks can trigger rebuilds automatically.
Not for page content on a static site, because pages are fetched once per build rather than per visit. Images are still served from Sanity's CDN, so image bandwidth and requests are counted.
Conclusion
A static Astro and Sanity site is built from a few simple parts: schemas in the Studio, a data module of named GROQ queries, getStaticPaths() to turn documents into pages, paginate() for archives and components that build image URLs and render Portable Text. With useCdn: false, each build sees the latest published content, and the output is plain HTML that any static host can serve.
Automatic rebuilds close the loop. A deploy hook on your host and a filtered Sanity webhook mean editors publish and the site updates a short while later, with no developer involved. When you need previews or real-time content, add server rendering for just those routes and keep the rest of the site static and fast.


