Keep Your Sanity-Powered Blog Static in Next.js 15
Discover essential steps to eliminate dynamic fetches and maintain static routes with Sanity and Next.js 15.

⚡ Next.js Implementation Guides
In-depth Next.js guides covering App Router, RSC, ISR, and deployment. Get code examples, optimization checklists, and prompts to accelerate development.
I thought my blog posts were safely cached behind ISR until Vercel billed me for 1 200 function invocations in a single day. The culprit was sneaky: both page.tsx and generateMetadata were still calling sanityFetch, which forced the route back to server rendering even though revalidate was set. This guide walks you through the exact steps I used to eliminate those dynamic fetches, keep the App Router page purely static, and wire Sanity’s webhook into a clean revalidation flow.
Step 1: Cache Sanity Fetches With unstable_cache
The first fix was creating shared helpers that wrap Sanity queries in unstable_cache. This guarantees that both the page component and metadata pull from the same cached payload, making it safe for static generation.
// File: src/lib/sanity/post-cache.ts
import { unstable_cache } from 'next/cache'
import type { POST_QUERYResult, POSTS_QUERYResult } from '@/../sanity.types'
import { filterPublishedPosts } from '@/lib/helpers/dateUtils'
import { POST_QUERY, POSTS_QUERY } from '@/lib/sanity/queries/queries'
import { client } from './client'
const REVALIDATE_SECONDS = 604800 // 7 days
const fetchAllPosts = unstable_cache(
async (): Promise<POSTS_QUERYResult | null> => {
const posts = await client.fetch<POSTS_QUERYResult | null>(POSTS_QUERY)
if (!posts) {
return null
}
return filterPublishedPosts(posts)
},
['sanity-posts-all'],
{ revalidate: REVALIDATE_SECONDS, tags: ['post'] }
)
const fetchPostBySlug = unstable_cache(
async (slug: string): Promise<POST_QUERYResult | null> => {
if (!slug) return null
return client.fetch<POST_QUERYResult | null>(POST_QUERY, { slug })
},
['sanity-post-by-slug'],
{ revalidate: REVALIDATE_SECONDS, tags: ['post'] }
)
export async function getAllPublishedPostSlugs(): Promise<string[]> {
const posts = await fetchAllPosts()
if (!posts) return []
return posts
.map((post) => post.slug?.current)
.filter((slug): slug is string => Boolean(slug))
}
export async function getPostBySlug(slug: string) {
if (!slug) return null
return fetchPostBySlug(slug)
}
The helpers only request what the page needs and they register a post tag so we can invalidate them later. This pattern also keeps the Next.js data cache and ISR timers aligned.
Step 2: Refactor The Page To Force Static Rendering
With the helpers in place, I rewired page.tsx to avoid any direct Sanity calls, forced static mode, and left dynamicParams = true so brand-new posts still render on demand until the webhook fires.
// File: src/app/(non-intl)/blog/[slug]/page.tsx
import { Metadata } from 'next'
import Image from 'next/image'
import { notFound } from 'next/navigation'
import { ClapButton } from '@/components/blog/ClapButton'
import { Comments } from '@/components/comments/Comments'
import { BlogPostHeader } from '@/components/blog/blog-post-header'
import { BlogPostContent } from '@/components/blog/blog-post-content'
import { BlogPostFAQ } from '@/components/blog/blog-post-faq'
import { BlogPostAuthorFooter } from '@/components/blog/blog-post-author-footer'
import { RelatedPostsList } from '@/components/blog/RelatedPostsList'
import { InlineNewsletterSubscription } from '@/components/newsletter/InlineNewsletterSubscription'
import { TableOfContents } from '@/components/blog/TableOfContents'
import { MobileTOCButton } from '@/components/blog/MobileTOCButton'
import { ScrollToTopButton } from '@/components/reusable/ScrollToTopButton'
import { YouMightBeInterestedIn } from '@/components/blog/YouMightBeInterestedIn'
import { generatePostSchemaMarkup } from '@/lib/seo/schema-markup'
import { extractDescription } from '@/lib/blog/blog-utils'
import { urlForImage } from '@/lib/sanity/image'
import { getAllPublishedPostSlugs, getPostBySlug } from '@/lib/sanity/post-cache'
export const revalidate = 604800
export const dynamic = 'force-static'
export const dynamicParams = true
export async function generateStaticParams() {
const slugs = await getAllPublishedPostSlugs()
return slugs.map((slug) => ({ slug }))
}
export async function generateMetadata({
params,
}: {
params: Promise<{ slug: string }>
}): Promise<Metadata> {
const { slug } = await params
const post = await getPostBySlug(slug)
if (!post) {
return {
title: 'Post | Build with Matija',
description: 'The requested blog post could not be found.',
}
}
const description = extractDescription(post)
return {
title: `${post.title || 'Blog Post'} | Build with Matija`,
description,
alternates: { canonical: `https://buildwithmatija.com/blog/${post.slug?.current ?? ''}` },
openGraph: post.mainImage
? {
title: post.title || '',
description,
type: 'article',
publishedTime: post.publishedAt || undefined,
authors: post.author?.name ? [post.author.name] : [],
images: [
{
url: urlForImage(post.mainImage)?.width(1200).height(630).url() || '',
width: 1200,
height: 630,
alt: post.title || 'Blog post image',
},
],
}
: undefined,
}
}
export default async function BlogPostPage({
params,
}: {
params: Promise<{ slug: string }>
}) {
const { slug } = await params
const post = await getPostBySlug(slug)
if (!post) {
notFound()
}
const schemaMarkup = generatePostSchemaMarkup(post)
return (
<>
{schemaMarkup.map((schema, index) => (
<script
key={index}
type="application/ld+json"
dangerouslySetInnerHTML={{
__html: JSON.stringify(schema).replace(/</g, '\\u003c'),
}}
/>
))}
{/* component tree continues… */}
</>
)
}
Everything now consumes the cached helpers, so the route stays static. I also kept the optional copy of revalidate as a weekly safety net; the webhook handles real-time updates.
Step 3: Stop Child Components From Fetching At Runtime
YouMightBeInterestedIn previously hit Sanity directly, which ruined SSG. Refactoring it into a pure UI component that filters the preloaded relatedPosts array keeps the entire tree static.
// File: src/components/blog/YouMightBeInterestedIn.tsx
import Link from 'next/link'
import Image from 'next/image'
import type { Slug } from '@/../sanity.types'
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
import { formatDate } from '@/lib/helpers/utils'
import { filterPublishedPosts } from '@/lib/helpers/dateUtils'
import { urlForImage } from '@/lib/sanity/image'
interface RelatedPost {
_id: string
title?: string | null
slug?: Slug | null
mainImage?: {
asset?: { _ref?: string; _type?: string } | null
[key: string]: unknown
} | null
publishedAt?: string | null
}
interface Props {
relatedPosts?: RelatedPost[] | null
className?: string
}
export function YouMightBeInterestedIn({ relatedPosts, className }: Props) {
if (!relatedPosts || relatedPosts.length === 0) {
return null
}
const publishedPosts = filterPublishedPosts(
relatedPosts.filter((post): post is RelatedPost => Boolean(post?._id))
)
if (publishedPosts.length === 0) {
return null
}
return (
<section className={className}>
<h2 className="text-2xl font-bold mb-6">You might be interested in</h2>
{/* markup continues */}
</section>
)
}
Paired with the richer Sanity query (mainImage, publishedAt), this keeps everything predictable while still delivering full cards in the sidebar.
Step 4: Tie It Back To The Secure Webhook
The cached helpers use the post tag, so I extended the existing webhook to revalidate both the page path and that tag. If you need the full security breakdown, see Secure Sanity Webhooks in Next.js App Router.
// File: src/app/(non-intl)/api/revalidate/route.ts
import { NextRequest, NextResponse } from 'next/server'
import { revalidatePath, revalidateTag } from 'next/cache'
import { isValidSignature, SIGNATURE_HEADER_NAME } from '@sanity/webhook'
const WEBHOOK_SECRET = process.env.SANITY_WEBHOOK_SECRET
export async function POST(request: NextRequest) {
const body = await readBody(request)
if (WEBHOOK_SECRET && process.env.NODE_ENV !== 'development') {
const signature = request.headers.get(SIGNATURE_HEADER_NAME)
if (!signature) {
return NextResponse.json({ error: 'Missing webhook signature' }, { status: 401 })
}
const valid = await isValidSignature(body, signature, WEBHOOK_SECRET)
if (!valid) {
return NextResponse.json({ error: 'Invalid webhook signature' }, { status: 401 })
}
}
const payload = JSON.parse(body)
const { _type, slug } = payload
if (_type === 'post') {
if (slug?.current) {
revalidatePath(`/blog/${slug.current}`)
}
revalidatePath('/blog')
revalidateTag('post')
return NextResponse.json({ revalidated: true })
}
if (_type === 'category' || _type === 'author') {
revalidatePath('/blog')
return NextResponse.json({ revalidated: true })
}
return NextResponse.json({ message: 'No matching action' })
}
The GROQ webhook filter is simple: _type in ['post', 'category', 'author']. The projection includes _id, _type, slug, includeInVectorStore, and the category slugs. That’s enough to hit the right paths, keep the cached helpers honest, and trigger the optional vector store sync.
Step 5: Validate With Vercel And curl
After redeploying, pnpm next build reported /blog/[slug] as SSG with a one-week revalidate window. I confirmed the runtime behavior by hitting the live page with curl -I and watching the x-cache header return HIT. Vercel’s function metrics dropped to almost zero for blog page views, which lines up with what I shared in Reduce Vercel Fast Origin Transfer by 95% Using ISR.
Wrap-Up
What looked like a simple ISR setup still hid a few dynamic landmines in App Router. By moving Sanity fetches into cached helpers, forcing static mode, flattening components that previously fetched on render, and pairing everything with a secure webhook, I brought the blog back to true static behavior without losing fresh content. You can reuse the same playbook for any Sanity-backed route that mixes metadata, related content, and ISR.
Let me know in the comments if you have questions, and subscribe for more practical development guides.
Thanks, Matija