A plugin for Payload CMS which allows you to customize the local file paths when storing uploaded files. It is aimed at augmenting the default local storage capabilities of Payload CMS.
This plugin is developed and tested against Payload v3.
- Supports customizing file paths stored on disk. Paths can be nested subdirectories.
- Adds an endpoint to allow serving files that have slashes in filenames.
- e.g.
/api/media/a/nested/path/file.jpginstead of/api/media/a%2Fnested%2Fpath%2Ffile.jpg
- e.g.
- Hooks to rewrite the urls and thumbnailURLs to match the above endpoint.
- Fully compatible with the built-in image resizing and post deletion cleanup logic.
- Payload can still clean up the files with nested paths when the parent document is deleted.
- However, empty directories are not removed.
- This plugin only augments the built-in local storage. It does not play with any other
custom adapters such as S3, GCS, plugin-cloud-storage etc.
- The main purpose of this is to improve the file system performance when you have a large number of files.
- I assume cloud storage providers don't have the same performance issues and organizing files in a flat structure is not a problem.
- The added endpoint relies on the existing built-in static file handler.
It only injects a wildcard URL pattern, modifies the
req, and passes back to the original handler. It may break if Payload changes the internal static file handling logic in the future. - The modified
filenamesare not sanitized, and are not checked for existing overwrites. It will always overwrite existing files with the same path. - It is not possible to modify the filename but serving the file from a different url
(i.e. the
filenameand the url path must match.) Since this is what the built-in static handler expects, and the file deletion logic also relies on thefilenamefield to locate the file on disk.
npm install https://github.com/lkho/payload-custom-path-local-storage.git#semver:^1.0.0
# or
yarn add ...
# or
pnpm install ...payload.config.ts
import { buildConfig } from 'payload/config';
import { customPathLocalStorage } from '@lkho/payload-custom-path-local-storage';
export default buildConfig({
// ...
collections: [
{
slug: 'media',
upload: {
staticDir: './media',
adminThumbnail: 'thumbnail',
imageSizes: [
{
name: 'thumbnail',
width: 100,
height: 100,
},
],
},
},
],
plugins: [
customPathLocalStorage({
collections: {
media: {
// Optional: defaults to true
addFilePathEndpoint: true,
// Optional: omit to keep the original filenames
generateFilePath: ({ data, file, payloadUploadSizes }) => {
// modify the main file path
data.filename = '...';
if (data.sizes) {
for (const v of Object.values(data.sizes)) {
v.filename = '...';
}
}
return data;
},
},
},
}),
],
});function generateFilePath({ data }) {
const dir = new Date().toISOString().split('T')[0].replace(/-/g, '/'); // e.g. 2023/10/05
data.filename = `${dir}/${data.filename}`;
if (data.sizes) {
for (const v of Object.values(data.sizes)) {
v.filename = `${dir}/${v.filename}`;
}
}
return data;
}Will produce:
media/2025/
media/2025/10/
media/2025/10/05/
media/2025/10/05/foo.jpg
media/2025/10/05/foo-100x100.jpg
import { createHash } from 'crypto';
function generateFilePath({ data, file }) {
// use the main file's content to generate the hash
const hash = createHash('sha256').update(file.data).digest('hex');
const dir = `${hash.slice(0, 2)}/${hash.slice(2, 4)}`;
data.filename = `${dir}/${data.filename}`;
if (data.sizes) {
for (const v of Object.values(data.sizes)) {
v.filename = `${dir}/${v.filename}`;
}
}
}Will produce:
media/ab/
media/ab/cd/
media/ab/cd/foo.jpg
media/ab/cd/foo-100x100.jpg
import { createHash } from 'crypto';
import path from 'path';
function generateFilePath({ data, file, payloadUploadSizes }) {
const hash = (buf: Uint8Array) => {
return createHash('sha256').update(buf).digest('hex').slice(0, 4);
};
const mainFileHash = hash(file.data);
const dir = mainFileHash.slice(0, 2) + '/' + mainFileHash.slice(2, 4);
// append hash before the file extension
const parsed = path.parse(data.filename!);
data.filename = `${dir}/${parsed.name}.${mainFileHash}${parsed.ext}`;
if (data.sizes) {
for (const [k, v] of Object.entries(data.sizes)) {
if (payloadUploadSizes?.[k]) {
const parsed = path.parse(v.filename!);
v.filename = `${dir}/${parsed.name}.${hash(payloadUploadSizes[k])}${parsed.ext}`;
} else {
v.filename = `${dir}/${v.filename}`;
}
}
}
return data;
}Will produce:
media/00/
media/00/1a/
media/00/1a/foo.001a.jpg
media/00/1a/foo-100x100.9c4b.jpg
media/48/
media/48/fa/
media/48/fa/bar.48fa.jpg
media/48/fa/bar-100x100.67d0.jpg
This plugin is developed for my personal projects and is in early development. I may not have the time to response promptly, but I welcome contributions and feedback. Feel free to open issues or submit pull requests. Any contributions are welcome!