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.
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:
- It marks a switch in progress:
switchingreads true. - 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. - It writes the workspace's
_idto localStorage undersaas-workspace-current, and setscurrentWorkspace. - Then
workspace:changed, carrying{ workspace, previousWorkspace }, goes out through yourhandleEventcallback.
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 switch | What is holding the old data |
|---|---|
| No request for that screen's data | a key without the workspace id, or the Next.js Router Cache |
| A request with the new id, old rows on screen | placeholder data from the previous key |
| A request with the new id, then old rows return | a late response from the workspace you left |
| A request with the old id | something 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