Multitenancy
Last updated Sep 18, 2026 In a multi-tenant application, each tenant must only ever see their own data. AI Search supports two ways to isolate search per tenant: give each tenant its own instance, or share one instance and filter by tenant at query time.
Choose an approach
| Approach | How it isolates | Choose it when |
|---|---|---|
| Instance per tenant (recommended) | Each tenant gets a separate instance with its own storage and index | You need strong isolation, or you create and delete tenants at runtime |
| Shared instance with filtering | One instance holds every tenant; a metadata filter scopes each query | You have many small tenants and want the simplest setup |
Prerequisites
Both approaches use a Cloudflare Worker. Create the project first, then follow the option you chose.
- Sign up for a Cloudflare account ↗︎.
- Install
Node.js↗︎.
<details>
<summary>
Node.js version manager
</summary>
Use <a href="https://viteplus.dev/guide/env">Vite+ (<code>vp env</code>) ↗︎</a> to install and manage Node.js versions.
For supported Node.js versions, refer to the <a href="https://developers.cloudflare.com/workers/wrangler/install-and-update/#install-wrangler">Wrangler system requirements</a>.
</details>
Create a Worker project
Create a new Worker project using the create-cloudflare CLI (C3). C3 ↗︎ is a command-line tool designed to help you set up and deploy new applications to Cloudflare.
Create a new project named tenant-search by running:
npmyarnpnpm
npm create cloudflare@latest -- tenant-search
yarn create cloudflare tenant-search
pnpm create cloudflare@latest tenant-search
For setup, select the following options:
- For What would you like to start with?, choose
Hello World example. - For Which template would you like to use?, choose
Worker only. - For Which language do you want to use?, choose
TypeScript. - For Do you want to use git for version control?, choose
Yes. - For Do you want to add an AGENTS.md file to help AI coding tools understand Cloudflare APIs?, choose
Yes. - For Do you want to deploy your application?, choose
No(we will be making some changes before deploying).
Go to your application directory:
cd tenant-search
Option 1: One instance per tenant
This is the recommended approach. Each tenant gets a separate instance with its own storage and search index, so one tenant can never retrieve another tenant's documents.
Create an isolated AI Search instance for each tenant at runtime using the namespace binding.
namespace: tenants
AI Search instancetenant-a AI Search instancetenant-b AI Search instancetenant-c
Note
AI Search limits the number of instances per account. If you have more tenants than that limit, or run into any other AI Search limit, reach out through the Limit Increase Request Form ↗︎ and we can help.
Add the namespace binding to your Wrangler configuration file:
{
"$schema": "./node_modules/wrangler/config-schema.json",
"ai_search_namespaces": [
{
"binding": "TENANTS",
"namespace": "default",
"remote": true
}
]
}
[[ai_search_namespaces]]
binding = "TENANTS"
namespace = "default"
remote = true
The remote option lets wrangler dev proxy requests to your deployed instances, since AI Search does not run locally.
Built-in storage
Each tenant's instance holds documents that you upload directly to it, with no external data source.
Update src/index.ts. This Worker identifies the tenant from a request header, then creates, populates, searches, and deletes that tenant's instance.
src/index.jsjs
export default {
async fetch(request, env) {
const url = new URL(request.url);
// Identify the tenant from the request header.
const tenantId = request.headers.get("x-tenant-id");
if (!tenantId) {
return new Response("Missing x-tenant-id header", { status: 400 });
}
// Create a new instance for the tenant.
if (url.pathname === "/onboard" && request.method === "POST") {
const instance = await env.TENANTS.create({
id: `tenant-${tenantId}`,
});
return Response.json({ success: true, instance: await instance.info() });
}
// Upload a document to the tenant's instance.
if (url.pathname === "/upload" && request.method === "POST") {
const formData = await request.formData();
const file = formData.get("file");
const item = await env.TENANTS.get(`tenant-${tenantId}`).items.upload(
file.name,
await file.arrayBuffer(),
);
return Response.json({ success: true, item });
}
// Search the tenant's instance. Search is isolated to their instance.
if (url.pathname === "/search") {
const query = url.searchParams.get("q") || "";
const results = await env.TENANTS.get(`tenant-${tenantId}`).search({
messages: [{ role: "user", content: query }],
});
return Response.json(results);
}
// Delete the tenant's instance and all its data.
if (url.pathname === "/offboard" && request.method === "DELETE") {
await env.TENANTS.delete(`tenant-${tenantId}`);
return Response.json({ success: true });
}
return new Response("Not found", { status: 404 });
},
};
src/index.tsts
export type Env = {
TENANTS: AiSearchNamespace;
};
export default {
async fetch(request, env): Promise<Response> {
const url = new URL(request.url);
// Identify the tenant from the request header.
const tenantId = request.headers.get("x-tenant-id");
if (!tenantId) {
return new Response("Missing x-tenant-id header", { status: 400 });
}
// Create a new instance for the tenant.
if (url.pathname === "/onboard" && request.method === "POST") {
const instance = await env.TENANTS.create({
id: `tenant-${tenantId}`,
});
return Response.json({ success: true, instance: await instance.info() });
}
// Upload a document to the tenant's instance.
if (url.pathname === "/upload" && request.method === "POST") {
const formData = await request.formData();
const file = formData.get("file") as File;
const item = await env.TENANTS.get(`tenant-${tenantId}`).items.upload(
file.name,
await file.arrayBuffer(),
);
return Response.json({ success: true, item });
}
// Search the tenant's instance. Search is isolated to their instance.
if (url.pathname === "/search") {
const query = url.searchParams.get("q") || "";
const results = await env.TENANTS.get(`tenant-${tenantId}`).search({
messages: [{ role: "user", content: query }],
});
return Response.json(results);
}
// Delete the tenant's instance and all its data.
if (url.pathname === "/offboard" && request.method === "DELETE") {
await env.TENANTS.delete(`tenant-${tenantId}`);
return Response.json({ success: true });
}
return new Response("Not found", { status: 404 });
},
} satisfies ExportedHandler<Env>;
Start a local development server:
npx wrangler dev
Then onboard a tenant, upload a document to their instance, search it, and offboard. The x-tenant-id header scopes every request to that tenant's instance:
# Create an isolated instance for tenant "acme"
curl -X POST http://localhost:8787/onboard -H "x-tenant-id: acme"
# Upload a document to acme's instance
curl -X POST http://localhost:8787/upload -H "x-tenant-id: acme" -F "file=@./handbook.pdf"
# Search acme's instance
curl "http://localhost:8787/search?q=vacation+policy" -H "x-tenant-id: acme"
# Delete acme's instance and all of its data
curl -X DELETE http://localhost:8787/offboard -H "x-tenant-id: acme"
AI Search indexes uploads asynchronously, so wait a moment after uploading before you search.
R2
If your tenants' data already lives in R2, back each tenant's instance with R2 instead of uploading documents to built-in storage. Change the /onboard route from the built-in storage example to create an R2-backed instance. How you configure it depends on how the data is organized.
Note
Creating an r2-backed instance from a binding requires a service API token. Cloudflare registers one for your account automatically the first time you create an R2-backed instance through the dashboard or Wrangler, and reuses it afterward.
A bucket per tenant: if each tenant's data is already in its own bucket, point the instance at that whole bucket:
const instance = await env.TENANTS.create({
id: `tenant-${tenantId}`,
type: "r2",
source: `tenant-${tenantId}-bucket`,
});
A shared bucket: if every tenant's data lives in one bucket, organized by folder:
- my-bucket
- customers/
- acme/
- globex/
Scope each instance to that tenant's folder with path filtering, so it only ever indexes and searches that tenant's objects:
const instance = await env.TENANTS.create({
id: `tenant-${tenantId}`,
type: "r2",
source: "my-bucket",
source_params: {
include_items: [`/customers/${tenantId}/**`],
},
});
With either layout, AI Search indexes each tenant's objects on the next sync. Add documents by writing them to R2 rather than uploading through the Worker, and keep the search and offboard routes from the built-in storage example: each instance only ever returns its own tenant's results, and deleting an instance removes its search index while leaving the R2 objects untouched.
To try it, run npx wrangler dev and use the same onboard, search, and offboard requests as built-in storage. Skip the upload step, since AI Search indexes each tenant's objects directly from R2.
Option 2: Shared instance with metadata filtering on retrieval
Use a single AI Search instance and organize content by tenant using folder paths. This approach works with both R2 buckets and built-in storage. Apply metadata filters at query time so each tenant only retrieves their own documents.
This option searches an existing instance, so create one named shared-instance and add your content first. Refer to Get started.
Add the instance binding to your Wrangler configuration file:
{
"$schema": "./node_modules/wrangler/config-schema.json",
"ai_search": [
{
"binding": "SHARED_INSTANCE",
"instance_name": "shared-instance",
"remote": true
}
]
}
[[ai_search]]
binding = "SHARED_INSTANCE"
instance_name = "shared-instance"
remote = true
Organize your content by tenant using unique folder paths:
- customer-a
- logs/
- contracts/
- customer-b
- contracts/
Update src/index.ts to filter by the tenant's folder at query time:
src/index.jsjs
export default {
async fetch(request, env) {
const tenantId = request.headers.get("x-tenant-id");
if (!tenantId) {
return new Response("Missing x-tenant-id header", { status: 400 });
}
// Filter results to only return documents from this tenant's folder.
const results = await env.SHARED_INSTANCE.search({
messages: [{ role: "user", content: "When did I sign my agreement?" }],
ai_search_options: {
retrieval: {
filters: {
folder: { $gte: `${tenantId}/`, $lt: `${tenantId}0` },
},
},
},
});
return Response.json(results);
},
};
src/index.tsts
export type Env = {
SHARED_INSTANCE: AiSearchInstance;
};
export default {
async fetch(request, env): Promise<Response> {
const tenantId = request.headers.get("x-tenant-id");
if (!tenantId) {
return new Response("Missing x-tenant-id header", { status: 400 });
}
// Filter results to only return documents from this tenant's folder.
const results = await env.SHARED_INSTANCE.search({
messages: [{ role: "user", content: "When did I sign my agreement?" }],
ai_search_options: {
retrieval: {
filters: {
folder: { $gte: `${tenantId}/`, $lt: `${tenantId}0` },
},
},
},
});
return Response.json(results);
},
} satisfies ExportedHandler<Env>;
This example uses a "starts with" filter to match all files under the tenant's folder, including subfolders.
Start a local development server:
npx wrangler dev
Send a request as each tenant. The Worker scopes the search to that tenant's folder, so the results never overlap:
curl http://localhost:8787/ -H "x-tenant-id: customer-a"
curl http://localhost:8787/ -H "x-tenant-id: customer-b"
Deploy
Log in with your Cloudflare account, then deploy your Worker to make it accessible on the Internet:
npx wrangler login
npx wrangler deploy
Next steps
Namespaces
Group instances and manage them dynamically from a binding.
Filtering
Filter search results by metadata attributes at query time.
Was this helpful?
YesNo
On this page
{"@context":"https://schema.org","@type":"TechArticle","@id":"https://developers.cloudflare.com/ai-search/how-to/per-tenant-search/#page","headline":"Multitenancy","description":"Keep each tenant's data isolated in AI Search using a separate instance per tenant or a shared instance with metadata filtering.","url":"https://developers.cloudflare.com/ai-search/how-to/per-tenant-search/","inLanguage":"en","image":"https://developers.cloudflare.com/ai-search/how-to/per-tenant-search/og.png?v=8b26fb73d9feade3","dateModified":"2026-09-18","publisher":{"@type":"Organization","name":"Cloudflare","description":"One platform for your apps, agents, and workforce. Build, secure, and scale without managing infrastructure","url":"https://www.cloudflare.com/","sameAs":["https://github.com/cloudflare","https://www.linkedin.com/company/cloudflare","https://x.com/cloudflare"],"logo":{"@type":"ImageObject","url":"https://developers.cloudflare.com/logo.svg"},"address":{"@type":"PostalAddress","streetAddress":"101 Townsend St","addressLocality":"San Francisco","addressRegion":"CA","postalCode":"94107","addressCountry":"US"},"contactPoint":[{"@type":"ContactPoint","contactType":"Customer Support","url":"https://support.cloudflare.com/","availableLanguage":["English"]},{"@type":"ContactPoint","contactType":"Sales","url":"https://www.cloudflare.com/contact/","availableLanguage":["English"]}]},"isPartOf":{"@type":"WebSite","@id":"https://developers.cloudflare.com/#website","name":"Cloudflare Docs","url":"https://developers.cloudflare.com/"}}
description: Search a shared knowledge base and a tenant-specific one in a single query, and identify which instance each result came from. title: Search across multiple instances image: https://developers.cloudflare.com/ai-search/how-to/search-multiple-sources/og.png?v=5ec4bcffbb56cb19
Documentation Index
Fetch the complete documentation index at: https://developers.cloudflare.com/ai-search/llms.txt
Use this file to discover all available pages before exploring further.
Docs