SupaLaunch logo
SUPALAUNCH
ShowcaseDocs
SupaLaunch
HomeDemoDashboardDocumentationBlogShowcaseServices and FrameworksTerms of ServicePrivacy policy
Tools
DB Schema GeneratorStartup Idea Generator
Other Projects
Syntha AICreateChatsCron Expression GeneratorAI Prompts Generator
Contacts
Created with ❤️ by Denisdenis@supalaunch.com
  • Showcase
  • Docs
Blog

Implementing Pagination with Next.js and Supabase

20 Apr 2025
nextjssupabasepaginationdatabase

Launch your startup with our Supabase + NextJS Starter Kit

SupaLaunch is a SaaS boilerplate built using Supabase and Next.js. It includes authentication, Stripe payments, Postgres database, 20+ TailwindCSS themes, emails, OpenAI API streaming, file storage and more.

  • Save weeks of your time: No need to setup authentication, payments, emails, file storage, etc.
  • Focus on what matters: Spend your time building your product, not boilerplate code.
Get started with SupaLaunch

Pagination with Next.js and Supabase

When building data-rich applications, efficiently displaying large datasets is critical for performance and user experience. Pagination is an essential technique that allows users to navigate through chunks of data rather than loading everything at once. In this comprehensive guide, we'll explore how to implement pagination in a Next.js application using Supabase as the backend.

What You'll Learn

  • Setting up Supabase with Next.js for pagination
  • Implementing offset-based pagination
  • Building cursor-based pagination for better performance
  • Creating infinite scroll functionality
  • Handling pagination with search and filters
  • Optimizing pagination performance

Prerequisites

Before we begin, make sure you have:

  • A Next.js project set up
  • A Supabase account and project
  • Basic understanding of React and JavaScript/TypeScript

Setting Up Supabase with Next.js

Step 1: Install Required Packages

First, install the Supabase client library:

npm install @supabase/supabase-js
# or
yarn add @supabase/supabase-js

If you're using Next.js with authentication:

npm install @supabase/auth-helpers-nextjs
# or
yarn add @supabase/auth-helpers-nextjs

Step 2: Configure Environment Variables

Create a .env.local file in your project root:

NEXT_PUBLIC_SUPABASE_URL=YOUR_SUPABASE_URL
NEXT_PUBLIC_SUPABASE_ANON_KEY=YOUR_SUPABASE_ANON_KEY

Step 3: Create a Supabase Client

For a Next.js Pages Router project:

// lib/supabaseClient.js
import { createClient } from '@supabase/supabase-js'

const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL
const supabaseKey = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY

export const supabase = createClient(supabaseUrl, supabaseKey)

For a Next.js App Router project:

// lib/supabase.js
import { createClient } from '@supabase/supabase-js'

export function createSupabaseClient() {
  return createClient(
    process.env.NEXT_PUBLIC_SUPABASE_URL,
    process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY
  )
}

Offset-Based Pagination

Offset-based pagination is the most straightforward approach, using limit and offset parameters to retrieve a specific range of records.

Basic Implementation

// components/OffsetPagination.jsx
'use client' // If using App Router

import { useState, useEffect } from 'react'
import { supabase } from '../lib/supabaseClient' // Adjust path as needed

export default function OffsetPagination() {
  const [posts, setPosts] = useState([])
  const [loading, setLoading] = useState(true)
  const [currentPage, setCurrentPage] = useState(1)
  const [totalPages, setTotalPages] = useState(0)
  const pageSize = 10

  const fetchPosts = async (page) => {
    try {
      setLoading(true)
      
      // Calculate the offset
      const from = (page - 1) * pageSize
      const to = from + pageSize - 1
      
      // Fetch data with range
      const { data, error, count } = await supabase
        .from('posts')
        .select('*', { count: 'exact' })
        .order('created_at', { ascending: false })
        .range(from, to)
      
      if (error) throw error
      
      setPosts(data)
      
      // Calculate total pages
      if (count) {
        setTotalPages(Math.ceil(count / pageSize))
      }
      
    } catch (error) {
      console.error('Error fetching posts:', error)
    } finally {
      setLoading(false)
    }
  }
  
  useEffect(() => {
    fetchPosts(currentPage)
  }, [currentPage])
  
  const handleNextPage = () => {
    if (currentPage < totalPages) {
      setCurrentPage(currentPage + 1)
    }
  }
  
  const handlePrevPage = () => {
    if (currentPage > 1) {
      setCurrentPage(currentPage - 1)
    }
  }
  
  return (
    <div className="pagination-container">
      {loading ? (
        <p>Loading posts...</p>
      ) : (
        <>
          <div className="posts-grid">
            {posts.map((post) => (
              <div key={post.id} className="post-card">
                <h3>{post.title}</h3>
                <p>{post.excerpt}</p>
              </div>
            ))}
          </div>
          
          <div className="pagination-controls">
            <button 
              onClick={handlePrevPage} 
              disabled={currentPage === 1}
            >
              Previous
            </button>
            
            <span>{currentPage} of {totalPages}</span>
            
            <button 
              onClick={handleNextPage} 
              disabled={currentPage === totalPages}
            >
              Next
            </button>
          </div>
        </>
      )}
    </div>
  )
}

Using with Server Components (App Router)

For Next.js App Router, you can fetch data in a server component:

// app/posts/page.jsx
import { createSupabaseClient } from '@/lib/supabase'
import PaginationControls from '@/components/PaginationControls'
import PostsList from '@/components/PostsList'

export default async function PostsPage({ searchParams }) {
  const page = Number(searchParams.page) || 1
  const pageSize = 10
  
  const supabase = createSupabaseClient()
  
  const from = (page - 1) * pageSize
  const to = from + pageSize - 1
  
  const { data: posts, count } = await supabase
    .from('posts')
    .select('*', { count: 'exact' })
    .order('created_at', { ascending: false })
    .range(from, to)
  
  const totalPages = Math.ceil(count / pageSize)
  
  return (
    <div>
      <h1>Blog Posts</h1>
      <PostsList posts={posts} />
      <PaginationControls currentPage={page} totalPages={totalPages} />
    </div>
  )
}
// components/PaginationControls.jsx
'use client'

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

export default function PaginationControls({ currentPage, totalPages }) {
  const router = useRouter()
  const pathname = usePathname()
  
  const handlePageChange = (page) => {
    router.push(`${pathname}?page=${page}`)
  }
  
  return (
    <div className="pagination-controls">
      <button
        onClick={() => handlePageChange(currentPage - 1)}
        disabled={currentPage <= 1}
      >
        Previous
      </button>
      
      <span>Page {currentPage} of {totalPages}</span>
      
      <button
        onClick={() => handlePageChange(currentPage + 1)}
        disabled={currentPage >= totalPages}
      >
        Next
      </button>
    </div>
  )
}

Cursor-Based Pagination

Cursor-based pagination is more efficient for large datasets as it doesn't require counting all rows. It uses a unique identifier from the last item as a cursor to fetch the next set.

// components/CursorPagination.jsx
'use client'

import { useState, useEffect } from 'react'
import { supabase } from '../lib/supabaseClient'

export default function CursorPagination() {
  const [items, setItems] = useState([])
  const [loading, setLoading] = useState(false)
  const [nextCursor, setNextCursor] = useState(null)
  const [prevCursors, setPrevCursors] = useState([])
  const pageSize = 10
  
  const fetchItems = async (cursor = null, direction = 'next') => {
    try {
      setLoading(true)
      
      let query = supabase
        .from('items')
        .select('*')
        .order('id', { ascending: direction === 'next' })
        .limit(pageSize)
      
      if (cursor) {
        if (direction === 'next') {
          query = query.gt('id', cursor)
        } else {
          query = query.lt('id', cursor)
        }
      }
      
      const { data, error } = await query
      
      if (error) throw error
      
      if (direction === 'prev') {
        data.reverse() // Reverse to maintain chronological order
      }
      
      setItems(data)
      
      // Set cursor for next page
      if (data.length === pageSize) {
        setNextCursor(data[data.length - 1].id)
      } else {
        setNextCursor(null)
      }
      
    } catch (error) {
      console.error('Error fetching items:', error)
    } finally {
      setLoading(false)
    }
  }
  
  useEffect(() => {
    fetchItems()
  }, [])
  
  const handleNextPage = () => {
    if (nextCursor) {
      setPrevCursors([...prevCursors, items[0].id])
      fetchItems(nextCursor, 'next')
    }
  }
  
  const handlePrevPage = () => {
    if (prevCursors.length > 0) {
      const cursor = prevCursors.pop()
      setPrevCursors([...prevCursors])
      fetchItems(cursor, 'prev')
    }
  }
  
  return (
    <div>
      {loading ? (
        <p>Loading...</p>
      ) : (
        <>
          <div className="items-list">
            {items.map((item) => (
              <div key={item.id} className="item-card">
                <h3>{item.title}</h3>
                <p>{item.description}</p>
              </div>
            ))}
          </div>
          
          <div className="pagination-controls">
            <button
              onClick={handlePrevPage}
              disabled={prevCursors.length === 0}
            >
              Previous
            </button>
            
            <button
              onClick={handleNextPage}
              disabled={!nextCursor}
            >
              Next
            </button>
          </div>
        </>
      )}
    </div>
  )
}

Implementing Infinite Scroll

Infinite scroll provides a more modern user experience by loading more content as the user scrolls down the page.

// components/InfiniteScroll.jsx
'use client'

import { useState, useEffect, useRef, useCallback } from 'react'
import { supabase } from '../lib/supabaseClient'

export default function InfiniteScroll() {
  const [items, setItems] = useState([])
  const [loading, setLoading] = useState(false)
  const [hasMore, setHasMore] = useState(true)
  const [cursor, setCursor] = useState(null)
  const pageSize = 10
  
  const observer = useRef()
  const lastItemRef = useCallback(node => {
    if (loading) return
    
    if (observer.current) observer.current.disconnect()
    
    observer.current = new IntersectionObserver(entries => {
      if (entries[0].isIntersecting && hasMore) {
        loadMoreItems()
      }
    })
    
    if (node) observer.current.observe(node)
  }, [loading, hasMore])
  
  const fetchItems = async (initialFetch = false) => {
    try {
      setLoading(true)
      
      let query = supabase
        .from('items')
        .select('*')
        .order('created_at', { ascending: false })
        .limit(pageSize)
      
      if (cursor && !initialFetch) {
        query = query.lt('created_at', cursor)
      }
      
      const { data, error } = await query
      
      if (error) throw error
      
      if (initialFetch) {
        setItems(data)
      } else {
        setItems(prev => [...prev, ...data])
      }
      
      if (data.length < pageSize) {
        setHasMore(false)
      } else {
        setCursor(data[data.length - 1].created_at)
      }
      
    } catch (error) {
      console.error('Error fetching items:', error)
    } finally {
      setLoading(false)
    }
  }
  
  useEffect(() => {
    fetchItems(true)
  }, [])
  
  const loadMoreItems = () => {
    if (!loading && hasMore) {
      fetchItems()
    }
  }
  
  return (
    <div className="infinite-scroll-container">
      <div className="items-grid">
        {items.map((item, index) => {
          if (items.length === index + 1) {
            return (
              <div 
                ref={lastItemRef}
                key={item.id} 
                className="item-card"
              >
                <h3>{item.title}</h3>
                <p>{item.description}</p>
              </div>
            )
          } else {
            return (
              <div key={item.id} className="item-card">
                <h3>{item.title}</h3>
                <p>{item.description}</p>
              </div>
            )
          }
        })}
      </div>
      
      {loading && <p className="loading-indicator">Loading more items...</p>}
      
      {!hasMore && <p className="no-more-items">No more items to load</p>}
    </div>
  )
}

Pagination with Search and Filters

Combining pagination with search functionality and filters adds complexity but enhances user experience.

// components/SearchablePagination.jsx
'use client'

import { useState, useEffect } from 'react'
import { supabase } from '../lib/supabaseClient'

export default function SearchablePagination() {
  const [products, setProducts] = useState([])
  const [loading, setLoading] = useState(false)
  const [currentPage, setCurrentPage] = useState(1)
  const [totalPages, setTotalPages] = useState(0)
  const [searchTerm, setSearchTerm] = useState('')
  const [category, setCategory] = useState('all')
  const pageSize = 10
  
  const fetchProducts = async (page) => {
    try {
      setLoading(true)
      
      const from = (page - 1) * pageSize
      const to = from + pageSize - 1
      
      let query = supabase
        .from('products')
        .select('*', { count: 'exact' })
      
      // Apply search filter if search term exists
      if (searchTerm) {
        query = query.ilike('name', `%${searchTerm}%`)
      }
      
      // Apply category filter if not 'all'
      if (category !== 'all') {
        query = query.eq('category', category)
      }
      
      // Add sorting and pagination
      const { data, error, count } = await query
        .order('created_at', { ascending: false })
        .range(from, to)
      
      if (error) throw error
      
      setProducts(data)
      
      if (count) {
        setTotalPages(Math.ceil(count / pageSize))
      }
      
    } catch (error) {
      console.error('Error fetching products:', error)
    } finally {
      setLoading(false)
    }
  }
  
  useEffect(() => {
    // Reset to first page when search or filter changes
    setCurrentPage(1)
    fetchProducts(1)
  }, [searchTerm, category])
  
  useEffect(() => {
    fetchProducts(currentPage)
  }, [currentPage])
  
  const handleSearch = (e) => {
    e.preventDefault()
    // Search is already triggered by the useEffect
  }
  
  const handleNextPage = () => {
    if (currentPage < totalPages) {
      setCurrentPage(currentPage + 1)
    }
  }
  
  const handlePrevPage = () => {
    if (currentPage > 1) {
      setCurrentPage(currentPage - 1)
    }
  }
  
  return (
    <div className="searchable-pagination">
      <div className="search-filters">
        <form onSubmit={handleSearch}>
          <input
            type="text"
            placeholder="Search products..."
            value={searchTerm}
            onChange={(e) => setSearchTerm(e.target.value)}
          />
          <select 
            value={category} 
            onChange={(e) => setCategory(e.target.value)}
          >
            <option value="all">All Categories</option>
            <option value="electronics">Electronics</option>
            <option value="clothing">Clothing</option>
            <option value="books">Books</option>
          </select>
          <button type="submit">Search</button>
        </form>
      </div>
      
      {loading ? (
        <p>Loading products...</p>
      ) : (
        <>
          <div className="products-grid">
            {products.length > 0 ? (
              products.map((product) => (
                <div key={product.id} className="product-card">
                  <h3>{product.name}</h3>
                  <p>{product.description}</p>
                  <p className="price">${product.price}</p>
                  <span className="category">{product.category}</span>
                </div>
              ))
            ) : (
              <p>No products found matching your criteria.</p>
            )}
          </div>
          
          {totalPages > 0 && (
            <div className="pagination-controls">
              <button
                onClick={handlePrevPage}
                disabled={currentPage === 1}
              >
                Previous
              </button>
              
              <span>{currentPage} of {totalPages}</span>
              
              <button
                onClick={handleNextPage}
                disabled={currentPage === totalPages}
              >
                Next
              </button>
            </div>
          )}
        </>
      )}
    </div>
  )
}

Server-Side Pagination with Next.js API Routes

For applications that need SEO benefits or server-side rendering, implement pagination with API routes:

// pages/api/posts.js
import { supabase } from '../../lib/supabaseClient'

export default async function handler(req, res) {
  const { page = 1, size = 10, category } = req.query
  
  const pageSize = parseInt(size)
  const currentPage = parseInt(page)
  
  const from = (currentPage - 1) * pageSize
  const to = from + pageSize - 1
  
  try {
    let query = supabase
      .from('posts')
      .select('*', { count: 'exact' })
    
    if (category) {
      query = query.eq('category', category)
    }
    
    const { data, error, count } = await query
      .order('created_at', { ascending: false })
      .range(from, to)
    
    if (error) throw error
    
    res.status(200).json({
      posts: data,
      totalPages: Math.ceil(count / pageSize),
      currentPage: currentPage
    })
  } catch (error) {
    res.status(500).json({ error: error.message })
  }
}

Then consume it in your page component:

// pages/blog.js
import { useState, useEffect } from 'react'
import { useRouter } from 'next/router'

export default function BlogPage() {
  const [posts, setPosts] = useState([])
  const [loading, setLoading] = useState(true)
  const [totalPages, setTotalPages] = useState(0)
  const router = useRouter()
  const { page = 1, category } = router.query
  
  useEffect(() => {
    if (!router.isReady) return
    
    const fetchPosts = async () => {
      try {
        setLoading(true)
        
        const queryParams = new URLSearchParams({
          page: page,
          size: 10
        })
        
        if (category) {
          queryParams.append('category', category)
        }
        
        const response = await fetch(`/api/posts?${queryParams}`)
        const data = await response.json()
        
        setPosts(data.posts)
        setTotalPages(data.totalPages)
      } catch (error) {
        console.error('Error fetching posts:', error)
      } finally {
        setLoading(false)
      }
    }
    
    fetchPosts()
  }, [router.isReady, page, category])
  
  const handlePageChange = (newPage) => {
    const query = { page: newPage }
    
    if (category) {
      query.category = category
    }
    
    router.push({
      pathname: router.pathname,
      query: query
    })
  }
  
  return (
    <div className="blog-container">
      <h1>Blog Posts</h1>
      
      {loading ? (
        <p>Loading posts...</p>
      ) : (
        <>
          <div className="posts-grid">
            {posts.map((post) => (
              <div key={post.id} className="post-card">
                <h2>{post.title}</h2>
                <p>{post.excerpt}</p>
                <a href={`/blog/${post.slug}`}>Read more</a>
              </div>
            ))}
          </div>
          
          <div className="pagination-controls">
            <button
              onClick={() => handlePageChange(Number(page) - 1)}
              disabled={Number(page) <= 1}
            >
              Previous
            </button>
            
            <span>Page {page} of {totalPages}</span>
            
            <button
              onClick={() => handlePageChange(Number(page) + 1)}
              disabled={Number(page) >= totalPages}
            >
              Next
            </button>
          </div>
        </>
      )}
    </div>
  )
}

Optimizing Pagination Performance

To ensure your pagination implementation is as efficient as possible, consider these optimization strategies:

1. Use Indexed Columns

Make sure the columns you're ordering and filtering by are properly indexed in your Supabase database.

CREATE INDEX idx_posts_created_at ON posts (created_at DESC);

2. Select Only Needed Fields

Instead of selecting all fields with *, specify only the columns you need:

const { data, error } = await supabase
  .from('posts')
  .select('id, title, excerpt, created_at')
  .range(from, to)

3. Use Count Estimates for Large Tables

For very large tables, getting an exact count can be slow. Consider using count estimates:

// Get an estimate for better performance
const { count } = await supabase
  .from('posts')
  .select('id', { count: 'estimated' })

// Only get exact count for first page
if (currentPage === 1) {
  const { count: exactCount } = await supabase
    .from('posts')
    .select('id', { count: 'exact' })
  setTotalItems(exactCount)
}

4. Implement Data Caching

Cache previously fetched pages to reduce database queries:

const cachedPages = useRef({})

const fetchPage = async (page) => {
  // Check if we already have this page cached
  if (cachedPages.current[page]) {
    setPosts(cachedPages.current[page])
    return
  }
  
  // Fetch from Supabase
  const { data } = await supabase
    .from('posts')
    .select('*')
    .range((page - 1) * pageSize, page * pageSize - 1)
  
  // Cache the results
  cachedPages.current[page] = data
  setPosts(data)
}

Common Pagination Issues and Solutions

Deep Pagination Performance Problems

When dealing with very large offsets, performance can degrade significantly. Switching to cursor-based pagination is the best solution:

// Instead of:
.range(9990, 9999) // Slow for large offsets

// Use cursor-based approach:
.gt('id', lastSeenId)
.limit(10)

Handling Changes During Pagination

If data changes while a user is paginating, they might see duplicates or miss items. Solutions include:

  1. Implement optimistic UI updates
  2. Add timestamps to track when data was last fetched
  3. Use cursor-based pagination with consistent ordering

Accessibility Considerations

Ensure your pagination is keyboard-accessible and screen-reader friendly:

<nav aria-label="Pagination">
  <ul className="pagination">
    <li>
      <button
        onClick={handlePrevPage}
        disabled={currentPage === 1}
        aria-label="Go to previous page"
      >
        Previous
      </button>
    </li>
    <li aria-current="page">
      Page {currentPage} of {totalPages}
    </li>
    <li>
      <button
        onClick={handleNextPage}
        disabled={currentPage === totalPages}
        aria-label="Go to next page"
      >
        Next
      </button>
    </li>
  </ul>
</nav>

Conclusion

Implementing pagination in your Next.js application with Supabase can be approached in different ways, each with its own benefits:

  • Offset-based pagination is simpler to implement but can become inefficient for large datasets
  • Cursor-based pagination provides better performance and consistency for large datasets
  • Infinite scrolling offers a modern user experience but requires different UX considerations

The method you choose depends on your specific requirements, data size, and user experience goals. By following the examples and best practices in this guide, you'll be well-equipped to implement efficient pagination in your Next.js and Supabase projects.

Remember that proper indexing and query optimization are just as important as the pagination implementation itself. With the right approach, you can provide a smooth, fast experience for your users while efficiently managing your application's resources.

Happy coding!