nextjs-cms

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

OptionTypeDefaultDescription
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.
sizesee belownone (original dimensions)How the main image is sized.
thumbnail{ width, height, fit, quality?, background? }media.images.thumbnailHow the thumbnail is generated.
watermarkfalse | WatermarkObjectmedia.images.watermarkOverride the global watermark.
removeExtensionbooleantrueStore 3fa9… instead of 3fa9….webp.
blurPlaceholderboolean | nullnoneReserved. Accepted, not used yet.

Plus the common options.

What happens on upload

  1. Checks: the file's real type is detected from its bytes (not its name), and must be in fileType. Its size must be within maxFileSize. Corrupt images are rejected.
  2. Strict size (if size.strict): exact dimensions are required, otherwise the upload is rejected.
  3. Processing: EXIF metadata is stripped and the image is converted to WebP.
  4. Resizing according to size, then the watermark is applied if configured.
  5. Files: the main image goes to <uploads>/.photos/<section>/<name>, the thumbnail to <uploads>/.thumbs/<section>/<name>.
  6. 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 }
ModeAcceptsResultGood for
fit: 'cover'Any dimensionsExactly width×height, overflow cropped (centered)Cards, heroes, open-graph images
fit: 'contain'Any dimensionsExactly width×height, letterboxed with backgroundProduct shots, logos
strict: trueOnly exactly width×heightUnchanged dimensionsIcons, app assets, anything pixel-perfect
(omitted)Any dimensionsOriginal dimensionsWhen 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.

Good to know:

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 transparency

The 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 optionTypeDefaultDescription
imagePathstringrequiredWatermark 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.
sizenumber (0–1)0.2Width as a fraction of the image width.
opacitynumber (0–1)0.5Transparency.
marginnumber (px)0Inset from the edge. Ignored for center and tile.
tilebooleanfalseRepeat the mark across the whole image.
applyToThumbnailbooleanfalseWatermark 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

ColumnVARCHAR(50)
ValueGenerated file name, e.g. 3f9a0c1b7e2d4a6f8c0e1 (or with .webp)
Files<uploads>/.photos/<section>/<name> and <uploads>/.thumbs/<section>/<name>