BuildWithMatija
Get In Touch
  1. Home
  2. Blog
  3. Next.js
  4. Keep Your Sanity-Powered Blog Static in Next.js 15

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.

29th October 2025·Updated on:23rd October 2025·MŽMatija Žiberna·
Next.js
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

📄View markdown version
0

Comments

Leave a Comment

Your email will not be published

Stay updated! Get our weekly digest with the latest learnings on NextJS, React, AI, and web development tips delivered straight to your inbox.

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.

Table of Contents

  • Step 1: Cache Sanity Fetches With `unstable_cache`
  • Step 2: Refactor The Page To Force Static Rendering
  • Step 3: Stop Child Components From Fetching At Runtime
  • Step 4: Tie It Back To The Secure Webhook
  • Step 5: Validate With Vercel And `curl`
  • Wrap-Up
On this page:
  • Step 1: Cache Sanity Fetches With `unstable_cache`
  • Step 2: Refactor The Page To Force Static Rendering
  • Step 3: Stop Child Components From Fetching At Runtime
  • Step 4: Tie It Back To The Secure Webhook
  • Step 5: Validate With Vercel And `curl`
Build With Matija Logo

Build with Matija

Matija Žiberna

I turn scattered business knowledge into one usable system. End-to-end system architecture, AI integration, and development.

Quick Links

Projects
  • How I Work
  • Blog
  • RSS Feed
  • Services

    • Payload CMS Websites
    • Bespoke AI Applications
    • Advisory

    Payload

    • Payload CMS Websites
    • Payload CMS Developer
    • Audit
    • Migration
    • Pricing
    • Payload vs Sanity
    • Payload vs WordPress
    • Payload vs Strapi
    • Payload vs Contentful

    Industries

    • Manufacturing
    • Construction

    Get in Touch

    Have a project in mind? Let's discuss how we can help your business grow.

    Book a discovery callContact me →
    © 2026BuildWithMatija•Principal-led system architecture•All rights reserved