Silvestriblog
Oct 112 min read

Next.js + TanStack Query + Supabase + Supabase Cache Helpers: a guide

A detailed tutorial on how to implement this stack in your application.

Introduction

I’ve thought long and hard whether I really wanted to write this article, as there is already a very good guide about this written by Thor Schaeff, plus a great Youtube video by Dev Ed, for which I am very grateful. But since it took me an extremely long time to really figure out how this entire stack works, I thought I might expand on it a little and explain it in my own way. I hope this is useful to you, the reader.

First of all, the ingredients of the mix...

  • We are going to use Next.js App Router configuration.
  • Supabase will provide us with our PostgreSQL database.
  • TanStack Query (previously React Query) is our state manager, which we will use to cache data coming from our Supabase queries.
  • Supabase Cache Helpers is a library built on top of TanStack Query that just makes it easier to provide queries with the necessary QueryKeys. We’ll get into it soon.

To get started, you’ll need at least a decent knowledge of Next.js and Supabase.

Step 1: Install them all

Run the following command:

npm install @supabase/supabase-js @tanstack/react-query @supabase/ssr @supabase-cache-helpers/postgrest-react-query`

This will install all the aforementioned libraries.

Clarification: @supabase/ssr is the evolution of the previously named Supabase Auth Helpers. It is a package designed for frameworks like Next.js employing server-side rendering, and we’ll need it to declare our getSupabaseBrowserClient and useSupabaseServer later on. These two clients are necessary to use TanStack Query and Supabase, as we will see in the following steps.

Also, do not confuse @supabase/ssr with @supabase/supabase-js. The latter is part of the first package, and in this implementation, we will need it to infer its types and make the whole project Typescript-safe.

I know it’s confusionary, and Supabase’s documentation doesn’t help much in this regard, but it’s the easiest way I can explain it.

Step 2: Create the providers.tsx

In your app directory, create a providers.tsx file with the following content (or edit yours, if you already have one). This will set up the default TanStack Query options by providing it a staleTime value equal to 60 seconds (more on this after the code):

"use client"; import { isServer, QueryClient, QueryClientProvider } from "@tanstack/react-query"; // TanStack Query function makeQueryClient() { return new QueryClient({ defaultOptions: { queries: { // With SSR, we usually want to set some default staleTime // above 0 to avoid refetching immediately on the client staleTime: 60 * 1000, }, }, }); } let browserQueryClient: QueryClient | undefined = undefined; function getQueryClient() { if (isServer) { // Server: always make a new query client return makeQueryClient(); } else { // Browser: make a new query client if we don't already have one // This is very important, so we don't re-make a new client if React // suspends during the initial render. This may not be needed if we // have a suspense boundary BELOW the creation of the query client if (!browserQueryClient) browserQueryClient = makeQueryClient(); return browserQueryClient; } } export function ReactQueryClientProvider({ children }: { children: React.ReactNode }) { // NOTE: Avoid useState when initializing the query client if you don't // have a suspense boundary between this and the code that may // suspend because React will throw away the client on the initial // render if it suspends and there is no boundary const queryClient = getQueryClient(); return ( <QueryClientProvider client={queryClient}> {children} </QueryClientProvider> ); }

If you are not familiar with it, staleTime is set to 0 by default. By setting it to 60 seconds, we are basically considering our queries “fresh” for that amount of time, by grabbing the results directly from the cache instead of fetching from our database again. This leads to faster loading times when the same query is accessed multiple times. You can modify this value based on your needs.

What else are we doing in this file? We are essentially creating a provider, which we will use to wrap our app through Next.js’ layout.tsx file.

Step 3: Pass ReactQueryClientProvider via layout.tsx

Open layout.tsx in your app directory and wrap the {children} with the <ReactQueryClientProvider> we just created.

Here’s a practical example of my own layout file, in which I also have a <Header /> and <Footer /> components, though I only need TanStack Query in my main content:

import { ReactQueryClientProvider } from "./providers"; import Header from "@/components/global/header"; import Footer from "@/components/global/footer"; // Other imports here export default function RootLayout({ children, }: Readonly<{ children: React.ReactNode; }>) { return ( <html lang="en"> <body> <Header /> <main> <ReactQueryClientProvider>{children}</ReactQueryClientProvider> </main> <Footer /> </body> </html> ); }

Step 4: Create your TypedSupabaseClient

The TypedSupabaseClient is simply a Supabase Client with typing for Typescript. You can create a file called types.ts in your /utils folder with the following content:

import { SupabaseClient } from "@supabase/supabase-js"; import type { Database } from "@/utils/database.types"; export type TypedSupabaseClient = SupabaseClient<Database>;

Note that here we are also importing { Database } from the database.types, and then we are passing it to our SupabaseClient when creating its typed version.

This specific file can be generated via the Supabase CLI, and it’s going to provide you automatically with all the types for your database, based on the tables and fields you create.

In case you need it, here is the guide to generate the Typescript Types from Supabase.

Step 5: Create the Supabase Server and Browser Clients

Let’s finally export the useSupabaseServer and getSupabaseBrowserClient functions we mentioned in Step 1.

Under your utils folder, create a file called supabase-browser.ts:

import { createBrowserClient } from "@supabase/ssr"; import type { Database } from "@/utils/database.types"; import type { TypedSupabaseClient } from "@/utils/types"; import { useMemo } from "react"; let client: TypedSupabaseClient | undefined; function getSupabaseBrowserClient() { if (client) { return client; } client = createBrowserClient<Database>(process.env.NEXT_PUBLIC_SUPABASE_URL!, process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!); return client; } function useSupabaseBrowser() { return useMemo(getSupabaseBrowserClient, []); } export default useSupabaseBrowser;

Three things to note here.

  • First, this is where we are declaring our client as a TypedSupabaseClient.
  • Second, as you can see, we are wrapping the Supabase Browser Client in a useMemo() function - this is important so it doesn’t get recreated on every single render.
  • Third, we are assuming you already have Supabase’s environment variables in your .env file. If you don’t, you’ll need to create them.

Now, let’s do the same for the Server Client.

Under your utils folder, create a file called supabase-client.ts:

import { createServerClient } from "@supabase/ssr"; import { cookies } from "next/headers"; import { Database } from "./database.types"; export default function useSupabaseServer(cookieStore: ReturnType<typeof cookies>) { return createServerClient<Database>(process.env.NEXT_PUBLIC_SUPABASE_URL!, process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!, { cookies: { getAll() { return cookieStore.getAll(); }, setAll(cookiesToSet) { try { cookiesToSet.forEach(({ name, value, options }) => cookieStore.set(name, value, options)); } catch { // The `setAll` method was called from a Server Component. // This can be ignored if you have middleware refreshing // user sessions. } }, }, }); }

What’s peculiar about this code is that, in order to create our Supabase Server Client, we are passing functions to set and get cookies to it (as well as our environment variables).

This is because by default the server doesn’t have access to local storage, so tokens need to be stored in cookies. This is especially important if you need to implement Supabase Auth, which is Supabase’s way to authenticate and authorise users, and will also require you to implement a dedicated middleware, though it’s not the focus of this guide.

Step 6: Create a Supabase query to fetch data

This is where the fun part begins. We now need to create our Supabase query to fetch data from one of our database tables.

Let’s say we want to print the articles of our blog from a posts table. Here’s how we’d normally do it with Supabase alone:

let { data: posts, error } = await supabase.from("posts").select(*);

After having declared supabase, of course.

However, since we need to make this work with TanStack Query and Supabase Cache Helpers, and we’ll need to call the same query twice (one for prefetching on the server side, and another for fetching on the client side), here’s what our friend Thor recommends: create a query/get-posts.ts file under your app directory, then write a reusable query with our trusted TypedSupabaseClient, like so:

import { TypedSupabaseClient } from "@/utils/types"; export function getPost(client: TypedSupabaseClient) { return client.from("posts").select("*"); }

This is the function we’ll pass to TanStack Query.

Step 7: Prefetch the query on a Server Component

Let’s now get to our Server Component, for example a page.tsx file. I’ll give you the code first, then explain:

import PostsClient from "./page.client"; import { dehydrate, HydrationBoundary, QueryClient } from "@tanstack/react-query"; import { prefetchQuery } from "@supabase-cache-helpers/postgrest-react-query"; import useSupabaseServer from "@/utils/supabase-server"; import { cookies } from "next/headers"; import { getPosts } from "@/app/queries/get-posts"; export default async function Posts() { const queryClient = new QueryClient(); const cookieStore = cookies(); const supabase = useSupabaseServer(cookieStore); await prefetchQuery(queryClient, getPosts(supabase)); return ( <HydrationBoundary state={dehydrate(queryClient)}> <ToolsClient /> </HydrationBoundary> ); }

Here’s what we are doing.

First, we import a PostsClient (we’ll create it in the next step, so bear with me), which will display the posts on the client side. We also import a bunch of other stuff, including the useSupabaseServer function we created before, cookie from next/headers (remember we said they were important?), our getPosts query, and, most importantly, prefetchQuery from our Supabase Cache Helper library.

Such prefetchQuery is nothing more than a wrapper function to TanStack Query’s original queryClient.prefetchQuery, with one big difference: it’s going to generate the queryKey for us, automatically, based on the Supabase query function we pass it (getPosts in our case).

Here’s an example of a queryKey generated by Supabase Cache Helpers:

`["postgrest","null","public","posts","select=*","null","count=null","head=false",""]`

So, to make sure we got it right. We are doing:

await prefetchQuery(queryClient, getPosts(supabase));

Which corresponds exactly to:

const prefetchPosts = async () => { await queryClient.prefetchQuery({ queryKey: ["postgrest","null","public","posts","select=*","null","count=null","head=false",""], queryFn: getPosts, }) }

Why is this important, and why don’t we just do queryKey: [‘posts’]?

The answer comes from the Supabase Cache Helper documentation itself. This library helps us with not having to declare a dedicated queryKey every single time our query changes.

To explain it in other terms, imagine if you pulled up all posts first, then you only filtered from a particular author, then by date, then by last modified, and on and on and on… if you wanted to take advantage of TanStack Query’s cache effectively, you should assign all combinations their own queryKey. Pretty time consuming! But Supabase Cache Helpers does it all for us.

Lastly, as you can see we are wrapping our <ToolsClient /> client component with a <HydrationBoundary>. This is required by TanStack Query when using its prefetching function, and to sync the data between server and client. If we didn’t pass the dehydrated state to the Client Component to then hydrate, this would result from a refetch on the client side, thus defeating the purpose of doing a prefetch in the first place.

Step 8: Display data on the Client Component

Now that our data was already prefetched server side, we can now display it directly without the need for any loading state. We will still use isPending and isError as a fallback in case for any reason the data has not been prefetched on the server side, but we wouldn’t normally need it.

Let’s create a page.client.tsx file in the same folder:

"use client" import useSupabaseBrowser from "@/utils/supabase-browser"; import { getPosts } from "@/app/queries/get-posts"; import { useQuery } from "@supabase-cache-helpers/postgrest-react-query"; export default function PostsClient() { const supabase = useSupabaseBrowser(); const { data: posts, isPending, isError, error } = useQuery(getPosts(supabase)); if (isPending) { return <div>Loading...</div> } if (isError || !posts) { return <div>Error: {error?.message || 'Unknown error'}</div> } return ( <div> {posts?.map((post) => ( <div key={post.id}>{post.title}</div> ))} </div> ) }

This is pretty straightforward - the useQuery function we are calling comes from Supabase Cache Helpers, and just as per prefetchQuery, it is a wrapper of the regular TanStack Query function.

What this code is doing is checking if we had something in the cache on the server side. If yes, we’ll use that. If not, we’ll perform a client-side fetching. We then proceed to print our list of posts.

That’s a wrap!

I’d like to thank the original authors of the Supabase guide once again, which itself was inspired by an article on Remix + Supabase + ReactQuery.

If you think this article was useful, I’d appreciate a shout on X/Twitter. I’m trying to build a little audience, and a repost would go a long way.

Good luck!