Skip to content

Instantly share code, notes, and snippets.

@jonoroboto
Created April 28, 2025 16:09
Show Gist options
  • Save jonoroboto/890a80175813ba01d1f5e669585f2c66 to your computer and use it in GitHub Desktop.
Save jonoroboto/890a80175813ba01d1f5e669585f2c66 to your computer and use it in GitHub Desktop.
title date author description
Optimized Data Fetching in Next.js 15
2023-11-15
Principal Software Developer
Advanced techniques for optimized data fetching in Next.js 15 using Vercel platform features

Optimized Data Fetching in Next.js 15

Introduction

Next.js 15 introduces powerful new patterns for data fetching that leverage the full potential of React Server Components, combined with Vercel's platform capabilities. This talk explores advanced techniques to build lightning-fast applications with efficient data flows.

Key Concepts in Next.js 15

Next.js 15 builds on the App Router foundation while introducing several optimizations:

  • Parallel data fetching with zero client-side JavaScript
  • Granular cache control at the request level
  • Enhanced streaming capabilities
  • Improved error handling with better developer experience

Server Components: The Foundation

// Server Component (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

1. Route Segment Config for 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

2. Request-Level Cache Control (New in Next.js 15)

async function fetchPost(slug: string) {
  // New in Next.js 15: Request-level cache options
  return await fetch(`https://api.sanity.io/v2023-08-01/data/query/production?query=*[_type == "post" && slug.current == "${slug}"][0]`, {
    next: {
      // Control cache behavior per fetch
      revalidate: 30, // Seconds
      tags: [`post-${slug}`], // Custom tags for on-demand revalidation
    },
  }).then(res => res.json())
}

3. On-Demand Revalidation with Tags

// Route handler (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

1. Parallel Data Fetching

// 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}
    />
  )
}

2. Suspense for Progressive Loading

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

1. Error Boundary at the Route Level

// 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>
  )
}

2. Handling Errors with Error Boundaries per Component

// 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>
  )
}

3. Type-Safe Error Handling with tryCatch Pattern

Error handling is a critical aspect of robust data fetching. Let's implement a type-safe utility function that elegantly handles asynchronous operations:

// 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:

// Using the utility in a server component
async function PostPage({ params }: { params: { slug: string } }) {
  const [post, error] = await tryCatch(getPost(params.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} />
}

This pattern has several advantages:

  1. Type safety with generics ensures proper type inference
  2. Consistent error handling across the application
  3. No try/catch blocks cluttering component logic
  4. Clear distinction between successful and error states
  5. Can be extended to include specific error types

You can also create specialized variants for specific use cases:

// 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)
    return NextResponse.json(
      { error: errorMessage },
      { status: error.statusCode || 500 }
    )
  }
  
  return NextResponse.json({ data })
}

Working with Sanity CMS

1. Typed Queries with GROQ

// lib/sanity/query.ts
import { defineQuery } from 'groq'
import { imageFragment } from './fragments'

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

2. Optimized Sanity Client

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

1. Partial Prerendering (PPR)

// 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>
}

2. React Cache with Infinite Nested Component Trees

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!
  // ...
}

3. Background Prefetching and Preloading

'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

1. Custom Cache-Control Headers

// 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',
      },
    }
  )
}

2. Edge Runtime for Global Performance

// app/posts/[slug]/page.tsx
export const runtime = 'edge'

export default async function PostPage({ params }) {
  const post = await getPost(params.slug)
  return (/* Render post */)
}

Implementing ISR in Next.js 15

1. Time-based Revalidation

// 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 */)
}

2. On-Demand Revalidation with Webhooks

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

1. Real User Monitoring with Vercel Analytics

// 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>
  )
}

2. Server Timing for Request Analysis

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.

1. Next.js Image Component with Automatic Optimization

// components/OptimizedImage.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>
  )
}

2. Configuring Next.js Image Optimization

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

3. Advanced Image Loading Strategies

// components/ImageGallery.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>
  )
}

4. Integrating with Sanity CMS

// 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(),
  }
}

5. Runtime Optimization with Automatic Webp/AVIF Conversion

Vercel's Edge Network automatically serves optimized images in modern formats:

// 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 }) {
  const post = await fetchPostWithImage(params.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>
  )
}

Conclusion

Next.js 15 pushes the boundaries of web performance by combining React Server Components with advanced caching strategies and Vercel's platform capabilities:

  • Fine-grained cache control at the request level
  • Streaming and progressive rendering
  • Parallel data fetching without waterfalls
  • Robust error handling patterns
  • Edge runtime optimizations
  • Real-time analytics and monitoring

By combining these techniques, you can build applications that are both feature-rich and lightning fast.

Resources

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment