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.

·Matija Žiberna·
Keep Your Sanity-Powered Blog Static in 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.

No spam. Unsubscribe anytime.

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

0

Comments

Leave a Comment

Your email will not be published

10-2000 characters

• Comments are automatically approved and will appear immediately

• Your name and email will be saved for future comments

• Be respectful and constructive in your feedback

• No spam, self-promotion, or off-topic content

Matija Žiberna
Matija Žiberna
Full-stack developer, co-founder

I'm Matija Žiberna, a self-taught full-stack developer and co-founder passionate about building products, writing clean code, and figuring out how to turn ideas into businesses. I write about web development with Next.js, lessons from entrepreneurship, and the journey of learning by doing. My goal is to provide value through code—whether it's through tools, content, or real-world software.