Skip to content
Sign inJoin waitlist

Connect Sanity to an Astro template

Sanity is a hosted CMS: your content lives in Sanity’s cloud, and editors work in Sanity Studio, an editor that runs on its own — on your machine, or hosted free at <name>.sanity.studio. The Astro edition is a static site that reads Sanity while it builds. This guide sets up both for the Nitip Astro edition: at the end, editors manage services, projects, posts, jobs, team and testimonials in the Studio, and the site rebuilds from Sanity when they publish.

We followed every step below on a fresh copy of Nitip 1.0.0 with Sanity Studio 6.17 and Astro 7.3 — the setup, the import, the static build, and editing and reordering in the Studio with the site picking it up. Every other Framebrick Astro template works the same way: its collections come from the same kind of loaders in src/content.config.ts, so only the document types and fields differ — its Using a headless CMS page lists them.

my-site/
├── nitip-astro/ the website: this template
└── nitip-studio/ Sanity Studio: the editor
  • The site’s collections load from Sanity’s API instead of the JSON files. The schemas stay, so every entry is checked before a page uses it, and the pages and components don’t change.
  • While you work, the site listens to Sanity and reloads its collections a moment after you publish.
  • Online, Sanity calls your host’s deploy hook after every change, and the site rebuilds.
  • Images are downloaded from Sanity during the build and optimized like the template’s own, so visitors never load anything from Sanity.
  • The Nitip Astro edition, unzipped into a folder of its own (my-site/nitip-astro), with npm install done and npm run dev working (Install and run).
  • A free Sanity account — the first command below asks you to sign in or create one.
  • Node.js 22.12 or newer, and about 45 minutes.

In my-site (the folder that holds nitip-astro):

Terminal window
npx sanity@latest init --output-path nitip-studio

Sign in when it asks, then choose Create a new project, the default dataset (production, public), the Clean template and TypeScript. That makes nitip-studio, a Sanity Studio for your new project, and allows http://localhost:3333 (the Studio’s own address while you work) to use it.

Then, in nitip-studio, add the plugin that gives the Studio drag-and-drop lists, the library the import script uses to number them, and Node’s types for that script:

Terminal window
cd nitip-studio
npm install @sanity/orderable-document-list lexorank
npm install -D @types/node

Each document type mirrors a schema in nitip-astro/src/content/schemas.ts, field for field. Replace nitip-studio/schemaTypes/index.ts:

nitip-studio/schemaTypes/index.ts
import {orderRankField, orderRankOrdering} from '@sanity/orderable-document-list'
import {defineArrayMember, defineField, defineType} from 'sanity'
/*
* The document types editors manage in the Studio. Each one mirrors a schema in the site's src/content/schemas.ts, so its
* loaders (src/lib/sanity.ts there) only rename a few fields on the way out. Every type is sorted by drag and drop (orderRank, see structure.ts).
*/
/* Shared fields */
const slug = defineField({
name: 'slug',
type: 'slug',
description: 'The page address: lowercase words joined by hyphens.',
options: {source: 'title'},
validation: (rule) => rule.required(),
})
const required = (name: string, type: 'string' | 'text' = 'string') =>
defineField({name, type, validation: (rule) => rule.required()})
const image = (name: string, title?: string) =>
defineField({name, title, type: 'image', validation: (rule) => rule.required()})
const imagePair = (name: string) =>
defineField({
name,
type: 'array',
of: [defineArrayMember({type: 'image'})],
validation: (rule) => rule.required().length(2),
})
const lines = (name: string) =>
defineField({name, type: 'array', of: [defineArrayMember({type: 'text', rows: 2})]})
/* Text that may start with a bold phrase ("Instant Traffic: Drive qualified visitors…"). */
const highlighted = (name: string) =>
defineField({
name,
type: 'array',
of: [
defineArrayMember({
type: 'object',
name: 'item',
fields: [
defineField({
name: 'highlight',
type: 'string',
description: 'Bold phrase at the start (optional).',
}),
defineField({name: 'text', type: 'text', rows: 2}),
],
preview: {select: {title: 'highlight', subtitle: 'text'}},
}),
],
})
/* The hidden field the drag-and-drop order is stored in. */
const rank = (type: string) => orderRankField({type})
/* Post body: the blocks the template renders (schemas.ts → postBlockSchema), two list levels deep. */
const heading = defineType({
name: 'postHeading',
title: 'Heading',
type: 'object',
fields: [
defineField({name: 'bold', type: 'string'}),
defineField({name: 'text', type: 'string'}),
],
preview: {select: {title: 'text', subtitle: 'bold'}},
})
const paragraph = defineType({
name: 'postParagraph',
title: 'Paragraph',
type: 'object',
fields: [
defineField({name: 'highlight', type: 'string'}),
defineField({name: 'text', type: 'text'}),
],
preview: {select: {title: 'text', subtitle: 'highlight'}},
})
const listFields = (inner: string[]) => [
defineField({name: 'ordered', title: 'Numbered list', type: 'boolean', initialValue: false}),
defineField({
name: 'items',
type: 'array',
of: [
defineArrayMember({
type: 'object',
name: 'item',
fields: [
defineField({
name: 'content',
type: 'array',
of: inner.map((type) => defineArrayMember({type})),
}),
],
preview: {select: {title: 'content.0.text'}},
}),
],
}),
]
const listPreview = {
select: {ordered: 'ordered', items: 'items'},
prepare: ({ordered, items}: {ordered?: boolean; items?: unknown[]}) => ({
title: ordered ? 'Numbered list' : 'Bulleted list',
subtitle: `${items?.length ?? 0} items`,
}),
}
const subList = defineType({
name: 'postSubList',
title: 'List',
type: 'object',
fields: listFields(['postParagraph', 'postHeading']),
preview: listPreview,
})
const list = defineType({
name: 'postList',
title: 'List',
type: 'object',
fields: listFields(['postParagraph', 'postHeading', 'postSubList']),
preview: listPreview,
})
const service = defineType({
name: 'service',
type: 'document',
orderings: [orderRankOrdering],
fields: [
rank('service'),
required('title'),
slug,
defineField({name: 'number', type: 'string', description: 'Shown before the title, e.g. 01.'}),
imagePair('images'),
defineField({name: 'description', type: 'text'}),
defineField({
name: 'tools',
type: 'array',
of: [
defineArrayMember({
type: 'object',
name: 'tool',
fields: [
defineField({name: 'label', type: 'string'}),
defineField({name: 'href', type: 'url'}),
],
}),
],
}),
highlighted('offer'),
highlighted('benefits'),
defineField({
name: 'plans',
type: 'array',
of: [
defineArrayMember({
type: 'object',
name: 'plan',
fields: [
defineField({
name: 'key',
type: 'string',
description: 'Short id, e.g. weekly.',
validation: (rule) => rule.required(),
}),
defineField({name: 'label', type: 'string'}),
defineField({name: 'price', type: 'string'}),
defineField({name: 'note', type: 'string'}),
defineField({
name: 'included',
type: 'array',
of: [defineArrayMember({type: 'string'})],
}),
],
preview: {select: {title: 'label', subtitle: 'price'}},
}),
],
}),
],
})
const project = defineType({
name: 'project',
type: 'document',
orderings: [orderRankOrdering],
fields: [
rank('project'),
required('title'),
slug,
defineField({name: 'year', type: 'string'}),
image('image', 'Card image'),
defineField({
name: 'services',
type: 'array',
of: [defineArrayMember({type: 'reference', to: [{type: 'service'}]})],
}),
defineField({name: 'summary', type: 'text'}),
defineField({name: 'industry', type: 'string'}),
defineField({
name: 'overview',
type: 'object',
fields: [defineField({name: 'text', type: 'text'}), image('image')],
}),
defineField({
name: 'process',
type: 'object',
fields: [defineField({name: 'text', type: 'text'}), imagePair('images')],
}),
defineField({name: 'result', type: 'object', fields: [highlighted('items'), image('image')]}),
],
preview: {select: {title: 'title', subtitle: 'year', media: 'image'}},
})
const post = defineType({
name: 'post',
type: 'document',
orderings: [orderRankOrdering],
fields: [
rank('post'),
required('title'),
slug,
defineField({name: 'excerpt', type: 'text', rows: 2}),
image('image'),
defineField({name: 'category', type: 'string'}),
defineField({name: 'date', type: 'date', validation: (rule) => rule.required()}),
defineField({
name: 'body',
type: 'array',
of: [
defineArrayMember({type: 'postHeading'}),
defineArrayMember({type: 'postParagraph'}),
defineArrayMember({type: 'postList'}),
],
}),
],
preview: {select: {title: 'title', subtitle: 'category', media: 'image'}},
})
const career = defineType({
name: 'career',
type: 'document',
orderings: [orderRankOrdering],
fields: [
rank('career'),
required('title'),
slug,
defineField({name: 'summary', type: 'text'}),
lines('responsibilities'),
lines('requirements'),
lines('benefits'),
],
})
const teamMember = defineType({
name: 'teamMember',
title: 'Team member',
type: 'document',
orderings: [orderRankOrdering],
fields: [
rank('teamMember'),
required('name'),
defineField({name: 'role', type: 'string'}),
defineField({name: 'email', type: 'string'}),
image('photo'),
],
preview: {select: {title: 'name', subtitle: 'role', media: 'photo'}},
})
const testimonial = defineType({
name: 'testimonial',
type: 'document',
orderings: [orderRankOrdering],
fields: [
rank('testimonial'),
required('quote', 'text'),
required('name'),
defineField({name: 'company', type: 'string'}),
defineField({
name: 'service',
type: 'string',
description: 'The service the client used, shown under the name.',
}),
image('photo'),
image('logo'),
],
preview: {select: {title: 'name', subtitle: 'company', media: 'photo'}},
})
export const schemaTypes = [
service,
project,
post,
career,
teamMember,
testimonial,
heading,
paragraph,
list,
subList,
]

A few choices worth knowing:

  • Order. Every type has a hidden orderRank field; the Studio’s lists sort by it with drag and drop, and the site shows entries in that order. New entries go to the end.
  • Post bodies are blocks — Heading, Paragraph and List — the same blocks the template renders, with one list level inside another, which covers every post in the demo.
  • Pricing plans have a key field (weekly, monthly…) for the plan’s id.

These are the same types as in the Sanity + Next.js guide, so one Sanity project can feed either edition.

Then list the types in the Studio, each sortable. Create nitip-studio/structure.ts:

nitip-studio/structure.ts
import {orderableDocumentListDeskItem} from '@sanity/orderable-document-list'
import type {StructureResolver} from 'sanity/structure'
/* One list per collection, each sorted by drag and drop: the site shows entries in this order. */
const LISTS = [
{type: 'service', title: 'Services'},
{type: 'project', title: 'Projects'},
{type: 'post', title: 'Posts'},
{type: 'career', title: 'Careers'},
{type: 'teamMember', title: 'Team'},
{type: 'testimonial', title: 'Testimonials'},
]
export const structure: StructureResolver = (S, context) =>
S.list()
.title('Content')
.items(LISTS.map(({type, title}) => orderableDocumentListDeskItem({type, title, S, context})))

And use it in nitip-studio/sanity.config.ts:

nitip-studio/sanity.config.ts
import {defineConfig} from 'sanity'
import {structureTool} from 'sanity/structure'
import {visionTool} from '@sanity/vision'
import {schemaTypes} from './schemaTypes'
import {structure} from './structure'
// …
plugins: [structureTool(), visionTool()],
plugins: [structureTool({structure}), visionTool()],

This script copies the JSON collections and images from nitip-astro into Sanity. Create nitip-studio/seed.ts:

nitip-studio/seed.ts
/*
* Copies the demo content of the Astro site into Sanity:
* npx sanity exec seed.ts --with-user-token
* Reads the JSON in <site>/src/content/collections and uploads the images from <site>/src/assets/images. Document ids
* follow the slugs (service-<slug>, …), so running it again replaces the demo entries instead of adding copies.
*/
import fs from 'node:fs'
import path from 'node:path'
import {LexoRank} from 'lexorank'
import {getCliClient} from 'sanity/cli'
/* The Astro project, in the folder next to this one. */
const site = path.resolve('../nitip-astro')
/* The shapes of the JSON files (the site's src/content/schemas.ts). */
type Highlighted = {highlight?: string; text: string}
type PostBlock =
| {type: 'heading'; text: string; bold?: string}
| {type: 'paragraph'; highlight?: string; text: string}
| {type: 'list'; ordered?: boolean; items: PostBlock[][]}
type Service = {
slug: string
number: string
title: string
images: [string, string]
description: string
tools: {label: string; href: string}[]
offer: Highlighted[]
benefits: Highlighted[]
plans: {id: string; label: string; price: string; note: string; included: string[]}[]
}
type Project = {
slug: string
title: string
year: string
image: string
services: {label: string; slug: string}[]
summary: string
industry: string
overview: {text: string; image: string}
process: {text: string; images: [string, string]}
result: {items: Highlighted[]; image: string}
}
type Post = {
slug: string
title: string
excerpt: string
image: string
category: string
date: string
body: PostBlock[]
}
type Career = {
slug: string
title: string
summary: string
responsibilities: string[]
requirements: string[]
benefits: string[]
}
type TeamMember = {name: string; role: string; email: string; photo: string}
type Testimonial = {
quote: string
name: string
company: string
service: string
photo: string
logo: string
}
const read = <T>(name: string): T[] =>
JSON.parse(fs.readFileSync(path.join(site, 'src/content/collections', name), 'utf8'))
const client = getCliClient({apiVersion: '2026-10-01'})
/* "/images/blog-1.jpg" → an image field, uploading each file once. */
const uploaded = new Map<string, string>()
async function image(src: string) {
if (!uploaded.has(src)) {
const file = path.join(site, 'src/assets', src)
const asset = await client.assets.upload('image', fs.createReadStream(file), {
filename: path.basename(src),
})
uploaded.set(src, asset._id)
}
return {_type: 'image', asset: {_type: 'reference', _ref: uploaded.get(src)!}}
}
/* Array items need a _key in Sanity. */
const keyed = <T extends object>(items: T[]) => items.map((item, i) => ({_key: `k${i}`, ...item}))
const images = async (srcs: string[]) => keyed(await Promise.all(srcs.map(image)))
const highlighted = (items: Highlighted[]) => keyed(items.map((item) => ({_type: 'item', ...item})))
const slug = (current: string) => ({_type: 'slug', current})
/* The drag-and-drop order (orderRank), in the order of the demo files. */
const ranks = (count: number) => {
let rank = LexoRank.min()
return Array.from({length: count}, () => (rank = rank.genNext().genNext()).toString())
}
function block(item: PostBlock, nested = false): Record<string, unknown> {
if (item.type === 'heading') return {_type: 'postHeading', text: item.text, bold: item.bold}
if (item.type === 'paragraph')
return {_type: 'postParagraph', text: item.text, highlight: item.highlight}
return {
_type: nested ? 'postSubList' : 'postList',
...(item.ordered && {ordered: true}),
items: keyed(
item.items.map((content) => ({
_type: 'item',
content: keyed(content.map((inner) => block(inner, true))),
})),
),
}
}
/* sanity exec runs the script as CommonJS, so the work sits in a function instead of top-level await. */
async function seed() {
const documents: Record<string, unknown>[] = []
const add = async <T>(items: T[], make: (item: T) => Promise<Record<string, unknown>>) => {
const rank = ranks(items.length)
for (const [i, item] of items.entries())
documents.push({...(await make(item)), orderRank: rank[i]})
}
await add(
read<Service>('services.json'),
async ({slug: s, images: pair, tools, offer, benefits, plans, ...service}) => ({
_id: `service-${s}`,
_type: 'service',
...service,
slug: slug(s),
images: await images(pair),
tools: keyed(tools.map((tool) => ({_type: 'tool', ...tool}))),
offer: highlighted(offer),
benefits: highlighted(benefits),
plans: keyed(plans.map(({id, ...plan}) => ({_type: 'plan', key: id, ...plan}))),
}),
)
await add(
read<Project>('projects.json'),
async ({slug: s, image: img, services, overview, process, result, ...project}) => ({
_id: `project-${s}`,
_type: 'project',
...project,
slug: slug(s),
image: await image(img),
services: keyed(services.map((ref) => ({_type: 'reference', _ref: `service-${ref.slug}`}))),
overview: {text: overview.text, image: await image(overview.image)},
process: {text: process.text, images: await images(process.images)},
result: {items: highlighted(result.items), image: await image(result.image)},
}),
)
await add(read<Post>('posts.json'), async ({slug: s, image: img, body, ...post}) => ({
_id: `post-${s}`,
_type: 'post',
...post,
slug: slug(s),
image: await image(img),
body: keyed(body.map((item) => block(item))),
}))
await add(read<Career>('careers.json'), async ({slug: s, ...career}) => ({
_id: `career-${s}`,
_type: 'career',
...career,
slug: slug(s),
}))
await add(read<TeamMember>('team.json'), async ({photo, ...member}) => ({
_id: `team-${member.email.split('@')[0]}`,
_type: 'teamMember',
...member,
photo: await image(photo),
}))
await add(read<Testimonial>('testimonials.json'), async ({photo, logo, ...testimonial}) => ({
_id: `testimonial-${testimonial.company.toLowerCase()}`,
_type: 'testimonial',
...testimonial,
photo: await image(photo),
logo: await image(logo),
}))
/* Services first in the same transaction is fine: references are checked when it commits. */
const transaction = client.transaction()
for (const doc of documents) transaction.createOrReplace(doc as {_id: string; _type: string})
await transaction.commit()
console.log(`Seeded ${documents.length} documents and ${uploaded.size} images.`)
}
seed().catch((error) => {
console.error(error)
process.exit(1)
})

Run it in nitip-studio with your Sanity login (no API token needed):

Terminal window
npx sanity exec seed.ts --with-user-token

It ends with:

Seeded 49 documents and 80 images.

Running it again replaces the demo entries rather than adding copies. Sanity stores identical files once, so the download’s grey placeholder images may share one asset — your own photos each get theirs.

Terminal window
npm run dev

Open localhost:3333, sign in, and you’ll see Services, Projects, Posts, Careers, Team and Testimonials with the demo content. Leave it running.

5. Load the site’s collections from Sanity

Section titled “5. Load the site’s collections from Sanity”

Everything in this step happens in nitip-astro. Install Sanity’s client, and undici for the build setting below:

Terminal window
npm install @sanity/client
npm install -D undici

.env — tell the site which project to read (create the file if you haven’t for the forms). The project ID is in nitip-studio/sanity.config.ts:

nitip-astro/.env
# The Sanity project the site reads; the ID is in nitip-studio/sanity.config.ts. Not secrets: the dataset is public.
SANITY_PROJECT_ID=your-project-id
SANITY_DATASET=production

The loaders. Create nitip-astro/src/lib/sanity.ts. Each one runs a GROQ query whose result already has the shape of the template’s schema:

nitip-astro/src/lib/sanity.ts
/*
* Collections from Sanity. Each loader runs one GROQ query when the site builds (and when the dev server refreshes, see
* src/integrations/sanity-refresh.ts) whose projection already has the shape of a schema in src/content/schemas.ts;
* the schemas then check every entry before a page uses it.
*/
import { createClient } from "@sanity/client";
import type { PostBlock } from "../content/schemas";
export const client = createClient({
projectId: import.meta.env.SANITY_PROJECT_ID,
dataset: import.meta.env.SANITY_DATASET ?? "production",
apiVersion: "2026-10-01",
// A build should read what was just published; the CDN can lag a few seconds behind.
useCdn: false,
});
/** A loader for one collection, in the order editors set in the Studio (`position`, as with the JSON files). */
export function sanity(query: string, key = "slug", map: (entry: Record<string, unknown>) => Record<string, unknown> = (entry) => entry) {
return async () => {
const entries: Record<string, unknown>[] = await client.fetch(query);
return entries.map((entry, position) => {
const data = map(clean(entry));
return { id: String(data[key]), position, ...data };
});
};
}
/* Leaves out null fields, as the JSON in src/content/collections does. */
const clean = <T>(value: T): T => JSON.parse(JSON.stringify(value, (_, v) => (v === null ? undefined : v)));
/* Text with an optional bold start (highlightedSchema). */
const highlighted = `{ highlight, "text": coalesce(text, "") }`;
export const SERVICES = `*[_type == "service"] | order(orderRank) {
"slug": slug.current, "number": coalesce(number, ""), title, "images": images[].asset->url,
"description": coalesce(description, ""), "tools": coalesce(tools[]{ label, href }, []),
"offer": coalesce(offer[]${highlighted}, []), "benefits": coalesce(benefits[]${highlighted}, []),
"plans": coalesce(plans[]{ "id": key, "label": coalesce(label, ""), "price": coalesce(price, ""), "note": coalesce(note, ""), "included": coalesce(included, []) }, [])
}`;
export const PROJECTS = `*[_type == "project"] | order(orderRank) {
"slug": slug.current, title, "year": coalesce(year, ""), "image": image.asset->url,
"services": coalesce(services[]->{ "label": title, "slug": slug.current }, []),
"summary": coalesce(summary, ""), "industry": coalesce(industry, ""),
"overview": { "text": coalesce(overview.text, ""), "image": overview.image.asset->url },
"process": { "text": coalesce(process.text, ""), "images": process.images[].asset->url },
"result": { "items": coalesce(result.items[]${highlighted}, []), "image": result.image.asset->url }
}`;
export const POSTS = `*[_type == "post"] | order(orderRank) {
"slug": slug.current, title, "excerpt": coalesce(excerpt, ""), "image": image.asset->url,
"category": coalesce(category, ""), date, "body": coalesce(body, [])
}`;
export const CAREERS = `*[_type == "career"] | order(orderRank) {
"slug": slug.current, title, "summary": coalesce(summary, ""),
"responsibilities": coalesce(responsibilities, []), "requirements": coalesce(requirements, []), "benefits": coalesce(benefits, [])
}`;
export const TEAM = `*[_type == "teamMember"] | order(orderRank) {
name, "role": coalesce(role, ""), "email": coalesce(email, ""), "photo": photo.asset->url
}`;
export const TESTIMONIALS = `*[_type == "testimonial"] | order(orderRank) {
quote, name, "company": coalesce(company, ""), "service": coalesce(service, ""), "photo": photo.asset->url, "logo": logo.asset->url
}`;
/* A post body block from the Studio → the block the template renders. */
type BodyBlock = { _type: string; text?: string; bold?: string; highlight?: string; ordered?: boolean; items?: { content?: BodyBlock[] }[] };
function block(item: BodyBlock): PostBlock {
if (item._type === "postHeading") return { type: "heading", text: item.text ?? "", bold: item.bold };
if (item._type === "postParagraph") return { type: "paragraph", highlight: item.highlight, text: item.text ?? "" };
return { type: "list", ordered: item.ordered, items: (item.items ?? []).map((row) => (row.content ?? []).map(block)) };
}
export const toPost = (post: Record<string, unknown>) => ({ ...post, body: (post.body as BodyBlock[]).map(block) });

The collections. Replace nitip-astro/src/content.config.ts — the schemas stay, only the loaders change:

nitip-astro/src/content.config.ts
/*
* Collections: loaded from Sanity (src/lib/sanity.ts) and validated with the schemas in src/content/schemas.ts. Items
* are keyed by `slug` (team and testimonials by name); `position` keeps the order set in the Studio, which getCollection
* doesn't (src/lib/content.ts sorts by it).
*/
import { defineCollection } from "astro:content";
import { z } from "astro/zod";
import {
careerSchema,
postSchema,
projectSchema,
serviceSchema,
teamMemberSchema,
testimonialSchema,
} from "./content/schemas";
import { CAREERS, POSTS, PROJECTS, SERVICES, TEAM, TESTIMONIALS, sanity, toPost } from "./lib/sanity";
const position = { position: z.number().optional() };
export const collections = {
projects: defineCollection({ loader: sanity(PROJECTS), schema: projectSchema.extend(position) }),
services: defineCollection({ loader: sanity(SERVICES), schema: serviceSchema.extend(position) }),
posts: defineCollection({ loader: sanity(POSTS, "slug", toPost), schema: postSchema.extend(position) }),
careers: defineCollection({ loader: sanity(CAREERS), schema: careerSchema.extend(position) }),
team: defineCollection({ loader: sanity(TEAM, "name"), schema: teamMemberSchema.extend(position) }),
testimonials: defineCollection({ loader: sanity(TESTIMONIALS, "name"), schema: testimonialSchema.extend(position) }),
};

Updates while you work. Create nitip-astro/src/integrations/sanity-refresh.ts, which listens to Sanity while npm run dev runs and reloads the collections after a publish:

nitip-astro/src/integrations/sanity-refresh.ts
/*
* Dev only: listens to Sanity and reloads the collections when content is published, so a change in the Studio shows on
* the next page load without restarting `npm run dev`. A build reads Sanity anyway, so nothing is added there.
*/
import { createClient } from "@sanity/client";
import type { AstroIntegration } from "astro";
export default function sanityRefresh({ projectId, dataset }: { projectId: string; dataset: string }): AstroIntegration {
return {
name: "sanity-refresh",
hooks: {
"astro:server:setup": ({ refreshContent, logger }) => {
if (!refreshContent) return;
const client = createClient({ projectId, dataset, apiVersion: "2026-10-01", useCdn: false });
let timer: ReturnType<typeof setTimeout> | undefined;
// One publish sends several events; reload once they settle.
client.listen("*", {}, { visibility: "query", includeResult: false }).subscribe({
next: () => {
clearTimeout(timer);
timer = setTimeout(async () => {
await refreshContent({});
logger.info("Collections reloaded from Sanity.");
}, 500);
},
error: (error) => logger.warn(`Not listening to Sanity: ${error.message}`),
});
},
},
};
}

astro.config.mjs — add the integration, and allow images from Sanity’s CDN so Astro can download and resize them:

nitip-astro/astro.config.mjs
// @ts-check
import tailwindcss from "@tailwindcss/vite";
import { defineConfig, fontProviders } from "astro/config";
import { Agent, setGlobalDispatcher } from "undici";
import { loadEnv } from "vite";
import forms from "./src/integrations/forms";
import sanityRefresh from "./src/integrations/sanity-refresh";
import siteContent from "./src/content/site.json" with { type: "json" };
const { PUBLIC_FORM_MODE } = loadEnv(process.env.NODE_ENV ?? "production", process.cwd(), "");
const { PUBLIC_FORM_MODE, SANITY_PROJECT_ID = "", SANITY_DATASET = "production" } = loadEnv(process.env.NODE_ENV ?? "production", process.cwd(), "");
/* The build fetches every Sanity image to read its size and resize it: share a few connections instead of opening one
per image, and give each more time to connect. */
setGlobalDispatcher(new Agent({ connections: 8, connect: { timeout: 60_000 } }));
export default defineConfig({
// …
/* PUBLIC_FORM_MODE=server adds the form route; see src/lib/forms/README.md. */
integrations: [forms(PUBLIC_FORM_MODE)],
integrations: [forms(PUBLIC_FORM_MODE), sanityRefresh({ projectId: SANITY_PROJECT_ID, dataset: SANITY_DATASET })],
/* Images uploaded in Sanity: Astro downloads, resizes and converts them when it builds. */
image: { domains: ["cdn.sanity.io"] },

Why the setGlobalDispatcher line: to read each image’s size, Astro fetches it from Sanity’s CDN and closes the connection after the first bytes, so a build opens hundreds of new connections. Now and then one took longer than Node’s 10-second limit to connect, and the build stopped with FailedToFetchRemoteImageDimensions — about half of our builds. Sharing eight connections with a 60-second limit fixed it: 12 clean builds in a row passed.

With the Studio running, open a second terminal and start the site in nitip-astro:

Terminal window
npm run dev
  1. Open localhost:4321 — the site looks exactly as before, now read from Sanity.
  2. In the Studio, open Posts, pick one, change the title and click Publish. The site’s terminal logs Collections reloaded from Sanity; reload the post and the new title is there.
  3. Drag a post to another place in the list and reload the blog page: the order follows. (The lists also reorder from the keyboard: focus a row, press Space, move it with the arrow keys, Space again.)

Then build the static site from Sanity:

Terminal window
npm run build
npm run preview

dist/ now holds the whole site, with Sanity’s content and images baked in — the same plain files as before.

  • Page copy — headings, intros, the About text, button labels — is in src/content/pages/, and the site details (name, contact, social links) in src/content/site.json. They change rarely, so they stay in the repository.
  • src/content/collections/ keeps the demo JSON for the import script. The site no longer reads it.
  • The style guide page (/style-guide) shows the first project, service and post from Sanity, so keep at least one of each published — or delete src/pages/style-guide.astro if you don’t need it.
  • The contact and newsletter forms work as before (Forms).
  1. The Studio. In nitip-studio, run npx sanity deploy and pick a name: Sanity hosts the Studio for free at https://<name>.sanity.studio and allows that address to use your project. Editors sign in there with their own Sanity accounts (invite them under Members in sanity.io/manage).

  2. The site deploys as before (Vercel, Netlify, Cloudflare Pages), with two environment variables on the host: SANITY_PROJECT_ID and SANITY_DATASET. The build fetches the content and images from Sanity.

  3. Publishing. Create a deploy hook for the site on its host — a URL that starts a build — then in sanity.io/manage → your project → API → Webhooks, create one that calls it:

    Setting Value
    URL your host’s deploy hook
    Dataset production
    Trigger on Create, Update, Delete
    Filter _type in ["service", "project", "post", "career", "teamMember", "testimonial"]
    HTTP method POST

    Every publish then rebuilds the site, live a minute or two later. Several publishes in a row start several builds; hosts queue them.

We tested every step above locally, including the static build from Sanity and the Studio; deploying the Studio and the webhook follow Sanity’s deployment and webhook docs, since they need a public address.

FailedToFetchRemoteImageDimensions while building. astro.config.mjs is missing the setGlobalDispatcher line (step 5). If it still happens, check that the build machine can reach cdn.sanity.io.

Remote image … is not allowed by your image configuration. astro.config.mjs is missing the image line from step 5.

The build stops on an entry, or after a publish the dev server logs data does not match collection schema. The schemas check every entry, and the message names the collection, the entry and the field — usually a required image or text left empty in the Studio. Until it’s fixed, the dev server lists only the entries before it in that collection. Fill it in and publish.

A publish doesn’t show in development. The site’s terminal should log Collections reloaded from Sanity a moment after you publish; if it says Not listening to Sanity, check SANITY_PROJECT_ID in .env and restart npm run dev. Restarting also reloads everything.

Another astro dev server is already running. Astro 7 runs one dev server per project: stop the old one with npx astro dev stop, or replace it with npm run dev -- --force.

The seed can’t find the files. It reads ../nitip-astro: keep both folders side by side, or change site at the top of seed.ts.

The Studio asks you to add a CORS origin. The address you opened it on isn’t allowed yet — add it in sanity.io/manage under API → CORS origins, with credentials allowed.

Still stuck? Contact support with the step and the error message.