payload-seed
Type-safe, dependency-ordered seeding for Payload CMS. Declare seed data in files and let the plugin order, upload, and create it.
@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-seedWhat'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 falseRender 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 | GlobalSlugrequiredThe 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[] | GlobalDatarequiredReturns 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 | stringSkip 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) => RefPoint 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?) => FileTokenAttach 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.seedAssetcollection (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.jpgThe 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 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_SEEDmust equal"true", or it returns403and 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 withENABLE_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) unlessENABLE_SEEDis exactly"true". The in-codeseed()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
refand 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
_fileisn't found under<assetsDir>/<subdir>/, the engine logs_file '…' not foundand 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 withassetSubDirs. - 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 everyref()at it flips between valid and error. Register it unconditionally and let it be skipped at runtime instead (custom.seedDisabled, ordefineSeed'sdisabled). - Typed refs need
generate:types.ref()keys are only checked once theSeedRegistryis generated; before that they fall back to runtime validation. Rename a_keyand 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'stsImport(), and tsx ≥ 4.21.1 mis-resolves a dependency's barerequire('crypto')under Node 24's module hooks — killing any custompayload <script>at boot. Pin tsx below the regression until it's fixed upstream (pnpm:overrides: { tsx: '4.19.4' }). The admin button andPOST /api/seedare unaffected.
Exports
| Export | From | What |
|---|---|---|
seedPlugin | @pro-laico/payload-seed | The plugin factory. |
defineSeed | @pro-laico/payload-seed | Declare 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-seed | Run the seed from a script / test / migration (see Running the seed). |
ref / file / isRef / isFileToken | @pro-laico/payload-seed | The 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-seed | Types 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-seed | The token value types themselves. |
SeedRegistry | @pro-laico/payload-seed | Augmentable interface generate:types fills in, so ref() keys are checked. Not imported by name. |
SeedButton | @pro-laico/payload-seed/components/SeedButton | The admin Seed your database button (wired via the import map when adminButton is on). |