Type something to search...
Building a Static Site With Astro and Sanity

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:

RouteBuilt 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/sitemap from the site value.
  • 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:

  1. 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.
  2. 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.
  3. Filter it: set the filter to _type in ["post", "category"] so unrelated documents don't trigger builds.
  4. 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.

Tags :
Share :

Related Posts

Common Sanity Mistakes and How to Fix Them

Common Sanity Mistakes and How to Fix Them

The most common Sanity mistakes are using the CDN where you need fresh data, fetching whole documents with ..., leaving slugs unvalidated, ship

Dive Deeper
How to Create a Custom Sanity Plugin

How to Create a Custom Sanity Plugin

To create a custom Sanity plugin, wrap a piece of Studio configuration in definePlugin from the sanity package and add it to the plugins ar

Dive Deeper
How to Deploy Sanity Studio to the Web

How to Deploy Sanity Studio to the Web

The quickest way to deploy Sanity Studio is to run npx sanity deploy from your Studio folder. The CLI builds the Studio, asks you to choose a h

Dive Deeper