@humaan/payload-storage-imagekit
v2.6.1
Published
ImageKit storage adapter plugin for Payload CMS uploads
Readme
payload-storage-imagekit
ImageKit storage adapter plugin for Payload CMS uploads.
This plugin integrates @payloadcms/plugin-cloud-storage with ImageKit and provides:
- Uploads to ImageKit per collection
- Optional client-side uploads for Payload admin to bypass request size limits on platforms like Vercel
- Required root folder with per-collection subfolders
- Stored ImageKit references (
imagekit.fileId,imagekit.url,imagekit.thumbnailUrl,imagekit.isPrivate) crop: falsefor configured upload collections- Opt-in
focalPointtoggle for configured upload collections - Admin thumbnails using ImageKit transforms (
tr=w-300,h-300) - Built-in thumbnail path for videos and PDFs in admin previews
- Browser-safe ImageKit transform helpers for frontend image rendering
- Migration utility for copying collection files between ImageKit folders
Installation
pnpm add @humaan/payload-storage-imagekitUsage
import { buildConfig } from 'payload'
import { imagekitStorage } from '@humaan/payload-storage-imagekit'
export default buildConfig({
collections: [
{
slug: 'media',
upload: true,
fields: [],
},
],
plugins: [
imagekitStorage({
clientUploads: true,
folder: '/payload-uploads',
collections: {
media: { focalPoint: true },
},
privateKey: process.env.IMAGEKIT_PRIVATE_KEY || '',
publicKey: process.env.IMAGEKIT_PUBLIC_KEY || '',
urlEndpoint: process.env.IMAGEKIT_URL_ENDPOINT || '',
}),
],
})Configuration
imagekitStorage(options) options:
collections(required):Partial<Record<UploadCollectionSlug, true | { focalPoint?: boolean; private?: boolean }>>. Usetruefor a normal (public) collection,{ focalPoint: true }to enable Payload's native focal-point picker for that collection, or{ private: true }to deliver the collection's files as ImageKit private files.folder(required): root upload folder in ImageKit, e.g./payload-uploadsprivateKey(required): ImageKit private API keypublicKey(required): ImageKit public keyurlEndpoint(required): ImageKit URL endpoint, e.g.https://ik.imagekit.io/your_idclientUploads(optional): whentrue, Payload admin uploads go directly from the browser to ImageKit before Payload fetches the file back for persistence and image-size generation. This helps avoid server request body limits such as Vercel's. Duplicate filenames still follow Payload's normal safe-name behavior, so a secondpug.jpgbecomespug-1.jpginstead of overwriting the first asset in ImageKit.signedUrlExpiresIn(optional): expiry in seconds for the signed URLs generated for private collections. Defaults to900(15 minutes). Signed URLs are generated fresh on every request, so a short window is safe.enabled(optional): enable / disable pluginfocalPoint(optional): broad default for Payload's native focal-point picker (upload.focalPoint) on every configured collection. Focal points are disabled by default for configured collections, so prefer the per-collectioncollections.<slug>.focalPointoption for mixed upload collections, e.g. enable it formediaand leave it off fordocuments. Whentrue, editors can place a focal point andfocalX/focalY(0–100) are stored on each doc for focal-aware cropping at render time. A collection's ownupload.focalPointstill takes precedence.alwaysInsertFields(optional): pass-through to cloud storage plugindisablePayloadAccessControl(optional): pass-through to cloud storage plugin
Exports
imagekitStorage(primary)migrateFolderpayloadStorageImageKit(alias ofimagekitStorage)@humaan/payload-storage-imagekit/image:buildImageKitTransformationwithImageKitTransformationcomputeFocalCropRectgetImageKitVideoSrcgetImageKitThumbnailSrcgetVideoPosterSrcsrcParamsPayloadImageKit(alias ofwithImageKitTransformation)
Frontend Image Transforms
Use the browser-safe image export in app image components to build ImageKit URLs from Payload upload data without importing the server plugin entry.
import {
withImageKitTransformation,
} from '@humaan/payload-storage-imagekit/image'
const src = withImageKitTransformation(media.imagekit.url, {
focalX: media.focalX,
focalY: media.focalY,
height: 800,
originalHeight: media.height,
originalWidth: media.width,
quality: 80,
width: 1200,
})Provide focalX/focalY with the original image dimensions when you want the helper to compute a clamped cm-extract crop before resizing. This avoids ImageKit rejecting out-of-bounds coordinate crops.
Use getImageKitVideoSrc before adding video transforms when the stored ImageKit asset URL may not include a .mp4 or .mov extension. It appends ImageKit's /ik-video.mp4 optimization hint only when the URL lacks a supported video extension.
Use getVideoPosterSrc when you need ImageKit's generated ik-thumbnail.jpg URL for a video poster. It also applies the /ik-video.mp4 hint first when the source URL lacks a .mp4 or .mov extension. Use getImageKitThumbnailSrc for non-video thumbnail URLs such as PDF previews.
import {
getImageKitVideoSrc,
getVideoPosterSrc,
withImageKitTransformation,
} from '@humaan/payload-storage-imagekit/image'
const video = withImageKitTransformation(getImageKitVideoSrc(media.imagekit.url), {
height: 720,
quality: 80,
vc: 'h264',
width: 1280,
})
const poster = withImageKitTransformation(getVideoPosterSrc(video), {
height: 720,
quality: 80,
width: 1280,
})Private Files
ImageKit private files are uploaded with isPrivateFile: true and cannot be accessed through their plain URL — they return 401 unless requested through a time-limited signed URL.
Mark any upload collection private by passing { private: true } instead of true:
imagekitStorage({
folder: '/payload-uploads',
collections: {
media: true, // public
documents: { private: true }, // private
},
signedUrlExpiresIn: 900, // optional, seconds (default 900)
privateKey: process.env.IMAGEKIT_PRIVATE_KEY || '',
publicKey: process.env.IMAGEKIT_PUBLIC_KEY || '',
urlEndpoint: process.env.IMAGEKIT_URL_ENDPOINT || '',
})How it works:
- Upload: files in a private collection are uploaded with
isPrivateFile: true(both server-side and client-side uploads). - Per-document status: each document records its actual ImageKit privacy at upload time in
imagekit.isPrivate(hidden from the admin form). A file's privacy on ImageKit is fixed when it is uploaded, so this stored flag — not the collection's current setting — is the source of truth for delivery. This means existing files keep working if you later flip a collection'sprivatesetting. (Documents uploaded before this flag existed fall back to the collection setting.) - Delivery: files are served through Payload's own file route (
/api/<collection>/file/<filename>), which enforces the collection'saccess.readcontrol and then redirects to a freshly signed, expiring ImageKit URL. Nothing signed is ever persisted to the database. - Admin thumbnails are routed through the same Payload file route so they sign on demand and respect access control (including ImageKit's
ik-thumbnail.jpgpreviews for videos and PDFs, with/ik-video.mp4applied first for videos without a.mp4or.movextension). - Admin indicators: private collections get a note in their admin description (visible in the list and edit views). The ImageKit metadata group is hidden while uploading and shown after a file has been stored, with
imagekit.urlvisible as a read-only field andimagekit.isPrivateshown as read-only when the stored file is private. - Signed URLs: because the stored
imagekit.urlis the unsigned URL (which 401s for private files), private documents also expose a virtualimagekit.signedUrlfield — a freshly signed, time-limited URL computed on each read (never persisted). Use it through Payload's API to reach the file directly on ImageKit. The access-control-gated alternative is the document's top-levelurl(the Payload file route). - Access control is required. Because signing happens at request time behind Payload access control,
disablePayloadAccessControlcannot be combined with private collections — doing so throws at startup. Set per-collectionaccess.readrules to control who can read each file.
Folder Migration
Use migrateFolder to copy uploads from one ImageKit root folder to another (e.g. staging to production).
Behavior:
- Copies files one-by-one per collection
- Creates destination folders automatically if they do not exist
- Optional database update via Payload local API
- Dry-run is enabled by default (
dryRun: true) - Resolves each source asset by the stored
imagekit.fileId, then copies the ImageKit-returned file path into the destination folder. This avoids mismatches between Payload'sfilenamefield and ImageKit's normalized file names. - Reruns are idempotent, including dry-runs: if a document's stored
imagekit.fileIdalready resolves to a file in the destination collection folder, that document is skipped. - Handles private files: mark a collection
{ private: true }and migrated documents are stored withimagekit.isPrivate: true. The fast server-side copy preserves the source file's ImageKit privacy; when the copy falls back to re-uploading from the source URL, the source URL is signed (so a private source can be read) and the file is re-uploaded withisPrivateFile: true. Because ImageKit privacy is fixed at upload, migrating assumes the destination collection's privacy matches the source — copying a public file into a{ private: true }collection keeps the file public on ImageKit.
Example:
import { migrateFolder } from '@humaan/payload-storage-imagekit'
import config from '@payload-config'
import { getPayload } from 'payload'
const payload = await getPayload({ config })
const results = await migrateFolder({
collections: { media: true },
dryRun: true,
from: process.env.IMAGEKIT_FOLDER!,
payload,
privateKey: process.env.IMAGEKIT_PRIVATE_KEY!,
to: process.env.IMAGEKIT_FOLDER_MIGRATION_DESTINATION!,
urlEndpoint: process.env.IMAGEKIT_URL_ENDPOINT!,
})
console.log(results)
await payload.destroy()migrateFolder(options) options:
collections(required):Partial<Record<UploadCollectionSlug, true | { private?: boolean }>>from(required): source root folder, e.g./payload-uploads-stagingto(required): destination root folder, e.g./payload-uploads-productionprivateKey(required): ImageKit private API keyurlEndpoint(required): ImageKit URL endpointdryRun(optional, defaulttrue): whentrue, logs what would happen without writespayload(optional): when provided anddryRunisfalse, updatesurl+imagekit.*fieldssignedUrlExpiresIn(optional, default900): expiry in seconds for the signed URL used to read a private source file during the re-upload fallback
Run a migration script:
# dry-run (default)
pnpx tsx --env-file=../.env imagekit-migration.ts
# live run
DRY_RUN=false pnpx tsx --env-file=../.env imagekit-migration.tsNotes
- Files are uploaded under:
<folder>/<collectionSlug>/<filename>. - With
clientUploads: true, the browser uploads the original file directly to ImageKit, then Payload continues its normal document save flow using the uploaded asset. - Deletion prefers stored
imagekit.fileId, with path lookup fallback. - Admin thumbnail behavior:
- Images:
<imagekit.url>?tr=w-300,h-300 - Videos:
<imagekit.url>/ik-thumbnail.jpg?tr=w-300,h-300or<imagekit.url>/ik-video.mp4/ik-thumbnail.jpg?tr=w-300,h-300when the video URL has no.mp4or.movextension - PDFs:
<imagekit.url>/ik-thumbnail.jpg?tr=w-300,h-300
- Images:
Development
pnpm dev
pnpm test:int
pnpm build