Blogs / infinite-scroll-react-query-supabase-nextjs



Implementing Infinite Scroll with React Query & Supabase Pagination in Next.js

#infinite-scroll, #react-query, #supabase, #nextjs, #pagination

Sep 18, 202416 min read



React Query + Supabase

Scrolling forever? Yes, please!

Scrolling forever with React Query, Supabase, and Next.js? Spectacular—give me 14 of them right now.

Supabase lets you paginate results, and when you pair this with React Query’s useInfiniteQuery hook, you’ll enhance user experience, cut down on load times, and make your app feel like a breeze to navigate. Your users—and your browser—will thank you.

In this article, we’ll cover:

  • Using the intersection observer to help implement infinite scrolling (so no external packages)
  • React Query’s Hydration feature
  • Prefetching an infinite query server-side
  • Using the infinite query client-side
  • Creating typed Supabase clients for client and server components
  • Using reusable queries

If this sounds like what you’re looking for, let’s get started.

Prerequisites

This article assumes you already have a Next.js App Router project running with a Supabase backend. You should also have your Supabase clients configured. If not, no worries. I’ll direct you—to the docs though 🥲.

Supabase SSR Helpers

We’ll be working with both client and server-side code, so it’s essential to set up your Supabase client correctly. If your Supabase clients are already configured, you can skip this step.

To use Server-Side Rendering (SSR) with Supabase, you need to configure your Supabase client to use cookies. The @supabase/ssr package helps you do this for JavaScript/TypeScript applications.

I’d love to walk you through it, but the Supabase docs have done a fantastic job already.

Depending on your setup, you can choose between two approaches:

  1. For Simple Applications: Follow the steps in this article, particularly this section. This method is straightforward and doesn’t require middleware.
  2. For More Auth Reliant Setups: Use the steps from the Setting up Server-Side Auth for Next.js, which includes additional details on Creating a Supabase client for SSR. This approach is more involved but suitable for more complex setups.

Regardless of the method you choose, you’ll end up with:

  • Configured environment variables
  • Exported createClient() functions for your Supabase clients
  • Optional middleware logic

Generating TypeScript Types

This step is optional but it helps to make TypeScript happy. You can generate TypeScript types for your Supabase clients.

  1. Log in to Supabase and initialize:

    npx supabase login
    npx supabase init
    
  2. Generate TypeScript types:

    To get the types run this command with the project ID from your Supabase dashboard:

    npx supabase gen types --lang=typescript --project-id "$PROJECT_REF" --schema public > database.types.ts
    

    However, this is going to dump the types into your terminal. To save them to a file instead, use:

    npx supabase gen types --lang=typescript --project-id "your_project_id" --schema public > utils/supabase/database.types.ts
    

    Adjust the path as needed. In this example, the types are saved in the utils/supabase directory.

  3. Integrate types into your Supabase clients:

    Client-Side Supabase Client:

    import { createBrowserClient } from "@supabase/ssr";
    import { Database } from "./utils/supabase/database.types";
    
    export function createClient() {
      return createBrowserClient<Database>(/* client config */);
    }
    

    Server-Side Supabase Client:

    import { Database } from "@/utils/supabase/database.types";
    import { createServerClient } from "@supabase/ssr";
    import { cookies } from "next/headers";
    
    export function createClient() {
      const cookieStore = cookies();
      return createServerClient<Database>(/* server config */);
    }
    

With these steps, you'll have fully typed Supabase clients ready to go. 🚀

Working with React Query

React Query is an excellent tool for managing server-state in your app. It simplifies data fetching, caching, and synchronization between your client and server, making it ideal for implementing features like infinite scroll.

To start using React Query in your app, follow these steps:

  1. Install React Query and the Devtools

    npm install @tanstack/react-query
    npm install @tanstack/react-query-devtools
    

    The React Query Devtools is optional but helpful for debugging.

  2. Set up the Query Client Provider

    Since React Query’s QueryClientProvider is only compatible with client components, we can't directly include it in the root layout.tsx file. To make it work, we need to create a dedicated client component that wraps our app with the provider.

    // src/utils/Provider.tsx
    
    "use client";
    
    import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
    import { ReactQueryDevtools } from "@tanstack/react-query-devtools";
    import React, { useState } from "react";
    
    function Provider({ children }: { children: React.ReactNode }) {
      const [queryClient] = useState(
        () =>
          new QueryClient({
            defaultOptions: {
              queries: {
                staleTime: 60 * 1000,
              },
            },
          })
      );
    
      return (
        <QueryClientProvider client={queryClient}>
          <ReactQueryDevtools initialIsOpen={false} position="bottom" />
          {children}
        </QueryClientProvider>
      );
    }
    
    export default Provider;
    

    This is the recommended configuration for the queryClient from the documentation.

    Next, make sure to use the Provider in the layout.tsx (or rootlayout file) to wrap everything, including the children.

    import Navbar from "@/src/components/Navbar";
    import Provider from "@/src/utils/Provider";
    import "@/styles/globals.css";
    import type { Metadata } from "next";
    import { Inter } from "next/font/google";
    
    const inter = Inter({ subsets: ["latin"] });
    
    export const metadata: Metadata = {
      title: "Create Next App",
      description: "Generated by create next app",
    };
    
    export default function RootLayout({
      children,
    }: Readonly<{
      children: React.ReactNode;
    }>) {
      return (
        <html lang="en">
          <body className={`${inter.className} text-zinc-700`}>
            <Provider>
              <Navbar />
              {children}
            </Provider>
          </body>
        </html>
      );
    }
    

Querying Data with Supabase Pagination

Supabase makes pagination simple by allowing you to limit queries to a specific range of results. You can read about the range modifier here.

Let’s apply this in our own reusable query.

Getting the Range

This helper function helps calculates which items to fetch based on the current page and the limit (items per page). It returns a "range" we can provide to Supabase's .range() method to paginate.

// src/utils/paginate.ts

// returns a range of numbers to be used for pagination.
export function getRange(page: number, limit: number) {
  const from = page * limit;
  const to = from + limit - 1;

  return [from, to];
}

Let's say you are fetching 10 items per page (limit = 10):

  • Page 0 returns [0, 9], fetching items 0 to 9.
  • Page 1 returns [10, 19], fetching items 10 to 19.

Building a Reusable Query

This query function fetches the user’s books from a specific shelf using pagination.

We need a function that can handle pagination and is flexible enough to work on both the client and server side. Here’s how we achieve that:

import { getRange } from "@/src/utils/paginate";
import { Database } from "@/utils/supabase/database.types";
import { SupabaseClient } from "@supabase/supabase-js";

export async function getBooksOnShelf(
  client: SupabaseClient<Database>,
  page: number
) {
  try {
    const range = getRange(page, 5);

    const { data, error } = await client
      .from("saved_books")
      .select("id, book_id, cover_id")
      .eq("user_id", "102af2d6e")
      .eq("shelf_id", 36)
      .order("created_at", { ascending: false })
      .range(range[0], range[1]);

    if (error) throw error;

    return data;
  } catch (error) {
    console.error("Error fetching books on shelf:", error);
    throw error;
  }
}
  1. The client parameter makes the function flexible, allowing it to be used on both client and server sides. By passing the client as an argument, we can configure it differently at run time.
  2. The getRange function calculates the exact range of items to fetch, depending on the current page number and the limit of items per page. It tells Supabase which portion of the dataset to fetch.

Implementing Infinite Scroll

Before we fetch the data, we need to ensure that when we do, we can load more content as the user scrolls down the page. To achieve this, we’ll build an InfiniteScroll component using the native Intersection Observer API.

This approach will allow us to detect when the user reaches the bottom of the page and automatically load more data without the need for any external libraries.

'use client';

import { LoadingIcon } from "@/src/components/icons";
import React, { useEffect, useRef } from "react";

type Props = {
  isLoadingIntial: boolean;
  isLoadingMore: boolean;
  children: React.ReactNode;
  loadMore: () => void;
};

function InfiniteScroll(props: Props) {
  const observerElement = useRef<HTMLDivElement | null>(null);
  const { isLoadingIntial, isLoadingMore, children, loadMore } = props;

  useEffect(() => {
    // is element in view?
    function handleIntersection(entries: IntersectionObserverEntry[]) {
      entries.forEach((entry) => {
        if (entry.isIntersecting && (!isLoadingMore || !isLoadingIntial)) {
          loadMore();
        }
      });
    }

    // create observer instance
    const observer = new IntersectionObserver(handleIntersection, {
      root: null,
      rootMargin: "100px",
      threshold: 0,
    });

    if (observerElement.current) {
      observer.observe(observerElement.current);
    }

    // cleanup function
    return () => observer.disconnect();
  }, [isLoadingMore, isLoadingIntial, loadMore]);

  return (
    <>
      <>{children}</>

      <div ref={observerElement} id="obs">
        {isLoadingMore && !isLoadingIntial && (
          <div className="wrapper flex justify-center items-center h-20">
            <LoadingIcon/>
          </div>
        )}
      </div>
    </>
  );
}

export default InfiniteScroll;

Let’s break down how it works.

The Props

  • isLoadingIntial: This tells us if the initial data load is still in progress; the first batch of data is being fetched.
  • isLoadingMore: Tracks if additional data is being loaded when the user scrolls.
  • children: This contains the content that’s already been loaded and displayed on the page.
  • loadMore: A function that triggers fetching more data when the user reaches the bottom.

The Intersection Observer We use the Intersection Observer API to monitor when the user scrolls near the bottom of the page.

The observer watches an element placed at the bottom of the content (observerElement). When this element comes into view (entry.isIntersecting), the loadMore() function is triggered—provided we're not in a loading state—and we automatically fetch the next set of data. The rootMargin: "100px" ensures that content starts loading a bit earlier, before the user actually reaches the bottom, creating a smoother experience.

While the next set of data is being fetched, we show a loading spinner (using LoadingIcon) to let the user know more content is on its way.

In our useEffect hook, we also include a cleanup function to avoid memory leaks and ensure that the IntersectionObserver stops observing once the component unmounts or when the dependency values change.

Now that our component is built, let’s fetch the data.

Prefetching on the Server

Before we fetch the data client-side, we’ll prefetch it first on the server. While this step is optional (you could fetch data client-side only), prefetching on the server is a smart move for better load times and smoother interactions.

Why Prefetch?

Server-side prefetching helps eliminate delays between user interactions and data retrieval, especially for infinite scrolling where users continuously load new content. By serving pre-fetched data, we minimize wait times, boost performance, and create a more responsive experience.

Your users POV

Let’s break down how we can do this server-side:

import BooksOnShelf from "@/src/components/TempBooksOnShelf";
import { getBooksOnShelf } from "@/src/utils/getBooksOnShelfForUser";
import { createClient } from "@/utils/supabase/server";
import { dehydrate, HydrationBoundary, QueryClient } from "@tanstack/react-query";

async function page() {
  const queryClient = new QueryClient();
  const supabase = createClient();

  await queryClient.prefetchInfiniteQuery({
    queryKey: ["books-on-shelf", 36],
    queryFn: ({ pageParam }) => getBooksOnShelf(supabase, pageParam),
    initialPageParam: 0,
  });

  return (
    <HydrationBoundary state={dehydrate(queryClient)}>
      <BooksOnShelf />
    </HydrationBoundary>
  );
}

export default page;

Let’s walk through the code.

The page component is a server component that prefetches data from Supabase.

We start by creating a new instance of the QueryClientand Supabase. The QueryClient is used to interact with the cache and our Supabase client will be used to interact with our database. Note that we are importing our server-side version of our Supabase client.

We use prefetchInfiniteQuery to prefetch and cache the first set of paginated data from the database before rendering the page. Just like a regular query it accepts a few options:

  • queryKey: This uniquely identifies the query.
  • queryFn: This is the function that will be called to fetch the data. It passes the current page to getBooksOnShelf(), which we created earlier.
  • initialPageParam: We set the initial page to 0 to get the first batch of books.

Infinite Queries can be prefetched like regular queries. By default, only the first page of the query will be prefetched and will be stored under the given queryKey. If you want to prefetch multiple pages, you’ll need to use the pages option and provide a getNextPageParam function. You can see how to do this here.

Just prefetching the data isn’t enough. We need serialize it so it can be passed to the client.

  • HydrationBoundary: This wraps our client component and makes sure React Query can pick up the prefetched data on the client.
  • dehydrate(queryClient): This serializes the prefetched data and embeds it into the HTML that gets sent to the client..

So, we grab the data on the server, and HydrationBoundary delivers it to the client like magic. But what’s really going on behind the scenes? Let’s understand how exactly HydrationBoundary does its job.

Hydration in React Query

Hydration is the process of transferring data that was pre-fetched on the server directly to the client, so the client doesn't have to fetch it again. This is achieved by serializing the data into a "dehydrated" format with state={dehydrate(queryClient)}.

But what do we mean by dehydrated?

When you prefetch data, it is put into a "dehydrated" state. This freezes the data in a serializable format. The dehydrated data is then embedded in the HTML that gets sent to the client.

Once the client receives this HTML, React Query "rehydrates" the dehydrated data into its client-side cache. This means the client can use the data immediately without making another network request. This not only reduces loading times but also ensures that users see content as soon as the page loads.

Even though client-side fetching alone works, this approach is more efficient because it reduces:

  • Server Load: Data is fetched only once.
  • Network Load: Serialized data is embedded in the page, reducing the need for further requests.
  • User Friction: Eliminates loading states and speeds up content delivery.

Now that we’ve got that covered, let’s move on to fetching data client-side.

Infinite Scrolling and Fetching Data Client-Side

While server-side prefetching gives us a head start, we still need to implement client-side data fetching to handling new data requests as users interact with the page.

The BooksOnShelf component is designed to display a list of books with infinite scrolling functionality. It uses React Query’s useInfiniteQuery hook to fetch data incrementally as the user scrolls down the page.

"use client";

import InfiniteScroll from "@/src/components/InfiniteScroll";
import { getBooksOnShelf } from "@/src/utils/getBooksOnShelfForUser";
import { createClient } from "@/utils/supabase/client";
import { useInfiniteQuery } from "@tanstack/react-query";
import Image from "next/image";

function BooksOnShelf() {
  const supabase = createClient();

  const {
    isLoading,
    isError,
    error,
    data,
    isFetchingNextPage,
    hasNextPage,
    fetchNextPage,
  } = useInfiniteQuery({
    queryKey: ["books-on-shelf", 36],
    queryFn: ({ pageParam }) => getBooksOnShelf(supabase, pageParam),

    initialPageParam: 0,

    getNextPageParam: (lastPage, allPages) => {
      const nextPage: number | undefined = lastPage?.length
        ? allPages?.length
        : undefined;

      return nextPage;
    },
  });
  
  if (isLoading) {
    return (
      <p>Loading ... </p>
    );
  }

  if (isError) {
    return (
      <p className="wrapper">{"message" in error ? error.message : error}</p>
    );
  }

  if (!data || data.pages.length === 0) {
    return (
      <p>It looks like you haven&apos;t saved any books to this shelf yet.</p>
    );
  }

  const pages = data.pages.flat();

  const renderBooks = pages.map((book) => {
    return (
      <li key={book.id}>
        <Image
          src={`https://covers.openlibrary.org/b/id/${book.cover_id}-L.jpg`}
          alt={`Cover of ${book.book_id}`}
          width={300}
          height={350}
          className="bg-slate-200 object-cover rounded-xl  cover"
        />
      </li>
    );
  });

  return (
    <>
      <InfiniteScroll
        isLoadingIntial={isLoading}
        isLoadingMore={isFetchingNextPage}
        loadMore={() => hasNextPage && fetchNextPage()}
      >
        <ul className="wrapper snippets-wrapper">{renderBooks}</ul>
      </InfiniteScroll>
    </>
  );
}

export default BooksOnShelf;

Let’s break down how this component works:

The useInfiniteQuery hook manages the fetching of paginated data, allowing us to load new data in chunks as the user scrolls, instead of all at once. This hook should include the same options you passed to prefetchInfiniteQuery(), ensuring consistency between server-side prefetching and client-side fetching. Additionally, useInfiniteQuery requires the getNextPageParam option to handle loading subsequent pages, and can accept other query-related options as needed, like caching or error handling.

Note that we are importing our client-side version of our Supabase client.

Configuring Pagination with getNextPageParam

The getNextPageParam function determines the next page to fetch based on the current and previous pages.

It gets passed two arguments:

  1. lastPage: Data from the most recent query (last page fetched).
  2. allPages: Array of all previously fetched pages.

It returns the pageParam; the value used for the next query. If it returns undefined, it indicates there are no more pages to fetch, ending the infinite scroll.

How It Works

The lastPage contains the data returned from Supabase. In this scenario its an array of books.

lastPage: {
    id: number;
    book_id: string;
    cover_id: string | null;
}[]

If you’re using Typescript you can hover over last page to inspect the type definition and adjust your getNextPageParam logic as needed. Here’s how pagination works:

  • If lastPage contains items, there are more pages to load, so getNextPageParam returns the total number of pages already fetched (allPages.length).
  • If lastPage is empty, it returns undefined, stopping further fetches, signaling the end of pagination.

Working with the Data

The paginated data returned by useInfiniteQuery is stored in the data.pages array. Each "page" is a set of results from a query. Here's an example:


// what data looks like when pageParam is 1
Object { pages: (2) […], pageParams: (2) […] }

// what each property looks like
pageParams: Array [ 0, 1 ]
pages: Array [ (5) […], (5) […] ]

// what data.pages look like
0: Array(5) [ {…}, {…}, {…}, … ]
1: Array(5) [ {…}, {…}, {…}, … ]

After checking if the fetch was successful and data.pages does exist, we can use the .flat() method to merge all the pages into a single array of books. We can then map over this result and render each book. I’d typically account for a null cover_id, but let’s keep it simple for now.

Finally, the InfiniteScroll component wraps around the list of books. It monitors the user's scroll position and triggers the loadMore function when the user reaches the bottom, which calls fetchNextPage to load more books, if available.


And there you have it! You’ve got infinite scrolling working, data fetching under control, and loading spinners taking a back seat. Now your app’s performance is smooth, and your users are happy—mission accomplished! 🚀

Further Reading

Want to dig deeper into React Query's hydration? Check out this article for more on how it works and tips on server-side data fetching. It’s a quick read and super helpful!


Back to Blogs