You're reading docs for Nextly Alpha. APIs may change between releases.

Guides

Media & Storage

Configure storage adapters (local disk, S3, Vercel Blob, Uploadthing) and named image sizes for uploaded media.

Nextly's media system is plugin-based. The default is local disk storage -- zero configuration, files written to ./public/uploads/ and served by Next.js as static assets. For production deployments where the filesystem isn't durable (Vercel, container runtimes), opt into S3, Vercel Blob, or Uploadthing.

Storage adapters at a glance

AdapterServicesWhen to use
Built-in (local disk)Local filesystem under ./public/uploads/Development, single-server self-host
@nextlyhq/storage-s3AWS S3, Cloudflare R2, MinIO, DigitalOcean Spaces, Supabase StorageMost production deployments
@nextlyhq/storage-vercel-blobVercel BlobVercel deployments
@nextlyhq/storage-uploadthingUploadthingUploadthing users

Storage adapters are plugins added to the storage array in defineConfig(). Each plugin declares which collections it handles, and the MediaStorage manager routes uploads accordingly:

Upload Request -> MediaStorage -> resolve collection -> plugin adapter -> storage backend

Default: local disk

When no storage plugin is configured, Nextly uses the built-in local-disk adapter. Files are stored under ./public/uploads/{year}/{month}/ and served by Next.js at /uploads/... as static assets. On first upload, Nextly auto-adds public/uploads/ to your .gitignore.

This is what makes local development work without setting up any cloud credentials.

Warning: Local disk storage doesn't survive serverless restarts. If you deploy to Vercel, AWS Lambda, Cloudflare Workers, or any platform with ephemeral filesystems, switch to a cloud adapter before going to production.

Explicit configuration

If you want to customise the local paths (e.g. write to ./public/media/ instead), import localStorage and add it explicitly:

// nextly.config.ts
import { defineConfig } from "@nextlyhq/nextly/config";
import { localStorage } from "@nextlyhq/nextly/storage";

export default defineConfig({
  storage: [
    localStorage({
      basePath: "./public/media", // default: "./public/uploads"
      baseUrl: "/media",          // default: "/uploads"
      collections: { media: true },
    }),
  ],
});

Auto-detection (zero config)

Use getStorageFromEnv() to let Nextly pick a storage adapter based on environment variables. Useful for moving between dev (no env vars -> local disk) and production (set the env vars and the right adapter is loaded automatically).

// nextly.config.ts
import { defineConfig } from "@nextlyhq/nextly/config";
import { getStorageFromEnv } from "@nextlyhq/nextly/storage";

export default defineConfig({
  storage: await getStorageFromEnv(),
});

Detection cascade (first match wins):

  1. BLOB_READ_WRITE_TOKEN set -> Vercel Blob.
  2. S3_BUCKET (+ S3_REGION, AWS_ACCESS_KEY_ID, AWS_SECRET_ACCESS_KEY) set -> S3 (also covers R2, MinIO, Supabase Storage, DigitalOcean Spaces).
  3. UPLOADTHING_TOKEN set -> Uploadthing.
  4. None of the above -> Local disk (./public/uploads/).

If an env var is set but the matching adapter package isn't installed, Nextly logs a warning and falls back to the next cascade tier.

S3 adapter

Works with any S3-compatible service. Install:

pnpm add @nextlyhq/storage-s3
npm install @nextlyhq/storage-s3
yarn add @nextlyhq/storage-s3
bun add @nextlyhq/storage-s3

AWS S3

// nextly.config.ts
import { defineConfig } from "@nextlyhq/nextly/config";
import { s3Storage } from "@nextlyhq/storage-s3";

export default defineConfig({
  storage: [
    s3Storage({
      bucket: process.env.S3_BUCKET!,
      region: process.env.S3_REGION!,
      accessKeyId: process.env.AWS_ACCESS_KEY_ID!,
      secretAccessKey: process.env.AWS_SECRET_ACCESS_KEY!,
      collections: { media: true },
    }),
  ],
});

Cloudflare R2

R2 needs a custom endpoint and a public URL (R2 buckets don't have default public URLs):

s3Storage({
  bucket: process.env.R2_BUCKET!,
  region: "auto",
  accessKeyId: process.env.R2_ACCESS_KEY_ID!,
  secretAccessKey: process.env.R2_SECRET_ACCESS_KEY!,
  endpoint: `https://${process.env.R2_ACCOUNT_ID}.r2.cloudflarestorage.com`,
  publicUrl: process.env.R2_PUBLIC_URL,
  collections: { media: true },
})

MinIO (self-hosted)

MinIO requires path-style URLs:

s3Storage({
  bucket: "my-bucket",
  region: "us-east-1",
  accessKeyId: process.env.MINIO_ACCESS_KEY!,
  secretAccessKey: process.env.MINIO_SECRET_KEY!,
  endpoint: "http://localhost:9000",
  forcePathStyle: true,
  collections: { media: true },
})

DigitalOcean Spaces

Spaces is S3-compatible. Use the regional endpoint:

s3Storage({
  bucket: process.env.SPACES_BUCKET!,
  region: process.env.SPACES_REGION!,
  accessKeyId: process.env.SPACES_KEY!,
  secretAccessKey: process.env.SPACES_SECRET!,
  endpoint: `https://${process.env.SPACES_REGION}.digitaloceanspaces.com`,
  collections: { media: true },
})

Supabase Storage

Supabase Storage exposes an S3-compatible endpoint. No separate adapter needed:

s3Storage({
  bucket: "my-bucket",
  region: "us-east-1",
  accessKeyId: process.env.SUPABASE_S3_ACCESS_KEY!,
  secretAccessKey: process.env.SUPABASE_S3_SECRET_KEY!,
  endpoint: `https://${process.env.SUPABASE_PROJECT_ID}.supabase.co/storage/v1/s3`,
  forcePathStyle: true,
  collections: { media: true },
})

S3 configuration reference

OptionTypeDefaultDescription
bucketstringrequiredS3 bucket name.
regionstringrequiredAWS region (use "auto" for R2).
accessKeyIdstringoptionalAccess key. Falls back to AWS_ACCESS_KEY_ID env var.
secretAccessKeystringoptionalSecret key. Falls back to AWS_SECRET_ACCESS_KEY env var.
endpointstring--Custom endpoint for S3-compatible services (R2, MinIO, Spaces, etc.).
publicUrlstring--Public CDN / custom domain URL for serving files.
forcePathStylebooleanfalseUse path-style URLs (https://endpoint/bucket/key). Required for MinIO and Supabase.
aclstring"private"Object ACL: "private", "public-read", "bucket-owner-full-control", etc.
signedDownloadsbooleanfalseIssue signed URLs for downloads of private objects.
signedUrlExpiresInnumber3600Signed URL expiry in seconds.
cacheControlstring"public, max-age=31536000"Cache-Control header on uploaded objects.
contentDispositionstring--"inline" or "attachment". Sets the Content-Disposition header.

Note: Default ACL is "private". If you want objects to be world-readable directly from S3 (rather than via signed URLs), set acl: "public-read" and verify your bucket policy permits it. R2 ignores ACL settings -- configure public access in the R2 dashboard instead.

Vercel Blob adapter

Optimised for Vercel deployments with built-in CDN. Install:

pnpm add @nextlyhq/storage-vercel-blob
npm install @nextlyhq/storage-vercel-blob
yarn add @nextlyhq/storage-vercel-blob
bun add @nextlyhq/storage-vercel-blob
// nextly.config.ts
import { defineConfig } from "@nextlyhq/nextly/config";
import { vercelBlobStorage } from "@nextlyhq/storage-vercel-blob";

export default defineConfig({
  storage: [
    vercelBlobStorage({
      token: process.env.BLOB_READ_WRITE_TOKEN,
      collections: { media: true },
    }),
  ],
});

If token is omitted, the adapter reads BLOB_READ_WRITE_TOKEN from the environment automatically.

Uploadthing adapter

Install:

pnpm add @nextlyhq/storage-uploadthing
npm install @nextlyhq/storage-uploadthing
yarn add @nextlyhq/storage-uploadthing
bun add @nextlyhq/storage-uploadthing
// nextly.config.ts
import { defineConfig } from "@nextlyhq/nextly/config";
import { uploadthingStorage } from "@nextlyhq/storage-uploadthing";

export default defineConfig({
  storage: [
    uploadthingStorage({
      token: process.env.UPLOADTHING_TOKEN,
      collections: { media: true },
    }),
  ],
});

Files are served via Uploadthing's CDN (utfs.io).

Per-collection routing and overrides

Each plugin's collections map controls which collection slugs it handles. Pass true to enable with defaults, or an object to customise per-collection behaviour:

s3Storage({
  bucket: process.env.S3_BUCKET!,
  region: process.env.S3_REGION!,
  accessKeyId: process.env.AWS_ACCESS_KEY_ID!,
  secretAccessKey: process.env.AWS_SECRET_ACCESS_KEY!,
  collections: {
    // Public media -- defaults
    media: true,

    // Private docs -- signed downloads, custom prefix, client-side uploads
    "private-docs": {
      prefix: "private/",
      signedDownloads: true,
      signedUrlExpiresIn: 900,
      clientUploads: true,
    },
  },
})
OptionTypeDefaultDescription
prefixstring--Folder prefix for this collection's uploads.
clientUploadsbooleanfalseEnable client-side direct uploads (bypasses serverless body limits).
signedDownloadsbooleanfalseIssue signed URLs for download.
signedUrlExpiresInnumber3600Signed-URL expiry in seconds.

You can also register multiple plugins in the storage array if different collections need different backends -- for instance, public media on Vercel Blob and private documents on S3.

Named image sizes

Nextly can automatically generate resized image variants for every uploaded image, using sharp. Configure them in nextly.config.ts (code-first), through the admin Settings page (UI), or both -- the runtime de-dupes by name and keeps both sets queryable.

Code-first configuration

// nextly.config.ts
import { defineConfig } from "@nextlyhq/nextly/config";

export default defineConfig({
  media: {
    imageSizes: [
      { name: "thumbnail", width: 150, height: 150, fit: "cover", format: "webp", quality: 80 },
      { name: "small", width: 480, fit: "inside", format: "auto", quality: 80 },
      { name: "medium", width: 768, fit: "inside", format: "auto", quality: 80 },
      { name: "large", width: 1200, fit: "inside", format: "auto", quality: 80 },
      { name: "og", width: 1200, height: 630, fit: "cover", format: "jpeg", quality: 90 },
    ],
  },
  storage: [
    // ... your storage config
  ],
});

Size options

OptionTypeDefaultDescription
namestringrequiredUnique key. Used in the API response and as part of the variant filename.
widthnumber--Target width in pixels (omit to auto-calculate from height).
heightnumber--Target height in pixels (omit to auto-calculate from width).
fitstring"inside""inside" (shrink to fit), "cover" (crop to fill), "contain", or "fill".
qualitynumber80Image quality (1-100).
formatstring"auto""auto" (WebP when possible), "webp", "jpeg", "png", or "avif".

Visual configuration

Go to Settings -> Image Sizes in the admin panel to manage sizes through the UI. Code-defined sizes appear as read-only; UI-created sizes are fully editable.

REST endpoints

MethodPathDescription
GET/api/image-sizesList configured sizes (read).
POST/api/image-sizesCreate a new size (UI-managed).
GET/api/image-sizes/:idGet a single size.
PATCH/api/image-sizes/:idUpdate a size.
DELETE/api/image-sizes/:idDelete a size.
GET/api/image-sizes/regeneration-statusStatus of an in-progress regeneration job.
POST/api/image-sizes/regenerateRe-generate variants for existing media.

The list endpoint is permission-gated by read-settings; mutation endpoints require manage-settings.

Using sizes from your frontend

The media API response includes every generated variant under sizes:

{
  "id": "abc-123",
  "url": "/uploads/2026/04/photo.jpg",
  "width": 3000,
  "height": 2000,
  "sizes": {
    "thumbnail": { "url": "...", "width": 150, "height": 150 },
    "medium": { "url": "...", "width": 768, "height": 512 },
    "large": { "url": "...", "width": 1200, "height": 800 }
  }
}

With next/image:

import Image from "next/image";

const media = await nextly.media.findByID({ id: "abc-123" });
const large = media?.sizes?.large;

<Image
  src={large?.url ?? media!.url}
  width={large?.width ?? media!.width}
  height={large?.height ?? media!.height}
  alt={media?.altText ?? ""}
/>

Crop point (focal point)

Each media record can carry a focal point that controls how "cover" crops behave. Set it from the admin media library by clicking the image in the edit dialog. The values are stored as focalX (0-100, left to right) and focalY (0-100, top to bottom).

Use them with CSS object-position for responsive cropping:

<img
  src={media.url}
  style={{ objectFit: "cover", objectPosition: `${media.focalX}% ${media.focalY}%` }}
/>

Environment variables

Auto-detection cascade

VariableTriggers
BLOB_READ_WRITE_TOKENVercel Blob
S3_BUCKETS3 adapter
UPLOADTHING_TOKENUploadthing
(none of the above)Local disk

S3 / R2 / MinIO / Spaces / Supabase

VariableDescription
S3_BUCKETBucket name.
S3_REGIONRegion ("auto" for R2).
AWS_ACCESS_KEY_IDAccess key ID.
AWS_SECRET_ACCESS_KEYSecret access key.
S3_ENDPOINTCustom endpoint (R2, MinIO, Supabase, Spaces).
S3_PUBLIC_URLPublic URL for serving files.
S3_FORCE_PATH_STYLESet to "true" for MinIO and Supabase.

Vercel Blob

VariableDescription
BLOB_READ_WRITE_TOKENVercel Blob read-write token.

Uploadthing

VariableDescription
UPLOADTHING_TOKENUploadthing v7+ API token.

Migrating between adapters

When switching adapters (e.g. local disk -> S3 for production):

  1. Existing files stay in the old storage. New uploads go to the new adapter.
  2. URLs stored in the database still point to the old storage.
  3. For a full migration you'll need to copy the files and rewrite the URLs in the media table.

A common pattern is to switch automatically based on env vars:

  • Development -> no cloud env vars -> local disk.
  • Staging / Production -> set S3_BUCKET etc. -> S3.

getStorageFromEnv() makes this a one-liner in nextly.config.ts.

Next steps