Features
Scopes & Multi-Tenancy
Scopes let you filter content by a dimension like region, tenant, or site. Unlike roles (which control what actions a user can take), scopes control which data a user sees and edits.
When to use scopes
- Regional content — editors in the Southeast manage stores and posts for their region only
- Multi-tenant SaaS — each customer's data is isolated within the same Convex project
- Multi-site — a single admin manages content for multiple websites
- Franchise model — franchise owners see only their locations
Collection configuration
Add scope to any collection definition to enable scope filtering:
import { defineVextroCollection, f } from "vextro";
export const stores = defineVextroCollection({
slug: "stores",
label: "Stores",
collectionType: "content",
tableName: "stores",
fields: {
name: f.text({ required: true }),
regionScope: f.id("regions", { required: true }),
// ... other fields
},
scope: {
field: "regionScope",
type: "region",
},
}); The scope.field points to the field that holds the scope value (typically an f.id() relationship). Vextro automatically generates a by_{field} index for efficient scope-based queries.
Scope types
The type string identifies the kind of scope. Common values:
| Type | Use case |
|---|---|
"region" | Geographic content partitioning |
"tenant" | Multi-tenant isolation |
"site" | Multi-site content management |
You can use any string. The type is used to match scope cookie values and user assignments.
Admin module callbacks
The host app provides three callbacks to the admin module that control scope behavior:
import { createVextroAdminModule } from "vextro/convex/admin";
const admin = createVextroAdminModule({
// ... other config
/** Returns scope IDs the user can access, or null for unrestricted. */
getUserScopes: async (ctx, scopeType) => {
if (scopeType !== "region") return null;
return getUserRegionScopes(ctx);
},
/** Validates that the user can write to a specific scope value. */
canAccessScope: async (ctx, scopeType, scopeValue) => {
if (scopeType !== "region") return true;
const user = await getCurrentUser(ctx);
if (!user) return false;
return canAccessRegion(ctx, user, scopeValue);
},
/** Returns options for the scope selector dropdown. */
listScopeOptions: async (ctx, scopeType) => {
if (scopeType !== "region") return [];
const regions = await ctx.db
.query("regions")
.withIndex("by_status", (q) => q.eq("status", "published"))
.collect();
return regions.map((r) => ({ id: String(r._id), label: r.name }));
},
}); getUserScopes
Returns an array of scope IDs the current user can access, or null for unrestricted access (admin-level). Used for read filtering.
canAccessScope
Validates that the user has write access to a specific scope value. Called during document creation, updates, and deletes.
listScopeOptions
Returns the list of options shown in the scope selector dropdown in the admin sidebar.
User scope assignments
Scope assignments are per-user, not per-role. The host app owns the storage for assignments. A typical approach uses a join table:
const userScopes = defineVextroCollection({
slug: "user-scopes",
label: "User Scopes",
collectionType: "system",
tableName: "userScopes",
fields: {
userId: f.id("users", { required: true }),
scopeId: f.text({ required: true }),
scopeType: f.text({ required: true }),
},
indexes: [
{ name: "by_userId", fields: ["userId"] },
{ name: "by_userId_and_scopeType", fields: ["userId", "scopeType"] },
],
}); Backwards compatibility: users with no scope assignments default to unrestricted access. Admin users (with cms:admin permission) bypass scope filtering entirely.
Admin UI behavior
Scope selector
When scope is configured in VextroConfig, a scope selector dropdown appears in the admin sidebar header. Switching scope sets a vextro-scope cookie and reloads the page.
// In your admin config
export const adminConfig: VextroConfig = {
// ... other config
scope: {
types: ["region"],
defaultType: "region",
labels: { region: "Region" },
},
}; List views
Collection list views are automatically filtered by the active scope. Only documents matching the selected scope appear. Unscoped collections are unaffected.
Document creation
When creating a new document in a scoped collection, the scope field is automatically set to the active scope value. If the user has access to multiple scopes, the scope field is editable.
Document editing
For existing documents, scope access is validated server-side. If the user doesn't have access to a document's scope, the mutation will reject the update.
Global search
Search results from scoped collections respect the active scope filter. Results from unscoped collections appear regardless of scope.
Relationship scoping
When scope config exists, all relationship fields are scope-filtered by default. Pickers for scoped collections only show options matching the active scope. This prevents accidental cross-tenant data exposure — forgetting to annotate a field won't leak data.
const posts = defineVextroCollection({
// ...
fields: {
relatedStore: f.id("stores"), // Filtered by scope (default behavior)
author: f.id("users"), // Not filtered (users has no scope config)
globalCategory: f.id("categories", {
scopeFilter: false, // Explicit opt-out: shows all categories
}),
},
}); Scope filtering only applies when the target collection has a scope config. Relationships to unscoped collections (like users or categories without scope) are never filtered regardless of the setting.
Opting out
To allow cross-scope references on a specific field, set scopeFilter: false:
relatedStore: f.id("stores", { scopeFilter: false }), To disable scope filtering project-wide (not recommended for multi-tenancy), set defaultScopeFilter: false in the scope config:
scope: {
types: ["region"],
defaultType: "region",
labels: { region: "Region" },
defaultScopeFilter: false, // All relationships show cross-scope options
}, The resolution order is: field-level scopeFilter > project-level defaultScopeFilter > true.
Cache invalidation
Document mutations on scoped collections emit scope-specific cache invalidation tags in addition to the standard collection prefix:
- Standard:
{collectionSlug}: - Scope-specific:
{collectionSlug}:scope:{scopeValue}
This enables targeted invalidation. For example, updating a Southeast store only invalidates pages rendering Southeast content, not content from other regions.
Complete example
Here's a full end-to-end setup for regional content management:
1. Collection definitions
// convex/collections/locations/regions.ts
export const regions = defineVextroCollection({
slug: "regions",
label: "Regions",
collectionType: "content",
tableName: "regions",
fields: {
name: f.text({ required: true }),
code: f.slug({ required: true }),
},
});
// convex/collections/locations/stores.ts
export const stores = defineVextroCollection({
slug: "stores",
label: "Stores",
collectionType: "content",
tableName: "stores",
fields: {
name: f.text({ required: true }),
regionScope: f.id("regions", { required: true }),
address: f.text(),
},
scope: {
field: "regionScope",
type: "region",
},
}); 2. Policy helpers
// convex/lib/policy.ts
export async function getUserRegionScopes(ctx) {
const user = await requireActiveUser(ctx);
if (hasPermission(user, "cms:admin")) return null;
const scopes = await ctx.db
.query("userScopes")
.withIndex("by_userId_and_scopeType", (q) =>
q.eq("userId", user._id).eq("scopeType", "region")
)
.collect();
if (scopes.length === 0) return null;
return scopes.map((s) => s.scopeId);
} 3. Admin module wiring
// convex/admin.ts
const admin = createVextroAdminModule({
getUserScopes: async (ctx, scopeType) => {
if (scopeType !== "region") return null;
return getUserRegionScopes(ctx);
},
canAccessScope: async (ctx, scopeType, scopeValue) => {
if (scopeType !== "region") return true;
const user = await getCurrentUser(ctx);
if (!user) return false;
return canAccessRegion(ctx, user, scopeValue);
},
listScopeOptions: async (ctx, scopeType) => {
if (scopeType !== "region") return [];
const regions = await ctx.db
.query("regions")
.withIndex("by_status", (q) => q.eq("status", "published"))
.collect();
return regions.map((r) => ({ id: String(r._id), label: r.name }));
},
}); 4. Admin app config
// apps/admin/src/config/admin.ts
export const adminConfig: VextroConfig = {
brandName: "My App",
scope: {
types: ["region"],
defaultType: "region",
labels: { region: "Region" },
},
}; With this setup, editors see a "Region" dropdown in the sidebar. Selecting a region filters all scoped collection views, automatically sets the scope on new documents, and validates scope access on every mutation.