lkho

payload custom path local storage

by lkho
Media & images
v3
^3
MIT

Overview

Summary generated from the README

Augments Payload's built-in local file storage so uploaded files can be saved under custom, nested paths. A per-collection generateFilePath function rewrites the filename for the main file and each resized image. The plugin adds an endpoint that serves files with slashes in their names and rewrites URLs and thumbnail URLs to match. It targets Payload v3 and does not work with cloud storage adapters.

Features

  • Per-collection generateFilePath function to set custom nested paths for stored files
  • Endpoint that serves files whose names contain slashes without URL encoding
  • Rewrites url and thumbnailURL values to match the new endpoint
  • Works with built-in image resizing and deletion cleanup of nested files
  • Optional toggle to disable the file path endpoint

Use cases

  • Organize uploads into date-based subfolders on disk
  • Spread many files across hashed directories to improve file system performance
  • Add content hashes to filenames for cache busting

From the author

A Payload CMS local storage plugin which allows you to specify a custom path for stored files.

Installation

npm i @lkho/payload-custom-path-local-storage

4

Stars

1

Forks

1

Open Issues

1 years ago

Last Updated

0

Weekly Downloads

v

npm Version

Dependencies

GitHub stars

Trends appear after 7 days of data. Daily snapshots have not started yet.

npm weekly downloads

Trends appear after 7 days of data. Daily snapshots have not started yet.

Each point is npm's rolling 7-day download count.

Health Score

8/100Poor

Composite score based on GitHub activity, stars, npm downloads, publish recency, dependency count, and package size.

Badge for your README

Payload Directory health badge
Markdown
[![Payload Directory](https://payloaddirectory.dev/badge/1074214439-root.svg)](https://payloaddirectory.dev/plugins/1074214439-root)
HTML
<a href="https://payloaddirectory.dev/plugins/1074214439-root"><img src="https://payloaddirectory.dev/badge/1074214439-root.svg" alt="Payload Directory"></a>
Updated Oct 11, 2025Created Oct 11, 2025

README

Payload Custom Path Local Storage Plugin

License: MIT Downloads


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.

Features / Capabilities

  • 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.jpg instead of /api/media/a%2Fnested%2Fpath%2Ffile.jpg
  • 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.

Limitations / Notes

  • 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 filenames are 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 filename and the url path must match.) Since this is what the built-in static handler expects, and the file deletion logic also relies on the filename field to locate the file on disk.

Installation

npm install https://github.com/lkho/payload-custom-path-local-storage.git#semver:^1.0.0
# or
yarn add ...
# or
pnpm install ...

Usage

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;
          },
        },
      },
    }),
  ],
});

Examples

Place files in date sub folders

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

Hash paths to avoid too many files in a single directory

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

Append hash to filenames for cache busting

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

Contributing

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!

Similar plugins