You want default content in a fresh Strapi database: categories, a settings single type, roles, sample articles for a new developer. Migrations look like the obvious tool: they are versioned, they run once, and every other framework does it this way.
Then you write one, deploy it in the same commit as the content type it seeds, and it fails because the table does not exist.
That is not a bug. It is the startup order, and once you know it the right approach is obvious.
The startup order
On every start, Strapi does this:
- Load the schema: content types and components become database models; relations are validated.
- Run pending migrations: your files in
/database/migrationsfirst, then Strapi's internal ones. Each runs in its own transaction and is tracked so it never runs twice. - Sync the database schema: compare content types against the database, create tables and columns, drop what is no longer in the schemas, alter the rest.
- Persist the new schema as the reference for next time.
- Run
register(), thenbootstrap().
The docs state it directly: "Migrations run before the schema sync, so an up() function still sees the database in its previous state. Write migrations against the old schema, not the one you are migrating to."
So a migration shipped alongside a new content type runs at step 2, and the table it needs is not created until step 3. Nothing you can do inside the migration changes that.
Use bootstrap() instead
bootstrap() runs at step 5, after the schema sync, with every table in place and the full strapi object available. That is where seeding belongs.
export default {
register({ strapi }) {},
async bootstrap({ strapi }) {
await seedCategories(strapi);
await seedSiteSettings(strapi);
},
};
async function seedCategories(strapi) {
const count = await strapi.documents('api::category.category').count();
if (count > 0) {
return; // already seeded
}
const categories = [
{ name: 'Engineering', slug: 'engineering' },
{ name: 'Product', slug: 'product' },
{ name: 'Community', slug: 'community' },
];
for (const data of categories) {
await strapi.documents('api::category.category').create({
data,
status: 'published',
});
}
strapi.log.info(`Seeded ${categories.length} categories`);
}
async function seedSiteSettings(strapi) {
const existing = await strapi.documents('api::site-setting.site-setting').findFirst();
if (existing) {
return;
}
await strapi.documents('api::site-setting.site-setting').create({
data: {
siteName: 'My Site',
defaultLocale: 'en',
},
status: 'published',
});
strapi.log.info('Seeded site settings');
}Two details that matter:
status: 'published'. With Draft & Publish enabled, documents created without it are drafts, and drafts are invisible to the default REST API. You will seed successfully and then wonder why /api/categories returns an empty array.
The Document Service, not the Entity Service. Strapi 5 uses strapi.documents(uid). The v4 strapi.entityService API is deprecated, so a seed script copied from a v4 tutorial will not work unchanged.
Making it idempotent
bootstrap() runs on every start, so the seed must be safe to run repeatedly. There are three good patterns.
Count first: simplest
const count = await strapi.documents('api::category.category').count();
if (count > 0) return;Good for "seed only an empty database". The weakness: adding a fourth category later never runs, because the count is no longer zero.
Upsert by a unique key: most flexible
async function upsertCategories(strapi) {
const categories = [
{ name: 'Engineering', slug: 'engineering' },
{ name: 'Product', slug: 'product' },
{ name: 'Community', slug: 'community' },
{ name: 'Design', slug: 'design' }, // added later, still gets created
];
for (const data of categories) {
const existing = await strapi
.documents('api::category.category')
.findFirst({ filters: { slug: data.slug } });
if (existing) {
continue; // or update it, if seeds should be authoritative
}
await strapi.documents('api::category.category').create({
data,
status: 'published',
});
}
}This handles growing seed sets, which is what you actually want over the life of a project.
A seed marker in the plugin store: for one-time work
For work that must run exactly once regardless of what the data looks like:
async function seedOnce(strapi, key: string, fn: () => Promise<void>) {
const store = strapi.store({ type: 'plugin', name: 'my-seeds' });
const done = await store.get({ key });
if (done) return;
await fn();
await store.set({ key, value: true });
strapi.log.info(`Seed "${key}" completed`);
}
// usage
await seedOnce(strapi, '2026-09-initial-categories', () => seedCategories(strapi));This is the "seeding table" instinct from the original forum thread, and Strapi already has the storage for it, so you do not need your own table.
Guarding by environment
Sample content belongs in development, not production:
export default {
register() {},
async bootstrap({ strapi }) {
// Always: reference data the app needs to function
await seedCategories(strapi);
await seedSiteSettings(strapi);
// Development only: demo content
if (process.env.NODE_ENV === 'development' || process.env.SEED_DEMO === 'true') {
await seedDemoArticles(strapi);
}
},
};Distinguish the two kinds honestly:
- Reference data: categories, statuses, a settings singleton, default roles. The app is broken without it. Seed it everywhere.
- Demo content: sample articles, placeholder images, test users. Convenience for local work. Never in production.
Seeding from a file
Beyond a handful of records, inline data gets unwieldy:
import fs from 'node:fs/promises';
import path from 'node:path';
async function seedFromFile(strapi, uid: string, file: string, uniqueField: string) {
const raw = await fs.readFile(path.join(strapi.dirs.app.root, 'data', file), 'utf8');
const records = JSON.parse(raw);
let created = 0;
for (const data of records) {
const existing = await strapi.documents(uid).findFirst({
filters: { [uniqueField]: data[uniqueField] },
});
if (existing) continue;
await strapi.documents(uid).create({ data, status: 'published' });
created += 1;
}
if (created) {
strapi.log.info(`Seeded ${created} records into ${uid} from ${file}`);
}
}
export default {
register() {},
async bootstrap({ strapi }) {
await seedFromFile(strapi, 'api::category.category', 'categories.json', 'slug');
},
};Keep the JSON in ./data/ and commit it, because the seed data is part of the project.
When a migration is the right tool
Migrations are not the wrong tool in general; they are the wrong tool for seeding new content types. Use them for what they are designed for: transforming existing data before the schema sync removes the old structure.
The canonical case is renaming a field. In one release you add the new column and the old one is still present. A migration copies the data across before the sync drops the old column:
'use strict';
async function up(knex) {
const hasOld = await knex.schema.hasColumn('authors', 'full_name');
const hasNew = await knex.schema.hasColumn('authors', 'first_name');
if (!hasOld || !hasNew) {
return;
}
const rows = await knex('authors').select('id', 'full_name');
for (const row of rows) {
const [first, ...rest] = (row.full_name ?? '').trim().split(/\s+/);
await knex('authors')
.where({ id: row.id })
.update({ first_name: first ?? '', last_name: rest.join(' ') });
}
}
module.exports = { up };Three things worth internalising:
- Guard with
hasColumn. The migration must be safe on a fresh database where neither column exists. up()runs in a transaction. A failure rolls the whole thing back.- There is no
down(). Strapi does not support down migrations; reverting is manual. Take a backup before running anything destructive.
Files go in ./database/migrations, named so alphabetical order equals execution order; the YYYY.MM.DDTHH.mm.ss.name.js convention exists for that reason. There is no CLI to run them; they run at startup.
For TypeScript migrations, set useTypescriptMigrations: true in config/database settings so Strapi looks in the build directory.
Choosing between them
| Need | Use |
|---|---|
| Default categories, statuses, settings on a fresh install | bootstrap() |
| Demo content for local development | bootstrap(), guarded by NODE_ENV |
| Transform existing data before a schema change | A migration |
| Backfill a column added in this release | A migration (guard with hasColumn) |
| Move real content between environments | Data Transfer |
| One-off cleanup on production | A script run manually, not a migration |
That last row is worth stating plainly: for a genuine one-off, write a script and run it deliberately with npx strapi console or a standalone Node script using the Strapi factory. A migration that exists only to fix one incident is a permanent file that runs forever on every fresh database.
A note on Data Transfer
If what you actually want is "get production content into my local environment", seeding is the wrong frame entirely. Use Data Transfer:
npx strapi transfer --from https://cms.example.com/admin --from-token <transfer-token>Seeding is for data the application defines. Transfer is for data the editors created.




