# 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.

*Published: 2022-12-22*
*Canonical: https://makerkit.dev/blog/saas/supabase-react-query*

---

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

```bash
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:

```tsx {% title="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:

```tsx {% title="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:

```tsx {% title="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:

```tsx {% title="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

```tsx {% title="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

```tsx {% title="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

```tsx {% title="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

```tsx {% title="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

```tsx {% title="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:

```tsx {% title="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:

```tsx
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](/blog/tutorials/pagination-supabase-react), include the page in the query key:

```tsx
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:

```tsx
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:

```tsx
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](/blog/tutorials/real-time-notifications-supabase-nextjs) for more on this pattern.

{% faq
   title="Frequently Asked Questions"
   items=[
     {"question": "Do I need React Query if I'm using Supabase?", "answer": "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."},
     {"question": "Should I use Supabase Realtime or TanStack Query for live data?", "answer": "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."},
     {"question": "How do I handle Supabase errors with TanStack Query?", "answer": "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."},
     {"question": "What staleTime should I use?", "answer": "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

- Learn about [paginating Supabase data with React](/blog/tutorials/pagination-supabase-react) for large datasets
- Explore [clean React patterns](/blog/tutorials/clean-react) for structuring your hooks and components
- Check the [TanStack Query docs](https://tanstack.com/query/latest) for advanced patterns like infinite queries and suspense
