✨ Strapi MCP is now Generally Available - let your agents manage your Strapi content ✨

EcosystemIntermediate7 min read

Seeding Data in Strapi: Why Migrations are the Wrong Tool

September 9, 2026
Seeding Data in Strapi: Why Migrations are the Wrong Tool

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:

  1. Load the schema: content types and components become database models; relations are validated.
  2. Run pending migrations: your files in /database/migrations first, then Strapi's internal ones. Each runs in its own transaction and is tracked so it never runs twice.
  3. 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.
  4. Persist the new schema as the reference for next time.
  5. Run register(), then bootstrap().

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

NeedUse
Default categories, statuses, settings on a fresh installbootstrap()
Demo content for local developmentbootstrap(), guarded by NODE_ENV
Transform existing data before a schema changeA migration
Backfill a column added in this releaseA migration (guard with hasColumn)
Move real content between environmentsData Transfer
One-off cleanup on productionA 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.

Further reading

Theodore Kelechukwu OnyejiakuDevRel and Community | Software Developer | Technical Writer

Theodore is a Technical Writer and a full-stack software developer. He loves writing technical articles, building solutions, and sharing his expertise.

Strapi and TypeScript: compiling when there are type errors
EcosystemAdvanced·6 min read

Strapi and TypeScript: compiling when there are type errors

A single type error stops your Strapi dev server. The tsconfig settings that let it build anyway, and when each one is actually appropriate.

·September 9, 2026
"Cannot read properties of undefined (reading 'attributes')" in Strapi
EcosystemIntermediate·6 min read

"Cannot read properties of undefined (reading 'attributes')" in Strapi

Strapi will not boot because a content type references a component or relation that does not exist. How to find the broken reference quickly.

·September 9, 2026
How to debug a 500 Internal Server Error in Strapi
EcosystemBeginner·7 min read

How to debug a 500 Internal Server Error in Strapi

A 500 means Strapi threw an exception it did not expect. The real message is in your server logs, not the browser. A systematic way to find it.

·September 9, 2026