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 |
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.
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 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>
)
}
// 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
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())
}
// 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 })
}
}
// 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}
/>
)
}
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} />
}
// 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>
)
}
// 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>
)
}
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:
- Type safety with generics ensures proper type inference
- Consistent error handling across the application
- No try/catch blocks cluttering component logic
- Clear distinction between successful and error states
- 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 })
}
// 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}
}
}
}`)
// 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')
}
}
// 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>
}
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!
// ...
}
'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>
)
}
// 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',
},
}
)
}
// app/posts/[slug]/page.tsx
export const runtime = 'edge'
export default async function PostPage({ params }) {
const post = await getPost(params.slug)
return (/* Render post */)
}
// 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 */)
}
// 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 })
}
}
// 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>
)
}
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
}
}
Next.js 15 with Vercel provides powerful built-in image optimization capabilities that significantly improve Core Web Vitals and user experience.
// 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>
)
}
// 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
// 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>
)
}
// 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(),
}
}
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>
)
}
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.