Implementing Infinite Scroll with React Query & Supabase Pagination in Next.js
#infinite-scroll, #react-query, #supabase, #nextjs, #pagination
Sep 18, 202416 min read
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:
- For Simple Applications: Follow the steps in this article, particularly this section. This method is straightforward and doesn’t require middleware.
- 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.
-
Log in to Supabase and initialize:
npx supabase login npx supabase init
-
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. -
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:
-
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.
-
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 rootlayout.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 thelayout.tsx
(orrootlayout
file) to wrap everything, including thechildren
.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;
}
}
- 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. - 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.
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 QueryClient
and 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 togetBooksOnShelf()
, which we created earlier.initialPageParam
: We set the initial page to0
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'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:
lastPage
: Data from the most recent query (last page fetched).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, sogetNextPageParam
returns the total number of pages already fetched (allPages.length
). - If
lastPage
is empty, it returnsundefined
, 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