Storage

Uploaded images have to live somewhere. Next BlogPanel puts them in the MongoDB you already configured, lets you move them to Cloudflare R2 when that stops being the right answer, and lets you plug in any other backend when neither is.

MongoDB GridFS — the default

Nothing to set up. If BLOGPANEL_MONGODB_URI works, image uploads work. Files are written to a GridFS bucket named media in the same database as your posts, and served back from ${apiPath}/media/file/[...key]/api/blog/media/file/... with the default paths.

The honest trade-offs:

  • No CDN. Bytes are served by your Next.js server, from your origin, on every request that misses a cache. Responses carry Cache-Control: public, max-age=31536000, immutable, so browsers and any CDN you put in front of your app will hold onto them, but the package does not provide one.
  • It shares your database quota. On an Atlas free cluster that is 512MB total, images and posts together.
  • It works out of the box. No bucket, no keys, no second dashboard, no second bill.

How much fits in 512MB

Less than the 2MB cap on the original suggests, because the original is not the only thing stored.

Every upload writes up to seven objects: the compressed WebP original, one responsive width for each of 640, 768, 1024, 1280 and 1920 that is narrower than the source, and a 200x200 thumbnail. A 2560px-wide source gets all seven; a 1600px source gets six (640, 768, 1024 and 1280 are all narrower than 1600 — 1920 is not — plus the thumbnail and the original); a 500px source gets two. The responsive widths together cover roughly as many pixels as the original does, so in bytes they add about as much again — sometimes more, because small images cost more bytes per pixel.

A worked example, for a typical 2000px blog photo:

Object Rough size
WebP original 200KB
1280w, 1024w, 768w, 640w 140 + 95 + 60 + 45KB
200x200 thumbnail 10KB
Total per image ~550KB

So budget 500-800KB per uploaded image, not 150-250KB. On the Atlas free tier that is several hundred images in 512MB — the earlier "roughly two thousand" figure in this file counted the original only and was wrong by a factor of three. Upload images that sit near the 2MB cap and the number falls further, to something closer to a hundred.

And derivatives are never reclaimed. createMedia records only the original's storage key, and DELETE removes only that key. The six derivative objects are written under their own independently generated UUID keys that nothing persists, so after the media row is deleted there is no record anywhere of what they were called: they cannot be listed against a post, cannot be deleted through the API, and cannot be found by hand except by scanning the bucket for keys with a -640w/-thumb suffix. Deleting a media item therefore frees roughly a third of what that image actually occupies, and the rest stays until you drop the GridFS bucket or clean it out yourself. Fixing this needs the media schema to record every derivative key and is tracked as a follow-up, not shipped in 1.0.

Because GridFS objects are served from your own origin rather than a separate host, an uploaded SVG carrying a <script> would otherwise be same-origin stored XSS. The streaming route sends X-Content-Type-Options: nosniff and Content-Security-Policy: default-src 'none'; sandbox on every response, including 404s. That route is deliberately unauthenticated — images are public assets referenced from published posts. Every other media operation stays behind the API key.

Cloudflare R2 — the scale-up

Set all five variables and R2 becomes the backend on the next upload:

BLOGPANEL_R2_ACCOUNT_ID=your-account-id
BLOGPANEL_R2_ACCESS_KEY=your-access-key
BLOGPANEL_R2_SECRET_KEY=your-secret-key
BLOGPANEL_R2_BUCKET=blog-media
BLOGPANEL_R2_PUBLIC_URL=https://media.yourdomain.com

All five, or none. The check is for non-empty values, not valid ones — which means the placeholder lines in the generated .env.local.example are enough to select R2 and send every upload at credentials that do not exist. Delete or comment those five lines out unless you have a real bucket.

Objects are stored under media/YYYY/MM/<uuid>-<filename> and served from BLOGPANEL_R2_PUBLIC_URL, so they leave your origin entirely and get Cloudflare's CDN for free.

Forcing a backend

BLOGPANEL_STORAGE overrides the automatic choice:

BLOGPANEL_STORAGE=gridfs      # stay on MongoDB even with R2 configured
BLOGPANEL_STORAGE=r2          # use R2; fails loudly if it is not fully configured
BLOGPANEL_STORAGE=my-backend  # a provider you registered under that name

Useful for keeping a staging deployment off the production bucket, or for testing a migration before committing to it.

The value is any resolvable provider name, not a fixed list of two — that is how a custom provider is selected. A name that does not resolve throws StorageProviderNotRegisteredError naming every provider that does, rather than quietly reverting to the automatic choice. A blank value (BLOGPANEL_STORAGE=) reads as unset.

Switching backends later

Changing the backend affects new uploads only. Existing files stay where they are, and stay reachable — each media document records the provider it was written under, and deletes are dispatched to that provider rather than to whatever is configured today. A GridFS-era image is still deleted from GridFS after you move to R2.

The same holds for custom providers, with one obligation attached: a provider named on existing documents must stay registered even after you stop uploading through it, or those deletes fail (loudly, and without losing the row — see When a name cannot be resolved).

Moving the old files across is a separate job that the package does not do for you.

Free tiers, if you are comparing

Option Free tier Setup Trade-off
MongoDB GridFS (default) Shares your Atlas 512MB None No CDN; served by your server
Cloudflare R2 10GB, 10M reads/mo, no egress fees 5 env vars + bucket Best at scale; recommended upgrade
Vercel Blob ~1GB Zero on Vercel Ties the free path to one host
Supabase Storage 1GB Project + keys Another dashboard
Backblaze B2 10GB Bucket + keys Needs a CDN in front to be cheap
ImageKit 20GB bandwidth/mo Account Transforms included; another vendor
UploadThing 2GB Account Simplest API of the third parties

Only the first two ship with the package. Every one of the rest can be plugged in yourself — see Custom storage providers — but you write and maintain that class; nothing below R2 in this table is code we ship, test, or support. For most blogs the default is fine until it isn't, and then it's R2.

The two size limits

They are not the same knob, and they work in opposite directions.

editor.maxUploadSize — 10MB — is a gate. It is the largest file the upload endpoint will accept. Bigger files get 413 FILE_TOO_LARGE before the bytes are buffered.

editor.maxImageSize — 2MB — is a target. It is the largest file that will ever be stored. Images above it are compressed down to fit, not refused.

The compression ladder

Every accepted raster image is re-encoded to WebP and stepped through quality levels, then width caps, stopping at the first result that fits under maxImageSize:

Step Width cap WebP quality
1-4 none 85, 75, 65, 55
5-8 2560px 85, 75, 65, 55
9-12 1920px 85, 75, 65, 55

A normal image costs one encode, because step 1 usually already fits. Worst case is twelve.

upload 5.2MB JPEG
  → webp q85     2.9MB  ✗
  → webp q75     1.8MB  ✓  stored

upload 12MB JPEG
  → rejected: over 10MB ingest limit

If all twelve steps overshoot, the upload fails with 422 IMAGE_TOO_LARGE and a message naming the best size achieved. In practice that takes a pathological image — enormous dimensions with high-entropy detail everywhere.

Exceptions

  • SVG bypasses the ladder. sharp cannot meaningfully shrink vector markup, so an SVG over maxImageSize is rejected with 413 rather than compressed. Under the limit it is stored as-is.
  • If sharp is unavailable, the file is uploaded raw with no conversion, no ladder, and no derivatives.
  • Accepted MIME types: image/jpeg, image/png, image/gif, image/webp, image/svg+xml, image/avif. Anything else gets 400 INVALID_TYPE.

Derivatives

Alongside the compressed original, each upload produces responsive WebP widths (640, 768, 1024, 1280, 1920 — only those narrower than the source) and a 200x200 thumbnail. These are generated before the database row is written, and every one of them is awaited rather than left to finish in the background, because serverless runtimes can freeze the process the moment a response is returned.

Uploads are atomic. If a derivative fails, the original is deleted again and the whole upload errors. If the database write fails after every object landed, all of them are deleted. A failed upload never leaves objects in storage that nothing references.

A successful upload does, though — permanently. Each derivative gets its own generateKey() UUID, and only the original's key is written to the media document, so from the moment the upload completes nothing in the database knows the derivative keys. They are unreachable and unreclaimable; see How much fits in 512MB for what that costs you.

Deleting media

DELETE ${apiPath}/media?id=<id> removes the database row first, then asks the provider that actually holds the object to delete it. The response reports both halves:

{
  "success": true,
  "data": {
    "deleted": true,
    "storageDeleted": true,
    "storageKey": "media/2026/07/a1b2c3d4-photo.webp",
    "provider": "gridfs"
  }
}
Field Meaning
deleted The database record is gone. Always true on a 200.
storageDeleted Whether the stored file was also removed.
storageKey The key that was targeted.
provider Name of the provider that was actually asked — gridfs, r2, or a custom one.
warning Present only when storageDeleted is false.

storageDeleted: false means the file is stranded. The row is gone, so the item will never appear in the media library again and nothing in the UI can reach it — but the bytes are still in storage, still counting against your quota. The response stays HTTP 200 because the database half genuinely succeeded, and carries a warning string plus the storageKey so you can clean it up by hand. The server logs the provider, the key and the underlying error at the same time.

Only the original is deleted. The responsive derivatives and the thumbnail are not tracked individually, so nothing can find them to delete them — they are left behind for good, still counting against your quota. This is a known 1.0 limitation, not a transient failure like storageDeleted: false above.

The provider interface

Every backend — the two built in, and any you add — implements one interface:

interface StorageProvider {
  readonly name: string;
  upload(file: Buffer, filename: string, contentType: string): Promise<MediaUploadResult>;
  delete(key: string): Promise<void>;
  list(prefix?: string): Promise<StorageObject[]>;
}

interface MediaUploadResult {
  key: string;          // opaque storage key; `delete(key)` must accept it back
  url: string;          // what the browser loads; persisted permanently
  size: number;         // bytes actually stored
  contentType: string;  // MIME type actually stored
}

interface StorageObject {
  key: string;
  size: number;
  lastModified: Date;
}

All three types are exported from both next-blogpanel and next-blogpanel/lib.

name is what makes provider-aware deletes possible. Providers generate identically-shaped keys — both built-ins use the shared generateKey(), and custom ones are encouraged to — so asking the wrong one to delete a key does not error. It finds nothing and returns cleanly, silently orphaning the object. Recording name on every media document is what stops that from happening after a backend switch.

R2StorageProvider builds its endpoint from BLOGPANEL_R2_ACCOUNT_ID as https://<account>.r2.cloudflarestorage.com, so it is a Cloudflare R2 client rather than a general S3 one. For plain S3 or an S3-compatible host, write a provider (below) rather than trying to bend the R2 one.

Custom storage providers

Implement the interface, register the instance before any request is served, and point BLOGPANEL_STORAGE at its name. That is the whole extension point — Vercel Blob, Supabase Storage, Backblaze B2, ImageKit, UploadThing, S3, or an internal service all fit it.

1. Write the provider

A complete, working example — a real S3 provider, the case R2StorageProvider deliberately does not cover. Install the SDK in your own project first (npm install @aws-sdk/client-s3); this package depends on it internally, but do not rely on that hoisting into your node_modules:

// lib/s3-storage-provider.ts
import {
  S3Client,
  PutObjectCommand,
  DeleteObjectCommand,
  ListObjectsV2Command,
} from '@aws-sdk/client-s3';
import {
  generateKey,
  type StorageProvider,
  type MediaUploadResult,
  type StorageObject,
} from 'next-blogpanel';

export class S3StorageProvider implements StorageProvider {
  // Written to `provider` on every media document and read back to route the
  // delete. Never change it once objects exist under it.
  readonly name = 's3';

  private client = new S3Client({
    region: process.env.AWS_REGION!,
    credentials: {
      accessKeyId: process.env.AWS_ACCESS_KEY_ID!,
      secretAccessKey: process.env.AWS_SECRET_ACCESS_KEY!,
    },
  });

  private bucket = process.env.S3_BUCKET!;
  private publicUrl = process.env.S3_PUBLIC_URL!; // e.g. https://cdn.example.com

  async upload(
    file: Buffer,
    filename: string,
    contentType: string
  ): Promise<MediaUploadResult> {
    // Reuse the package's key generator: media/YYYY/MM/<uuid>-<safe-name>.
    // You may generate your own, but read "What your provider must
    // guarantee" below before you do.
    const key = generateKey(filename);

    await this.client.send(
      new PutObjectCommand({
        Bucket: this.bucket,
        Key: key,
        Body: file,
        ContentType: contentType,
      })
    );

    return {
      key,
      url: `${this.publicUrl}/${key}`,
      size: file.length,
      contentType,
    };
  }

  async delete(key: string): Promise<void> {
    await this.client.send(
      new DeleteObjectCommand({ Bucket: this.bucket, Key: key })
    );
  }

  async list(prefix = 'media/'): Promise<StorageObject[]> {
    const response = await this.client.send(
      new ListObjectsV2Command({ Bucket: this.bucket, Prefix: prefix })
    );

    return (response.Contents || []).map((obj) => ({
      key: obj.Key || '',
      size: obj.Size || 0,
      lastModified: obj.LastModified || new Date(),
    }));
  }
}

2. Register it

Registration has to happen before any request is served. app/next-blogpanel.setup.ts — the module next-blogpanel init generates, which every scaffolded route already imports — is the right home, and it ships with a commented-out block for exactly this:

// app/next-blogpanel.setup.ts
import { setConfig, registerStorageProvider } from 'next-blogpanel';
import config from '../next-blogpanel.config';
import { S3StorageProvider } from '../lib/s3-storage-provider';

setConfig(config);
registerStorageProvider(new S3StorageProvider());

If you wrote your own route handlers instead of using the scaffold, import that setup module at the top of each of them. Next.js instantiates server modules per route entry, so no single route can register on behalf of the others — the import has to be in all of them. (Registration itself is stored process-wide on a globalThis slot, the same mechanism setConfig() uses, so one call reaching any entry point covers the whole process. The repeated import is what guarantees at least one call has run, whichever route a request hits first.)

Calling registerStorageProvider() more than once with the same name is expected and harmless: each bundled copy of the setup module constructs its own instance, and the last one wins. Registering over gridfs or r2 is refused — those names are already recorded on existing media documents, and redirecting their deletes to a different backend is exactly the silent stranding this whole mechanism exists to prevent.

3. Select it

BLOGPANEL_STORAGE=s3

Registering a provider makes it resolvable; BLOGPANEL_STORAGE makes it active. Skip this variable and the normal GridFS/R2 auto-selection still decides where new uploads go, however many providers you registered.

The registration has to outlive the last upload, though — it is needed for as long as any object exists under that name, not just while you are writing new ones. Every media document written under s3 records that name, and deleting one resolves it again. You can safely stop selecting the provider (move BLOGPANEL_STORAGE back to gridfs, or drop it) while leaving it registered; that is a normal backend switch. Removing the registration itself while such documents exist makes their deletes fail loudly (see below) instead of quietly deleting nothing.

What your provider must guarantee

Keys round-trip. Whatever upload() returns as key is stored verbatim in Media.storageKey and handed straight back to delete(key) and to list() results. It must be the complete identifier your backend needs — not a fragment that only means something alongside state your provider holds in memory.

Keys are unique. nbk_media.storageKey carries a unique index, so a collision fails the database write and rolls the upload back. generateKey() gives you media/YYYY/MM/<uuid>-<sanitised-filename>; if you generate your own, keep a UUID or equivalent in it. Nothing prevents a custom key shape — but the media/ prefix is what list() defaults to and what the health check probes, so staying with it is the path of least surprise.

delete() of a key that is not there resolves, and does not throw. Upload rollback calls it on best-effort cleanup paths, and a retried delete will hit an already-deleted key. Both built-ins behave this way. Throwing turns an idempotent retry into a 200-with-storageDeleted: false.

url is what the browser will load, and it is permanent. It goes into the media document and into the body of every post that embeds the image. Changing your public URL later does not rewrite them. Two shapes are accepted:

  • Absolutehttps://cdn.example.com/media/…. Correct for any off-origin backend, and what the example above returns. absoluteUrl() in src/lib/url.ts passes these through untouched, so Open Graph tags, JSON-LD and RSS enclosures work with no BLOGPANEL_SITE_URL involvement.
  • Root-relative/api/blog/media/file/…, a single leading slash. This is what GridFS returns, because it serves bytes from your own origin. absoluteUrl() prefixes these with BLOGPANEL_SITE_URL where an absolute URL is required. Only return this shape if your app genuinely serves the bytes at that path.

It does not have to be absolute — but //cdn.example.com/… (protocol-relative) and data: URIs are not accepted: MediaSchema requires http(s)://… or a single-leading-slash path.

size and contentType describe what was stored, not what was handed in. Images arrive already re-encoded to WebP, so echoing the incoming contentType is normally right; if your backend transforms further, report the result.

upload() is fully awaited before it resolves. Serverless runtimes can freeze the process the moment a response is returned, so anything still in flight is silently dropped.

The instance is shared. One registered object serves every concurrent request in the process. Hold clients and config on it; do not hold per-request state.

name is stable and unique. It is a permanent identifier in your database, not a label. Renaming it strands every object recorded under the old name.

When a name cannot be resolved

getStorageProviderByName() throws StorageProviderNotRegisteredError — it never falls back to the configured default, because a default asked to delete a key it does not hold finds nothing and reports success, stranding the object with nothing logged. The error names the unresolvable provider and every provider that is resolvable in that process.

Two places surface it:

  • POST ${apiPath}/media returns 500 STORAGE_PROVIDER_NOT_REGISTERED with the full message. Nothing was written.
  • DELETE ${apiPath}/media resolves the provider before it removes the database row, so this failure is recoverable: you get 500 STORAGE_PROVIDER_NOT_REGISTERED, the row is still there, and retrying after you register the provider deletes the object properly.

The usual causes are a typo in BLOGPANEL_STORAGE, a provider dropped from the setup module while documents still name it, or a renamed provider.

next-blogpanel health and custom providers

The CLI runs as its own process and never loads your app's setup module, so it can never resolve a custom provider — by construction, not by failure. It reports that as a and leaves the exit code alone, so a deploy gate or CI step branching on next-blogpanel health does not break on a healthy install. If the database already holds objects under that name, it says so too, since that is proof your app registered the provider successfully.

It still fails (, exit 1) when the name looks like a misspelling of one that exists — of a built-in, or of a provider your stored media documents name — and tells you what you probably meant. A name that resembles nothing and has stored nothing yet is treated as a legitimate new provider and warns rather than fails; if it really is wrong, the server refuses the first upload with STORAGE_PROVIDER_NOT_REGISTERED.