---
title: "Per-locale publishing"
description: "localizeStatus: publish locales independently, opt out, migrate."
requested_language: ar
language: en
translation_notice: "This page isn't translated yet"
url: https://docs.systhema.app/ar/payload/localization/per-locale-publishing
version: unreleased (main)
docs_index: https://docs.systhema.app/ar/llms.txt
---
> This page isn't translated yet. Showing English.


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 off

One option, in `withSysthema`:

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

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

```ts
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 shared

**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 scope

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 site

`_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 command

```bash
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 migrations

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

```ts title="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 push

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`:

```bash
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 state

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 directly

`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.

| Argument      | Meaning                                                                                    |
| ------------- | ------------------------------------------------------------------------------------------ |
| `collections` | Which slugs to convert. Defaults to the Systhema drafts collections present in the config. |
| `dryRun`      | Report what would change without writing. Introspection still runs.                        |
| `log`         | Progress 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 caveats

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

- [payloadcms/payload#16395](https://github.com/payloadcms/payload/issues/16395)
- [payloadcms/payload#17914](https://github.com/payloadcms/payload/issues/17914)
- [payloadcms/payload#17923](https://github.com/payloadcms/payload/issues/17923)

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.
