nextjs-cms

Media & Uploads

Where uploaded files are stored, how images are processed, and how to serve them to your website.

Where files go

All uploads are written under one folder, set by media.upload.path in cms.config.ts (resolved from the project root):

cms.config.ts
media: {
    upload: { path: '.uploads', preserveExtension: true },
}

Inside it, files are grouped by type and section name:

.uploads/
├── .photos/
│   ├── posts/        ← photoField images, gallery photos, rich-text inline images
│   └── products/
├── .thumbs/
│   ├── posts/        ← thumbnails, same file names as in .photos
│   └── products/
├── .videos/
│   └── products/
└── .documents/
    └── products/
SourceFolderFile name
photoField, gallery.photos/<section>/ and .thumbs/<section>/21 hex chars, WebP (.webp suffix if removeExtension: false)
richTextField inline images.photos/<section>/21 hex chars, allowImageUploads.format
videoField.videos/<section>/21 hex chars, no extension
documentField.documents/<section>/21 hex chars, no extension

The database stores only the file name. Build URLs by combining your public base URL, the folder, the section name, and the name.

Good to know:

Uploaded files are served inside the admin through authenticated routes (/api/photo, /api/video, /api/document), so the admin works no matter where media.upload.path points. Your website needs its own way to serve them, described below.

Serving files to your website

Pick one depending on your hosting.

Option 1: shared disk + a static file server

When the admin and website run on the same server (a VPS, a container with a shared volume), point your web server at the uploads folder:

nginx.conf
location /media/ {
    alias /var/www/my-cms/.uploads/;
    expires 30d;
    add_header Cache-Control "public, immutable";
    # names without an extension: tell browsers what they are
    location ~ ^/media/\.(photos|thumbs)/ { default_type image/webp; }
}

File names are random and never reused, so aggressive caching is safe.

Option 2: a route handler in your website

If the website is a Next.js app on the same machine:

website/app/media/[...path]/route.ts
import { readFile } from 'node:fs/promises'
import path from 'node:path'

const ROOT = process.env.CMS_UPLOADS_DIR! // e.g. /var/www/my-cms/.uploads
const TYPES: Record<string, string> = { '.photos': 'image/webp', '.thumbs': 'image/webp' }

export async function GET(_req: Request, { params }: { params: Promise<{ path: string[] }> }) {
    const segments = (await params).path
    const [folder] = segments
    if (!folder || !(folder in TYPES) || segments.some((s) => s.includes('..'))) {
        return new Response('Not found', { status: 404 })
    }
    try {
        const file = await readFile(path.join(ROOT, ...segments))
        return new Response(file, {
            headers: { 'Content-Type': TYPES[folder]!, 'Cache-Control': 'public, max-age=31536000, immutable' },
        })
    } catch {
        return new Response('Not found', { status: 404 })
    }
}

Option 3: sync to object storage / CDN

For serverless websites, copy new files to S3, R2 or similar in an afterCreate / afterUpdate hook, then build URLs from the CDN domain.

Using next/image

import Image from 'next/image'

<Image
    src={`${process.env.NEXT_PUBLIC_MEDIA_URL}/.photos/posts/${post.cover}`}
    alt={post.title}
    width={1600}
    height={900}
/>

Add the media host to images.remotePatterns in the website's next.config.ts. Since the CMS already resizes to the field's size, you can also use unoptimized and skip a second optimization pass.

Image processing

Photo processing is powered by sharp. For every photo:

  1. The real format is detected from the bytes and checked against fileType.
  2. EXIF metadata (including GPS location) is stripped.
  3. The image is converted to WebP.
  4. It's resized according to the field's size (cover, contain, strict, or original size).
  5. A thumbnail is generated according to thumbnail (or the global default).
  6. A watermark is applied if configured.

Set defaults in cms.config.ts:

cms.config.ts
media: {
    images: {
        background: { r: 255, g: 255, b: 255, alpha: 1 }, // padding color for fit: 'contain'
        image: { quality: 85 },                           // main image quality (50–100)
        thumbnail: {
            size: { width: 400, height: 300, fit: 'cover' },
            quality: 75,
        },
        watermark: false,
    },
}

Per-field options override these. See photoField.

Warning:

media.images.image.size and media.upload.preserveExtension are accepted in the config but not used by the current version. Set size on each photo field, and use removeExtension on the field to control extensions.

Watermarks

Add a watermark to every photo, then turn it off or change it where needed.

cms.config.ts
media: {
    images: {
        watermark: {
            imagePath: './public/watermark.png', // PNG with transparency
            position: 'bottom-right',
            size: 0.15, // 15% of the image width
            opacity: 0.6,
            margin: 20,
            applyToThumbnail: false,
        },
    },
}

Resolution order: field (photoField({ watermark })) → gallery (gallery.watermark) → global (media.images.watermark). The first one that is set wins, and it replaces the others entirely. false at any level turns watermarking off there.

photoField({ name: 'logo', watermark: false }) // never watermark logos

Positions: top-left, top, top-right, left, center, right, bottom-left, bottom, bottom-right. Use tile: true to repeat the mark across the whole image.

Upload size limits

Each field has a maxFileSize (2 MB by default for photos, videos and documents). Your infrastructure also limits request sizes; raise both when accepting large files:

  • nginx: client_max_body_size 200m;
  • Next.js server actions / route handlers: check your deployment platform's body size limit.

Deleting files

Files are removed from disk when:

  • An item is deleted (all its photos, thumbnails, videos, documents, gallery photos, and inline images).
  • A photo, video, or document is replaced by a new upload on edit.
  • An inline image is removed from a rich-text field.
  • A translation is deleted (its localized files).

Backups

Your content is in two places. Back up both:

  1. The MySQL database.
  2. The media.upload.path folder.