Next.js Data Fetching Patterns with Supabase: Server Components, Streaming, and Caching
Developer Guide

Next.js Data Fetching Patterns with Supabase: Server Components, Streaming, and Caching

Complete guide to data fetching patterns in Next.js with Supabase. Master Server Components, streaming, parallel queries, and caching for optimal performance.

2026-03-14
16 min read
Next.js Data Fetching Patterns with Supabase: Server Components, Streaming, and Caching

Next.js Data Fetching Patterns with Supabase: Server Components, Streaming, and Caching#

The App Router fundamentally changed data fetching in Next.js. Instead of getServerSideProps and getStaticProps, you fetch data directly in Server Components using async/await.

Combined with Supabase, this enables powerful patterns: streaming data as it loads, parallel queries, automatic caching, and optimal performance without complex state management.

This guide covers production-ready data fetching patterns that make your Next.js + Supabase apps fast and maintainable.

Prerequisites#

  • Next.js 14+ with App Router
  • Supabase project configured
  • Understanding of React Server Components
  • TypeScript (recommended)

Server Components: The Default Choice#

Server Components run on the server and can directly access your database:

// app/posts/page.tsx
import { createClient } from '@/lib/supabase/server'

export default async function PostsPage() {
  const supabase = await createClient()
  
  const { data: posts, error } = await supabase
    .from('posts')
    .select('*')
    .order('created_at', { ascending: false })
  
  if (error) {
    throw new Error('Failed to fetch posts')
  }
  
  return (
    <div>
      <h1>Posts</h1>
      {posts.map(post => (
        <article key={post.id}>
          <h2>{post.title}</h2>
          <p>{post.content}</p>
        </article>
      ))}
    </div>
  )
}

Benefits:

  • No client-side JavaScript for data fetching
  • Database credentials never exposed to client
  • Automatic request deduplication
  • Built-in caching
  • SEO-friendly (HTML includes data)

Streaming with Suspense#

Don't wait for all data before showing the page. Stream content as it loads:

// app/dashboard/page.tsx
import { Suspense } from 'react'
import { UserStats } from './UserStats'
import { RecentActivity } from './RecentActivity'
import { AnalyticsChart } from './AnalyticsChart'

export default function DashboardPage() {
  return (
    <div>
      <h1>Dashboard</h1>
      
      {/* Fast query - loads immediately */}
      <Suspense fallback={<div>Loading stats...</div>}>
        <UserStats />
      </Suspense>
      
      {/* Slower query - streams when ready */}
      <Suspense fallback={<div>Loading activity...</div>}>
        <RecentActivity />
      </Suspense>
      
      {/* Complex query - streams independently */}
      <Suspense fallback={<div>Loading analytics...</div>}>
        <AnalyticsChart />
      </Suspense>
    </div>
  )
}

Each component fetches its own data:

// app/dashboard/UserStats.tsx
import { createClient } from '@/lib/supabase/server'

export async function UserStats() {
  const supabase = await createClient()
  
  const { data: { user } } = await supabase.auth.getUser()
  
  const { count: postCount } = await supabase
    .from('posts')
    .select('*', { count: 'exact', head: true })
    .eq('user_id', user?.id)
  
  const { count: commentCount } = await supabase
    .from('comments')
    .select('*', { count: 'exact', head: true })
    .eq('user_id', user?.id)
  
  return (
    <div>
      <p>Posts: {postCount}</p>
      <p>Comments: {commentCount}</p>
    </div>
  )
}

The page shell renders immediately. Each Suspense boundary streams content when its data is ready.

Parallel Data Fetching#

Fetch multiple queries simultaneously:

// app/post/[id]/page.tsx
import { createClient } from '@/lib/supabase/server'

export default async function PostPage({ params }: { params: { id: string } }) {
  const supabase = await createClient()
  
  // ❌ Sequential - slow
  // const { data: post } = await supabase.from('posts').select().eq('id', params.id).single()
  // const { data: comments } = await supabase.from('comments').select().eq('post_id', params.id)
  // const { data: author } = await supabase.from('profiles').select().eq('id', post.user_id).single()
  
  // ✅ Parallel - fast
  const [
    { data: post },
    { data: comments },
    { data: relatedPosts }
  ] = await Promise.all([
    supabase.from('posts').select('*, profiles(*)').eq('id', params.id).single(),
    supabase.from('comments').select('*, profiles(*)').eq('post_id', params.id),
    supabase.from('posts').select('*').limit(3)
  ])
  
  return (
    <article>
      <h1>{post.title}</h1>
      <p>By {post.profiles.name}</p>
      <div>{post.content}</div>
      
      <section>
        <h2>Comments ({comments.length})</h2>
        {comments.map(comment => (
          <div key={comment.id}>{comment.content}</div>
        ))}
      </section>
      
      <aside>
        <h3>Related Posts</h3>
        {relatedPosts.map(p => (
          <a key={p.id} href={`/post/${p.id}`}>{p.title}</a>
        ))}
      </aside>
    </article>
  )
}

Promise.all() executes all queries simultaneously, reducing total loading time.

Caching Strategies#

Next.js automatically caches fetch requests. For Supabase, implement caching manually:

Static Data (Revalidate Periodically)#

// app/posts/page.tsx
import { createClient } from '@/lib/supabase/server'

export const revalidate = 3600 // Revalidate every hour

export default async function PostsPage() {
  const supabase = await createClient()
  
  const { data: posts } = await supabase
    .from('posts')
    .select('*')
    .order('created_at', { ascending: false })
  
  return (
    <div>
      {posts.map(post => (
        <article key={post.id}>
          <h2>{post.title}</h2>
        </article>
      ))}
    </div>
  )
}

This page is statically generated at build time and revalidated every hour.

Dynamic Data (No Caching)#

// app/dashboard/page.tsx
export const dynamic = 'force-dynamic'

export default async function DashboardPage() {
  const supabase = await createClient()
  
  const { data: { user } } = await supabase.auth.getUser()
  
  // Always fetch fresh data
  const { data: userPosts } = await supabase
    .from('posts')
    .select('*')
    .eq('user_id', user?.id)
  
  return <div>{/* personalized content */}</div>
}

Partial Caching with React cache()#

Cache expensive queries within a single request:

// lib/queries.ts
import { cache } from 'react'
import { createClient } from '@/lib/supabase/server'

export const getPost = cache(async (id: string) => {
  const supabase = await createClient()
  
  const { data, error } = await supabase
    .from('posts')
    .select('*, profiles(*)')
    .eq('id', id)
    .single()
  
  if (error) throw error
  return data
})

// Multiple components can call getPost(id) - only executes once per request

Use in components:

// app/post/[id]/page.tsx
import { getPost } from '@/lib/queries'

export default async function PostPage({ params }: { params: { id: string } }) {
  const post = await getPost(params.id)
  
  return (
    <article>
      <h1>{post.title}</h1>
      <PostContent postId={params.id} />
      <PostMetadata postId={params.id} />
    </article>
  )
}

// app/post/[id]/PostContent.tsx
import { getPost } from '@/lib/queries'

export async function PostContent({ postId }: { postId: string }) {
  const post = await getPost(postId) // Uses cached result
  return <div>{post.content}</div>
}

Tagged Caching for Granular Invalidation#

Tag queries for precise cache invalidation:

// lib/queries.ts
import { unstable_cache } from 'next/cache'
import { createClient } from '@/lib/supabase/server'

export const getPosts = unstable_cache(
  async () => {
    const supabase = await createClient()
    const { data } = await supabase.from('posts').select('*')
    return data
  },
  ['posts'],
  {
    tags: ['posts'],
    revalidate: 3600
  }
)

export const getPost = unstable_cache(
  async (id: string) => {
    const supabase = await createClient()
    const { data } = await supabase.from('posts').select('*').eq('id', id).single()
    return data
  },
  ['post'],
  {
    tags: ['posts', 'post'],
    revalidate: 3600
  }
)

Invalidate specific caches:

// actions/posts.ts
'use server'

import { revalidateTag } from 'next/cache'

export async function createPost(formData: FormData) {
  // Create post logic
  
  revalidateTag('posts') // Invalidates all queries tagged with 'posts'
}

export async function updatePost(id: string, formData: FormData) {
  // Update post logic
  
  revalidateTag('posts')
  revalidateTag(`post-${id}`) // Invalidate specific post
}

Error Handling#

Handle errors gracefully with error boundaries:

// app/posts/error.tsx
'use client'

export default function PostsError({
  error,
  reset
}: {
  error: Error & { digest?: string }
  reset: () => void
}) {
  return (
    <div>
      <h2>Failed to load posts</h2>
      <p>{error.message}</p>
      <button onClick={reset}>Try again</button>
    </div>
  )
}

Throw errors in Server Components:

// app/posts/page.tsx
export default async function PostsPage() {
  const supabase = await createClient()
  
  const { data: posts, error } = await supabase
    .from('posts')
    .select('*')
  
  if (error) {
    throw new Error('Failed to fetch posts')
  }
  
  if (!posts || posts.length === 0) {
    return <div>No posts found</div>
  }
  
  return <div>{/* render posts */}</div>
}

Loading States#

Create loading.tsx for automatic loading UI:

// app/posts/loading.tsx
export default function PostsLoading() {
  return (
    <div>
      <h1>Posts</h1>
      <div className="space-y-4">
        {[...Array(5)].map((_, i) => (
          <div key={i} className="animate-pulse">
            <div className="h-6 bg-gray-200 rounded w-3/4 mb-2" />
            <div className="h-4 bg-gray-200 rounded w-full" />
          </div>
        ))}
      </div>
    </div>
  )
}

Or use inline Suspense:

// app/posts/page.tsx
import { Suspense } from 'react'

export default function PostsPage() {
  return (
    <div>
      <h1>Posts</h1>
      <Suspense fallback={<PostsSkeleton />}>
        <PostsList />
      </Suspense>
    </div>
  )
}

async function PostsList() {
  const supabase = await createClient()
  const { data: posts } = await supabase.from('posts').select('*')
  
  return (
    <div>
      {posts.map(post => (
        <article key={post.id}>{post.title}</article>
      ))}
    </div>
  )
}

function PostsSkeleton() {
  return <div>Loading posts...</div>
}

Pagination Patterns#

Offset Pagination#

Simple but slower for large datasets:

// app/posts/page.tsx
import { createClient } from '@/lib/supabase/server'

export default async function PostsPage({
  searchParams
}: {
  searchParams: { page?: string }
}) {
  const page = parseInt(searchParams.page || '1')
  const pageSize = 10
  const from = (page - 1) * pageSize
  const to = from + pageSize - 1
  
  const supabase = await createClient()
  
  const { data: posts, count } = await supabase
    .from('posts')
    .select('*', { count: 'exact' })
    .order('created_at', { ascending: false })
    .range(from, to)
  
  const totalPages = Math.ceil((count || 0) / pageSize)
  
  return (
    <div>
      {posts.map(post => (
        <article key={post.id}>{post.title}</article>
      ))}
      
      <div>
        {page > 1 && (
          <a href={`/posts?page=${page - 1}`}>Previous</a>
        )}
        <span>Page {page} of {totalPages}</span>
        {page < totalPages && (
          <a href={`/posts?page=${page + 1}`}>Next</a>
        )}
      </div>
    </div>
  )
}

Cursor Pagination#

Better performance for large datasets:

// app/posts/page.tsx
export default async function PostsPage({
  searchParams
}: {
  searchParams: { cursor?: string }
}) {
  const supabase = await createClient()
  const pageSize = 10
  
  let query = supabase
    .from('posts')
    .select('*')
    .order('created_at', { ascending: false })
    .limit(pageSize + 1) // Fetch one extra to check if there's a next page
  
  if (searchParams.cursor) {
    query = query.lt('created_at', searchParams.cursor)
  }
  
  const { data: posts } = await query
  
  const hasMore = posts.length > pageSize
  const displayPosts = hasMore ? posts.slice(0, pageSize) : posts
  const nextCursor = hasMore ? posts[pageSize - 1].created_at : null
  
  return (
    <div>
      {displayPosts.map(post => (
        <article key={post.id}>{post.title}</article>
      ))}
      
      {nextCursor && (
        <a href={`/posts?cursor=${nextCursor}`}>Load More</a>
      )}
    </div>
  )
}

Search and Filtering#

Implement search with URL params:

// app/posts/page.tsx
export default async function PostsPage({
  searchParams
}: {
  searchParams: { q?: string; category?: string }
}) {
  const supabase = await createClient()
  
  let query = supabase
    .from('posts')
    .select('*')
    .order('created_at', { ascending: false })
  
  if (searchParams.q) {
    query = query.textSearch('title', searchParams.q)
  }
  
  if (searchParams.category) {
    query = query.eq('category', searchParams.category)
  }
  
  const { data: posts } = await query
  
  return (
    <div>
      <SearchForm />
      {posts.map(post => (
        <article key={post.id}>{post.title}</article>
      ))}
    </div>
  )
}

// components/SearchForm.tsx
'use client'

import { useRouter, useSearchParams } from 'next/navigation'

export function SearchForm() {
  const router = useRouter()
  const searchParams = useSearchParams()
  
  function handleSubmit(e: React.FormEvent<HTMLFormElement>) {
    e.preventDefault()
    const formData = new FormData(e.currentTarget)
    const q = formData.get('q') as string
    
    const params = new URLSearchParams(searchParams)
    if (q) {
      params.set('q', q)
    } else {
      params.delete('q')
    }
    
    router.push(`/posts?${params.toString()}`)
  }
  
  return (
    <form onSubmit={handleSubmit}>
      <input
        type="search"
        name="q"
        defaultValue={searchParams.get('q') || ''}
        placeholder="Search posts..."
      />
      <button type="submit">Search</button>
    </form>
  )
}

Mixing Server and Client Components#

Fetch in Server Components, add interactivity in Client Components:

// app/posts/page.tsx (Server Component)
import { createClient } from '@/lib/supabase/server'
import { PostList } from './PostList'

export default async function PostsPage() {
  const supabase = await createClient()
  
  const { data: posts } = await supabase
    .from('posts')
    .select('*')
    .order('created_at', { ascending: false })
  
  return <PostList initialPosts={posts} />
}

// app/posts/PostList.tsx (Client Component)
'use client'

import { useState } from 'react'

export function PostList({ initialPosts }: { initialPosts: Post[] }) {
  const [posts, setPosts] = useState(initialPosts)
  const [filter, setFilter] = useState('all')
  
  const filteredPosts = filter === 'all'
    ? posts
    : posts.filter(p => p.category === filter)
  
  return (
    <div>
      <select value={filter} onChange={e => setFilter(e.target.value)}>
        <option value="all">All</option>
        <option value="tech">Tech</option>
        <option value="design">Design</option>
      </select>
      
      {filteredPosts.map(post => (
        <article key={post.id}>{post.title}</article>
      ))}
    </div>
  )
}

Common Pitfalls#

1. Using Client Supabase in Server Components#

// ❌ Wrong
import { createClient } from '@/lib/supabase/client'

export default async function Page() {
  const supabase = createClient() // Browser client in Server Component!
}

// ✅ Correct
import { createClient } from '@/lib/supabase/server'

export default async function Page() {
  const supabase = await createClient()
}

2. Fetching in Client Components Unnecessarily#

// ❌ Unnecessary client-side fetching
'use client'

import { useEffect, useState } from 'react'

export default function PostsPage() {
  const [posts, setPosts] = useState([])
  
  useEffect(() => {
    fetch('/api/posts').then(/* ... */)
  }, [])
}

// ✅ Fetch in Server Component
export default async function PostsPage() {
  const supabase = await createClient()
  const { data: posts } = await supabase.from('posts').select('*')
  
  return <PostList posts={posts} />
}

3. Not Handling Loading States#

// ❌ No loading state
export default async function Page() {
  const data = await slowQuery()
  return <div>{data}</div>
}

// ✅ With Suspense
export default function Page() {
  return (
    <Suspense fallback={<Loading />}>
      <DataComponent />
    </Suspense>
  )
}

4. Sequential Queries#

// ❌ Sequential - slow
const post = await getPost(id)
const author = await getAuthor(post.author_id)
const comments = await getComments(id)

// ✅ Parallel - fast
const [post, comments] = await Promise.all([
  getPost(id),
  getComments(id)
])

Summary#

Modern data fetching in Next.js with Supabase:

  • Fetch in Server Components by default
  • Use Suspense for streaming and loading states
  • Parallelize queries with Promise.all()
  • Cache strategically with revalidate and tags
  • Handle errors with error boundaries
  • Keep Client Components minimal

This approach delivers fast, SEO-friendly applications with minimal client-side JavaScript.

[INTERNAL LINK: react-server-components-deep-dive] [INTERNAL LINK: nextjs-performance-optimization] [INTERNAL LINK: nextjs-supabase-caching-strategies]

Frequently Asked Questions

|

Have more questions? Contact us