How to Use Supabase with TanStack Query (React Query v5)

Learn how to use Supabase with TanStack Query v5 for caching, optimistic updates, and automatic re-fetching. Includes TypeScript examples and common patterns.

TanStack Query is a data-fetching and state management library for React that handles caching, background synchronization, and server state.

When paired with Supabase, it manages client-side state while the Supabase SDK handles database connections. The result: automatic caching, background re-fetching, and optimistic updates without writing manual state management code.

Updated January 2026 for TanStack Query 5.x and @supabase/ssr 0.5.x.

Why Pair Supabase with TanStack Query?

The Supabase JavaScript SDK fetches data, but it doesn't manage client-side state. Every component that needs data makes a new request. TanStack Query solves this:

  1. Caching: Fetch once, read from cache everywhere. Configurable stale times let you control when data refreshes.
  2. Automatic re-fetching: Data refreshes on window focus, network reconnect, or after mutations.
  3. Optimistic updates: Update the UI before the server responds, roll back on error.

Since Supabase's realtime feature doesn't stream full snapshots (you get change events, not the new state), TanStack Query is the cleanest way to keep your UI in sync without writing manual state management.

Setup

Install Dependencies

npm install @supabase/supabase-js @supabase/ssr @tanstack/react-query

Create a Supabase Browser Client

The @supabase/ssr package replaced the older auth-helpers packages. Use createBrowserClient for client components:

lib/supabase/browser-client.ts

import { createBrowserClient } from '@supabase/ssr';
import type { Database } from '@/database.types';
let client: ReturnType<typeof createBrowserClient<Database>> | undefined;
export function getSupabaseBrowserClient() {
if (client) {
return client;
}
client = createBrowserClient<Database>(
process.env.NEXT_PUBLIC_SUPABASE_URL!,
process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!,
);
return client;
}

The singleton pattern prevents creating multiple Supabase clients during React's render cycles, which can cause authentication state issues.

If you're using Makerkit, you can import useSupabase directly from @kit/supabase/hooks/use-supabase instead of creating your own.

Create a Hook for the Client

Wrap the browser client in a hook for consistent access:

hooks/use-supabase.ts

import { useMemo } from 'react';
import { getSupabaseBrowserClient } from '@/lib/supabase/browser-client';
export function useSupabase() {
return useMemo(getSupabaseBrowserClient, []);
}

Configure the QueryClient

Add the QueryClientProvider at the root of your client component tree:

app/providers.tsx

'use client';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { useState } from 'react';
export function Providers({ children }: { children: React.ReactNode }) {
const [queryClient] = useState(
() =>
new QueryClient({
defaultOptions: {
queries: {
staleTime: 60 * 1000, // 1 minute
},
},
}),
);
return (
<QueryClientProvider client={queryClient}>
{children}
</QueryClientProvider>
);
}

Creating the QueryClient inside useState ensures each request gets its own instance during SSR while reusing the same instance on the client.

Fetching Data with useQuery

The Query Function Pattern

Separate your Supabase queries from your hooks. This lets you reuse the same query on both client and server:

queries/get-organization.ts

import type { SupabaseClient } from '@supabase/supabase-js';
import type { Database } from '@/database.types';
type Client = SupabaseClient<Database>;
export function getOrganizationById(client: Client, organizationId: number) {
return client
.from('organizations')
.select('id, name, created_at')
.eq('id', organizationId)
.throwOnError()
.single();
}

Using throwOnError() means the query rejects on Supabase errors, which TanStack Query catches and exposes via error.

The Query Hook

hooks/use-organization-query.ts

import { useQuery } from '@tanstack/react-query';
import { useSupabase } from '@/hooks/use-supabase';
import { getOrganizationById } from '@/queries/get-organization';
export function useOrganizationQuery(organizationId: number) {
const client = useSupabase();
return useQuery({
queryKey: ['organization', organizationId],
queryFn: async () => {
const { data } = await getOrganizationById(client, organizationId);
return data;
},
});
}

The queryKey uniquely identifies this data in the cache. Include all variables that affect the result.

Using the Hook in a Component

components/organization-header.tsx

'use client';
import { useOrganizationQuery } from '@/hooks/use-organization-query';
export function OrganizationHeader({ organizationId }: { organizationId: number }) {
const { data: organization, isPending, error } = useOrganizationQuery(organizationId);
if (isPending) {
return <div>Loading...</div>;
}
if (error) {
return <div>Failed to load organization</div>;
}
return <h1>{organization.name}</h1>;
}

Verification: Load the page and check the Network tab for a single Supabase request. Navigate away and back: no new request should fire because data is served from cache.

Note: TanStack Query v5 renamed isLoading to isPending. The old isLoading now means isPending && isFetching (useful for distinguishing initial load from background refresh).

Mutations with useMutation

The Mutation Function

mutations/update-organization.ts

import type { SupabaseClient } from '@supabase/supabase-js';
import type { Database } from '@/database.types';
type Client = SupabaseClient<Database>;
export function updateOrganization(
client: Client,
params: { id: number; name: string },
) {
return client
.from('organizations')
.update({ name: params.name })
.eq('id', params.id)
.throwOnError()
.select('id, name, created_at')
.single();
}

The Mutation Hook with Cache Invalidation

hooks/use-update-organization.ts

import { useMutation, useQueryClient } from '@tanstack/react-query';
import { useSupabase } from '@/hooks/use-supabase';
import { updateOrganization } from '@/mutations/update-organization';
export function useUpdateOrganization() {
const client = useSupabase();
const queryClient = useQueryClient();
return useMutation({
mutationFn: (params: { id: number; name: string }) => {
return updateOrganization(client, params).then(({ data }) => data);
},
onSuccess: (data, variables) => {
// Invalidate and refetch
queryClient.invalidateQueries({
queryKey: ['organization', variables.id],
});
},
});
}

Unlike useQuery, the useMutation hook in v5 still supports onSuccess, onError, and onSettled callbacks.

Using the Mutation in a Form

components/organization-form.tsx

'use client';
import { useUpdateOrganization } from '@/hooks/use-update-organization';
export function OrganizationForm({ organizationId }: { organizationId: number }) {
const updateOrganization = useUpdateOrganization();
const handleSubmit = (event: React.FormEvent<HTMLFormElement>) => {
event.preventDefault();
const formData = new FormData(event.currentTarget);
const name = formData.get('name') as string;
updateOrganization.mutate(
{ id: organizationId, name },
{
onSuccess: () => {
// Handle success (toast, redirect, etc.)
},
onError: (error) => {
// Handle error
console.error('Update failed:', error.message);
},
},
);
};
return (
<form onSubmit={handleSubmit}>
<input name="name" type="text" required />
<button type="submit" disabled={updateOrganization.isPending}>
{updateOrganization.isPending ? 'Saving...' : 'Save'}
</button>
</form>
);
}

Optimistic Updates

Update the UI immediately, roll back if the mutation fails:

hooks/use-update-organization-optimistic.ts

import { useMutation, useQueryClient } from '@tanstack/react-query';
import { useSupabase } from '@/hooks/use-supabase';
import { updateOrganization } from '@/mutations/update-organization';
interface Organization {
id: number;
name: string;
created_at: string;
}
export function useUpdateOrganizationOptimistic() {
const client = useSupabase();
const queryClient = useQueryClient();
return useMutation({
mutationFn: (params: { id: number; name: string }) => {
return updateOrganization(client, params).then(({ data }) => data);
},
onMutate: async (variables) => {
// Cancel outgoing refetches
await queryClient.cancelQueries({
queryKey: ['organization', variables.id],
});
// Snapshot current value
const previous = queryClient.getQueryData<Organization>([
'organization',
variables.id,
]);
// Optimistically update
if (previous) {
queryClient.setQueryData(['organization', variables.id], {
...previous,
name: variables.name,
});
}
return { previous };
},
onError: (error, variables, context) => {
// Roll back on error
if (context?.previous) {
queryClient.setQueryData(
['organization', variables.id],
context.previous,
);
}
},
onSettled: (data, error, variables) => {
// Refetch to ensure server state
queryClient.invalidateQueries({
queryKey: ['organization', variables.id],
});
},
});
}

Use optimistic updates for actions where the success rate is high and immediate feedback matters (renaming, toggling, reordering). For destructive actions like deletes, consider showing a pending state instead.

Common Pitfalls

Avoid these mistakes when using TanStack Query with Supabase:

  1. Forgetting .throwOnError(): Without it, Supabase returns errors in the response object instead of throwing. TanStack Query won't catch them, and your error state stays empty while data is undefined.

  2. Missing variables in queryKey: If your query depends on userId and page, include both: ['items', userId, page]. Otherwise, changing one won't trigger a refetch.

  3. Creating multiple Supabase clients: Always use a singleton pattern or useMemo. Multiple clients cause auth state synchronization issues.

  4. Using staleTime: 0 everywhere: The default causes refetches on every component mount. Set a reasonable staleTime (60 seconds is a good start) for data that doesn't change constantly.

  5. Callbacks in useQuery: v5 removed onSuccess/onError from queries (they still work in useMutation). Use useEffect to react to query state changes instead.

  6. Bypassing RLS with wrong client: The browser client uses the anon key and respects Row Level Security. If queries return empty when they shouldn't, check your RLS policies.

Common Patterns

Dependent Queries

Fetch data that depends on another query:

const { data: user } = useQuery({
queryKey: ['user'],
queryFn: fetchUser,
});
const { data: projects } = useQuery({
queryKey: ['projects', user?.id],
queryFn: () => fetchProjectsByUserId(client, user!.id),
enabled: !!user?.id, // Only runs when user.id exists
});

Paginated Queries

For paginating Supabase data, include the page in the query key:

export function useOrganizationsQuery(page: number, pageSize = 10) {
const client = useSupabase();
return useQuery({
queryKey: ['organizations', 'list', { page, pageSize }],
queryFn: async () => {
const from = page * pageSize;
const to = from + pageSize - 1;
const { data, count } = await client
.from('organizations')
.select('*', { count: 'exact' })
.range(from, to)
.throwOnError();
return { data, count, page, pageSize };
},
});
}

Prefetching

Prefetch data before the user navigates:

const queryClient = useQueryClient();
const client = useSupabase();
const handleMouseEnter = (organizationId: number) => {
queryClient.prefetchQuery({
queryKey: ['organization', organizationId],
queryFn: () =>
getOrganizationById(client, organizationId).then(({ data }) => data),
});
};

What Changed in TanStack Query v5

If you're upgrading from v4:

v4v5
isLoading (initial load)isPending
isLoading (any loading)isPending && isFetching
onSuccess/onError in useQueryRemoved (use useEffect or global handlers)
onSuccess/onError in useMutationStill supported
keepPreviousData: trueplaceholderData: keepPreviousData
Multiple function overloadsSingle object parameter

The keepPreviousData change is a common migration gotcha. In v5, import the utility function:

import { keepPreviousData } from '@tanstack/react-query';
useQuery({
queryKey: ['items', page],
queryFn: fetchItems,
placeholderData: keepPreviousData,
});

The removal of callbacks from useQuery was intentional. They fired on every render that resolved the query, not just on fresh fetches, leading to bugs. Handle side effects in useEffect or at the QueryCache level instead.

When to Use Supabase Realtime Instead

Use TanStack Query when:

  • You control when data refreshes
  • You want caching and optimistic updates
  • Data changes less than once per second

Use Supabase Realtime when:

  • Multiple users edit the same data simultaneously
  • You need sub-second updates (chat, collaborative editing)
  • You're fine managing state manually

If unsure: Start with TanStack Query. Add a Realtime subscription later to trigger invalidateQueries when relevant changes occur. This gives you the best of both worlds.

You can combine both: use TanStack Query as your primary state layer, and subscribe to Supabase Realtime to trigger invalidateQueries when relevant changes occur. Check out real-time notifications with Supabase and Next.js for more on this pattern.

Frequently Asked Questions

Do I need React Query if I'm using Supabase?
You don't need it, but it solves client-side caching, background re-fetching, and optimistic updates that Supabase's SDK doesn't handle. If you're building anything beyond a simple CRUD app, TanStack Query significantly reduces the state management code you write.
Should I use Supabase Realtime or TanStack Query for live data?
Use Realtime for truly collaborative features where multiple users edit simultaneously. Use TanStack Query for most other cases, where you want caching, optimistic updates, and controlled re-fetching. You can combine both by using Realtime events to trigger query invalidation.
How do I handle Supabase errors with TanStack Query?
Call .throwOnError() on your Supabase queries. This converts Supabase errors into thrown exceptions that TanStack Query catches and exposes via the error property. Without throwOnError(), Supabase returns errors in the response object instead of throwing.
What staleTime should I use?
It depends on how fresh your data needs to be. 0 (default) means data is immediately stale and refetches on every mount. 60000 (1 minute) works well for data that doesn't change frequently. Infinity means data never automatically refetches. Start with 60 seconds and adjust based on your use case.

Next Steps