Features Apograph CMS on GitHub

Vercel Blob

Media plugin @apograph/media-server@apograph/media-admin

Smallest setup on Vercel — and the one backend that cannot keep media private.

Documents 0.5.2 Updated Edit this page Report a problem

On this page

The smallest adapter in the set, and the one with a caveat that belongs in a deployment decision rather than in code.

Every blob is world-readable

Read this before choosing it

Vercel Blob has one access mode: public. The URL it returns is permanent, unguessable and unauthenticated. Anyone who obtains it — a copied <img src>, a browser extension, a proxy log, a forwarded email — can fetch that asset forever, with no reference to who they are.

The Media Library is otherwise private by default: every raw read checks workspace membership, and a non-member gets the same 404 as a missing asset. This backend cannot uphold that for anyone holding the underlying URL.

The app keeps enforcing its own rules — the API returns its own route, never the blob URL — but a second, public copy of the bytes exists and cannot be revoked short of deleting the blob.

Fine for a marketing site’s images. Not fine for a workspace whose media is confidential. No adapter can paper over that.

Install

npm install @apograph/media-provider-vercel-blob
import { createVercelBlobStorageProvider } from '@apograph/media-provider-vercel-blob';

MediaServerPlugin({
    provider: createVercelBlobStorageProvider(config.plugins.media.storage),
    config: config.plugins.media
});

Configuration

{ token?, keyPrefix?, api? } — and on Vercel itself, nothing at all.

VariableWhat it does
BLOB_READ_WRITE_TOKENOnly needed off Vercel; the SDK reads it there itself

api is the injection seam. @vercel/blob exports free functions rather than a client, so the seam is the three-function interface the provider accepts — the same one a caller could substitute to route through their own transport.

directUrl is false, deliberately

The store’s URL never expires and carries no per-request disposition. The port’s directUrl promises both — a short lifetime, and a pinned Content-Disposition and Content-Type — so declaring it would be a lie that hands out an unrevocable link and serves an uploaded .html inline from the blob host.

Proxying also keeps the membership check on the request path, which is the library’s actual access rule.

This is the clearest case in the codebase of a capability being a claim about what a backend can honour, not a wish.

What not to lose in a refactor

addRandomSuffix: false. The pathname this adapter computes is the storage key. With the SDK’s default suffix on, the blob lands somewhere the adapter cannot name, and every later read or remove misses.

The upload and the pipeline are awaited together. Awaiting the upload first meant that when it rejected, the pipeline’s own rejection had no handler yet — an unhandled rejection, which in Node kills the process rather than failing the request. The contract suite’s mid-stream-failure case catches this, and did.