How Securo Implements Multi-Workspace Support: A Deep Dive into the React Architecture
Securo's multi-workspace support is built around a dedicated React context (WorkspaceContext) that centralizes workspace state, persists the active workspace ID in localStorage, and automatically scopes all API requests and cached data to the selected workspace.
This architecture enables users to seamlessly switch between isolated workspaces—such as different companies or teams—while the server remains stateless and simply filters data based on a workspace_id header. In this article, we'll examine exactly how securo-finance/securo implements this pattern, from the core context provider to the API integration layer.
The Core Architecture: WorkspaceContext
At the heart of Securo's multi-workspace system lies WorkspaceContext, defined in frontend/src/contexts/workspace-context.tsx. This React context serves as the single source of truth for all workspace-related state and actions throughout the application.
The WorkspaceProvider wraps the entire application hierarchy (see frontend/src/App.tsx), ensuring every component can access workspace state through the useWorkspace hook. This provider manages three critical responsibilities:
- Workspace list management – Fetches and stores all workspaces the authenticated user can access
- Active workspace resolution – Determines which workspace is currently active based on persisted preferences
- Permission derivation – Computes user capabilities (
canManage,canWrite) based on their role in the active workspace
Persisting Workspace Selection Across Sessions
Securo stores the active workspace ID in the browser's localStorage under the key workspace_id, defined as WORKSPACE_STORAGE_KEY in frontend/src/lib/api.ts. This persistence layer serves two purposes: it remembers the user's selection across page reloads, and it provides the value that the API interceptor reads to attach the workspace header.
When the WorkspaceProvider mounts, it executes loadWorkspaces to reconcile the stored ID with the server-side workspace list:
const loadWorkspaces = useCallback(async () => {
setIsLoading(true);
try {
const fetched = await workspacesApi.list(); // GET /api/workspaces
setList(fetched);
// Reconcile stored workspace ID with the fetched list
const storedId = localStorage.getItem(WORKSPACE_STORAGE_KEY);
const found = fetched.find(w => w.id === storedId);
if (found) setCurrentId(found.id);
else if (fetched.length) {
const fallbackId = fetched[0].id;
localStorage.setItem(WORKSPACE_STORAGE_KEY, fallbackId);
setCurrentId(fallbackId);
} else {
localStorage.removeItem(WORKSPACE_STORAGE_KEY);
setCurrentId(null);
}
} finally {
setIsLoading(false);
}
}, []);
This reconciliation handles edge cases gracefully: if the stored workspace ID is stale (user was removed from a workspace) or missing entirely, the provider falls back to the first available workspace.
Switching Workspaces and Cache Invalidation
The switchWorkspace function demonstrates Securo's careful approach to data isolation. When a user selects a different workspace, the system must ensure no cached data from the previous workspace bleeds through:
// inside WorkspaceProvider
const switchWorkspace = useCallback(async (id: string) => {
if (id === currentId) return;
// Persist the new ID so the interceptor includes it on the next request
localStorage.setItem(WORKSPACE_STORAGE_KEY, id);
setCurrentId(id);
// Invalidate all queries that were scoped to the previous workspace
await queryClient.resetQueries();
}, [currentId, queryClient]);
The queryClient.resetQueries() call is critical here—it immediately clears React Query's entire cache, forcing all active queries to refetch with the new workspace_id header. This prevents transactions, rules, or payees from Workspace A from briefly appearing while Workspace B loads.
API Integration: Automatic Header Injection
Securo's API layer in frontend/src/lib/api.ts implements workspace scoping transparently through an Axios request interceptor. Every outgoing HTTP request automatically receives the workspace_id header from localStorage, without individual API calls needing to manage this concern:
// Conceptual representation of the interceptor pattern in api.ts
api.interceptors.request.use((config) => {
const workspaceId = localStorage.getItem(WORKSPACE_STORAGE_KEY);
if (workspaceId) {
config.headers['workspace_id'] = workspaceId;
}
return config;
});
This design keeps server-side logic simple: the backend remains stateless and merely filters all data by the workspace_id header present in each request. No session-based workspace tracking or complex authentication contexts are required on the server.
Consuming Workspace Context in UI Components
Components throughout Securo consume useWorkspace to access the active workspace and permission checks. The workspace-switcher component in frontend/src/components/workspace-switcher.tsx renders the workspace selection UI and invokes switchWorkspace on user selection.
Page components like Transactions, Rules, Payees, and Budgets use the context to scope their data fetching:
const { current, canWrite } = useWorkspace();
const { data: transactions } = useQuery(
['transactions', current?.id],
() => transactionsApi.list(current!.id),
{ enabled: !!current }
);
Including current?.id in the queryKey ensures React Query maintains separate cache entries per workspace. The canWrite boolean gates UI actions—buttons for creating or editing records only appear when the user has write permissions in the active workspace.
Permission Model and Access Control
The WorkspaceContext derives permission booleans from the user's role in the active workspace:
current– The full workspace object (id, name, settings)role– The user's role string (e.g., 'admin', 'member', 'viewer')canManage– Whether the user can administer workspace settings and memberscanWrite– Whether the user can create and modify data within the workspace
These computed properties centralize authorization logic, preventing scattered permission checks and ensuring consistent UI behavior. When current changes via workspace switching, all derived permissions recalculate automatically.
Key Implementation Files
| File | Responsibility |
|---|---|
frontend/src/contexts/workspace-context.tsx |
Core React context with provider, state management, and useWorkspace hook |
frontend/src/lib/api.ts |
WORKSPACE_STORAGE_KEY constant and Axios interceptor for header injection |
frontend/src/components/workspace-switcher.tsx |
UI component for workspace selection |
frontend/src/App.tsx |
Application root that wraps the tree with WorkspaceProvider |
Page components (transactions.tsx, rules.tsx, etc.) |
Consumers that scope queries and permissions to the active workspace |
Summary
Securo's multi-workspace implementation demonstrates a clean separation of concerns between client-side state management and server-side data filtering:
- WorkspaceContext centralizes all workspace state and actions in a single React context
- localStorage persistence remembers user preferences across sessions with graceful fallback handling
- Query cache invalidation ensures complete data isolation when switching workspaces
- Axios interceptor transparently injects the workspace header without burdening individual API calls
- Permission derivation provides consistent access control throughout the UI
This architecture lets Securo deliver a seamless multi-workspace experience with minimal server complexity—the backend simply trusts the workspace_id header and returns appropriately filtered data.
Frequently Asked Questions
How does Securo remember which workspace I was using after I refresh the page?
Securo stores the active workspace ID in localStorage under the key workspace_id. When the application loads, the WorkspaceProvider reads this value and validates it against the list of workspaces you have access to. If the stored ID is valid, that workspace becomes active; otherwise, it falls back to your first available workspace.
What happens to my data when I switch workspaces?
All cached data is immediately cleared via queryClient.resetQueries() when switchWorkspace is called. This ensures you never see stale data from a previous workspace. React Query then refetches fresh data using the new workspace_id header, so each workspace maintains completely isolated data views.
Why does Securo use a header instead of a URL path for workspace scoping?
The workspace_id header approach keeps URLs clean and consistent across workspaces. It also simplifies bookmarking and sharing—links to /transactions work regardless of which workspace is active, since the workspace context travels with the user's session rather than the URL. The server remains stateless and simply filters by the header value.
How do permission checks work across different workspaces?
The WorkspaceContext derives canManage and canWrite booleans from your role in the currently active workspace. When you switch workspaces, these values recalculate automatically based on your role in the new workspace. This means you might have full admin rights in one workspace but read-only access in another, with the UI adapting instantly to show only permitted actions.
Have a question about this repo?
These articles cover the highlights, but your codebase questions are specific. Give your agent direct access to the source. Share this with your agent to get started:
curl -s "https://instagit.com/install.md" Maintain an open-source project? Get it listed too →