Payload Plugins
Plugins

payload-seed

Type-safe, dependency-ordered seeding for Payload CMS. Declare seed data in files and let the plugin order, upload, and create it.

For AI / LLMs: View Markdown

@pro-laico/payload-seed is an automatic, type-safe helper for writing seed values for your collections, globals, and more. It rides the same payload generate:types you already run, so the typing stays accurate and up to date on its own, which means you and your AI tools get real feedback while writing seed content, instead of guessing at shapes. It also handles relationships and assets for you, so you never have to worry about upload order or ending up with empty references.

It also ties into the other Pro Laico plugins, so uploading assets (and working with those plugins in general) just falls into place.

Think of it as a bootstrap tool. It's for the initial data a site ships with: the content you'd otherwise hand-enter once to stand a project up, or that you let an AI scaffold for you. Running the seed uploads that content into your real storage and database, so it ends up where it belongs (your DB, S3, Mux, and so on) instead of living in your repo. It's not meant for incremental, day-to-day content changes.

pnpm add @pro-laico/payload-seed

What's included

Everything you need to seed Payload CMS as easily as possible.

  • Typed seed helper functions: payload-seed automatically generates types for the collections and globals you use. With one of this package's helper functions, you can write type-safe seed data for anything.
  • Relationship handling: You don't have to deal with empty references in your seeded docs anymore. payload-seed handles reference seed order automatically.
  • Asset handling: Dedicated asset upload handlers can be attached to payload-seed from other plugins, on top of a standard Payload upload handler built into our type-safe helper functions.
  • Seed anywhere: Seed locally via the CLI, by pinging the seed endpoint, or with the included seed button right in your Payload admin dashboard.

Quickstart

Add the plugin

Register seedPlugin in your Payload config. Leave definitions empty for now; you'll fill it in once your seed files exist.

import { buildConfig } from 'payload'
import { seedPlugin } from '@pro-laico/payload-seed'

export default buildConfig({
  plugins: [
    seedPlugin({
      definitions: [],
      adminButton: true,
    }),
  ],
  // ...
})

Using adminButton: true? The button is registered by string path, so regenerate the admin import map with pnpm payload generate:importmap (skip it if you only run the seed via the CLI / endpoint).

Define your data

Write one seed.ts per collection or global with defineSeed (it infers which from the slug). Each collection record needs a _key (a local handle other files point at); upload collections carry their file on _file, and relationship fields point at other records with ref().

// src/collections/Media/seed.ts
import { defineSeed } from '@pro-laico/payload-seed'

export default defineSeed('media', ({ file }) => [
  {
    _key: 'serviceImg',
    _file: file('service-a.jpg'), // assets/media/service-a.jpg
    alt: 'Consulting',
  },
])
// src/collections/Services/seed.ts
import { defineSeed } from '@pro-laico/payload-seed'

export default defineSeed('services', () => [
  {
    _key: 'consulting',
    title: 'Consulting',
  },
])
// src/collections/Posts/seed.ts
import { defineSeed } from '@pro-laico/payload-seed'

export default defineSeed('posts', ({ ref }) => [
  {
    _key: 'launch',
    title: 'We launched',
    heroImage: ref('media', 'serviceImg'),
    relatedService: ref('services', 'consulting'),
  },
])

Add them to the plugin

Import each seed.ts export and hand the whole set to definitions. The same array drives the run and the injected types.

import { buildConfig } from 'payload'
import { seedPlugin } from '@pro-laico/payload-seed'
import media from './collections/Media/seed'
import services from './collections/Services/seed'
import posts from './collections/Posts/seed'

export default buildConfig({
  plugins: [
    seedPlugin({
      definitions: [media, services, posts],
      adminButton: true,
    }),
  ],
  // ...
})

Enable, then run

The seed is gated by the ENABLE_SEED kill switch (off by default). Set it, then run the seed one of three ways: the bundled command, the admin button (adminButton: true), or the endpoint:

ENABLE_SEED=true pnpm payload seed   # CLI (Local API)
# or set ENABLE_SEED=true in your env, then:
#   click "Seed your database" in the admin header
#   or POST /api/seed  (any authenticated user)

The seed is destructive. It clears and recreates every seeded collection. ENABLE_SEED is off by default: opt in per-environment while iterating, and never set it in production.

Plugin options

seedPlugin(options?) is the single entry point. One call registers the payload seed command, the POST /api/seed endpoint, the optional admin button, and the type augmentation that checks your refs. It does nothing useful without definitions.

The Reference tab is the interactive view; TypeScript is the same shape in code, every option at its default.

definitionsSeedDefinition[]

The seed definitions: your defineSeed exports. The same array feeds the seed run and the typed SeedRegistry injected into payload-types.ts. Omit it and the seed runs but warns there is nothing to do.

assetsDirstringdefault 'assets'

The assets root where _file source files live, relative to the project root. A native upload's file is looked up under its per-collection subdir (see assetSubDirs) then the root; providers look under their own subdir.

assetSubDirsPartial<Record<CollectionSlug, string>>default {}

Per-collection subdirectory (under assetsDir) for a collection's _file source files. Defaults to the collection slug (media resolves under assets/media/), so a folder named after the collection just works. Set an entry to use a different folder name, e.g. { media: 'images' } or { fontOriginal: 'font' }.

adminButtonbooleandefault false

Render the Seed your database button in the admin header.

import { seedPlugin } from '@pro-laico/payload-seed'

// Every option at its default.
seedPlugin({
  // definitions: [media, services, posts], // no default; your seed.ts exports
  assetsDir: 'assets',
  assetSubDirs: {}, // default folder per collection is its slug; override here, e.g. { media: 'images' }
  adminButton: false,
})

defineSeed

Declares the seed data for one collection or global, one export default per seed.ts file. It infers which from the slug: a collection slug means the builder returns an array of records; a global slug means it returns a single data object (no _key). Returns a seed definition you add to seedPlugin({ definitions }).

The Reference tab lists the parameters; TypeScript is a minimal example.

slugCollectionSlug | GlobalSlugrequired

The collection or global to seed into. Typed against your Payload types, so an unknown slug is a TypeScript error. The slug also picks the builder's return shape: an array of records for a collection, a single object for a global.

build(tokens) => Record[] | GlobalDatarequired

Returns the data to create: an array of records for a collection, or one data object for a global. Receives the { ref, file } tokens as its argument, so you point at other docs and attach files inline, with nothing to import.

opts.disabledboolean | string

Skip this definition at seed time without unregistering it, so the generated seed-ref types don't change (a string becomes the warning's reason). Rarely set by hand — a collection that declares custom.seedDisabled (e.g. payload-mux without credentials) is skipped automatically. See Disabled seeds.

// src/collections/Services/seed.ts
import { defineSeed } from '@pro-laico/payload-seed'

// slug + a builder returning the data (an array of records for a collection).
export default defineSeed('services', () => [
  {
    _key: 'consulting',
    title: 'Consulting',
  },
])

Collections

Each record is the collection's own data plus a required _key (a local handle other seed files target with ref(slug, key)) and, for upload / provider collections, an optional _file carrying its source file. Point relationship fields at other records with ref(); attach a file with file() on _file:

// src/collections/Media/seed.ts: each doc carries its file on `_file`
import { defineSeed } from '@pro-laico/payload-seed'

export default defineSeed('media', ({ file }) => [
  {
    _key: 'serviceImg',
    _file: file('service-a.jpg'), // assets/media/service-a.jpg
    alt: 'Consulting',
  },
])
// src/collections/Posts/seed.ts: point at other seeded docs by ref
import { defineSeed } from '@pro-laico/payload-seed'

export default defineSeed('posts', ({ ref }) => [
  {
    _key: 'launch',
    title: 'We launched',
    heroImage: ref('media', 'serviceImg'),          // upload-field relationship, resolved by ref
    relatedService: ref('services', 'consulting'),  // typed dependency edge across files
  },
])

Records are typed against your generated Payload types (RequiredDataFromCollectionSlug<slug>), with relationship fields widened to also accept ref() tokens. Change a collection's fields and the seed file shows a TS error until it matches; unknown fields are rejected too. The producing side (_key) stays a free string; only the consuming side (ref) is checked.

Globals

Pass a global slug and the builder returns a single data object instead of an array (a global is a singleton, so there's no _key). It gets the same tokens, and globals are updated after all their dependencies exist:

// src/globals/SiteSettings/seed.ts
import { defineSeed } from '@pro-laico/payload-seed'

export default defineSeed('site-settings', ({ ref }) => ({
  /* SiteSettings data; ref('services', 'consulting') etc. */
}))

Tokens

Inside a build callback you get two tokens. The engine resolves them at create time, and ref doubles as the dependency edge that orders the run.

ref(collection, key) => Ref

Point at another seeded doc by its _key, e.g. ref('services', 'consulting'). Records a dependency edge so the target is created first. Both arguments are checked against the SeedRegistry once types are generated. Use it for any relationship / upload field.

file(name, options?) => FileToken

Attach a source file to a doc, on its _file meta-key, e.g. file('hero.jpg'). Delivered as a native upload or a provider ingest depending on the collection (see Files). options is an optional bag for provider ingest (e.g. a font weight).

You never import either; they're handed to every build callback (({ ref, file }) => …), so they can't drift from the data they point at. file goes on the record's _file meta-key; everything else is an ordinary field.

Run payload generate:types and every ref() is checked against your real _keys: rename or remove a seeded item and each stale reference becomes a TS error (unknown collections error too). See How it works for the mechanism.

Files (_file)

An asset is just a seeded doc that carries a file. Put it on the record's _file meta-key with the file() token. How the file is delivered depends on the doc's collection. The engine decides, you don't:

  • An upload collection (e.g. media, images, icon) → the bytes are read and uploaded as the doc's file.
  • A custom.seedAsset collection (e.g. Mux) → the resolved path + options are handed to the collection's own ingest hook (see Custom ingestion).
// src/collections/Media/seed.ts
import { defineSeed } from '@pro-laico/payload-seed'

export default defineSeed('media', ({ file }) => [
  {
    _key: 'hero',
    _file: file('hero.jpg'), // assets/media/hero.jpg
    alt: 'Hero',
    focalX: 78,
    focalY: 32,
  },
])

file(name, options?): name is the source filename (resolved below); options is an optional bag merged into a custom.seedAsset collection's ingest source field (e.g. a Mux playbackPolicy) and ignored for native uploads. The doc's own fields (alt, focal point, title, …) are ordinary record fields, typed against the collection, so an upload collection that requires alt makes the seed record require it too. Focal points set the upload's focal point, so seeded images crop to the right subject the moment they're served (e.g. by @pro-laico/payload-images).

Where files live

Every _file resolves to the same three-part path — the assets dir, a per-collection folder, then the name you pass:

<assetsDir> / <collection folder> / <file name>
assets      / media              / hero.jpg

The folder is the collection slug, so this needs no config. On disk:

root/
  assets/                  ← assetsDir        (default 'assets', relative to project root)
    media/                 ← collection folder (the slug; rename via assetSubDirs)
      hero.jpg             ← file('hero.jpg')
      portraits/           ← a subpath in the name nests further
        jane.jpg           ← file('portraits/jane.jpg')

The defineSeed using those files:

// src/collections/Media/seed.ts
export default defineSeed('media', ({ file }) => [
  {
    _key: 'hero',
    _file: file('hero.jpg'), // assets/media/hero.jpg
    alt: 'Hero',
  },
  {
    _key: 'jane',
    _file: file('portraits/jane.jpg'), // assets/media/portraits/jane.jpg
    alt: 'Jane',
  },
])

A _file can also be an absolute path (file('/tmp/logo.svg')), used as-is for a file outside the tree.

Consider gitignoring your assets folder in a real project. Since seeding is a one-time bootstrap that uploads these files into your real storage, the source originals don't need to live in the repo afterward, and they can be large enough to bloat it. (The example sandboxes in this repo commit theirs only so the demos run in CI.)

Custom ingestion

Most collections take a plain Payload upload for their _file and seed natively. A few ingest their file through their own hook instead — e.g. @pro-laico/payload-mux's mux-video, whose bytes go to Mux. Mark such a collection's own config and the engine hands the _file to that hook (via a sourceField) rather than uploading bytes:

// the collection's own config — usually set by its plugin, not by you
{
  slug: 'mux-video',
  custom: {
    seedAsset: { sourceField: 'source' }, // or `seedAsset: true` for the defaults
  },
  fields: [
    // the field the engine writes `{ file, ...options }` to, read by the ingest hook
    { name: 'source', type: 'json', admin: { hidden: true } },
    // ...the rest of the collection
  ],
}
seedAssettrue | { sourceField?, subdir? }

Set on the collection's custom.seedAsset. true uses the defaults below; pass an object to override them.

sourceFieldstringdefault 'source'

The field the engine sets to { file, ...options } for the collection's ingest hook to read.

subdirstringdefault <collection slug>

Source-file folder under assetsDir. Override per-collection with the plugin's assetSubDirs.

The engine auto-discovers the marker from the live config — nothing to register, and the owning plugin never imports payload-seed. The doc then seeds like any other (_file + ref()). Collections whose asset is a plain upload (payload-fonts' fontOriginal, payload-images' image) need no marker. See payload-mux → Seeding for a worked example.

Disabled seeds

Some collections can't always seed — @pro-laico/payload-mux's mux-video needs API credentials to ingest a clip. Don't gate the definition on the environment (that shifts the generated seed-ref types with it); the collection declares it instead:

// set by the owning plugin when it can't ingest — e.g. payload-mux without MUX_* env vars
{
  slug: 'mux-video',
  custom: { seedDisabled: 'Mux credentials not set (MUX_TOKEN_ID / MUX_TOKEN_SECRET)' },
}

The engine skips that definition (warning with the reason) and drops any optional field whose ref() points at it — a required ref is a hard error. The definition stays registered, so types don't change with the environment; set the env vars and the next run seeds it and fills the dropped refs back in. To gate a definition yourself, defineSeed takes the same flag directly: defineSeed('reports', build, { disabled: !process.env.REPORTS_API_KEY }).

Running the seed

Four entry points run the same engine over the same definitions — pick by where you are. Three of them (the admin button, the HTTP endpoint, and the CLI) sit behind the ENABLE_SEED kill switch: if ENABLE_SEED isn't exactly "true", they refuse to run — leave it unset in production. The fourth, seed(), is the in-code path and is deliberately not gated (that's what lets a test drive it). Every entry point is destructive — it clears the seeded collections before recreating them — so run it on purpose. (See Quickstart for setting ENABLE_SEED.)

Do you need a user first? The admin button and POST /api/seed require a logged-in Payload user (any user — not just an admin). The CLI and seed() run over the Local API with access control bypassed, so they need no user at all — reach for them to bootstrap an empty database (you can even seed your first admin user as part of the run).

The friendliest path: a "Seed your database" button in the admin header. Turn it on with adminButton: true, set ENABLE_SEED=true, then click it — it POSTs to /api/seed as the logged-in user and reports success (or the error) inline.

// payload.config.ts
seedPlugin({ definitions: [media, services, posts], adminButton: true })

Best for: local development and demos — the quickest way to reseed while you build.

The plugin registers POST /api/seed — a Payload REST route, and the very route the admin button calls. Hit it from a script, a CI step, or curl against a running app. It's guarded twice; both must pass:

  • ENABLE_SEED must equal "true", or it returns 403 and does nothing (the primary safety — leave it unset in production and the route is inert).
  • The request must be authenticated — a Payload auth cookie or API key — or 403. Any logged-in user qualifies, so gate by environment with ENABLE_SEED, not by role.
# Send your Payload auth cookie or API key (here: an API key on the `users` collection).
curl -X POST https://your-app.com/api/seed \
  -H 'Authorization: users API-Key <your-api-key>'
# 200 → { "success": true, "created": { "media": 3, "services": 2, "posts": 1 }, "order": [ … ] }

On success it returns 200 with { success, created, order }. A validation failure (bad ref, duplicate _key, unknown field/slug) returns 400 with { error, issues } — the same named, collected issues the engine produces, so you can fix them without digging through server logs. Any other failure logs server-side and returns a generic 500 ({ error: 'Error seeding data.' }), so internals never reach the client. Best for: CI/CD, or seeding a deployed environment over HTTP.

The plugin adds a payload seed command (a bin on the package) that runs over the Local API — no HTTP, no auth, just a terminal. Wire a script and run it with the switch on:

// package.json
{ "scripts": { "seed": "payload seed" } }
ENABLE_SEED=true pnpm seed
# [payload-seed] clearing collections...
# [payload-seed] seeding documents...
# [payload-seed] seed complete.

Best for: a terminal, or a pre-deploy CI step that seeds before the app serves traffic. Note that payload seed boots through Payload's tsx-based CLI; on some Node + database-adapter combinations that loader has bugs — if it trips, use the admin button or endpoint instead (they run in the app's own runtime).

Call the engine directly with seed() — for tests, migrations, or a custom script. It builds a Local API req if you don't pass one and resolves the options you hand it. Unlike the other three it is not behind ENABLE_SEED (the gate lives on the entry points), so a test can drive the real seed:

import { seed } from '@pro-laico/payload-seed'
import { getPayload } from 'payload'
import config from '@payload-config'

const payload = await getPayload({ config })
const result = await seed({ payload, options: { definitions: [media, services, posts] } })

result.created  // → { media: 3, services: 2, posts: 1 }              (created docs per collection)
result.order    // → ['media:hero', 'services:consulting', 'posts:launch']  (topo-sorted create order)
result.deferred // → fields created null to break a ref cycle, set in the second pass
result.skipped  // → [{ slug, reason }] definitions skipped this run (disabled / custom.seedDisabled)

seed() returns { created, order, deferred, skipped }created and order are the same shape the endpoint response carries (alongside success: true) and the CLI logs on completion. Still destructive; call it deliberately. Best for: integration tests and migrations.

How it works

Every entry point runs the same engine. You don't need any of this to write seeds; it's here for when you're debugging or curious.

What a seed run does

Trigger a seed from any entry point and the engine runs the same fixed sequence — starting from your in-memory defineSeed output and ending with a fully-populated database:

Build the model

Skip any disabled definition (its own disabled, or the collection's custom.seedDisabled — warning per skip), then run each remaining builder to produce the collection records (with their _file) and the globals. Optional fields whose ref() points at a skipped definition are dropped (required ones error).

Validate

Every ref targets a real collection and resolves to a seeded doc, every _file sits on an upload or custom.seedAsset collection, no duplicate _key within a collection, no unknown top-level fields. All issues are collected and thrown at once, naming the collection, _key, and field.

Build the dependency graph & topo-sort

Every ref(collection, key) becomes an edge; a depth-first sort orders each doc after the docs it references. A cycle is broken by deferring an optional field; an all-required cycle is a hard error naming it.

Clear the seeded collections

Upload collections and any collection with delete hooks clear via payload.delete so those hooks fire (e.g. external-asset cleanup); plain collections are wiped directly.

Create docs in dependency order

Resolve each doc's ref tokens to real ids and deliver its _file — a native upload, or a source-field value for a custom.seedAsset collection's ingest hook. A field deferred to break a cycle is created null.

Resolve deferred references

If a cycle was broken, set each deferred field now that every doc exists — one update per deferred field.

Update globals

Last, after every doc exists — resolving their refs too.

How typed refs are generated

The plugin injects a SeedRegistry into payload-types.ts via Payload's own typescript.postProcess hook, riding the same generate:types command you already run. That makes the augmentation global with no import, exactly like Payload's generated GeneratedTypes, so ref('services', 'consulting') is checked against your real _keys. Without codegen, refs fall back to runtime validation: safe either way, fully safe with it.

Disable revalidation

The engine sets context: { disableRevalidate: true } on every create, update, and delete. Have your afterChange / afterDelete revalidation hooks check for it and skip, so a bulk seed doesn't fire a revalidation per doc:

export const revalidatePost: CollectionAfterChangeHook = ({ doc, context }) => {
  if (!context.disableRevalidate) revalidatePath(`/posts/${doc.slug}`)
  return doc
}

Gotchas

A few things to keep in mind:

  • Every run is destructive. A seed clears each collection it touches before recreating it — a reset, not an append. Re-running wipes and rebuilds, so never point it at data you want to keep.
  • Nothing runs without ENABLE_SEED. The admin button, endpoint, and CLI all no-op (403) unless ENABLE_SEED is exactly "true". The in-code seed() is the exception — it skips the gate, so it will wipe whatever database it's handed. Guard your script / test setup accordingly.
  • Circular references need an optional field. Two docs that reference each other are fine — the engine breaks the cycle by deferring one side's ref and setting it in a second pass once both docs exist. That field just has to be optional (it's created null first). A cycle where every ref sits on a required field is a hard error naming it (posts:a -> authors:b -> posts:a) — make one side optional to break it.
  • A missing source file only warns. If a _file isn't found under <assetsDir>/<subdir>/, the engine logs _file '…' not found and keeps going — the doc is still created (and an upload collection that requires a file may then error downstream). Watch the logs for typos. The subdir defaults to the collection slug; override it with assetSubDirs.
  • Don't env-gate a definition out of the array. Conditionally registering a definition (...(withMux ? [videos] : [])) makes the generated seed-ref types depend on the environment — the dev server regenerates them on boot and every ref() at it flips between valid and error. Register it unconditionally and let it be skipped at runtime instead (custom.seedDisabled, or defineSeed's disabled).
  • Typed refs need generate:types. ref() keys are only checked once the SeedRegistry is generated; before that they fall back to runtime validation. Rename a _key and you must regenerate types for the compile-time check to catch stale references.
  • Globals can't be referenced, and update last. ref() points at collection docs only — a global can reference docs, but a doc can't reference a global. Globals are applied after every doc exists.
  • CLI dies with ENOENT … node:crypto?tsx-namespace=… on Node 24? That's tsx, not the seed. Payload's CLI loads bin scripts through tsx's tsImport(), and tsx ≥ 4.21.1 mis-resolves a dependency's bare require('crypto') under Node 24's module hooks — killing any custom payload <script> at boot. Pin tsx below the regression until it's fixed upstream (pnpm: overrides: { tsx: '4.19.4' }). The admin button and POST /api/seed are unaffected.

Exports

ExportFromWhat
seedPlugin@pro-laico/payload-seedThe plugin factory.
defineSeed@pro-laico/payload-seedDeclare a collection's seed records or a global's seed data (inferred from the slug; one default export per seed.ts).
seed@pro-laico/payload-seedRun the seed from a script / test / migration (see Running the seed).
ref / file / isRef / isFileToken@pro-laico/payload-seedThe token constructors + type guards, for code that builds seed data outside a builder callback (unit tests, composed fragments). Inside a builder, use the supplied tokens.
SeedTokens / WithRefs / CollectionSeedData / GlobalSeedData@pro-laico/payload-seedTypes for seed helpers composed across files: type a fragment's { ref, file } parameter, widen generated data types to accept Ref tokens.
Ref / FileToken@pro-laico/payload-seedThe token value types themselves.
SeedRegistry@pro-laico/payload-seedAugmentable interface generate:types fills in, so ref() keys are checked. Not imported by name.
SeedButton@pro-laico/payload-seed/components/SeedButtonThe admin Seed your database button (wired via the import map when adminButton is on).

On this page