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:
- Caching: Fetch once, read from cache everywhere. Configurable stale times let you control when data refreshes.
- Automatic re-fetching: Data refreshes on window focus, network reconnect, or after mutations.
- 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-queryCreate 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:
Forgetting
.throwOnError(): Without it, Supabase returns errors in the response object instead of throwing. TanStack Query won't catch them, and yourerrorstate stays empty whiledatais undefined.Missing variables in
queryKey: If your query depends onuserIdandpage, include both:['items', userId, page]. Otherwise, changing one won't trigger a refetch.Creating multiple Supabase clients: Always use a singleton pattern or
useMemo. Multiple clients cause auth state synchronization issues.Using
staleTime: 0everywhere: 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.Callbacks in
useQuery: v5 removedonSuccess/onErrorfrom queries (they still work inuseMutation). UseuseEffectto react to query state changes instead.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:
| v4 | v5 |
|---|---|
isLoading (initial load) | isPending |
isLoading (any loading) | isPending && isFetching |
onSuccess/onError in useQuery | Removed (use useEffect or global handlers) |
onSuccess/onError in useMutation | Still supported |
keepPreviousData: true | placeholderData: keepPreviousData |
| Multiple function overloads | Single 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?
Should I use Supabase Realtime or TanStack Query for live data?
How do I handle Supabase errors with TanStack Query?
What staleTime should I use?
Next Steps
- Learn about paginating Supabase data with React for large datasets
- Explore clean React patterns for structuring your hooks and components
- Check the TanStack Query docs for advanced patterns like infinite queries and suspense