Skip to content
OTFotf
All posts

Delete one tenant's files without scanning a shared Blob store

D
DaveAuthor
7 min read
Delete one tenant's files without scanning a shared Blob store

A multi-tenant app on Vercel can keep every customer's files in one Blob store under a pathname prefix, or give each customer a store. On 23 September 2026 Vercel removed the caps of 100 stores on Hobby, 500 on Pro, and 1,000 on Enterprise. One store per tenant is worth the create when export and delete must be a credential boundary. A mistaken pathname then cannot read or delete another tenant, because that credential does not open the other store. A prefix is still the right layout when your server builds every pathname from the authenticated tenant id and deletes from URLs saved on that tenant's rows.

Creating a store is one Blob Advanced Operation, the same class as put(), copy(), and list(). On Pro that class is $5.00 per million. On Hobby it counts toward the 2,000 advanced operations included each month. Deleting a store is free. Storage, operations, and data transfer for the same bytes cost the same in one store or in many. The create buys a unit you can hand to a job, wipe, or destroy. It does not buy a cheaper disk.

What still bills when stores are unlimited

Storage size, simple operations, advanced operations, and data transfer still bill on use. Storage is the monthly average size. A simple operation is a cache-miss read by URL, or head(). An advanced operation is put(), copy(), list(), or a store create. Data transfer bills when a blob is downloaded. Every URL access is also an edge request, and a cache miss adds fast origin transfer. A blob larger than 512 MB is never cached, so every read of it is a miss.

del() is free of charge. A batched del([urls]) still counts each blob toward the rate limit, so 100 pathnames are 100 operations against that limit, not one HTTP call. Hobby allows 1,200 simple and 900 advanced operations per minute. Pro allows 7,200 and 4,500. Dashboard browsing and uploads count as advanced operations too.

Hobby includes 1 GB of storage, the first 10,000 simple operations, the first 2,000 advanced operations, and the first 10 GB of transfer each month. Past that, Blob access stops and the overage is not billed. Extra stores do not raise the 1 GB ceiling. About 2,000 creates in a month, with no put() or list() beside them, spend the Hobby advanced allowance on empty stores.

Private and public stores share storage and operation prices. A private file is read by your server, then streamed to the caller, so both hops bill. A public URL is fetched by the client. Tenant documents belong in a private store. Access mode and region are fixed at create time. The CLI uses iad1 when you omit --region.

When a pathname prefix is enough

One store, one credential. On Vercel the SDK pairs a short-lived OIDC token with one BLOB_STORE_ID and refreshes that token. Outside Vercel, or for a signed browser upload, the credential is BLOB_READ_WRITE_TOKEN. An explicit token argument always beats OIDC.

A shared store with pathname tabs is fragile when one credential opens every tenant

import { del, list, put } from '@vercel/blob';

export async function putTenantFile(tenantId: string, name: string, body: Blob) {
  return put(`tenants/${tenantId}/${name}`, body, {
    access: 'private',
    addRandomSuffix: true,
  });
}

export async function deleteTenantPrefix(tenantId: string) {
  const prefix = `tenants/${tenantId}/`;
  let cursor: string | undefined;

  do {
    const page = await list({ prefix, cursor, limit: 1000 });
    if (page.blobs.length > 0) {
      await del(page.blobs.map((blob) => blob.url));
    }
    cursor = page.hasMore ? page.cursor : undefined;
  } while (cursor);
}

addRandomSuffix defaults to false. Turn it on so two uploads of the same name do not collide, then delete the URL put() returned. Each list() page is one advanced operation. The default page size is 1,000. del() does not throw if the URL is already gone, so a bad delete can look finished. Removed objects can stay on the CDN for up to a minute. Objects your rows forgot stay until a later list sees them.

Keep the prefix when your server is the only writer, you never hand the credential to a tenant or another service, and tenants are small enough that the sweep is a few list() calls. A leaked server credential that already exposes the rest of the account is not fixed by a second store. A public store fails even with a perfect prefix: anyone with the URL can read the file.

11 production screens. Login, database, payments — all wired.

The SaaS Dashboard Kit ships everything already connected. Nothing to set up. Live demo at saas.otf-kit.dev.

See the live demo

When a store per tenant is worth the create

Create a user-created store when the unit you export, revoke, or destroy is the tenant. POST https://api.vercel.com/storage/stores/blob takes name (required, at most 70 characters), access (private or public, default public), and an optional region. Set access to private. That POST is one advanced operation. vercel blob create-store <name> --access private is the same create.

The published 200 schema for that POST lists access, region, kind, size, and project metadata. It does not list an id, so do not invent body.store.id. Match the name you sent with vercel blob list-stores --all, save that id on the tenant row, and pass it as storeId. Leave the project's single BLOB_STORE_ID on the production store. Pointing it at a tenant store moves production traffic.

One store credential is the unit you can hand out, empty, or delete

import { put } from '@vercel/blob';

export async function putInTenantStore(storeId: string, name: string, body: Blob) {
  return put(name, body, {
    access: 'private',
    addRandomSuffix: true,
    storeId,
  });
}

export async function deleteTenantStore(storeId: string, platformToken: string) {
  const response = await fetch(
    `https://api.vercel.com/storage/stores/blob/${encodeURIComponent(storeId)}`,
    {
      method: 'DELETE',
      headers: { Authorization: `Bearer ${platformToken}` },
    },
  );
  if (!response.ok) {
    throw new Error(`Blob store delete failed: ${response.status}`);
  }
  const body = (await response.json()) as { id: string };
  return body.id;
}

Pass storeId as store_<id> or <id>. Leave VERCEL_OIDC_TOKEN in the environment. Copying it into oidcToken skips refresh, and later calls fail with 403. A read-write token is the override for a job outside Vercel, and it opens only that store.

Read the tenant row before you POST. A retry opens a second store and bills another advanced operation. An owned API stores the first success and replays it. The first create is the one you keep.

The platform token that may create and delete stores can destroy every tenant store. Keep it off the customer request path. Rotate that secret with dual-key overlap. A project-default store is a different object: private, lazy, OIDC, and the mutation APIs do not manage it. Delete only a user-created store.

Export is still list() with no prefix, then get() or the download URL. There is no export-store call. This storeId cannot return the next tenant. Listing a large tenant still costs one advanced operation per thousand objects. Delete can skip that list.

vercel blob empty-store <store-id> deletes every blob and keeps the store id. vercel blob delete-store <store-id> removes the store. Both take --yes in a script. Empty the store when the tenant stays. Delete it when the tenant is gone. Store deletion is free.

Split production, staging, and preview

Production, staging, and shared preview are three stores. Three advanced operations, once, stop a preview write from landing on production objects. Make that split even when every production tenant shares one store.

One preview store still mixes branches. Every deployment that shares BLOB_STORE_ID can list the others. For a disposable branch, create a store, pass that storeId only into that deployment, and DELETE /storage/stores/blob/{id} when the branch closes. The delete is free. Bytes the preview wrote still bill until then. Do not point preview at the production store id to skip the create.

When you don't need this

Skip a store per tenant for a single customer, or for public assets. Skip it when every blob URL already sits on the tenant row: offboarding is del() of that list, and production still stays off the preview store. Skip it on Hobby if signups would create thousands of stores. About 2,000 creates consume the monthly advanced allowance. Stay on one private store and a server-built prefix until the plan can absorb them.

Sweep a prefix as a paced job, not a tight loop. Refuse that burst before it melts the rate limit. Each list() page is an advanced operation, and each URL in a batched del() counts toward the rate limit.

OTF kits do not ship a per-tenant Blob layout yet. The owned upload path starts from the templates at https://otf-kit.dev/templates.

Sources

vercelbackendarchitecture
OTF SaaS Dashboard Kit

Ship the product, not the setup.

  • 11 production screens — auth, billing, team, analytics, settings
  • Real database, payments, and login — all wired on day 1
  • AI configs pre-tuned so your agent extends instead of regenerates
Need more than components?

Full-stack kits.
Pay once, own the code.

Auth, database, and payments already connected — so you ship product, not setup. Or take every kit in the Bundle.

Everything Bundle — $149See full pricing

Get the free AI configs pack

Pre-tuned AI configs for Cursor, Claude, and Lovable — drop them in and your AI tool instantly understands your project.

No spam. Unsubscribe any time.

Prefer the free SDK? Star it on GitHub →