I recently moved one of my sites from full SSG (prerender every page at build time) to SSR with CDN caching. The trigger? I manage a client site with thousands of blog posts — full prerendering was pushing Netlify's build timeout and every Sanity publish meant waiting minutes for a rebuild.
The solution is embarrassingly simple: let the SSR function render pages on first request, cache them at the CDN edge with infinite TTL, and purge specific cache tags when content changes in Sanity. It's ISR (Incremental Static Regeneration) without being locked into Next.js or Vercel.
The problem with full SSG at scale
With TanStack Start's crawlLinks: true, the prerender step follows every link from the homepage and generates static HTML for each page. Works great for 20 posts. At 500+, you're looking at:
• 10-30 minute builds (each page fetches from Sanity)
• Netlify's 15-minute build timeout on free tier
• Every content change = full rebuild of all pages
• Sanity API rate limits at scale (25 req/s burst on free plan)
The nuclear option is to just SSR everything with no caching — but then every visitor pays the latency tax of a server round-trip to Sanity. We want the best of both: static-like speed with instant content updates.
The architecture: SSR + CDN cache + tag purge
Here's the flow:
1. First request hits the CDN → cache miss → SSR function runs, fetches from Sanity, returns HTML
2. CDN caches the response (tagged with e.g. post:my-slug)
3. All subsequent requests → served from CDN edge (no SSR, no Sanity call)
4. Author publishes in Sanity → webhook fires → purge function invalidates the tags
5. Next request → back to step 1 with fresh content
The CDN TTL is effectively infinite (s-maxage=31536000). Freshness is controlled by purge, not expiry. Build time stays constant regardless of post count.
Step 1: Disable prerendering for CMS pages
In vite.config.ts, turn off crawlLinks and only prerender truly static pages (ones that don't fetch from your CMS):
// vite.config.ts
tanstackStart({
prerender: {
enabled: true,
crawlLinks: false,
autoStaticPathsDiscovery: false,
},
pages: [{ path: '/about', prerender: { enabled: true } }],
}),CMS-driven pages (homepage, blog posts) are now served by the SSR function at runtime. The Netlify plugin generates a server.mjs with preferStatic: true — static files win, everything else goes through SSR.
Step 2: Add CDN cache headers to your routes
TanStack Start routes accept a headers function that sets HTTP response headers. Use Netlify-CDN-Cache-Control for CDN behavior and Netlify-Cache-Tag for purge targeting:
// routes/post.$slug.tsx
export const Route = createFileRoute('/post/$slug')({
headers: ({ params }) => ({
'Netlify-CDN-Cache-Control': 'public, s-maxage=31536000, stale-while-revalidate=31536000',
'Netlify-Cache-Tag': `post:${params.slug},posts-list`,
'Cache-Control': 'public, max-age=0, must-revalidate',
}),
loader: async ({ params }) => {
const post = await fetchPost(params.slug)
if (!post) throw notFound()
return { post }
},
})And for your homepage (which shows a post listing):
// routes/index.tsx
export const Route = createFileRoute('/')({
headers: () => ({
'Netlify-CDN-Cache-Control': 'public, s-maxage=31536000, stale-while-revalidate=31536000',
'Netlify-Cache-Tag': 'homepage,posts-list',
'Cache-Control': 'public, max-age=0, must-revalidate',
}),
loader: async () => {
const posts = await fetchPosts()
return { posts }
},
})Key detail: Cache-Control stays at max-age=0 for browsers (they always revalidate), while Netlify-CDN-Cache-Control tells the CDN to cache indefinitely. The CDN strips Netlify-CDN-Cache-Control before sending to the client.
Step 3: Create the purge function
A Netlify Function that receives Sanity's webhook payload and purges the relevant cache tags:
// netlify/functions/purge-cache.ts
import { purgeCache } from '@netlify/functions'
export default async (req: Request) => {
if (req.method !== 'POST') {
return new Response('Method not allowed', { status: 405 })
}
const body = await req.json()
const tags: string[] = ['posts-list', 'homepage']
const slug = body.slug?.current
if (slug) {
tags.push(`post:${slug}`)
}
await purgeCache({ tags })
return new Response(JSON.stringify({ purged: tags }), {
status: 202,
headers: { 'Content-Type': 'application/json' },
})
}
export const config = { path: '/api/purge-cache' }Install @netlify/functions as a dependency (npm install @netlify/functions). The purgeCache helper handles auth automatically when running on Netlify.
Step 4: Add belt-and-suspenders headers in netlify.toml
Netlify merges headers from netlify.toml with function responses. Add a fallback for post pages:
# netlify.toml
[[headers]]
for = "/post/*"
[headers.values]
Netlify-CDN-Cache-Control = "public, s-maxage=31536000, stale-while-revalidate=31536000"
Cache-Control = "public, max-age=0, must-revalidate"Step 5: Configure the Sanity webhook
In Sanity Manage → API → Webhooks, create a webhook with:
• URL: https://yoursite.com/api/purge-cache
• Trigger on: Create, Update, Delete
• Filter: _type == "post"
• Projection: {_type, slug, _id}
• HTTP method: POST
• Drafts: Off (only fires on publish)
That's it. When you publish a post, Sanity sends the slug to your function, the function purges that post's cache tag plus the posts-list tag (which invalidates the homepage listing), and the next visitor gets fresh content. Latency from publish to live: ~2-3 seconds.
Trade-offs vs full SSG
The first request after a purge is slightly slower (~200-500ms) because the SSR function needs to fetch from Sanity. After that, it's CDN-fast. In practice, the homepage and popular posts are cached within seconds of a purge from organic traffic.
You also need the SSR function to remain available — if it errors, there's no static fallback. But your function is simple (fetch from Sanity, render React) and Sanity's CDN has excellent uptime.
In exchange, you get: constant build times (30 seconds vs 30 minutes), instant content updates (seconds vs minutes), and the exact same architecture whether you have 20 posts or 20,000.
For the vibe coders
If you're handing this off to an AI agent or junior dev, here's the recipe in plain English:
1. Disable prerendering for CMS pages in vite.config.ts
2. Add headers() to each route that returns Netlify-CDN-Cache-Control and Netlify-Cache-Tag
3. Create a Netlify Function at /api/purge-cache that calls purgeCache with the relevant tags
4. Add a Sanity webhook that POSTs to /api/purge-cache on document publish
5. Deploy. Done.
The whole implementation is ~50 lines of code across 4 files. No external services, no Redis, no background jobs. The CDN is your cache layer, Sanity webhooks are your invalidation mechanism.