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]
Originally published at https://www.iloveblogs.blog
Top comments (0)