photoField
Image upload with validation, resizing, WebP conversion, thumbnails, and watermarks, powered by sharp.
import { photoField } from 'nextjs-cms/core/fields'Basic usage
photoField({ name: 'image' })Accepts a JPEG up to 2 MB. The image is converted to WebP, and a thumbnail is generated using the defaults in cms.config.ts.
With every option
photoField({
name: 'cover',
label: { en: 'Cover image', ar: 'صورة الغلاف' },
required: true,
order: 2,
localized: false,
conditionalRules: [{ field: hasCover, condition: 'equals', value: true }],
fileType: ['jpeg', 'png', 'webp'],
maxFileSize: { size: 5, unit: 'mb' },
size: { width: 1600, height: 900, fit: 'cover', quality: 85 },
thumbnail: { width: 480, height: 270, fit: 'cover', quality: 75 },
removeExtension: true,
watermark: {
imagePath: './public/watermark.png',
position: 'bottom-right',
size: 0.15,
opacity: 0.7,
margin: 24,
tile: false,
applyToThumbnail: false,
},
})Options
| Option | Type | Default | Description |
|---|---|---|---|
fileType | ('jpeg' | 'jpg' | 'png' | 'webp')[] | ['jpeg'] | Accepted formats. 'jpeg' also accepts .jpg. |
maxFileSize | { size: number; unit: 'kb' | 'mb' } | { size: 2, unit: 'mb' } | Largest accepted upload. |
size | see below | none (original dimensions) | How the main image is sized. |
thumbnail | { width, height, fit, quality?, background? } | media.images.thumbnail | How the thumbnail is generated. |
watermark | false | WatermarkObject | media.images.watermark | Override the global watermark. |
removeExtension | boolean | true | Store 3fa9… instead of 3fa9….webp. |
blurPlaceholder | boolean | null | none | Reserved. Accepted, not used yet. |
Plus the common options.
What happens on upload
- Checks: the file's real type is detected from its bytes (not its name), and must be in
fileType. Its size must be withinmaxFileSize. Corrupt images are rejected. - Strict size (if
size.strict): exact dimensions are required, otherwise the upload is rejected. - Processing: EXIF metadata is stripped and the image is converted to WebP.
- Resizing according to
size, then the watermark is applied if configured. - Files: the main image goes to
<uploads>/.photos/<section>/<name>, the thumbnail to<uploads>/.thumbs/<section>/<name>. - Column: the generated name (21 random hex characters) is saved.
Replacing a photo on edit deletes the old files. Deleting the item deletes them too.
Options in detail
size
size has three modes, chosen by its shape:
// 1. Crop to fill: any upload, cropped to exactly 1600×900
size: { width: 1600, height: 900, fit: 'cover' }
// 2. Fit and pad: any upload, scaled to fit 800×800 and padded with a background
size: { width: 800, height: 800, fit: 'contain', background: { r: 255, g: 255, b: 255, alpha: 1 } }
// 3. Strict: the upload must already be exactly 512×512; it's not resized
size: { width: 512, height: 512, strict: true }| Mode | Accepts | Result | Good for |
|---|---|---|---|
fit: 'cover' | Any dimensions | Exactly width×height, overflow cropped (centered) | Cards, heroes, open-graph images |
fit: 'contain' | Any dimensions | Exactly width×height, letterboxed with background | Product shots, logos |
strict: true | Only exactly width×height | Unchanged dimensions | Icons, app assets, anything pixel-perfect |
| (omitted) | Any dimensions | Original dimensions | When you resize at display time |
All modes accept quality (1–100), defaulting to media.images.image.quality. background (only with contain) falls back to media.images.background, which is transparent by default.
The global media.images.image.size in cms.config.ts isn't applied to photo fields in the current version. Set size on each field that needs resizing.
thumbnail
Every photo gets a thumbnail, used in the admin and available to your website. Two modes:
thumbnail: { width: 400, height: 300, fit: 'cover', quality: 70 }
thumbnail: { width: 400, height: 400, fit: 'contain', background: { r: 0, g: 0, b: 0, alpha: 0 } }Without thumbnail, the field uses media.images.thumbnail from cms.config.ts (default 200×200, contain, quality 80). Thumbnails are always WebP.
fileType
fileType: ['jpeg', 'png', 'webp'] // most common
fileType: ['png'] // logos with transparencyThe format is checked from the file content, so a .jpg renamed to .png is rejected when png is the only allowed type. Everything is converted to WebP on save regardless.
watermark
// Use the global watermark (default when omitted)
photoField({ name: 'image' })
// Turn it off for this field
photoField({ name: 'logo', watermark: false })
// A field-specific watermark
photoField({
name: 'listing_photo',
watermark: { imagePath: './brand/mark.png', position: 'center', size: 0.4, opacity: 0.25, tile: true },
})| Watermark option | Type | Default | Description |
|---|---|---|---|
imagePath | string | required | Watermark image, relative to the project root. Use a PNG with transparency. |
position | 'top-left' | 'top' | 'top-right' | 'left' | 'center' | 'right' | 'bottom-left' | 'bottom' | 'bottom-right' | 'center' | Placement. |
size | number (0–1) | 0.2 | Width as a fraction of the image width. |
opacity | number (0–1) | 0.5 | Transparency. |
margin | number (px) | 0 | Inset from the edge. Ignored for center and tile. |
tile | boolean | false | Repeat the mark across the whole image. |
applyToThumbnail | boolean | false | Watermark the thumbnail too. |
A field's watermark replaces the global one entirely (it isn't merged).
removeExtension
Files are always WebP. With the default removeExtension: true the stored name has no extension (a1b2c3...), so your website must send Content-Type: image/webp itself. Set false to store a1b2c3....webp, which static file servers and CDNs handle without configuration.
photoField({ name: 'image', removeExtension: false })Examples
Blog cover
photoField({
name: 'cover',
required: true,
fileType: ['jpeg', 'png', 'webp'],
maxFileSize: { size: 5, unit: 'mb' },
size: { width: 1600, height: 900, fit: 'cover' },
thumbnail: { width: 480, height: 270, fit: 'cover' },
removeExtension: false,
})Product image on white
photoField({
name: 'image',
fileType: ['jpeg', 'png', 'webp'],
size: { width: 1000, height: 1000, fit: 'contain', background: { r: 255, g: 255, b: 255, alpha: 1 } },
thumbnail: { width: 300, height: 300, fit: 'contain', background: { r: 255, g: 255, b: 255, alpha: 1 } },
})Exact-size app icon
photoField({ name: 'icon', fileType: ['png'], size: { width: 512, height: 512, strict: true } })Displaying on your website
const MEDIA = process.env.NEXT_PUBLIC_MEDIA_URL // where the uploads folder is served
<img src={`${MEDIA}/.photos/posts/${post.cover}`} alt={post.title} />
<img src={`${MEDIA}/.thumbs/posts/${post.cover}`} alt='' width={480} height={270} />See Media & Uploads for serving files and using next/image.
Storage
| Column | VARCHAR(50) |
| Value | Generated file name, e.g. 3f9a0c1b7e2d4a6f8c0e1 (or with .webp) |
| Files | <uploads>/.photos/<section>/<name> and <uploads>/.thumbs/<section>/<name> |