Frontend Multitenancy
How React apps choose an active Gonvex tenant and how that tenant reaches the runtime.
Frontend Multitenancy
Gonvex generated bindings are project-level. A tenant is not a different generated API; it is runtime context.
That means this stays the same for every tenant:
import { api } from "./gonvex/_generated/api";
const tasks = useQuery(api.tasks.list, {});
const createTask = useMutation(api.tasks.create);
What changes is the active tenant attached to the Gonvex client session.
Runtime Context
Every frontend call resolves to this runtime context:
runtime URL
-> project id
-> auth token / user
-> active tenant id
-> tenant database route
-> function context
The frontend can choose a tenant, but it is not trusted as authority. The runtime must verify that the authenticated user belongs to that tenant before executing queries, mutations, actions, or subscriptions.
Configure the Client
For a Vite app, configure the runtime URL and project id in environment variables:
VITE_GONVEX_WS_URL=ws://localhost:8080/ws
VITE_GONVEX_PROJECT_ID=whagons-5
Create the client once for the selected project:
import { ConvexReactClient, GonvexProvider } from "./gonvex/_generated/react";
const gonvex = new ConvexReactClient(import.meta.env.VITE_GONVEX_WS_URL, {
project: import.meta.env.VITE_GONVEX_PROJECT_ID,
});
export function AppRoot() {
return (
<GonvexProvider client={gonvex}>
<App />
</GonvexProvider>
);
}
The project id is sent as a project query parameter on the WebSocket URL.
Set the Active Tenant
Today the client exposes tenant context through setAuth:
gonvex.setAuth({
token: sessionToken,
tenant: activeTenantId,
});
If you do not have auth wired yet in local development, you can still set only the tenant:
gonvex.setAuth({ tenant: "acme" });
For production apps, always send a real auth token too. The runtime rejects tenant access unless the authenticated user is a member of that tenant.
Safe Tenant Switching Pattern
When the active tenant changes, do not keep showing data from the previous tenant as if it belongs to the new tenant.
Update the existing client:
gonvex.setAuth({ token, tenant: nextTenantId });
The open socket reauthenticates. The runtime detaches and reattaches active live queries with the new verified tenant, resets durable sync collections when the visibility scope changes, and issues a new cache scope. React query/sync hooks clear their previous scoped result until the new scope produces local or server data.
With native Gonvex auth, use the provider helper:
await auth.setActiveTenant(nextTenantId);
Remount or clear any additional tenant-specific state owned by your application; the Gonvex client can only clear the query/sync state it manages.
Landlord Data vs Tenant Data
Multi-tenant apps usually have two kinds of data:
Landlord / project data
projects, tenants, memberships, billing, routing, global users
Tenant data
tasks, workspaces, customers, files, domain tables
The frontend should normally query tenant data through generated functions after selecting a tenant. It should not connect directly to tenant databases or store tenant database URLs.
Tenant list and membership screens should come from landlord/project-level functions or runtime endpoints. Tenant business screens should run under the active tenant context.
Generated Table Scopes
The generated frontend folder separates landlord and tenant table metadata:
gonvex/_generated/
schema.ts
landlord/tables.ts
tenant/tables.ts
Use the tenant metadata for business-data UI and the landlord metadata for app-owned project/control-plane UI:
import { landlord, tenant } from "./gonvex/_generated/schema";
import { tables as landlordTables } from "./gonvex/_generated/landlord/tables";
import { tables as tenantTables } from "./gonvex/_generated/tenant/tables";
const tenantTableNames = Object.keys(tenantTables);
const landlordTableNames = Object.keys(landlordTables);
All tenant databases for a project use the same tenant.tables schema. Switching tenants changes which database those tables target; it does not change the generated TypeScript bindings.
Dashboard Data Browser
The dashboard Data tab mirrors the same idea for development:
Landlord / project DBmeans no tenant id is attached.- A tenant DB selection sends the selected tenant id.
- Row/table requests include
tenant=<tenantId>andx-gonvex-tenant-id: <tenantId>. - The runtime resolves that tenant id to the configured tenant database.
The rows in a tenants table are just rows in the currently selected database. They are not the same thing as the dashboard's active tenant database selector. Tenant databases must be created or configured through the runtime tenant registry.
Backend Function Context
Backend functions read tenant context from Gonvex, not from user-provided args:
func ListTasks(ctx *gonvex.QueryCtx, args ListTasksArgs) ([]Task, error) {
tenantID := ctx.TenantID
if ctx.User == nil || !can(ctx.Permissions, "tasks:read") {
return nil, errors.New("permission denied")
}
return queryTasks(ctx.Context, ctx.DB, tenantID, args)
}
can is an application-defined helper over the membership-derived
ctx.Permissions JSON object.
For database-per-tenant projects, the database route already scopes the data. For shared-table projects, functions and generated DB helpers must also enforce tenant predicates such as WHERE tenant_id = $1.
Rules of Thumb
- Generated bindings are shared by all tenants in a project.
- The selected tenant belongs in client/runtime session state, not in every function argument.
- Tenant IDs from the browser are inputs to verify, not security proof.
- Clear or remount tenant-scoped UI state when switching tenants.
- Never put tenant database URLs in frontend code.
- Use landlord/project data only for routing, membership, and tenant selection; keep business data tenant-local.