---
title: "Keep Your Sanity-Powered Blog Static in Next.js 15"
slug: "how-to-keep-sanity-powered-blog-static-nextjs-15"
published: "2025-10-29"
updated: "2025-10-23"
validated: "2025-10-23"
categories:
  - "Next.js"
tags:
  - "Next.js 15"
  - "Sanity"
  - "static site generation"
  - "ISR"
  - "webhooks"
  - "cached fetches"
  - "blog optimization"
  - "dynamic parameters"
  - "site performance"
  - "SEO best practices"
  - "React framework"
  - "Next.js tutorial"
llm-intent: "reference"
audience-level: "intermediate"
framework-versions:
  - "next.js@15"
  - "sanity@3"
  - "typescript@5"
  - "vercel@latest"
status: "stable"
llm-purpose: "Optimize your Sanity blog in Next.js 15! Learn techniques to maintain static routes and reduce server load effectively."
llm-prereqs:
  - "Access to Next.js"
  - "Access to Sanity"
  - "Access to TypeScript"
  - "Access to Vercel"
llm-outputs:
  - "Completed outcome: Optimize your Sanity blog in Next.js 15! Learn techniques to maintain static routes and reduce server load effectively."
---

**Summary Triples**
- (page.tsx and generateMetadata, should not call, sanityFetch (client.fetch) directly to keep the App Router route static)
- (unstable_cache helpers, must wrap, Sanity queries used by both the page component and metadata)
- (unstable_cache, should include, a stable cache key, revalidate seconds, and optional tags (e.g., tags:['post']))
- (Shared fetch helpers (e.g., fetchAllPosts/fetchPostBySlug), ensure, both page.tsx and generateMetadata use the same cached payload)
- (Sanity webhook, must call, a protected revalidation API route on Vercel to trigger ISR revalidation)
- (Revalidation endpoint, should validate, a secret token and call Next.js revalidate for exact paths or use tags)
- (Revalidate schedule, can be set via, unstable_cache revalidate value (e.g., 7 days) and webhooks to push updates immediately)
- (Testing static behavior, use, next build + next start and inspect that serverless invocations are not triggered by page loads)

### {GOAL}
Optimize your Sanity blog in Next.js 15! Learn techniques to maintain static routes and reduce server load effectively.

### {PREREQS}
- Access to Next.js
- Access to Sanity
- Access to TypeScript
- Access to Vercel

### {STEPS}
1. Cache Sanity Fetches Using unstable_cache
2. Refactor for Static Rendering
3. Prevent Runtime Fetching in Child Components
4. Integrate Secure Webhooks
5. Validate Deployment with Vercel

<!-- llm:goal="Optimize your Sanity blog in Next.js 15! Learn techniques to maintain static routes and reduce server load effectively." -->
<!-- llm:prereq="Access to Next.js" -->
<!-- llm:prereq="Access to Sanity" -->
<!-- llm:prereq="Access to TypeScript" -->
<!-- llm:prereq="Access to Vercel" -->
<!-- llm:output="Completed outcome: Optimize your Sanity blog in Next.js 15! Learn techniques to maintain static routes and reduce server load effectively." -->

# Keep Your Sanity-Powered Blog Static in Next.js 15
> Optimize your Sanity blog in Next.js 15! Learn techniques to maintain static routes and reduce server load effectively.
Matija Žiberna · 2025-10-29

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.

```typescript
// 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.

```typescript
// 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.

```typescript
// 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](https://www.buildwithmatija.com/blog/secure-sanity-webhooks-nextjs-app-router).

```typescript
// 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](https://www.buildwithmatija.com/blog/reduce-vercel-fast-origin-transfer-isr-nextjs).

## 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

## LLM Response Snippet
```json
{
  "goal": "Optimize your Sanity blog in Next.js 15! Learn techniques to maintain static routes and reduce server load effectively.",
  "responses": [
    {
      "question": "What does the article \"Keep Your Sanity-Powered Blog Static in Next.js 15\" cover?",
      "answer": "Optimize your Sanity blog in Next.js 15! Learn techniques to maintain static routes and reduce server load effectively."
    }
  ]
}
```