A Payload CMS 3.x plugin that adds interactive image cropping to your collections. Define crop presets per field, let editors crop images in an intuitive modal, and automatically generate optimized image variants via Sharp — all stored and ready to use in your frontend.
- Interactive crop modal — powered by
react-image-cropwith aspect ratio constraints - Multiple crop presets per field — desktop, mobile, social, etc., each with its own dimensions and format
- Multi-size crops — one crop selection generates multiple output sizes (e.g. 1200×675, 768×432, 390×219) for responsive images without making editors crop separately per breakpoint
- Server-side image generation — Sharp processes crops on save; supports
webp,jpeg, andpng - S3 / cloud storage —
onCropGeneratedcallback receives the Sharp buffer and can upload to any storage provider, returning a CDN URL - Automatic cleanup — crop files are deleted when the source media is removed
- Frontend utilities —
getCropUrlandresolveMediaCrophelpers for templates - Localized labels —
labelfields accept aRecord<string, string>locale map (Payload'sStaticLabeltype); the admin panel resolves the correct language automatically - TypeScript-first — full types exported for all config and values
- Payload
^3.80.0 - React
^18.0.0or^19.0.0 - Sharp
^0.33.0(peer dependency — install separately)
npm install payload-plugin-image-cropper
# or
pnpm add payload-plugin-image-cropperSharp is a peer dependency and must be installed in your project:
npm install sharp// payload.config.ts
import path from 'path'
import { buildConfig } from 'payload'
import { cropImagePlugin } from 'payload-plugin-image-cropper'
export default buildConfig({
collections: [
{
slug: 'media',
upload: {
staticDir: 'public/media', // relative to process.cwd()
},
fields: [],
},
],
plugins: [
cropImagePlugin({
mediaCollectionSlug: 'media', // default: 'media'
mediaDir: path.join(process.cwd(), 'public/media'), // must match staticDir
}),
],
})Important:
mediaDirmust be an absolute path pointing to the same directory as the media collection'sstaticDir. Always useprocess.cwd()rather than__dirname/import.meta.url— Payload resolvesstaticDirrelative to the working directory, not the config file location.
Add an s3 block to cropImagePlugin using the same values you already pass to @payloadcms/storage-s3 — no extra imports or custom functions needed. The plugin creates the S3 client internally, uploads crops on save, and deletes them automatically when the source media document is removed.
import { s3Storage } from '@payloadcms/storage-s3'
import { cropImagePlugin } from 'payload-plugin-image-cropper'
export default buildConfig({
plugins: [
s3Storage({
acl: 'public-read',
bucket: process.env.S3_BUCKET,
collections: {
media: {
generateFileURL: ({ filename, prefix }) => {
return [process.env.CDN_ENDPOINT, prefix, filename].filter(Boolean).join('/')
},
prefix: process.env.S3_PREFIX,
},
},
config: {
credentials: {
accessKeyId: process.env.S3_ACCESS_KEY,
secretAccessKey: process.env.S3_SECRET_KEY,
},
endpoint: process.env.S3_ENDPOINT, // omit for AWS, required for DO Spaces / MinIO
forcePathStyle: false,
region: process.env.S3_REGION,
},
}),
cropImagePlugin({
mediaCollectionSlug: 'media',
mediaDir: path.join(process.cwd(), 'public/media'),
s3: {
acl: 'public-read',
bucket: process.env.S3_BUCKET,
config: {
credentials: {
accessKeyId: process.env.S3_ACCESS_KEY,
secretAccessKey: process.env.S3_SECRET_KEY,
},
endpoint: process.env.S3_ENDPOINT,
forcePathStyle: false,
region: process.env.S3_REGION,
},
prefix: process.env.S3_PREFIX,
generateUrl: ({ filename, prefix }) => {
return [process.env.CDN_ENDPOINT, prefix, filename].filter(Boolean).join('/')
},
},
}),
],
})The s3 config mirrors @payloadcms/storage-s3 so the values are identical — copy the bucket, config, acl, and prefix across, and write the same generateUrl arrow function you use for generateFileURL. The plugin handles PutObject on crop save and ListObjects + DeleteObject on source media deletion.
mediaDiris still needed to read the source image. If you usedisableLocalStorage: true, the source file is fetched from its URL automatically andmediaDiris unused.
import { cropImageField } from 'payload-plugin-image-cropper'
{
slug: 'posts',
fields: [
cropImageField({
name: 'heroImage',
label: 'Hero Image',
crops: [
{
name: 'desktop',
label: 'Desktop (16:9)',
width: 1920,
height: 1080,
aspectRatio: 16 / 9,
format: 'webp',
quality: 85,
},
{
name: 'mobile',
label: 'Mobile (4:5)',
width: 828,
height: 1035,
aspectRatio: 4 / 5,
},
],
}),
],
}Use sizes instead of width / height when you need the same crop region at different resolutions — e.g. a card image that should be 1200 px wide on desktop but 390 px on mobile. The editor crops once; the plugin generates all sizes automatically.
cropImageField({
name: 'cardImage',
label: 'Card Image',
crops: [
{
name: 'card',
label: 'Card (16:9)',
aspectRatio: 16 / 9,
sizes: [
{ name: 'lg', label: 'Large (desktop)', width: 1200, height: 675 },
{ name: 'md', label: 'Medium (tablet)', width: 768, height: 432 },
{ name: 'sm', label: 'Small (mobile)', width: 390, height: 219 },
],
},
],
})Generated URLs are stored under compound keys: card.lg, card.md, card.sm.
| Option | Type | Default | Description |
|---|---|---|---|
mediaCollectionSlug |
string |
'media' |
Slug of the collection that stores media uploads |
mediaDir |
string |
process.cwd() + '/public/media' |
Absolute path to the media storage directory |
s3 |
S3CropConfig |
— | S3 / S3-compatible storage. When set, crops are uploaded to the bucket and deleted automatically — no custom code required. |
onCropGenerated |
function |
— | (Advanced) Low-level hook called after Sharp processes each crop. Return { url } to store a custom URL and skip the local disk write, or return void to write to disk. |
| Option | Type | Description |
|---|---|---|
bucket |
string |
Required. S3 bucket name. |
config |
object |
Required. S3 client config — same object passed to @payloadcms/storage-s3. |
config.region |
string |
Required. AWS / provider region. |
config.credentials |
object |
accessKeyId + secretAccessKey. Omit to use environment variables or instance profile. |
config.endpoint |
string |
Custom endpoint for S3-compatible providers (DigitalOcean Spaces, MinIO, etc.). |
config.forcePathStyle |
boolean |
Force path-style URLs. |
generateUrl |
(args: { filename, prefix? }) => string |
Required. Build the public URL for a crop file. Same logic as generateFileURL in s3Storage. |
acl |
string |
Object ACL, e.g. 'public-read'. |
prefix |
string |
Key prefix inside the bucket. Mirrors prefix in s3Storage collection config. |
| Option | Type | Default | Description |
|---|---|---|---|
name |
string |
— | Required. Field name in the document |
crops |
CropDefinition[] |
— | Required. Array of crop presets |
label |
string | Record<string, string> |
— | Display label in the admin panel. Accepts a locale map (see Localized labels). |
required |
boolean |
false |
Whether a selection is required |
mediaCollectionSlug |
string |
'media' |
Override the media collection slug for this field |
admin.condition |
function |
— | Conditionally show this field |
admin.description |
string |
— | Help text shown below the field |
A crop preset is either a single-size variant (specify width + height) or a multi-size variant (specify sizes). The two forms are mutually exclusive.
Single-size
| Option | Type | Default | Description |
|---|---|---|---|
name |
string |
— | Required. Unique machine-readable key (e.g. 'desktop') |
label |
string | Record<string, string> |
— | Required. Human-readable tab label in the crop modal. Accepts a locale map (see Localized labels). |
width |
number |
— | Required. Output image width in pixels |
height |
number |
— | Required. Output image height in pixels |
aspectRatio |
number |
— | Constrain the crop selection (e.g. 16 / 9) |
format |
'webp' | 'jpeg' | 'png' |
'webp' |
Output image format |
quality |
number |
80 |
Sharp quality (1–100, ignored for PNG) |
Multi-size (use sizes instead of width / height)
| Option | Type | Default | Description |
|---|---|---|---|
name |
string |
— | Required. Unique machine-readable key (e.g. 'card') |
label |
string | Record<string, string> |
— | Required. Human-readable tab label in the crop modal. Accepts a locale map. |
sizes |
SizeDefinition[] |
— | Required. Output sizes to generate from this crop region |
aspectRatio |
number |
— | Constrain the crop selection |
format |
'webp' | 'jpeg' | 'png' |
'webp' |
Output image format (applies to all sizes) |
quality |
number |
80 |
Sharp quality (applies to all sizes) |
| Option | Type | Description |
|---|---|---|
name |
string |
Required. Size key suffix (e.g. 'lg'). Combined with the crop name as card.lg. |
width |
number |
Required. Output width in pixels |
height |
number |
Required. Output height in pixels |
label |
string | Record<string, string> |
Human-readable label shown in the admin UI. Accepts a locale map. |
Every label field in cropImageField, CropDefinition, and SizeDefinition accepts either a plain string or a locale map matching Payload's StaticLabel type (Record<string, string>). The admin panel resolves the correct language automatically based on the editor's active language, falling back to 'en', then the first available key.
cropImageField({
name: 'heroImage',
label: { en: 'Hero Image', de: 'Heldenbild' },
crops: [
{
name: 'desktop',
label: { en: 'Desktop', de: 'Desktop' },
width: 1920,
height: 1080,
aspectRatio: 16 / 9,
},
{
name: 'mobile',
label: { en: 'Mobile', de: 'Mobil' },
width: 828,
height: 1470,
aspectRatio: 9 / 16,
},
],
})With multi-size crops:
cropImageField({
name: 'cardImage',
label: { en: 'Card Image', de: 'Kartenbild' },
crops: [
{
name: 'card',
label: { en: 'Card (16:9)', de: 'Karte (16:9)' },
aspectRatio: 16 / 9,
sizes: [
{ name: 'lg', label: { en: 'Large (desktop)', de: 'Groß (Desktop)' }, width: 1200, height: 675 },
{ name: 'md', label: { en: 'Medium (tablet)', de: 'Mittel (Tablet)' }, width: 768, height: 432 },
{ name: 'sm', label: { en: 'Small (mobile)', de: 'Klein (Mobil)' }, width: 390, height: 219 },
],
},
],
})Locale maps work alongside Payload's i18n config — no extra setup is required in the plugin.
import { getCropUrl, resolveMediaCrop, resolveLabel } from 'payload-plugin-image-cropper/utilities'
// Resolve a StaticLabel to a string
const label = resolveLabel({ en: 'Hero Image', de: 'Heldenbild' }, 'de') // → 'Heldenbild'
// URL for a named crop
const url = getCropUrl(post.heroImage, 'desktop')
// → '/media/my-photo-crop-desktop-5-10-90x80-1920x1080.webp'
// Full media object with the crop URL injected as `url`
const media = resolveMediaCrop(post.heroImage, 'mobile')
// → { id: '...', filename: '...', url: '/media/...mobile....webp', ... }Both helpers are safe to call with null or undefined — they return '' / null respectively. When no generated crop exists yet, getCropUrl falls back to the original image.url so the field degrades gracefully before an editor has cropped the image.
Pass the size name as the third argument to getCropUrl:
const lgUrl = getCropUrl(post.cardImage, 'card', 'lg')
const mdUrl = getCropUrl(post.cardImage, 'card', 'md')
const smUrl = getCropUrl(post.cardImage, 'card', 'sm')
// Compound-key shorthand — equivalent to the above
const lgUrl = getCropUrl(post.cardImage, 'card.lg')Use resolveMediaCrop when you need the full media object. Pass the outputSize object as the third argument so the returned object reflects the actual pixel dimensions of that size:
const lgMedia = resolveMediaCrop(post.cardImage, 'card', { width: 1200, height: 675 }, 'lg')
const mdMedia = resolveMediaCrop(post.cardImage, 'card', { width: 768, height: 432 }, 'md')
const smMedia = resolveMediaCrop(post.cardImage, 'card', { width: 390, height: 219 }, 'sm')
// → { id: '...', filename: '...', url: '/media/...card.lg....webp', width: 1200, height: 675, ... }<picture>
<source media="(min-width: 1024px)" srcset={lgUrl}>
<source media="(min-width: 640px)" srcset={mdUrl}>
<img src={smUrl} alt="Card image">
</picture>import Image from 'next/image'
import { getCropUrl } from 'payload-plugin-image-cropper/utilities'
function cardImageLoader({ width }) {
if (width <= 390) return getCropUrl(post.cardImage, 'card', 'sm')
if (width <= 768) return getCropUrl(post.cardImage, 'card', 'md')
return getCropUrl(post.cardImage, 'card', 'lg')
}
<Image
loader={cardImageLoader}
src={getCropUrl(post.cardImage, 'card', 'lg')}
alt="Card"
width={1200}
height={675}
sizes="(max-width: 390px) 390px, (max-width: 768px) 768px, 1200px"
priority
/>cropImageField stores a group with three sub-fields on the document:
Single-size crops:
{
heroImage: {
image: '64abc...', // relation ID to the media document
cropData: {
desktop: { x: 5, y: 10, width: 90, height: 80 },
mobile: { x: 20, y: 0, width: 60, height: 100 },
},
generatedUrls: {
desktop: '/media/photo-crop-desktop-5-10-90x80-1920x1080.webp',
mobile: '/media/photo-crop-mobile-20-0-60x100-828x1035.webp',
},
}
}Multi-size crops — compound keys {cropName}.{sizeName}:
{
cardImage: {
image: '64abc...',
cropData: {
card: { x: 0, y: 12, width: 100, height: 75 },
},
generatedUrls: {
'card.lg': '/media/photo-crop-card-0-12-100x75-1200x675.webp',
'card.md': '/media/photo-crop-card-0-12-100x75-768x432.webp',
'card.sm': '/media/photo-crop-card-0-12-100x75-390x219.webp',
},
}
}The crop field component is a client component. If your Payload project customizes the admin bundle, import it from the /client export path:
import { CropImageField } from 'payload-plugin-image-cropper/client'- The editor selects or uploads a media file in the field.
- They open the crop modal and define a crop region for each preset.
- On save, the field calls the
/api/{mediaCollectionSlug}/generate-cropendpoint once per size (multi-size crops fan out automatically). - The endpoint uses Sharp to extract, resize, and encode each crop region to the configured format.
- Generated files are written to
mediaDiron disk (or handed toonCropGeneratedfor cloud upload). - The public URLs are stored in
generatedUrlsunder their key (or compound key for multi-size). - When the source media document is deleted, all associated crop files are removed automatically.
MIT