Guides
Build guide

Stale data on screen after a workspace switch

Stale data after a workspace switch is a cache that never learned the workspace changed. One switch with the Network tab open tells you which cache. The fix is a key, not a purge.

Dharmendra Jagodana11 min read

In short

Stale data after a workspace switch is your cache, not the SDK. switchToWorkspace sets currentWorkspace, saves the id to localStorage and fires workspace:changed. It never touches React Query, SWR or the Next.js Router Cache. Key every query by workspace id, put the workspace in the URL, and nothing needs clearing.

The switcher says the new workspace. The table under it still lists the old workspace's rows. Click over to another browser tab and back, and the rows fix themselves.

That last detail is most of the diagnosis. Nothing on the BuildBase side is stale: currentWorkspace changed the moment the switch finished. What is stale is a cache of yours that never found out, because the one thing it watches is its key, and its key did not move.

The usual forum answer is to clear everything on switch. It works, in the sense that rebooting works. Done in the obvious place, it also races the switch itself. And it leaves the actual defect, a key that does not name the workspace, exactly where it was.

Before you start

An app on the React SDK whose organization is in Platform mode. New organizations start in Personal mode (DEFAULT_WORKSPACE_MODE), one workspace per user and no switcher, so there is nothing to switch and nothing to go stale. Browser devtools open on the Network tab. If the workspace is missing from the switcher altogether, that is membership, not caching: see an invited member who cannot see the workspace.

What a switch actually changes

Four things, in this order. I read them out of the SDK's WorkspaceProvider rather than the docs, because the order is what matters later:

  1. It marks a switch in progress: switching reads true.
  2. It awaits your onWorkspaceChange({ workspace, user, role }) callback, if you passed one, and if a newer switch started while it was waiting, this one gives up there and goes no further, which is how a fast double-click ends on the second workspace rather than the first.
  3. It writes the workspace's _id to localStorage under saas-workspace-current, and sets currentWorkspace.
  4. Then workspace:changed, carrying { workspace, previousWorkspace }, goes out through your handleEvent callback.

That is the whole list. No page reload, no router.refresh(), and no idea your QueryClient exists. Nor should it. But it means everything your app fetched for the old workspace is still where you put it, under whatever name you gave it.

Key takeaway

A switch changes one value your code can see: currentWorkspace. A cache whose key does not include currentWorkspace._id has no reason to fetch anything.

The SDK's own subscription and credit providers follow the switch, because they read currentWorkspace._id and refetch when it changes. One caveat before you go hunting in your own code: if the stale thing is the SDK's own subscription, quota, credit or invoice data, check your SDK version first. That was our bug, and the version that fixed it is in the section on late responses below.

Switch once with the Network tab open

Filter the Network tab to your own API, clear it, and switch workspace once. What happens after the click puts you in one of four places:

After the switchWhat is holding the old data
No request for that screen's dataa key without the workspace id, or the Next.js Router Cache
A request with the new id, old rows on screenplaceholder data from the previous key
A request with the new id, then old rows returna late response from the workspace you left
A request with the old idsomething that ran before the switch finished

Read the id in the request path or body, not the response. The response belongs to whoever asked.

No request at all: the key never named the workspace

This is the common one. A query keyed ['projects'] is the same query in every workspace, so when currentWorkspace changes, TanStack Query sees nothing new to fetch and keeps serving what it has.

// Before: one cache entry shared by every workspace
const { currentWorkspace } = useSaaSWorkspaces();

const projects = useQuery({
  queryKey: ['projects'],
  queryFn: () => getProjects(currentWorkspace!._id),
});

It passes review because the queryFn does close over the new id after the re-render. That does not matter. The library decides whether to fetch from the key, and the key is identical before and after.

So why does clicking away and back fix it? refetchOnWindowFocus is on by default and refetches stale data on focus, and with the default staleTime of zero, everything is stale. By then the queryFn holds the new workspace id, so the refetch gets it right. SWR does the same through revalidateOnFocus, also on by default. That is why the bug reads as intermittent: anyone who switches and then tabs away never sees it.

Put the workspace in the key:

import { useSaaSWorkspaces } from '@buildbase/sdk/react';
import { useQuery } from '@tanstack/react-query';

import { getProjects } from '@/lib/api'; // your own fetcher

export const keys = {
  workspace: (workspaceId: string) => ['workspace', workspaceId] as const,
  projects: (workspaceId: string) =>
    ['workspace', workspaceId, 'projects'] as const,
};

export function useProjects() {
  const { currentWorkspace } = useSaaSWorkspaces();
  const workspaceId = currentWorkspace?._id ?? '';

  return useQuery({
    queryKey: keys.projects(workspaceId),
    queryFn: () => getProjects(workspaceId),
    enabled: workspaceId !== '',
  });
}

Now each workspace has its own entry. Switching back to one you just left shows its cached rows at once, which are its rows, and refetches them if they are stale. Nothing has to be cleared, because nothing is wrong.

In SWR the key is the first argument, and the same fix looks like this, with the null waiting until there is a workspace:

useSWR(workspaceId ? `/api/workspaces/${workspaceId}/projects` : null, fetcher);

A store you fill by hand is no different. A Zustand slice called projects is a cache key with no workspace in it.

No request, and the page is server-rendered

The current workspace lives in the browser, and the server cannot see it.

The React SDK keeps the id in localStorage. BuildBase's API has no "current workspace" for a session at all: every workspace route takes the id in its path (/api/v1/public/workspaces/:workspaceId/...), and the server SDK takes workspaceId as the first argument of every call scoped to one workspace. The session belongs to the user. So a server component that renders workspace data got its workspace id from somewhere you chose: the URL, or a cookie you set.

If it was a cookie, the Next.js Router Cache is the next suspect. It keeps the rendered server component payload for routes you already visited, in the browser. On Next.js 14, a dynamically rendered segment is reused for 30 seconds and a static one for 5 minutes. Next.js 15 changed the dynamic default to zero, but shared layouts are still not refetched on navigation, and back and forward still come from the cache. A sidebar rendered in a layout is exactly the part that stays behind. It is a cousin of the cached profile page in BuildBase on Next.js: a render that depended on a cookie, served from a cache that did not know it.

Next.js lists two ways to clear it: cookies.set or a revalidate inside a Server Action, and router.refresh(). A cookie written with document.cookie is on neither list. So if your server reads a workspace cookie, write it in onWorkspaceChange, which is what a before-hook is for, and refresh after the switch lands:

// app/providers.tsx
'use client';

import { useMemo } from 'react';
import { useRouter } from 'next/navigation';
import {
  ApiVersion,
  SaaSOSProvider,
  SDKEvent,
  type EventData,
  type EventType,
  type WorkspaceChangedEventData,
} from '@buildbase/sdk/react';

// The three callbacks from the Next.js setup guide, moved to module level.
import { getSession, handleAuthentication, onSignOut } from './auth-callbacks';

export function Providers({ children }: { children: React.ReactNode }) {
  const router = useRouter();

  const auth = useMemo(
    () => ({
      clientId: process.env.NEXT_PUBLIC_BUILDBASE_CLIENT_ID!,
      redirectUrl: process.env.NEXT_PUBLIC_BUILDBASE_REDIRECT_URL!,
      callbacks: {
        getSession,
        handleAuthentication,
        onSignOut,
        // onWorkspaceChange: write your workspace cookie here, before the switch.
        handleEvent: (eventType: EventType, data: EventData) => {
          if (eventType !== SDKEvent.WorkspaceChanged) return;
          const { workspace, previousWorkspace } =
            data as WorkspaceChangedEventData;
          // Page load restores the saved workspace with no previous one.
          if (!previousWorkspace || previousWorkspace._id === workspace._id) {
            return;
          }
          router.refresh();
        },
      },
    }),
    [router]
  );

  return (
    <SaaSOSProvider
      serverUrl={
        process.env.NEXT_PUBLIC_BUILDBASE_SERVER_URL ||
        'https://api.console.buildbase.app'
      }
      version={ApiVersion.V1}
      orgId={process.env.NEXT_PUBLIC_BUILDBASE_ORG_ID!}
      auth={auth}
    >
      {children}
    </SaaSOSProvider>
  );
}

Keep auth memoized. The provider rebuilds its callback config whenever one of those functions changes identity, and an inline object hands it new ones on every render.

I wrote this against the SDK's type definitions and its provider source, not in a running app, so treat it as the shape of the fix rather than a tested file.

The better fix is not to need it. Put the workspace in the URL, as /w/[workspaceId]/projects, and each workspace is a different route: the Router Cache cannot serve one workspace's payload for another, server components read the id from params, and a pasted link opens the right workspace. The URL becomes the source of truth and the SDK follows it, through one small component that calls switchToWorkspace when the workspace in the URL is not currentWorkspace. Our own console runs the same pattern keyed on the org in its session rather than a URL: a WorkspaceAutoSelector that switches the SDK to the workspace that org needs. The full argument, membership check included, is in multi-tenant Remix, and only the routing differs in Next.js.

Two tabs, one remembered workspace

saas-workspace-current is one localStorage key per origin. Every tab shares it, and the SDK writes it without watching it. Switch in tab B, and tab A's switcher goes on showing A, correctly, until tab A reloads and restores B.

A workspace cookie makes it arrive sooner. Tab A's next server render carries B's cookie while its switcher still says A. When the switcher and the page under it disagree about which workspace you are in, look here first. A URL holds one workspace per tab, which is the other reason it wins.

A request for the new workspace, but the old rows stay

The key changed, the request went out, and the old workspace's rows are still on screen until it lands. That is placeholder data doing its job on the wrong key.

placeholderData: keepPreviousData in TanStack Query v5, or keepPreviousData: true in v4, keeps the data from the last successful fetch on screen while a query with a new key loads. On a page number that is the feature. On a workspace id it renders one workspace's rows under another workspace's name. SWR's keepPreviousData: true does the same: it returns the previous key's data until the new data has loaded.

Keep the smoothing inside a workspace and drop it across one:

useQuery({
  queryKey: keys.projects(workspaceId),
  queryFn: () => getProjects(workspaceId),
  placeholderData: (previousData, previousQuery) =>
    previousQuery?.queryKey[1] === workspaceId ? previousData : undefined,
});

isPlaceholderData tells you which you are looking at, if you would rather dim the table than blank it.

A request for the new workspace, then the old rows come back

This one only shows up on fast switches or a slow network, which is why it survives testing. Throttle the Network tab to a slow preset and switch twice in quick succession to reproduce it.

Hand-rolled fetching in a useEffect has a race the libraries handle for you. Switch from A to B, A's request is the slower one, it lands second, and the last setState wins. React's own docs name it plainly: network responses may arrive in a different order than you sent them.

We shipped this one. Until SDK 0.0.54, released on 2026-07-10, fifteen of the SDK's own read hooks, useSubscription, useAllQuotaUsage, useCreditBalance, useInvoices and the rest, could render the previous workspace's subscription, quotas, credits or invoices after a switch. If those are what you see, upgrade before reading further.

The fix inside the SDK is the one you want in your own code. A newer request aborts the one it supersedes, and a response to an aborted request is dropped. useSubscription also remembers which workspace its data was fetched for, and returns null with loading: true while that is not the workspace it was asked about. In your own effect:

import { useEffect, useState } from 'react';
import { useSaaSWorkspaces } from '@buildbase/sdk/react';

import { getProjects, type Project } from '@/lib/api'; // your own fetcher

export function useProjectsByHand() {
  const { currentWorkspace } = useSaaSWorkspaces();
  const workspaceId = currentWorkspace?._id;
  const [data, setData] = useState<{
    workspaceId: string;
    rows: Project[];
  } | null>(null);
  const [error, setError] = useState<unknown>(null);

  useEffect(() => {
    if (!workspaceId) return;
    const controller = new AbortController();
    setError(null);
    getProjects(workspaceId, { signal: controller.signal })
      .then((rows) => setData({ workspaceId, rows }))
      .catch((err) => {
        // An aborted request belonged to a workspace we left. Anything else is real.
        if (!controller.signal.aborted) setError(err);
      });
    return () => controller.abort();
  }, [workspaceId]);

  // Data tagged with another workspace is treated as no data at all.
  const rows =
    workspaceId && data?.workspaceId === workspaceId ? data.rows : null;
  return { rows, error };
}

Tagging the data with its workspace id is the half people skip. The abort stops a late response from landing; the tag stops the previous workspace's rows from rendering in the gap before the new ones arrive.

A request for the old workspace, after the switch

Something ran before the switch finished. Usually that is a purge inside onWorkspaceChange, because that callback runs before the switch, not after it. The SDK awaits it and only then sets currentWorkspace, so anything you trigger in there that reads the current workspace reads the one being left.

The common version is queryClient.invalidateQueries() in onWorkspaceChange. Invalidation refetches active queries by default, and the active queries are the old workspace's, mounted on a screen that has not re-rendered yet. With an unkeyed ['projects'], that is worse than a wasted request. It refills the shared entry with the old workspace's rows, freshly fetched, right before the switch lands. Then the key does not change, so nothing asks again until the window regains focus.

Three more things about that callback are easy to miss. The docs say it also fires on page load, when the saved workspace is restored from storage. Before SDK 0.0.55, released on 2026-07-14, it fired once per mounted useSaaSWorkspaces() rather than once per switch, so an app with the hook in several components ran its purge several times. And setCurrentWorkspace skips it entirely, since its type documentation calls it direct, with no callback, while still firing workspace:changed.

So keep onWorkspaceChange for what its type says it is for: work that must finish before the switch, such as minting a token or saving state, and that may veto it by rejecting. React to a switch on workspace:changed, which fires after currentWorkspace is set, carries previousWorkspace, and comes through handleEvent, as in the provider above. Skip the event when previousWorkspace is missing; that is a page load, not a switch. The SDK's own analytics make the same cut, and only report workspace_switched when both ids are present and differ.

The other source of old-workspace requests is the SDK restoring the remembered workspace before your app has picked the one it wants. Our own console bills through BuildBase and hit exactly this. After an org switch the remembered saas-workspace-current still named the previous org's workspace, so the SDK selected it, every workspace-keyed provider fetched subscription, credits and usage for it, and then the console's own selection landed and all of it was fetched again. The fix was to write the right id into that key before the SDK reads it. The comment beside that code records the difference in a HAR as seven subscription reads becoming one per workspace.

Note why that comment counts requests rather than wrong rows. Every one of those providers was keyed by workspace id, so when the right selection landed they fetched under a new key instead of serving the old one. That is the argument of this page in one incident. With the workspace in the key, a switch at the wrong moment costs you a round trip. Without it, it costs you the right rows.

If you are still designing the workspace model rather than debugging it, multi-tenant workspaces in an afternoon makes the case for the workspace as the unit that owns data. Keying every cache by it is the same decision, made on the client.

Install

npm i @buildbase/sdk
workspaces
multi-tenancy
troubleshooting

Frequently Asked Questions

Should I call queryClient.clear() when the user switches workspace?

Not as the fix. A purge hides a query key that does not name the workspace. And onWorkspaceChange runs before currentWorkspace changes, so an invalidate there refetches the workspace being left. Put the workspace id in every workspace-scoped query key and the switch has nothing to clear.

Why does the old workspace data fix itself when I click away and back?

Because the query key did not change on the switch, so nothing refetched until the window regained focus. TanStack Query refetches stale queries on window focus by default, and SWR revalidates on focus by default. The fix is a key that includes the workspace id.

Does router.refresh() fix stale server components after a workspace switch?

Yes, when the server reads the workspace from a cookie. Next.js only clears its client Router Cache by itself when a Server Action sets the cookie, so call router.refresh() once the switch lands. A workspace in the URL avoids the question, because each workspace is a different route.

Where does BuildBase keep the current workspace?

In the browser. The React SDK stores the selected workspace id in localStorage under saas-workspace-current and restores it on page load. The server has no current workspace for a session: every workspace route takes the id in its path, so server code only knows the workspace your app passes it.

Ship it

Create a project and run this guide against your own workspace.

7-day free trialNo credit card requiredCancel anytime