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):
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/| Source | Folder | File 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.
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:
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:
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:
- The real format is detected from the bytes and checked against
fileType. - EXIF metadata (including GPS location) is stripped.
- The image is converted to WebP.
- It's resized according to the field's
size(cover,contain,strict, or original size). - A thumbnail is generated according to
thumbnail(or the global default). - A watermark is applied if configured.
Set defaults in 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.
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.
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 logosPositions: 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:
- The MySQL database.
- The
media.upload.pathfolder.