Developer Guide

Next.js + Supabase Data Fetching: Server Components

Data fetching patterns in Next.js with Supabase: Server Components, streaming, parallel queries, and caching for optimal performance.

Mahdi Ben RhoumaMahdi Ben Rhouma
Updated
16 min read

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:

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

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

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

Next.js's Fetching Data guide covers this same Promise.all() pattern. Fetch multiple queries simultaneously:

typescript
// app/post/[id]/page.tsx
import { createClient } from '@/lib/supabase/server'
 
export default async function PostPage({ params }: { params: Promise<{ id: string }> }) {
  const { id } = await params
  const supabase = await createClient()
  
  // ❌ Sequential - slow
  // const { data: post } = await supabase.from('posts').select().eq('id', id).single()
  // const { data: comments } = await supabase.from('comments').select().eq('post_id', 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', id).single(),
    supabase.from('comments').select('*, profiles(*)').eq('post_id', 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#

The supabase-js client makes its requests with fetch under the hood (it even accepts a custom fetch implementation via createClient's global.fetch option), but per the fetch API reference, Next.js does not cache a fetch call by default — you opt in with options.cache: 'force-cache' or options.next.revalidate. Since these patterns call the Supabase client rather than fetch directly, implement caching manually — and note the caveat below before reaching for revalidate or unstable_cache.

One caveat that applies to every pattern below: the server client from @/lib/supabase/server reads cookies() to attach the user's session. Reading cookies() makes the route dynamic — it opts the route out of static generation regardless of a revalidate export, and it means that client can't be used inside unstable_cache (which doesn't support request-scoped APIs like cookies() or headers() in a cached function). For data that's genuinely public and doesn't need the user's session — a public post list, for instance — use a cookie-less @supabase/supabase-js client created with the anon key instead of @/lib/supabase/server, and the caching options below apply as documented.

Static Data (Revalidate Periodically)#

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

If this route used a cookie-less Supabase client, this page would statically generate at build time and revalidate every hour. With the cookie-reading @/lib/supabase/server client shown here, revalidate doesn't make the page static — the cookies() read renders it dynamically, per request, regardless of the revalidate export. See the caveat above.

Dynamic Data (No Caching)#

typescript
// 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()#

Per the React docs, cache "is only for use with React Server Components" and lets you memoize the result of a data fetch or computation for the duration of a single request. Cache expensive queries within a single request:

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

typescript
// app/post/[id]/page.tsx
import { getPost } from '@/lib/queries'
 
export default async function PostPage({ params }: { params: Promise<{ id: string }> }) {
  const { id } = await params
  const post = await getPost(id)
  
  return (
    <article>
      <h1>{post.title}</h1>
      <PostContent postId={id} />
      <PostMetadata postId={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#

Next.js's unstable_cache reference documents the tags and revalidate options used below — and notes that a cached function can't read request-scoped APIs like cookies(). That's why this example uses a plain createClient(url, anonKey) from @supabase/supabase-js for these public post queries instead of the cookie-reading @/lib/supabase/server client used elsewhere in this guide:

typescript
// lib/queries.ts
import { unstable_cache } from 'next/cache'
import { createClient } from '@supabase/supabase-js'
 
// Cookie-less client: safe to call inside unstable_cache
const supabase = createClient(
  process.env.NEXT_PUBLIC_SUPABASE_URL!,
  process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!
)
 
export const getPosts = unstable_cache(
  async () => {
    const { data } = await supabase.from('posts').select('*')
    return data
  },
  ['posts'],
  {
    tags: ['posts'],
    revalidate: 3600
  }
)
 
export const getPost = (id: string) => unstable_cache(
  async () => {
    const { data } = await supabase.from('posts').select('*').eq('id', id).single()
    return data
  },
  ['post', id],
  // Tag includes the id so revalidateTag(`post-${id}`) below actually
  // matches an entry — a static 'post' tag here would make that call a
  // silent no-op (nothing carries that dynamic tag to invalidate).
  {
    tags: ['posts', `post-${id}`],
    revalidate: 3600
  }
)()

Invalidate specific caches:

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

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

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

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

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

typescript
// app/posts/page.tsx
import { createClient } from '@/lib/supabase/server'
 
export default async function PostsPage({
  searchParams
}: {
  searchParams: Promise<{ page?: string }>
}) {
  const { page: pageStr } = await searchParams
  const page = parseInt(pageStr || '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:

typescript
// app/posts/page.tsx
export default async function PostsPage({
  searchParams
}: {
  searchParams: Promise<{ cursor?: string }>
}) {
  const { cursor } = await searchParams
  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 (cursor) {
    query = query.lt('created_at', 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:

typescript
// app/posts/page.tsx
export default async function PostsPage({
  searchParams
}: {
  searchParams: Promise<{ q?: string; category?: string }>
}) {
  const { q, category } = await searchParams
  const supabase = await createClient()
  
  let query = supabase
    .from('posts')
    .select('*')
    .order('created_at', { ascending: false })
  
  if (q) {
    query = query.textSearch('title', q)
  }
  
  if (category) {
    query = query.eq('category', 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:

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

typescript
// ❌ 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#

typescript
// ❌ 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#

typescript
// ❌ 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#

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

React Server Components: Complete Deep Dive Next.js Performance Optimization for Indie Developers

Further reading: Next.js Server Actions with Supabase: Complete Guide.

Frequently Asked Questions

|

Have more questions? Contact us

Still stuck? Paste your error.

Paste the error message or stack trace — we check it against every fix and tool on the site.

Matching happens in your browser. When you search, a cleaned error signature is sent so we can spot errors we do not cover yet — never the raw text, your IP address or your e-mail. Do not paste passwords or keys. Details

One email a month — the latest explained errors

What the site published that month — mostly Next.js, Supabase and Node errors, explained. You confirm by email; unsubscribe any time in one click.