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.
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
Related Guides
Advanced Caching Strategies for Next.js and Supabase Applications
Master caching patterns including Redis integration, ISR optimization, SWR patterns, cache invalidation, and performance optimization for Next.js and Supabase applications at scale.
AI Integration for Next.js + Supabase Applications
Complete guide to integrating AI capabilities into Next.js and Supabase applications. Learn OpenAI integration, chat interfaces, vector search, RAG systems,...
Complete Guide to Building SaaS with Next.js and Supabase
Master full-stack SaaS development with Next.js 15 and Supabase. From database design to deployment, learn everything you need to build production-ready...