# Optimized data fetching in Next.js 15

> Data fetching patterns for Next.js 15, taken from our Vercel optimization talk: server components, caching choices, and real code from Turbo Start Sanity.

**Published:** 2024-11-15 | **Updated:** 2026-07-23 | **Categories:** Performance, Next.js
---

## Introduction
This blog post is a bit of an amalgamation of a talk we gave on [Optimising usage on Vercel](https://www.youtube.com/watch?v=V6Yd9wgY-uU), as well as snippets from our own code within [Turbo Start Sanity](https://github.com/robotostudio/turbo-start-sanity). We wanted to get the content out there, as a reference point as well as a cheeky way of capitalising on some nice SEO for dropping thousands of characters worth of Next.js code.

## Generic takeaways from Next.js
Some top-level advice I would advise following is to try to avoid `use client` until strictly necessary. If you have to use it, make it so that it's a smaller part of a component, rather than essentially wrapping a good chunk of your app within a client component. This differs if you're building something like Linear where it's almost all client side, and very dynamic.

Alongside this, we would emphasise that more than ever, it's worth jumping on the Tailwind CSS bandwagon. Because of the above, you really don't want something heavy like Material UI, as it's going to tank performance, especially as your app scales.

Moreover, if you're thinking of building an application with [Next.js](/services/nextjs) and [Sanity](/services/sanity), you probably want to get the data layer right from day one.

## Key Concepts in Next.js 15
Next.js 15 introduces several new features and improvements that enhance data fetching capabilities. These include server components, improved caching strategies, and more.

## Server Components
Server components allow you to render parts of your application on the server, reducing client-side JavaScript and improving load times.

```typescript title="app/page.tsx"
async function fetchPosts() {
  const posts = await client.fetch(`*[_type == "post"]{
    _id,
    title,
    slug,
    publishedAt
  }`)

  return posts
}

export default async function Page() {
  const posts = await fetchPosts()

  return (
    <section>
      <h1>Latest Posts</h1>
      <ul>
        {posts.map(post => (
          <li key={post._id}>{post.title}</li>
        ))}
      </ul>
    </section>
  )
}
```

## Fetching Patterns
### Route Segment Config for Cache Control
```typescript title="cache-control"
// Set cache behavior at the route level
export const dynamic = 'force-dynamic'
// Or use more granular settings
export const revalidate = 60 // Revalidate every 60 seconds
```

### Request-Level Cache Control (New in Next.js 15)
```typescript title="route-level-cache-control.tsx"
async function fetchPost(slug: string) {
  // New in Next.js 15: Request-level cache options
  // Always use parameterized queries to prevent GROQ injection
  const query = encodeURIComponent('*[_type  "post" && slug.current  $slug][0]')
  const params = encodeURIComponent(JSON.stringify({ slug }))

  return await fetch(`https://api.sanity.io/v2023-08-01/data/query/production?query=${query}&$slug="${params}"`, {
    next: {
      // Control cache behavior per fetch
      revalidate: 30, // Seconds
      tags: [`post-${slug}`], // Custom tags for on-demand revalidation
    },
  }).then(res => res.json())
}
```

### On demand
```typescript title="app/api/revalidate/route.ts"

import { revalidateTag } from 'next/cache'
import { NextRequest, NextResponse } from 'next/server'

export async function POST(request: NextRequest) {
  try {
    const { tag, secret } = await request.json()

    // Validate secret token
    if (secret !== process.env.REVALIDATION_SECRET) {
      return NextResponse.json({ error: 'Invalid token' }, { status: 401 })
    }

    // Revalidate the specific tag
    revalidateTag(tag)

    return NextResponse.json({ revalidated: true, tag })
  } catch (error) {
    return NextResponse.json({ error: 'Error revalidating' }, { status: 500 })
  }
}
```

## Advanced Caching Techniques
### Parallel Data Fetching
```typescript title="parallel-data-fetching.tsx"
// Fetch multiple resources in parallel without waterfall
async function DashboardPage() {
  // These run in parallel!
  const postsPromise = client.fetch(`*[_type == "post"][0...5]`)
  const usersPromise = client.fetch(`*[_type == "user"][0...5]`)
  const statsPromise = client.fetch(`*[_type == "stats"][0]`)

  // Wait for all data to be available
  const [posts, users, stats] = await Promise.all([
    postsPromise,
    usersPromise,
    statsPromise
  ])

  return (
    <Dashboard
      posts={posts}
      users={users}
      stats={stats}
    />
  )
}
```

### Suspense for progressive loading
```typescript title="suspense-for-progressive-loading.tsx"
import { Suspense } from 'react'
import { PostList, UserActivity, Stats, Skeleton } from '@/components'

export default function AdminPage() {
  return (
    <div className="grid grid-cols-12 gap-6">
      <div className="col-span-8">
        <Suspense fallback={<Skeleton type="posts" />}>
          <PostSection />
        </Suspense>
      </div>
      <div className="col-span-4">
        <Suspense fallback={<Skeleton type="stats" />}>
          <StatsSection />
        </Suspense>
        <Suspense fallback={<Skeleton type="users" />}>
          <UserSection />
        </Suspense>
      </div>
    </div>
  )
}

// These components can fetch their own data
async function PostSection() {
  const posts = await client.fetch(`*[_type == "post"][0...10]`)
  return <PostList posts={posts} />
}

async function StatsSection() {
  const stats = await client.fetch(`*[_type == "stats"][0]`)
  return <Stats data={stats} />
}

async function UserSection() {
  const users = await client.fetch(`*[_type == "user"][0...5]`)
  return <UserActivity users={users} />
}

```

## Error Handling and Fallbacks
### Error Boundary at the Route Level
```typescript title="app/posts/[slug]/error.tsx"
'use client'

import { useEffect } from 'react'
import { Button } from '@/components/ui/button'

interface ErrorProps {
  error: Error & { digest?: string }
  reset: () => void
}

export default function Error({ error, reset }: ErrorProps) {
  useEffect(() => {
    // Log the error to an error reporting service
    console.error('Posts error:', error)
  }, [error])

  return (
    <div className="flex flex-col items-center justify-center min-h-[50vh] p-6 text-center">
      <h2 className="text-2xl font-bold mb-4">Something went wrong!</h2>
      <p className="text-zinc-600 mb-6">
        We couldn't load this post. Please try again later.
      </p>
      <Button onClick={reset}>Try again</Button>
    </div>
  )
}

```

### Handling Errors with Error Boundaries per Component
```typescript title="component-level-error-boundary.tsx"
// Component-level error boundary
'use client'

import { ErrorBoundary } from 'react-error-boundary'

function ErrorFallback() {
  return (
    <div className="bg-red-50 border border-red-200 rounded-md p-4">
      <p className="text-sm text-red-700">
        We couldn't load this section. Please refresh the page.
      </p>
    </div>
  )
}

export function PostListWithErrorHandling({ children }) {
  return (
    <ErrorBoundary FallbackComponent={ErrorFallback}>
      {children}
    </ErrorBoundary>
  )
}
```

### Type-Safe Error Handling with tryCatch Pattern
Error handling is a critical aspect of reliable data fetching. Let's implement a type-safe utility function that elegantly handles asynchronous operations:

```typescript title="lib/utils/error-handling.ts"
// lib/utils/error-handling.ts

/**
 * A type-safe utility for handling promise rejections
 * Returns a tuple where:
 * - First element is the result (null if error occurred)
 * - Second element is the error (null if operation succeeded)
 */
export async function tryCatch<T, E extends Error = Error>(
  promise: Promise<T>
): Promise<[T, null] | [null, E]> {
  try {
    const result = await promise
    return [result, null]
  } catch (error) {
    return [null, error as E]
  }
}

```

Using this pattern in your data fetching functions:

```typescript title="server-component-version.tsx"
// Using the utility in a server component
async function PostPage({ params }: { params: Promise<{ slug: string }> }) {
  const { slug } = await params
  const [post, error] = await tryCatch(getPost(slug))

  if (error) {
    // Log the error for server-side tracking
    console.error('Failed to fetch post:', error)

    // Return a graceful error state
    return (
      <div className="error-container">
        <h2>Could not load post</h2>
        <p>Please try again later</p>
      </div>
    )
  }

  // Happy path - post was fetched successfully
  return <PostDisplay post={post} />
}

```

1. This pattern has several advantages:
1. Type safety with generics ensures proper type inference
1. Consistent error handling across the application
1. No try/catch blocks cluttering component logic
1. Clear distinction between successful and error states
1. Can be extended to include specific error types

You can also create specialized variants for specific use cases:

```typescript title="api-route-try-catch.ts"
// Specialized version for API routes
export async function apiTryCatch<T>(
  promise: Promise<T>,
  errorMessage = 'An error occurred'
): Promise<NextResponse> {
  const [data, error] = await tryCatch(promise)

  if (error) {
    console.error(errorMessage, error)
    // Use type guard to safely access statusCode if it exists
    const statusCode = (error as Error & { statusCode?: number }).statusCode ?? 500
    return NextResponse.json(
      { error: errorMessage },
      { status: statusCode }
    )
  }

  return NextResponse.json({ data })
}

```

## Working with Sanity CMS
### Typed Queries with GROQ
```typescript title="lib/sanity/query.ts"

import { defineQuery } from 'groq'
import { imageFragment } from './fragments'

export const POST_QUERY = defineQuery(`*[\n  _type  "post"\n  && slug.current  $slug\n][0]{\n  _id,\n  title,\n  publishedAt,\n  excerpt,\n  "slug": slug.current,\n  mainImage{\n    ${imageFragment}\n  },\n  body,\n  "author": author->{\n    _id,\n    name,\n    "slug": slug.current,\n    image{\n      ${imageFragment}\n    }\n  }\n}`)

```

### Optimized Sanity Client
```typescript title="lib/sanity/client.ts"
import { createClient } from 'next-sanity'
import { POST_QUERY } from './query'

export const client = createClient({
  projectId: process.env.NEXT_PUBLIC_SANITY_PROJECT_ID!,
  dataset: process.env.NEXT_PUBLIC_SANITY_DATASET!,
  apiVersion: '2023-11-01',
  useCdn: process.env.NODE_ENV === 'production',
})

// Type-safe fetching function
export async function getPost(slug: string) {
  try {
    const post = await client.fetch(POST_QUERY, { slug })

    if (!post) {
      throw new Error(`Post with slug ${slug} not found`)
    }

    return post
  } catch (error) {
    console.error(`Error fetching post with slug ${slug}:`, error)
    throw new Error('Failed to fetch post')
  }
}

```

## Next.js 15 Performance Features
### Partial Prerendering (PPR)
```typescript title="partial-pre-rendering.tsx"
// app/page.tsx
import { unstable_noStore as noStore } from 'next/cache'

export default function Page() {
  return (
    <>
      <HeaderSection />
      <DynamicContent />
      <StaticFooter />
    </>
  )
}

function HeaderSection() {
  // Static content, prerendered at build time
  return <header>...</header>
}

async function DynamicContent() {
  // Opt out of caching for this section
  noStore()

  const data = await fetchFreshData()

  return <main>{/* Render data */}</main>
}

function StaticFooter() {
  // Static content, prerendered at build time
  return <footer>...</footer>
}

```

### React Cache with Infinite Nested Component Trees
```typescript title="react-cache-version.tsx"
import { cache } from 'react'
import { client } from '@/lib/sanity/client'

// Cached fetcher function
export const getCategory = cache(async (slug: string) => {
  return client.fetch(`*[_type  "category" && slug.current  $slug][0]`, { slug })
})

// This can be used throughout the app
// without duplicating requests for the same data
async function CategoryHeader({ slug }: { slug: string }) {
  const category = await getCategory(slug)
  return <h1>{category.title}</h1>
}

async function CategoryPage({ slug }: { slug: string }) {
  const category = await getCategory(slug) // Reuses the same request!
  // ...
}

```

### Background Prefetching and Preloading
```typescript title="background-prefetch.ts"
'use client'

import { useCallback, useEffect } from 'react'
import { useRouter } from 'next/navigation'
import Link from 'next/link'

export function PostCard({ post }) {
  const router = useRouter()

  // Preload the post page when user hovers
  const prefetchPost = useCallback(() => {
    router.prefetch(`/posts/${post.slug}`)
  }, [router, post.slug])

  return (
    <div
      className="group cursor-pointer"
      onMouseEnter={prefetchPost}
    >
      <Link href={`/posts/${post.slug}`}>
        <h3>{post.title}</h3>
        <p>{post.excerpt}</p>
      </Link>
    </div>
  )
}

```

## Headers and Edge Optimization
### Custom Cache-Control Headers
```typescript title="app/api/posts/route.ts"
import { NextResponse } from 'next/server'
import { client } from '@/lib/sanity/client'

export async function GET() {
  const posts = await client.fetch(`*[_type == "post"][0...10]`)

  // Use custom cache control headers
  return NextResponse.json(
    { posts },
    {
      headers: {
        'Cache-Control': 'public, s-maxage=60, stale-while-revalidate=300',
      },
    }
  )
}

```

### Edge Runtime for Global Performance
```typescript title="app/posts/[slug]/page.tsx"
export const runtime = 'edge'

export default async function PostPage({ params }: { params: Promise<{ slug: string }> }) {
  const { slug } = await params
  const post = await getPost(slug)
  return (/* Render post */)
}

```

If you're looking for more performance tricks beyond data fetching, we covered a bunch in our post on [hidden gems in Next.js](/blog/hidden-gems-in-nextjs).

> **Next.js app development**: Data fetching, caching, and Next.js performance optimization for production apps. [Learn more](/services/nextjs)

## Implementing ISR in Next.js 15
### Time-based Revalidation
```typescript title="app/blog/page.tsx"
export const revalidate = 3600 // Revalidate every hour

export default async function BlogPage() {
  const posts = await client.fetch(`*[_type == "post"] | order(publishedAt desc)[0...12]`)
  return (/* Render posts */)
}

```

### On-Demand Revalidation with Webhooks
```typescript title="app/api/revalidate/route.ts"
import { NextRequest, NextResponse } from 'next/server'
import { revalidatePath, revalidateTag } from 'next/cache'

export async function POST(request: NextRequest) {
  try {
    const { searchParams } = new URL(request.url)
    const secret = searchParams.get('secret')

    // Validate webhook secret
    if (secret !== process.env.REVALIDATION_SECRET) {
      return NextResponse.json({ error: 'Invalid token' }, { status: 401 })
    }

    const body = await request.json()
    const { _type, slug } = body

    if (_type === 'post') {
      // Revalidate specific post
      revalidateTag(`post-${slug.current}`)
      // Also revalidate the posts list
      revalidatePath('/blog')

      return NextResponse.json({
        revalidated: true,
        message: `Revalidated post: ${slug.current}`
      })
    }

    return NextResponse.json({ message: 'No action taken' })
  } catch (error) {
    return NextResponse.json({ error: 'Error revalidating' }, { status: 500 })
  }
}

```

## Measuring Performance
### Real User Monitoring with Vercel Analytics
```typescript title="app/layout.tsx"
import { Analytics } from '@vercel/analytics/react'
import { SpeedInsights } from '@vercel/speed-insights/next'

export default function RootLayout({ children }) {
  return (
    <html lang="en">
      <body>
        {children}
        <Analytics />
        <SpeedInsights />
      </body>
    </html>
  )
}

```

### Server Timing for Request Analysis
```typescript title="server-timing-request-analysis.tsx"
async function fetchWithTiming(url: string, options = {}) {
  const start = Date.now()

  try {
    const response = await fetch(url, options)
    const data = await response.json()

    // Log timing info
    console.log(`Fetched ${url} in ${Date.now() - start}ms`)

    return data
  } catch (error) {
    console.error(`Error fetching ${url}:`, error)
    throw error
  }
}

```

## Image Optimization with Vercel
Next.js 15 with Vercel provides powerful built-in image optimization capabilities that significantly improve Core Web Vitals and user experience.

```typescript title="components/optimized-image.tsx"
import Image from 'next/image'
import { urlForImage } from '@/lib/sanity/image'

interface ImageProps {
  image: {
    asset: {
      _ref: string
    }
    alt?: string
    hotspot?: {
      x: number
      y: number
    }
  }
  width?: number
  height?: number
  sizes?: string
  priority?: boolean
  className?: string
}

export function OptimizedImage({
  image,
  width = 800,
  height = 600,
  sizes = '(max-width: 768px) 100vw, 800px',
  priority = false,
  className = '',
}: ImageProps) {
  if (!image?.asset?._ref) {
    return null
  }

  const imageUrl = urlForImage(image)
    .width(width)
    .height(height)
    .fit('crop')
    .auto('format')
    .url()

  return (
    <div className={`relative overflow-hidden ${className}`}>
      <Image
        src={imageUrl}
        alt={image.alt || 'Image'}
        width={width}
        height={height}
        sizes={sizes}
        priority={priority}
        className="object-cover"
        placeholder="blur"
        blurDataURL={`${imageUrl}&w=20&blur=30`}
      />
    </div>
  )
}

```

## Configuring Next.js Image Optimization
```typescript title="next.config.js"
/** @type {import('next').NextConfig} */
const nextConfig = {
  images: {
    // Include project-specific domains for targeted optimization
    domains: [
      // Format: project-id.api.sanity.io
      'yourprojectid.api.sanity.io',
      'cdn.sanity.io'
    ],
    formats: ['image/avif', 'image/webp'],
    deviceSizes: [640, 750, 828, 1080, 1200, 1920, 2048],
    remotePatterns: [
      {
        protocol: 'https',
        hostname: 'cdn.sanity.io',
        pathname: `/images/${process.env.NEXT_PUBLIC_SANITY_PROJECT_ID}/**`,
      },
    ],
  },
}

module.exports = nextConfig

```

### Advanced Image Loading Strategies
```typescript title="components/image-gallery.tsx"

'use client'

import { useInView } from 'react-intersection-observer'
import { OptimizedImage } from '@/components/OptimizedImage'

export function ImageGallery({ images }) {
  // Implement progressive loading based on viewport visibility
  return (
    <div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
      {images.map((image, index) => {
        // Use priority for the first visible image (LCP optimization)
        const isPriority = index === 0

        return (
          <LazyLoadedImage
            key={image._key || index}
            image={image}
            priority={isPriority}
          />
        )
      })}
    </div>
  )
}

function LazyLoadedImage({ image, priority = false }) {
  const { ref, inView } = useInView({
    triggerOnce: true,
    rootMargin: '200px 0px', // Start loading 200px before visible
  })

  return (
    <div ref={ref} className="aspect-w-16 aspect-h-9">
      {(inView || priority) && (
        <OptimizedImage
          image={image}
          priority={priority}
          sizes="(max-width: 640px) 100vw, (max-width: 1024px) 50vw, 33vw"
        />
      )}
    </div>
  )
}

```

### Integrating with Sanity CMS
```typescript title="lib/sanity/image.ts"
// lib/sanity/image.ts
import createImageUrlBuilder from '@sanity/image-url'
import { client } from './client'

// Create a configured Sanity image URL builder
export const imageBuilder = createImageUrlBuilder(client)

/**
 * Generates an optimized image URL from a Sanity image reference
 */
export function urlForImage(source) {
  if (!source?.asset?._ref) {
    return null
  }

  return imageBuilder.image(source)
}

/**
 * Helper to generate responsive image props
 */
export function getImageProps({ image, maxWidth = 2000 }) {
  // Calculate aspect ratio if dimensions are provided
  const aspectRatio = image.dimensions
    ? image.dimensions.width / image.dimensions.height
    : 16 / 9

  const width = Math.min(image.dimensions?.width || maxWidth, maxWidth)
  const height = Math.round(width / aspectRatio)

  return {
    src: urlForImage(image).width(width).height(height).url(),
    width,
    height,
    alt: image.alt || '',
    blurDataURL: urlForImage(image).width(20).height(20).blur(10).url(),
  }
}

```

### Runtime Optimization with Automatic Webp/AVIF Conversion
Vercel's Edge Network automatically serves optimized images in modern formats:

```typescript title="page.tsx"
// page.tsx
import { OptimizedImage } from '@/components/OptimizedImage'

async function fetchPostWithImage(slug: string) {
  const post = await client.fetch(`*[_type  "post" && slug.current  $slug][0]{
    _id,
    title,
    mainImage,
  }`, { slug })

  return post
}

export default async function Page({ params }: { params: Promise<{ slug: string }> }) {
  const { slug } = await params
  const post = await fetchPostWithImage(slug)

  return (
    <article className="max-w-4xl mx-auto">
      <div className="aspect-w-16 aspect-h-9 mb-8">
        {post.mainImage && (
          <OptimizedImage
            image={post.mainImage}
            priority={true} // Optimize above-the-fold images
            sizes="(max-width: 1024px) 100vw, 1024px"
          />
        )}
      </div>
      <h1 className="text-3xl font-bold">{post.title}</h1>
      {/* Rest of content */}
    </article>
  )
}

```