A modern, production-ready starter template for building fast, content-managed websites with Astro, Sanity CMS, and deployed on Cloudflare Workers.
- Astro 5 with SSR support for Cloudflare Workers
- Sanity CMS for content management
- Headless CMS with real-time preview
- GROQ queries for efficient data fetching
- Cloudflare Workers deployment for edge performance
- React integration for interactive components
- Turborepo monorepo structure for optimal DX
- TypeScript with auto-generated Sanity types
- PNPM as the package manager
├── apps/
│ └── web/ # Astro frontend application
│ ├── src/
│ │ ├── pages/ # Astro pages
│ │ ├── layouts/ # Layout components
│ │ └── lib/ # Utilities and Sanity client
│ ├── public/ # Static assets (including built Sanity Studio)
│ ├── dist/ # Build output for Cloudflare Workers
│ ├── astro.config.mjs # Astro configuration
│ └── wrangler.jsonc # Cloudflare Workers configuration
│
├── packages/
│ └── sanity/ # Sanity Studio and schema definitions
│ ├── src/
│ │ ├── schema/ # Content schemas (home, documents, objects)
│ │ └── queries/ # GROQ queries
│ └── sanity.config.ts # Sanity configuration
│
├── turbo.json # Turborepo task configuration
└── package.json # Root workspace configuration
- Node.js >= 18
- PNPM >= 10
- A Sanity account and project
- A Cloudflare account (for deployment)
# Clone this repository
git clone <your-repo-url> my-project
cd my-project
# Install dependencies
pnpm installThe easiest way to set up your environment variables is to use the Sanity CLI. This will automatically create the necessary .env files with your project credentials:
# Navigate to the sanity package and run sanity init
cd packages/sanity
npx sanity@latest init --env
# Then copy the environment variables to the web app
cd ../../apps/web
cp ../../packages/sanity/.env .env.localThe sanity init --env command will:
- Prompt you to log in to your Sanity account (if not already logged in)
- Let you select an existing project or create a new one
- Write the project ID and dataset to a
.envfile
After running the command, you may need to add additional variables to apps/web/.env.local:
NEXT_PUBLIC_SANITY_STUDIO_PROJECT_ID=your_project_id
NEXT_PUBLIC_SANITY_STUDIO_DATASET=production
NEXT_PUBLIC_URL=http://localhost:3000
SANITY_API_TOKEN=your_api_tokenIf you prefer to configure manually, create environment files for both the Sanity package and the web app:
packages/sanity/.env
SANITY_STUDIO_PROJECT_ID=your_project_id
SANITY_STUDIO_DATASET=productionapps/web/.env
SANITY_STUDIO_PROJECT_ID=your_project_id
SANITY_STUDIO_DATASET=production
SANITY_API_VERSION=2026-01-16
# Optional: For server-side authenticated requests
SANITY_TOKEN=your_api_tokenYou can find your Project ID in the Sanity dashboard.
pnpm devThis will start:
- Astro at http://localhost:3000
- Sanity Studio at http://localhost:3333
For Cloudflare Workers local development:
pnpm wrangler:dev| Command | Description |
|---|---|
pnpm dev |
Start all apps in development mode |
pnpm build |
Build all apps for production |
pnpm wrangler:dev |
Run Astro with Cloudflare Workers locally |
pnpm preview |
Preview production build |
pnpm typegen |
Generate TypeScript types from Sanity schemas |
This starter includes @tinloof/sanity-astro, a package that provides optimized data fetching with a two-level caching architecture for maximum performance on Cloudflare Workers.
┌─────────────────────────────────────────────────────────────────────┐
│ Browser Request │
└─────────────────────────────────────────────────────────────────────┘
│
▼
┌─────────────────────────────────────────────────────────────────────┐
│ CDN Edge (Page Cache) │
│ CDN-Cache-Control: max-age=3600, stale-while-revalidate │
│ │
│ • Caches full HTML responses at Cloudflare edge │
│ • Sub-50ms response times on cache HIT │
│ • Requires custom domain (not workers.dev) │
└─────────────────────────────────────────────────────────────────────┘
│ MISS
▼
┌─────────────────────────────────────────────────────────────────────┐
│ Cloudflare Worker (Query Cache) │
│ Cache API with manual SWR implementation │
│ │
│ • Caches individual Sanity query results │
│ • Serves stale data while revalidating in background │
│ • Works on workers.dev domains │
└─────────────────────────────────────────────────────────────────────┘
│ MISS
▼
┌─────────────────────────────────────────────────────────────────────┐
│ Sanity API │
│ │
│ • Fresh data fetched via GROQ │
│ • Returns syncTags for cache invalidation │
└─────────────────────────────────────────────────────────────────────┘
// src/sanity/index.ts
import { initSanity } from '@tinloof/sanity-astro'
export const { client, loadQuery } = await initSanity({
// Query cache: individual GROQ results (works on workers.dev)
cache: {
maxAge: 60 * 60 * 24, // 1 day
staleWhileRevalidate: 60 * 60 * 24 * 7, // 1 week
},
// Page cache: full HTML responses (requires custom domain)
pageCache: {
maxAge: 60 * 60, // 1 hour at CDN edge
staleWhileRevalidate: 60 * 60 * 24, // 1 day SWR window
browserMaxAge: 60, // 1 minute in browser
},
})---
// src/pages/index.astro
import { loadQuery } from '@/sanity'
const { data, cacheStatus, ms } = await loadQuery(Astro, {
query: `*[_type == "page" && slug.current == $slug][0]`,
params: { slug: 'home' },
})
---
<h1>{data.title}</h1>
<!-- cacheStatus: HIT | MISS | STALE | BYPASS -->
<!-- ms: time taken in milliseconds -->The loadQuery() function is the primary way to fetch data from Sanity. It handles caching, visual editing, and cache invalidation automatically.
const result = await loadQuery<MyType>(Astro, {
query: string, // GROQ query
params?: QueryParams, // Query parameters
cache?: CacheOptions | false, // Query cache options
pageCache?: PageCacheOptions | false, // Page cache options
})Returns:
| Property | Type | Description |
|---|---|---|
data |
T |
The query result |
perspective |
'published' | 'drafts' |
Current perspective |
cacheStatus |
'HIT' | 'MISS' | 'STALE' | 'BYPASS' |
Query cache status |
cacheAge |
number | undefined |
Age of cached data in seconds |
tags |
string[] | undefined |
Sanity syncTags for invalidation |
ms |
number |
Time taken in milliseconds |
Controls the Cloudflare Cache API for individual query results.
{
maxAge?: number, // Fresh duration (default: 1 day)
staleWhileRevalidate?: number, // SWR window (default: 1 week)
keyPrefix?: string, // Cache key prefix (default: 'sanity')
}Controls CDN edge caching via response headers. Requires a custom domain with Cloudflare proxy enabled.
{
maxAge?: number, // CDN cache duration (default: 1 hour)
staleWhileRevalidate?: number, // CDN SWR window (default: 1 day)
browserMaxAge?: number, // Browser cache (default: 60 seconds)
disabled?: boolean, // Disable page caching
}// Disable query cache only
const result = await loadQuery(Astro, {
query: MY_QUERY,
cache: false,
})
// Disable page cache only
const result = await loadQuery(Astro, {
query: MY_QUERY,
pageCache: false,
})
// Custom cache settings for this query
const result = await loadQuery(Astro, {
query: MY_QUERY,
cache: { maxAge: 60, staleWhileRevalidate: 300 },
pageCache: { maxAge: 300, browserMaxAge: 30 },
})The <SanityLive /> component provides real-time cache invalidation when content changes in Sanity.
---
// src/layouts/Layout.astro
import { SanityLive } from '@tinloof/sanity-astro/live'
---
<html>
<body>
<slot />
<SanityLive client:only="react" />
</body>
</html>How it works:
- SanityLive connects to Sanity's live events API
- When content changes, Sanity sends the affected
syncTags - SanityLive calls the purge endpoint (
/api/sanity/purge) - The purge endpoint clears both query cache and page cache entries
- The page refreshes with fresh data
Response headers set by loadQuery():
| Header | Value | Purpose |
|---|---|---|
CDN-Cache-Control |
public, max-age=3600, stale-while-revalidate=86400 |
Cloudflare edge caching |
Cache-Control |
public, max-age=60, must-revalidate |
Browser caching |
X-Sanity-Tags |
s1:abc123,s1:def456 |
Debug: Sanity syncTags |
Visual editing is automatically supported. When the sanity-visual-editing cookie is set:
- Query cache is bypassed
- Page cache headers are not set
- Draft content is fetched with stega encoding
- Content can be clicked to open in Sanity Studio
Important: Page-level CDN caching via CDN-Cache-Control headers only works with a custom domain proxied through Cloudflare.
| Domain Type | Query Cache | Page Cache |
|---|---|---|
*.workers.dev |
✅ Works | ❌ Not supported |
| Custom domain (Cloudflare proxy) | ✅ Works | ✅ Works |
To enable page caching:
- Go to Cloudflare Dashboard → Workers & Pages → Your Worker → Settings → Domains & Routes
- Add a custom domain (e.g.,
demo.yourdomain.com) - Verify
cf-cache-status: HITappears in response headers
Schemas are organized in packages/sanity/src/schema/:
schema/
├── home.ts # Homepage document schema
└── index.ts # Exports all schema types
- Create a new schema file in
packages/sanity/src/schema/:
// src/schema/page.ts
import { defineField, defineType } from "sanity";
export default defineType({
name: "page",
title: "Page",
type: "document",
fields: [
defineField({
name: "title",
type: "string",
title: "Title",
}),
defineField({
name: "content",
type: "text",
title: "Content",
}),
],
});- Export it from
packages/sanity/src/schema/index.ts:
import home from "./home";
import page from "./page";
export default [home, page];- Run
pnpm typegento update TypeScript types
Define queries in packages/sanity/src/queries/index.ts:
export const HOME_QUERY = `*[_type == "home"][0] {
title,
description
}`;- Build the project:
pnpm build- Deploy to Cloudflare:
cd apps/web
npx wrangler deployThe Sanity Studio is built and deployed as part of the Astro app at /cms.
Set these in your Cloudflare Workers dashboard:
SANITY_STUDIO_PROJECT_ID=your_project_id
SANITY_STUDIO_DATASET=production
SANITY_API_VERSION=2026-01-16
SANITY_TOKEN=your_api_token
SANITY_STUDIO_PROJECT_ID=your_project_id
SANITY_STUDIO_DATASET=productionMIT