Docs
Next

Per-locale publishing

localizeStatus: publish locales independently, opt out, migrate.

On this page

A Systhema project with locales publishes each locale on its own. _status is an ordinary localized field, so an editor can publish a page in hu while en stays a draft, and each locale's publish button acts on that locale only.

This is on by default whenever locales is configured, and it does nothing without it. A single-language project is unaffected in every way.

Turning it offLink to this section

One option, in withSysthema:

export default withSysthema(config, {
  locales: systhemaConfig.locales,
  localization: {
    localizeStatus: false,
  },
})

or in systhema.config.ts, which every withSysthema call in the project picks up:

packages: {
  payload: {
    enabled: true,
    localization: { localizeStatus: false },
  },
}

The plugin option wins when both declare it, in either direction.

Turning it off is a schema change too, in the other direction. Run systhema migrate migrate-localize-status only for the move onto per-locale status; the way back is the down half of the migration file, and it is lossy (see below).

Payload wants the switch in two places and refuses it in one. It needs experimental.localizeStatus at the top level, plus versions.drafts.localizeStatus on each collection. Its sanitizer silently forces the collection flag back to false (with a console warning) when the experimental flag is missing, so setting one without the other does nothing. Systhema sets both from the single option, on the three collections that have drafts: Pages, Posts and Components.

What stays sharedLink to this section

Globals. Header and Footer have drafts too, and they are deliberately left alone. Site chrome is one document every locale renders, and a per-locale published header is a different feature with a different set of questions (what does a locale with an unpublished header render?). Nothing stops you from setting versions.drafts.localizeStatus on your own global; Systhema will not.

Consumer collections. Only the three Systhema collections above are touched, and only when Systhema itself registered them. Replace pages through customCollections and its versions config is yours.

Everything the field policy decides. localizeStatus and localization.policy are separate switches over separate things. The policy decides which content fields are per-locale; localizeStatus decides whether the publication state is. You can run either without the other.

Revalidation scopeLink to this section

A status change used to be proof that every locale's public output moved, because there was one status. With per-locale status that is no longer true, and the revalidation hooks follow:

  • Publishing or unpublishing a single locale revalidates that locale's paths only.
  • A publish-all or unpublish-all request revalidates every locale. Payload sends the intent as the publishAllLocales or unpublishAllLocales query parameter, which utils/publishScope.ts reads.
  • Genuinely shared fields still fan out. Changing a page's parent or pageTemplate, or a post's author, postType or publishedAt, moves every locale whatever the status does.

Nothing changes for a project that opted out. _status stays in the shared-field list and a publish keeps fanning out.

The publish capability guards (pages.publish, posts.publish) follow the same rule. An editor without the capability cannot publish or unpublish one locale, and a publish-all request is blocked when any locale would move.

Migrating an existing siteLink to this section

_status leaves its shared column on pages, posts, components and their _v version tables, and lands in the _locales sibling tables, one row per locale. For live documents, the migration copies the current shared main-table _status into every existing locale row. Version rows remain history and use Payload's per-version status calculation, so a draft version saved over a published document does not unpublish the live page in every locale.

A production deploy runs with Drizzle push disabled, so shipping the code without the data move means the site queries columns nothing created and 500s on every read. @systhemaui/payload therefore logs a one-line error at startup naming the command below whenever the option is on and pages_locales has no _status column.

Include the data migration when upgrading. In development Drizzle pushes the schema on the next boot, and a push cannot move a column: it drops pages._status and adds pages_locales._status with its default, so every page comes back as a draft in every locale. systhema upgrade --allow-database runs the migration below before the first boot on the new package; if you updated by hand, run it yourself before pnpm dev.

The commandLink to this section

systhema migrate migrate-localize-status

systhema upgrade --allow-database includes this command as a database-writing hook. Without that flag, even with --yes, the hook is deferred. Run it deliberately before booting or deploying the new code. systhema upgrade --dry-run --json lists the hook without connecting to the database.

It boots Payload with PAYLOAD_MIGRATING=true (the switch Payload's own payload migrate uses to suppress the dev schema push), then does the move inside a real Payload transaction. It is idempotent: a second run reports alreadyMigrated and exits 0. It exits 0 without doing anything on a project with no locales, or one that opted out. On MongoDB it exits 1 and points at upstream's helper, which is the right tool there.

Projects that manage their schema with Payload migrationsLink to this section

If the project has a migration directory holding at least one migration, the command also generates the migration file production will apply:

src/migrations/20260908_120000_systhema_localize_status.ts
import { type MigrateDownArgs, type MigrateUpArgs, sql } from '@payloadcms/db-sqlite'
import { systhemaLocalizeStatus } from '@systhemaui/payload'

export async function up({ db, payload, req }: MigrateUpArgs): Promise<void> {
  await systhemaLocalizeStatus.up({ db, payload, req, sql })
}

export async function down({ db, payload, req }: MigrateDownArgs): Promise<void> {
  await systhemaLocalizeStatus.down({ db, payload, req, sql })
}

Payload writes the .json schema snapshot beside it, and the helper produces exactly the schema that snapshot describes, so the next payload migrate:create reports no changes for these tables. The command also records the migration as run in your local payload-migrations table, because it already applied it by hand.

Commit both files. Production sequencing is the usual one: payload migrate in the deploy step, before the new build boots. Both files ship in the same deploy as the code.

Projects still on dev pushLink to this section

With no migration directory there is no file to generate, so the command applies the change in place and says so. Before deploying the new build, run the same command against the production DATABASE_URI:

DATABASE_URI=<production> pnpm payload migrate-localize-status

systhema doctor's localize-status-schema check reports the drift until you do.

There is no safe halfway stateLink to this section

The helper moves the columns itself, so once it has run the old code (option off) reads a column that is gone, and until it runs the new code reads one that does not exist yet. Code and migration go out together.

The helper, called directlyLink to this section

systhemaLocalizeStatus is exported from @systhemaui/payload for a hand-written migration. The sql template tag comes from your adapter, which is why the helper takes it as an argument instead of importing one; import it from @payloadcms/db-postgres on Postgres.

ArgumentMeaning
collectionsWhich slugs to convert. Defaults to the Systhema drafts collections present in the config.
dryRunReport what would change without writing. Introspection still runs.
logProgress sink. Defaults to payload.logger.info.

up is idempotent. A second run is a logged no-op. down is lossy by construction, because collapsing several per-locale statuses back into one can only keep the default locale's. It also refuses to run while a collection still declares versions.drafts.localizeStatus, so set localizeStatus: false before rolling back.

Check the report before you trust a run. Each collection comes back with a status of migrated, alreadyMigrated or skipped, plus a reason when nothing happened.

The upstream caveatsLink to this section

Payload marks localizeStatus experimental, and it is not a settled feature yet. Known open issues at the time of writing:

If one of those bites, localizeStatus: false plus the migration file's down is the way back. Try the switch on a staging copy of the database first.