diff --git a/src/content/docs/cloudflare-for-platforms/workers-for-platforms/configuration/binding-groups/get-started.mdx b/src/content/docs/cloudflare-for-platforms/workers-for-platforms/configuration/binding-groups/get-started.mdx
new file mode 100644
index 000000000000000..20b2989191a4132
--- /dev/null
+++ b/src/content/docs/cloudflare-for-platforms/workers-for-platforms/configuration/binding-groups/get-started.mdx
@@ -0,0 +1,178 @@
+---
+title: Get started
+pcx_content_type: how-to
+sidebar:
+ order: 2
+---
+
+import { TypeScriptExample, WranglerConfig } from "~/components";
+
+This guide walks through configuring binding groups and using them to inject per-customer resources at runtime. Binding groups work with both [Dynamic Workers](/dynamic-workers/) and the Workers for Platforms [dispatch namespace](/cloudflare-for-platforms/workers-for-platforms/configuration/dynamic-dispatch/).
+
+## Configure the bindings
+
+On your platform Worker (the Worker Loader or dispatch Worker), add binding groups for each resource type your customers need. Each binding group is a single binding that manages many customer-scoped resources.
+
+
+
+```toml
+name = "my-platform"
+
+[[kv_namespaces]]
+binding = "CUSTOMER_KV"
+namespace_id = "platform-customer-stores"
+
+[[ai_search]]
+binding = "CUSTOMER_SEARCH"
+namespace = "platform-customer-indexes"
+
+[[r2_buckets]]
+binding = "CUSTOMER_STORAGE"
+bucket_name = "platform-customer-uploads"
+```
+
+
+
+## Use with Dynamic Workers
+
+In the Worker Loader, resolve each customer's resources from the binding groups and pass them as bindings to the Dynamic Worker.
+
+
+
+```ts
+export default {
+ async fetch(request: Request, env: Env) {
+ const customerId = await authenticateRequest(request);
+
+ // Get this customer's resources from each binding group
+ const customerKV = env.CUSTOMER_KV.get(customerId);
+ const customerSearch = env.CUSTOMER_SEARCH.get(customerId);
+ const customerBucket = env.CUSTOMER_STORAGE.get(customerId);
+
+ // Load or reuse this customer's Dynamic Worker
+ const worker = env.LOADER.get(`customer-${customerId}`, async () => {
+ const code = await env.PLATFORM_CODE.get(`${customerId}/worker.js`);
+
+ return {
+ compatibilityDate: "2026-01-01",
+ mainModule: "index.js",
+ modules: { "index.js": code },
+
+ // Pass in ONLY this customer's resources.
+ // The Dynamic Worker sees these as its own bindings.
+ bindings: {
+ KV: customerKV,
+ SEARCH: customerSearch,
+ STORAGE: customerBucket,
+ },
+ globalOutbound: null,
+ };
+ });
+
+ return worker.getEntrypoint().fetch(request);
+ },
+};
+```
+
+
+
+The Dynamic Worker receives `env.KV`, `env.SEARCH`, and `env.STORAGE` as standard bindings. The customer's code does not need to know about binding groups.
+
+## Use with Workers for Platforms dispatcher
+
+Pass binding groups when you dispatch to a user Worker. The bindings are injected at dispatch time instead of deploy time.
+
+
+
+```ts
+export default {
+ async fetch(request: Request, env: Env) {
+ const customerId = new URL(request.url).hostname.split(".")[0];
+
+ const userWorker = env.DISPATCHER.get(customerId, {
+ bindings: {
+ KV: env.CUSTOMER_KV.get(customerId),
+ DB: env.CUSTOMER_DBS.get(customerId),
+ SEARCH: env.CUSTOMER_SEARCH.get(customerId),
+ STORAGE: env.CUSTOMER_STORAGE.get(customerId),
+ },
+ });
+
+ return userWorker.fetch(request);
+ },
+};
+```
+
+
+
+## Understand resource auto-creation
+
+Resources within a binding group are created automatically on first use. You do not need a separate provisioning step.
+
+The flow works as follows:
+
+1. Your customer writes a normal Worker that calls `env.KV.put("key", "value")`.
+2. On the platform side, you pass in the binding group scoped to the customer ID.
+3. When the customer's Worker calls `env.KV.put()` for the first time, the runtime sees "this is a write to binding group `CUSTOMER_KV`, scoped to `acme-corp`" — and if a KV store for `acme-corp` does not exist yet, it creates one.
+4. Each resource is created as a real, first-class resource with its own ID and data.
+
+
+
+```ts
+export default {
+ async fetch(request: Request, env: Env) {
+ // First time this runs, the platform auto-creates a KV store
+ // scoped to this customer
+ await env.KV.put(
+ "visits",
+ String(Number((await env.KV.get("visits")) || 0) + 1),
+ );
+
+ const visits = await env.KV.get("visits");
+ return new Response(`Visit count: ${visits}`);
+ },
+};
+```
+
+
+
+## Resource naming
+
+The name passed to `.get()` becomes the resource name within the binding group. The binding group namespace provides the scope:
+
+```ts
+// Creates a KV namespace named "acme-corp"
+// within the "platform-customer-stores" group
+env.CUSTOMER_KV.get("acme-corp");
+```
+
+## Create resources that require configuration
+
+Some resources cannot be auto-created from just a name — they need configuration that you provide. For these, create the resource explicitly before it is used.
+
+
+
+```ts
+async function onboardCustomer(
+ customerId: string,
+ connectionString: string,
+ env: Env,
+) {
+ // Create a Hyperdrive config within the binding group
+ await env.CUSTOMER_DBS.create(customerId, {
+ connectionString: connectionString,
+ });
+}
+```
+
+
+
+Then at request time, pass it into the Worker the same way as any other binding group resource:
+
+```ts
+bindings: {
+ DB: env.CUSTOMER_DBS.get(customerId),
+}
+```
+
+For the full list of which bindings auto-create and which require explicit creation, refer to [Supported bindings](/cloudflare-for-platforms/workers-for-platforms/configuration/binding-groups/supported-bindings/).
diff --git a/src/content/docs/cloudflare-for-platforms/workers-for-platforms/configuration/binding-groups/index.mdx b/src/content/docs/cloudflare-for-platforms/workers-for-platforms/configuration/binding-groups/index.mdx
new file mode 100644
index 000000000000000..7a228c0153558f1
--- /dev/null
+++ b/src/content/docs/cloudflare-for-platforms/workers-for-platforms/configuration/binding-groups/index.mdx
@@ -0,0 +1,90 @@
+---
+title: Binding groups
+pcx_content_type: concept
+sidebar:
+ order: 6
+---
+
+import { DirectoryListing, TypeScriptExample } from "~/components";
+
+A Binding Group is a collection of resources of the same type — [KV](/kv/) stores, [R2](/r2/) buckets, [AI Search](/ai-search/) instances, [Durable Objects](/durable-objects/) — managed together under a single binding. Instead of creating and binding to each resource individually, you bind to the group once and access individual resources within it at runtime.
+
+Binding groups solve a core problem when building multi-tenant platforms: giving each customer their own isolated resources without changing your configuration or redeploying your Worker for every new customer.
+
+## Why use binding groups
+
+- **No pre-provisioning required** — Access a resource by name. If it does not exist, it is created on first use.
+- **Dynamic resource creation** — Create resources for each customer without changing your configuration or redeploying your Worker.
+- **Runtime resource selection** — Access any resource in the group by name. Pass in the customer identifier at runtime.
+- **Simple configuration** — One binding per resource type, regardless of how many customers you serve.
+
+## The Binding Group interface
+
+A binding group exposes the following methods:
+
+```ts
+interface BindingGroup {
+ // Get an existing resource by name — returns the standard binding type
+ get(name: string): T;
+
+ // Create a new resource in the group
+ create(name: string, options?: CreateOptions): Promise;
+
+ // Delete a resource from the group
+ delete(name: string): Promise;
+
+ // List all resources in the group
+ list(): Promise;
+}
+```
+
+For most resource types, `.get()` is all you need. The resource is created automatically on first use. For resources that require configuration to provision, such as [Hyperdrive](/hyperdrive/) or [Vectorize](/vectorize/), use `.create()` to set them up before first use.
+
+## How it works
+
+From the customer's perspective, their Worker has simple, familiar bindings — `env.KV`, `env.SEARCH`, `env.STORAGE`. They do not know they are part of a group. They do not have access to any other customer's resources. The isolation is enforced by the runtime.
+
+
+
+```ts
+export default {
+ async fetch(request: Request, env: Env) {
+ const url = new URL(request.url);
+
+ // Index a document
+ if (request.method === "POST" && url.pathname === "/docs") {
+ const doc = await request.json();
+ await env.SEARCH.upsert([doc]);
+ return new Response("Indexed", { status: 201 });
+ }
+
+ // Search
+ if (url.pathname === "/search") {
+ const q = url.searchParams.get("q");
+ const results = await env.SEARCH.search(q);
+ return Response.json(results);
+ }
+
+ // Read a setting from KV
+ if (url.pathname === "/settings") {
+ const settings = await env.KV.get("settings", "json");
+ return Response.json(settings);
+ }
+
+ // Serve a file from R2
+ if (url.pathname.startsWith("/files/")) {
+ const file = await env.STORAGE.get(url.pathname.slice(7));
+ if (!file) return new Response("Not found", { status: 404 });
+ return new Response(file.body);
+ }
+
+ return new Response("Not found", { status: 404 });
+ },
+};
+```
+
+
+
+On the platform side, your dispatcher resolves the right resources from each binding group and passes them into the customer's Worker. Refer to [Get started](/cloudflare-for-platforms/workers-for-platforms/configuration/binding-groups/get-started/) for the full configuration and dispatcher examples.
+
+
diff --git a/src/content/docs/cloudflare-for-platforms/workers-for-platforms/configuration/binding-groups/limits.mdx b/src/content/docs/cloudflare-for-platforms/workers-for-platforms/configuration/binding-groups/limits.mdx
new file mode 100644
index 000000000000000..c0c1dd82d29c884
--- /dev/null
+++ b/src/content/docs/cloudflare-for-platforms/workers-for-platforms/configuration/binding-groups/limits.mdx
@@ -0,0 +1,185 @@
+---
+title: Limits
+pcx_content_type: concept
+sidebar:
+ order: 4
+---
+
+import { TypeScriptExample } from "~/components";
+
+Binding group limits let you cap how much of a resource each tenant can consume. Limits map to the billable dimensions of each product — the same things you get charged for — so you can control costs and offer differentiated plan tiers.
+
+This extends the same model as [custom limits](/cloudflare-for-platforms/workers-for-platforms/configuration/custom-limits/) on Workers today. Custom limits apply to the Worker itself (CPU time, subrequests). Binding group limits apply to the resources the Worker uses (KV reads, D1 rows, R2 storage).
+
+## Set limits on a resource
+
+Pass `limits` as the second argument to `.get()`. Limits are enforced for the tenant over the billing cycle.
+
+
+
+```ts
+const customerKV = env.CUSTOMER_KV.get(customerId, {
+ limits: {
+ reads: 100_000, // per month
+ writes: 10_000, // per month
+ storedBytes: 1_073_741_824, // 1 GB total
+ },
+});
+```
+
+
+
+When a tenant exceeds a limit, the operation fails with an error. Your dispatcher can catch this and return an appropriate response — for example, a `429` with an upgrade prompt.
+
+## Set limits by plan tier
+
+Look up the tenant's plan at dispatch time and apply the corresponding limits. This is the same pattern as setting CPU and subrequest limits per plan — extended to resources.
+
+
+
+```ts
+const PLAN_LIMITS = {
+ free: {
+ kv: {
+ reads: 100_000,
+ writes: 10_000,
+ storedBytes: 100 * 1024 * 1024,
+ },
+ d1: {
+ rowsRead: 5_000_000,
+ rowsWritten: 100_000,
+ storedBytes: 500 * 1024 * 1024,
+ },
+ r2: {
+ classAOps: 100_000,
+ classBOps: 1_000_000,
+ storedBytes: 1024 * 1024 * 1024,
+ },
+ },
+ pro: {
+ kv: {
+ reads: 10_000_000,
+ writes: 1_000_000,
+ storedBytes: 10 * 1024 * 1024 * 1024,
+ },
+ d1: {
+ rowsRead: 25_000_000_000,
+ rowsWritten: 50_000_000,
+ storedBytes: 5 * 1024 * 1024 * 1024,
+ },
+ r2: {
+ classAOps: 10_000_000,
+ classBOps: 100_000_000,
+ storedBytes: 100 * 1024 * 1024 * 1024,
+ },
+ },
+};
+
+export default {
+ async fetch(request: Request, env: Env) {
+ const customerId = new URL(request.url).hostname.split(".")[0];
+ const planType = (await env.PLANS.get(customerId)) || "free";
+ const plan = PLAN_LIMITS[planType];
+
+ const userWorker = env.DISPATCHER.get(
+ customerId,
+ {
+ bindings: {
+ KV: env.CUSTOMER_KV.get(customerId, { limits: plan.kv }),
+ DB: env.CUSTOMER_DBS.get(customerId, { limits: plan.d1 }),
+ STORAGE: env.CUSTOMER_STORAGE.get(customerId, { limits: plan.r2 }),
+ },
+ },
+ {
+ // Worker-level limits
+ limits: {
+ cpuMs: planType === "free" ? 10 : 30,
+ subRequests: planType === "free" ? 5 : 20,
+ },
+ },
+ );
+
+ return userWorker.fetch(request);
+ },
+};
+```
+
+
+
+## Handle limit errors
+
+When a tenant exceeds a limit, the operation throws an exception. Catch it in your dispatcher to return an appropriate response.
+
+
+
+```ts
+export default {
+ async fetch(request: Request, env: Env) {
+ try {
+ const customerId = new URL(request.url).hostname.split(".")[0];
+ const plan = PLAN_LIMITS[(await env.PLANS.get(customerId)) || "free"];
+
+ const userWorker = env.DISPATCHER.get(customerId, {
+ bindings: {
+ KV: env.CUSTOMER_KV.get(customerId, { limits: plan.kv }),
+ },
+ });
+
+ return userWorker.fetch(request);
+ } catch (e) {
+ if (e.message.includes("limit exceeded")) {
+ return new Response("Usage limit exceeded. Upgrade your plan.", {
+ status: 429,
+ });
+ }
+ return new Response(e.message, { status: 500 });
+ }
+ },
+};
+```
+
+
+
+## Operations vs. storage limits
+
+Limits fall into two categories:
+
+**Operation limits** count actions over the billing cycle. They reset at the start of each monthly billing period.
+
+- KV reads, writes, deletes, lists
+- D1 rows read, rows written
+- R2 Class A and Class B operations
+
+**Storage limits** cap how much data a tenant can store. They are checked against current usage and do not reset.
+
+- KV stored data
+- D1 database size
+- R2 bucket size
+
+When a storage limit is reached, write operations that would increase stored data fail. Read operations are unaffected.
+
+## Supported limit fields
+
+| Binding | Field | Type | Description |
+| ---------------- | ------------------- | --------- | ------------------------------------------------------- |
+| KV | `reads` | Operation | Keys read per month |
+| | `writes` | Operation | Keys written per month |
+| | `deletes` | Operation | Keys deleted per month |
+| | `lists` | Operation | List operations per month |
+| | `storedBytes` | Storage | Maximum stored data in bytes |
+| D1 | `rowsRead` | Operation | Rows read per month |
+| | `rowsWritten` | Operation | Rows written per month |
+| | `storedBytes` | Storage | Maximum database size in bytes |
+| R2 | `classAOps` | Operation | Class A operations per month (writes, lists, multipart) |
+| | `classBOps` | Operation | Class B operations per month (reads, heads) |
+| | `storedBytes` | Storage | Maximum stored data in bytes |
+| Durable Objects | `requests` | Operation | Requests per month (HTTP, RPC, WebSocket messages) |
+| | `rowsRead` | Operation | SQLite rows read per month |
+| | `rowsWritten` | Operation | SQLite rows written per month |
+| | `storedBytes` | Storage | Maximum stored data in bytes |
+| Queues | `operations` | Operation | Standard operations per month (per 64 KB chunk) |
+| Analytics Engine | `dataPointsWritten` | Operation | Data points written per month |
+| | `readQueries` | Operation | Read queries per month |
+| Vectorize | `queriedDimensions` | Operation | Queried vector dimensions per month |
+| | `storedDimensions` | Storage | Maximum stored vector dimensions |
+| Hyperdrive | `queries` | Operation | Database queries per month |
diff --git a/src/content/docs/cloudflare-for-platforms/workers-for-platforms/configuration/binding-groups/migrate.mdx b/src/content/docs/cloudflare-for-platforms/workers-for-platforms/configuration/binding-groups/migrate.mdx
new file mode 100644
index 000000000000000..9fa8b273ca9d4eb
--- /dev/null
+++ b/src/content/docs/cloudflare-for-platforms/workers-for-platforms/configuration/binding-groups/migrate.mdx
@@ -0,0 +1,91 @@
+---
+title: Migrate to binding groups
+pcx_content_type: how-to
+sidebar:
+ order: 5
+---
+
+import { TypeScriptExample } from "~/components";
+
+If you have existing user Workers with bindings configured at deploy time, you can move those resources into binding groups and start passing them at dispatch time instead. This does not require redeploying your user Workers or copying any data.
+
+## Adopt existing resources
+
+Move an existing resource into a binding group with the Wrangler CLI. The resource keeps all its data — adoption is a metadata operation, not a data migration.
+
+```sh
+wrangler kv namespace move --namespace-id "abc123" --into-group "platform-customer-stores" --name "acme-corp"
+```
+
+You can also bulk-import resources from a file:
+
+```sh
+wrangler kv namespace import --into-group "platform-customer-stores" --from-file customers.csv
+```
+
+After adoption, the resource is accessible through the binding group with `.get()`:
+
+```ts
+// The existing KV namespace is now accessible by name
+env.CUSTOMER_KV.get("acme-corp");
+```
+
+The namespace ID does not change. The resource gains a name within the group and becomes accessible through the binding group API.
+
+## Adopt resources from deploy-time bindings
+
+If you use Workers for Platforms and currently attach bindings at deploy time through the [upload API](/cloudflare-for-platforms/workers-for-platforms/configuration/bindings/), you can adopt those resources into a binding group.
+
+
+
+```ts
+// One-time migration: adopt existing resources into binding groups
+const existingKVNamespaceId = await getWorkerBindingConfig("acme-corp", "KV");
+await env.CUSTOMER_KV.adopt(existingKVNamespaceId, "acme-corp");
+```
+
+
+
+After adoption, the resource is accessible through `env.CUSTOMER_KV.get("acme-corp")` and can be passed at dispatch time. The underlying KV namespace and all its data stays the same.
+
+## Migrate incrementally
+
+Bindings passed at dispatch time override deploy-time bindings with the same name. This means you can move one binding at a time.
+
+
+
+```ts
+export default {
+ async fetch(request: Request, env: Env) {
+ const customerId = new URL(request.url).hostname.split(".")[0];
+
+ // Phase 1: Move KV to binding groups first.
+ // DB, SEARCH, STORAGE still come from deploy-time config.
+ const userWorker = env.DISPATCHER.get(customerId, {
+ bindings: {
+ KV: env.CUSTOMER_KV.get(customerId),
+ },
+ });
+
+ return userWorker.fetch(request);
+ },
+};
+```
+
+
+
+Once you have verified that the migrated binding works correctly, move the next binding. Repeat until all bindings are passed at dispatch time.
+
+## Verify the migration
+
+After moving a binding to dispatch-time injection:
+
+1. Confirm reads return existing data (the resource was adopted, not recreated).
+2. Confirm writes persist as expected.
+3. Monitor logs and error rates for the affected user Workers.
+
+Only remove the deploy-time binding configuration after you have confirmed the dispatch-time binding works correctly.
+
+:::note
+If a customer gets a new [D1](/d1/) database from a binding group instead of adopting an existing one, that database starts empty. Apply the schema with `db.exec()` before sending production traffic.
+:::
diff --git a/src/content/docs/cloudflare-for-platforms/workers-for-platforms/configuration/binding-groups/supported-bindings.mdx b/src/content/docs/cloudflare-for-platforms/workers-for-platforms/configuration/binding-groups/supported-bindings.mdx
new file mode 100644
index 000000000000000..8159e621dc072f3
--- /dev/null
+++ b/src/content/docs/cloudflare-for-platforms/workers-for-platforms/configuration/binding-groups/supported-bindings.mdx
@@ -0,0 +1,43 @@
+---
+title: Supported bindings
+pcx_content_type: reference
+sidebar:
+ order: 3
+---
+
+This page lists the bindings that binding groups support and how each resource is created.
+
+## Auto-created bindings
+
+These bindings create the underlying resource automatically on first use. You do not need a separate provisioning step.
+
+| Binding | What gets created | Creation trigger |
+| ----------------------------------------------------------- | ----------------- | ---------------------------------------------------------------- |
+| [KV](/kv/) | KV namespace | First write |
+| [R2](/r2/) | R2 bucket | First `put()` |
+| [D1](/d1/) | D1 database | First use (empty SQLite database, apply schema with `db.exec()`) |
+| [AI Search](/ai-search/) | Search index | First `upsert()` |
+| [Durable Objects](/durable-objects/) | DO namespace | First `.get()` of an object within the namespace |
+| [Queues](/queues/) | Queue | First `send()` |
+| [Analytics Engine](/analytics/analytics-engine/) | Dataset | First write |
+| [Rate Limiting](/workers/runtime-apis/bindings/rate-limit/) | Rate limiter | First `.limit()` call |
+| [Workflows](/workflows/) | Workflow instance | First `.create()` call |
+
+## Explicitly created bindings
+
+These bindings require configuration before they can be used. Create the resource with `.create()` during customer onboarding or setup, then reference it with `.get()` at runtime.
+
+| Binding | What gets created | Required configuration |
+| -------------------------------------------------------------------- | ------------------------- | -------------------------------------- |
+| [Hyperdrive](/hyperdrive/) | Hyperdrive config | Connection string |
+| [Vectorize](/vectorize/) | Vectorize index | Dimensions and metric |
+| [mTLS](/workers/runtime-apis/bindings/mtls/) | Client certificate | Certificate and private key |
+| [Service Bindings](/workers/runtime-apis/bindings/service-bindings/) | Binding to another Worker | Target Worker name |
+| [Pipelines](/pipelines/) | Pipeline | Destination config (R2 bucket, format) |
+
+## Limited support
+
+| Binding | Status |
+| ---------------------------------------- | ------------------------------------------------------------------------------- |
+| [Browser Rendering](/browser-rendering/) | Per-instance support does not exist yet. Each account gets one shared instance. |
+| [Secrets Store](/secrets-store/) | Per-customer support does not exist yet. |