Docs
Systhema Design (opens in new tab)
Changelog

v1.7.0 breaking and behavioral changes

The 23 changes a 1.6 project meets when it upgrades to 1.7.0.

On this page

Part of the v1.7.0 release notes.

Running systhema upgrade adopts most of these for you, via seven codemods, the token migration and two post-upgrade database hooks (including the _status column move, which runs as a hook rather than a manual SQL step). The steps you do by hand are the ones a tool can't safely touch: installing the new @payloadcms/plugin-import-export peer (§6), opting into the inverted localization policy and writing the Payload migration that backs it (§7), passing --allow-database so the two data hooks run at all (§3), regenerating the Payload import map after enabling the Cloudflare module (§9), running systhema migrate migrate-header-nav-dbnames on a SQLite project that came through 1.6.0 (§10), the one-line footer prop replacement (§5), role definitions stored outside .ts/.tsx (§1), in-page anchors pointing at an old block id (§18), and re-saving a block whose Container width was dropped while the option was hidden (§19). Each entry below marks what, if anything, is left for you.

1. SEO global folded into General Settings, global.seo.* capabilities renamed (#111 (opens in new tab))Link to this section

The standalone seo global is gone. Its fields now live on a General Settings SEO tab placed after Pages, and generatePageMetadata reads the seo subtree of general-settings.

RemovedReplacement
global.seo.readglobal.general-settings.seo.read
global.seo.updateglobal.general-settings.seo.update
global.seo.*global.general-settings.seo.*
GET /api/globals/seo (unauthenticated)Local API server-side, or an authenticated read

General Settings tabs are now read-gated per tab. New capabilities global.general-settings.{pages,seo,emails,cookieConsent}.read are registered, and seoManager sees only the SEO tab's fields.

systhema upgrade does the automatic half. The rename-seo-capability-strings codemod rewrites the quoted capability strings across the project's src and app directories, and the migrate-seo-data post-upgrade hook copies the legacy global's rows at the database level (idempotent, --force to overwrite). The legacy seo table is deliberately left in place for you to drop.

What's left for you: role definitions stored outside .ts/.tsx (JSON, YAML, database rows) need a manual sweep, and a decoupled frontend that fetched /api/globals/seo anonymously must read server-side through the Local API or authenticate with a user holding global.general-settings.seo.read. Setting generalSettings: false with seo: true now removes the SEO defaults UI and logs a startup warning.

The tab also gains Yoast-style title templating (a Website Name field, a Title Separator picker, and a Title Template defaulting to %page_title% %title_separator% %website_name%), a token-chip editor with a %-triggered variable picker, a length counter measuring the resolved title, and a social preview covering Google, Facebook, X, LinkedIn, Discord and Slack.

2. Per-locale publishing is on by default for projects with locales (#171 (opens in new tab))Link to this section

On any project with locales configured, _status moves out of a shared column into the _locales sibling tables of pages, posts, components and their _v version tables.

This is automated. systhema upgrade runs the migrate-localize-status post-upgrade hook, gated on the project being a Payload project with locales that has not opted out. The hook performs the whole transition in a transaction, derives each locale's state from version history, writes the matching Payload migration file when the project keeps a migration directory, and is idempotent.

Opt out with:

withSysthema(baseConfig, {
  localization: { localizeStatus: false },
})

and skip the hook with --skip migrate-localize-status.

The rule, stated plainly: upgrade with systhema upgrade, never with a bare package update. A dev schema push cannot move a column; it drops it and re-adds it. Ship the new code without the hook and every page comes back as a draft in every locale.

A new slow, report-only localize-status-schema doctor check compares the config's expectation against the live database in both directions, and @systhemaui/payload logs one error line at boot when the option is on and the database still holds a shared status.

systhema upgrade --yes used to run every post-upgrade hook, including the two that write to the database (migrate-seo-data and migrate-localize-status). Database-writing hooks are now excluded unless --allow-database is selected. The plan names each such hook, its command, its DATABASE_URI target and the exact --skip <id> flag, and an interactive run confirms them separately from the dependency, code and token changes.

What's left for you: pass --allow-database (or confirm the hooks when prompted), or run each deferred hook yourself with systhema migrate <id> before the updated app starts or deploys. The upgrade lists the hooks it left out, with their commands, at the end of its output. A CI script that ran systhema upgrade --yes and relied on the hooks running needs the extra flag.

4. robots.ts replaced by a robots.txt Route Handler (#116 (opens in new tab))Link to this section

Next's static robots.ts metadata file has no request context, so its Sitemap: line could only ever carry one global NEXT_PUBLIC_SERVER_URL. On a site serving one domain per locale, every domain advertised the primary domain's sitemap.

The scaffold is now src/app/robots.txt/route.ts, a Route Handler deriving the origin from x-forwarded-host, then host, then NEXT_PUBLIC_SERVER_URL. It must keep export const dynamic = 'force-dynamic': reading request.headers is not enough on its own, because Next prerenders the handler anyway and bakes one host's body.

This applies to every project, locales or not. systhema upgrade runs the host-aware-robots codemod, which swaps the file only when the old one's whitespace-normalized digest matches a known scaffolded version. A customised robots.ts is left in place with a note, and Next refuses to build a project carrying both, so remove one by hand in that case.

The layoutBackground prop is removed from FooterSimpleInstance and FooterAdvancedInstance in both @systhemaui/react and @systhemaui/next, and from RootFooter in @systhemaui/payload. It is now a TypeScript error rather than a prop that quietly did nothing.

Core paints the footer surface from three colour tokens instead: footer.simple.background on the simple footer's root <footer>, footer.main.background on .footer-main, and footer.bottom.background on .footer-bottom. A footer's surface is its theme plus those tokens; anything else comes from a className or the project's own CSS.

Migration is manual and one line. No codemod ships for it:

- <FooterSimpleInstance layoutBackground="alternative" />
+ <FooterSimpleInstance className="bg-layout-alternative" />

theme is unchanged and is the supported way to switch the footer's palette; a theme on the root footer re-resolves the band tokens inside the themed scope. :where(.footer-simple) stays at zero specificity, so a class you pass still wins.

The footer band background tokens need no token migration. After this removal, footer.main.background and footer.bottom.background default to the page background exactly as the Figma library defines them, and #174 (opens in new tab) removed the previously planned migration on purpose.

6. Form submissions require @payloadcms/plugin-import-export, and add two tables (#140 (opens in new tab))Link to this section

Import/export is registered by withSysthema wherever the Forms module is on, so the export drawer works with no per-project setup.

Install the peer. @payloadcms/plugin-import-export contributes admin components that land in the consumer's import map, so it must be a peer dependency, like plugin-seo, plugin-redirects and plugin-nested-docs. A missing one gives Module not found: @payloadcms/plugin-import-export/rsc. There is no codemod for it; systhema upgrade's dependency handling and the peer-deps doctor check are the surfaces that report it.

Two new tables. Enabling import/export registers the plugin's exports and imports collections, which existing projects pick up on their next schema push. Import stays off for submissions, so the imports collection is hidden and create/update locked, but the tables exist.

Two new job tasks, and on PostgreSQL that means two enum labels. The plugin also pushes createCollectionExport and createCollectionImport onto jobs.tasks. Every registered task slug is a label of the payload_jobs task-slug enums, so on PostgreSQL both enum_payload_jobs_task_slug and enum_payload_jobs_log_task_slug gain the two labels (alongside the built-in inline). A project running with Drizzle push enabled gets them on the next boot. A push-off project gets them from systhema migrate --schema, which adds missing enum labels as part of its additive plan — run it before the new code deploys, or a job write fails on a label the type does not have.

One more thing to know: importExportPlugin is an async Payload plugin, which matters for any project code that resolves config.plugins synchronously.

7. localization.policy: 'all' is opt-in and needs a back-fill migration (#141 (opens in new tab))Link to this section

The default stays 'legacy', so upgrading the package changes nothing on its own. That default is load-bearing: an existing multi-locale project already has locales enabled, so a policy that widened on package upgrade would move columns into _locales siblings on a routine pnpm update, and a production deploy runs with Drizzle push disabled, so the new code would query columns nothing created and the site would 500 on every read, in a way that never reproduces in dev.

Switching to 'all' is a schema change and is not automated. The required order:

  1. Run the dry run: pnpm payload systhema-localization-report (add --json for machine output). It prints exactly which fields would widen.
  2. Write a Payload migration that calls backfillSysthemaLocalization, exported from @systhemaui/payload. It copies the shared value into every locale, is idempotent and resumable, refuses to overwrite a translation, and offers missingParentRow: 'cloneDefaultLocale' for a locale row that does not exist yet.
  3. Apply that migration before the new code deploys.

A multi-locale project that has not declared a policy gets a one-time startup notice and a localization-policy doctor warning; an explicit policy: 'legacy' silences both.

Measured cost on a real five-locale SQLite instance with 120 pages: the _locales row count did not change at all (2,520 rows before and after), pages_locales went from 19 to 36 columns, the database file grew 5.9%, and read latency was not measurable above process-order noise.

Full reference: docs/payload/localization/field-policy.md.

8. Payload peer floor moves to ^3.89.0, and AVIF comes back with sharp 0.35.4 (#197 (opens in new tab), #162 (opens in new tab), #133 (opens in new tab))Link to this section

@systhemaui/payload's peer floor for payload and every @payloadcms/* package moves from ^3.85.0 to ^3.89.0 (through ^3.86.0 in July and ^3.88.0 in August). A project pinned below 3.89.0 gets a peer warning; systhema upgrade carries the bumps and re-keys the bundled lexical patch. The floor follows the version Systhema is tested on, and it is the one peer Systhema sets above Payload's own: every other peer (next, react, react-dom) now mirrors the range Payload's packages declare, so @systhemaui/payload accepts React ^19.0.1 || ^19.1.2 || ^19.2.1 and @systhemaui/react and @systhemaui/next accept that or React 18.

The Next.js peer range is deliberately left at upstream's own floor. The recommendation to take 16.3.5 travels through the dependency bump, not the peer range.

AVIF image optimization was off in Next 16.3.3 and is back from 16.3.4, which requires sharp 0.35.4 or newer, the release with the patched libheif. A project that opted into images.formats: ['image/avif', …] serves AVIF again once both are in place. A project on Next 16.3.4 or newer with sharp still at 0.34 has the vulnerable decoder reachable again, so take the sharp bump with the Next one. No Systhema template or scaffold ever set images.formats, so a stock project is unaffected either way.

The scaffolds now ship TypeScript 6.0.3, and @systhemaui/payload's typescript peer is ^5 || ^6. A project that moves itself to TypeScript 6 deletes baseUrl from its tsconfig.json (a deprecation error in 6, and redundant next to paths); that file is project-owned, so it is a one-line manual step, not a codemod. @payloadcms/plugin-mcp is incompatible with TypeScript 6 at runtime. Keep TypeScript 5 when that plugin is enabled until its upstream fix lands.

9. The Cloudflare module needs an import-map regeneration (#110 (opens in new tab))Link to this section

Enabling the cloudflare plugin option registers an admin global and four dashboard widgets, all of which contribute components to the Payload import map. The import map is generated from the static config, so a newly registered component path resolves to nothing until it is regenerated.

This is manual. After enabling the module, run payload generate:importmap (or start next dev once). systhema upgrade does not do it for you, because the module is off by default and the upgrade has no way to know you are about to turn it on.

10. SQLite projects that came through 1.6.0 still carry the old Header nav tables (#125 (opens in new tab))Link to this section

The Header nav dbName reconciliation shipped in v1.6.0 was Postgres-only and printed a false all-clear on SQLite, leaving those projects to meet Drizzle's interactive "created or renamed?" prompt on their next schema push. createTableName returns dbName verbatim for every adapter, so SQLite carries exactly the same stale tables.

The reconciliation engine is now shared and handles both adapters: Postgres renames constraints, indexes, enums and sequences from the catalog, SQLite drops and recreates indexes from stored DDL and lets SQLite rewrite child foreign keys itself. Both paths are transactional and idempotent.

What's left for you: a SQLite project that upgraded through v1.6.0 should run

systhema doctor              # the pending-db-renames check names the remediation
systhema migrate migrate-header-nav-dbnames

The new pending-db-renames doctor check connects read-only through the project's DATABASE_URI and compares the live catalog against the expected names. It is version-independent, so it also catches a project that jumped several versions and can no longer be healed by the upgrade path.

Post-upgrade hooks also gained a kind discriminator of 'rename' or 'shape', and systhema upgrade blocks before touching anything when a 'shape' hook is planned and no Payload migration directory exists. No 'shape' hook exists yet, so that block cannot fire today.

11. The accessibility pass changes rendered markup (#105 (opens in new tab))Link to this section

The DOM changes on every page. Nothing needs a codemod; the changes ship with the package upgrade. What to expect:

  • New ARIA attributes throughout (aria-expanded, aria-controls, aria-haspopup, aria-current, aria-describedby, aria-invalid, role="alert", role="radiogroup").
  • Footer link collections wrapped in <ul> and <li> with display: contents, so the layout is byte-identical and only the accessibility tree gains the list.
  • New-tab links carry rel="noopener noreferrer" plus an "opens in new tab" hint, as visually-hidden text or folded into the aria-label.
  • Google Maps embeds render through GoogleMapEmbed instead of @next/third-parties, which has been removed as a dependency.
  • Visitors with prefers-reduced-motion see reveals snap to their end state rather than animate.

If you have CSS or tests selecting on the old markup, check them.

@next/third-parties globally augmented Window with [key: string]: any. Any window as Record<string, unknown> cast in your own code that silently relied on that now needs to be written window as unknown as Record<…>.

12. Scroll reveals fire at the element's top edge (#122 (opens in new tab))Link to this section

AosListener is rewritten on IntersectionObserver and no longer measures during scroll. The trigger point moves as a result: on desktop, elements previously revealed at their midpoint and now reveal once the top edge is 20px inside the viewport bottom, so reveals fire earlier and a tall section no longer sits visible but transparent. Re-arm now happens once the element is more than 10% of the viewport height below the fold, rather than the moment it clears the viewport.

The public API and the class vocabulary (.aos, .animates-on-scroll, .animated, .animates-once, .animate-disable, .aos-disable-children) are unchanged, and the engine is newly exported as startAosListener(). Browsers without IntersectionObserver keep a measured fallback at the same trigger points.

13. Scroll-reveal keyframes end at transform: none (#165 (opens in new tab))Link to this section

The reveal keyframes ended at translate3d(0, 0, 0). With animation-fill-mode: forwards that left every revealed element a containing block for absolutely positioned descendants forever, so an overflow: hidden ancestor could not clip them. They now end at transform: none.

Motion is visually identical. What's left for you: a layout that relied on that retained containing block needs an explicit position: relative plus a z-index on the element in question. No codemod, since only your own CSS knows which element that is.

14. Video preload resolves to metadata for existing content (#129 (opens in new tab))Link to this section

Every uploaded video rendered by the built-in page and post templates and by the Lexical converters hardcoded preload="auto", so a large hero video downloaded in full during the LCP window. A shared Preload select (Metadata, None, Auto) is added to VideoBlock, HeroSimpleBlock, HeroFeatureBlock, the combined page hero, and FeatureBlock when mediaType is video.

A defaultValue only reaches documents created after the field exists, so stored content resolves to metadata, not the auto it used to get. This is deliberate: preload is an advisory hint with no visual consequence, metadata still paints the first frame and reads the duration, and preserving auto would mean the fix reached nobody until every video block was re-saved by hand. An editor who wants eager buffering picks Auto.

15. Captcha scripts load lazily by default (#130 (opens in new tab))Link to this section

reCAPTCHA and Turnstile scripts used to fetch during hydration wherever the form sat on the page. On a real production site that was roughly 555 KB of Turnstile payload, about a quarter of the page weight, for a control most visitors never scrolled to.

A per-provider lazy flag under forms.fields.<provider> now defaults to true. Loading is deferred until one of three signals opens a form-scoped gate: an IntersectionObserver on the widget with a 300px margin, focus or pointer-down or a value change on any field of that form, or submit, which opens the gate and waits for the widget before validating (bounded by a 10s timeout, with an extra 2.5s token grace for Turnstile).

An above-the-fold form is unaffected, because IntersectionObserver delivers its first callback for an already-intersecting target in the same frame the effect runs, and browsers without it resolve to eager. The visible change is that a below-the-fold widget appears as the visitor approaches it. Restore the old behaviour with lazy: false.

16. Built-in hero titles render as <h1> (#169 (opens in new tab))Link to this section

The built-in page template rendered the feature hero's title as an <h2>, and the post and archive templates rendered every hero title as a styled <div>, so a page on a feature hero, every post and every archive shipped with no <h1>. DefaultPage, DefaultPost and DefaultArchive now emit a real <h1> for all three hero types.

The feature hero keeps its text-h2 class, so nothing moves visually. What's left for you: any project CSS or test selecting div.hero-feature-title or an h2 in that position needs updating by hand. No codemod.

17. Post titles go through the General Settings title template (#158 (opens in new tab))Link to this section

generatePostMetadata now runs a post title through resolveSeoTitle with General Settings SEO's websiteName, titleSeparator and titleTemplate, the same as pages do. A post titled "Ten ways to ship faster" becomes "Ten ways to ship faster – Acme" in <title>, openGraph.title and twitter.title.

An explicit SEO Meta Title on the post is still passed through untemplated. No codemod; the change is the intended behaviour and needs no project action.

18. Anchor ids change for a blockName containing a run of capitals (#166 (opens in new tab))Link to this section

toUrlCase derives the #id every Systhema block emits from its editor-facing blockName, and its /([A-Z])/g rule split a run of capitals letter by letter, so a name like "ESG report" produced e-s-g-report. Two rules replace the one: break on a lowercase-to-capital boundary, and break before the last capital of a run followed by a capitalised word. ABCDef becomes abc-def, and a trailing or standalone run stays one word. camelCase and PascalCase splitting is unchanged, and toKebabCase (which generates CSS class and variable names) deliberately keeps the old pattern.

What's left for you: a block whose blockName contains a capital run derives a new anchor id, so any in-page anchor or bookmark pointing at the old letter-split id needs updating. No codemod exists and none is possible: the ids come from editor-authored content in the database. Styling keyed to these ids was never supported.

toUrlCase also sanitises form-upload filenames, which get the same improvement.

19. The Container width option reappears in six blocks (#138 (opens in new tab), #139 (opens in new tab))Link to this section

Six Payload blocks (card, carousel, embed, form, googleMaps, media) gated their "Container" width option on parseInt(size) > 0 over article.paddingX. A resolved token value is not a plain length: the pipeline emits --spacing(60 / 4), or a viewport unit, or a bare number under a swap rule, and parseInt returns NaN on all of them. The option had never rendered, even though the shipped padding is 60px at md and 88px at lg.

The gate is now isPositiveTokenLength(value), which reads the token's numeric sign, identical in every unit Systhema emits and needing no viewport, so it answers the same on the server, in a build step and in the browser.

What's left for you: a block that was re-saved through the editor while the option was missing dropped back to 'default' and needs its width set back to Container by hand. A block that was never re-saved keeps its stored 'container' value, which is valid again the moment the option reappears.

20. systhema-core obfuscate refuses to obfuscate classes on a CMS-backed site (#123 (opens in new tab))Link to this section

Class obfuscation is prerender-only. It never rewrites .next/server/** JavaScript, because those chunks carry Payload block and collection slugs whose strings collide with class names, so anything rendered at runtime would keep the original names and lose its styling while the build stayed green.

The command therefore refuses and exits non-zero when it detects a PayloadCMS project, ISR routes, on-demand dynamic routes, or a force-dynamic / revalidate export. --allow-dynamic overrides it for a genuinely static site. A CMS-backed site now gets a loud build failure instead of a silently unstyled page, and no config change is required to get it.

A new obfuscate-build-step doctor check warns when obfuscateClasses is on but no package.json script chains systhema-core obfuscate after the build, and errors when the flag is set on a PayloadCMS project at all.

21. FontAwesome picker fields keep faIconPickerField (#189 (opens in new tab))Link to this section

The 1.6 adopt-icon-picker codemod renamed faIconPickerField to iconPickerField without changing its import path. The FontAwesome subpath exports only faIconPickerField, so a deep import came out invalid and payload generate:types failed with a missing export. The codemod now preserves FontAwesome fields: their icon map, custom-option merging, field defaults and raw SVG storage stay as they were, because the generic picker inherits plugin packs and stores a different shape by default.

Projects already past the 1.6 migration are repaired automatically. Editing the old codemod cannot reach them, so the 1.7 upgrade runs a separate repair-fa-icon-picker-import codemod that restores the deep import and its bound references:

-import { iconPickerField } from '@systhemaui/payload/fields/iconPicker/fontAwesome'
+import { faIconPickerField } from '@systhemaui/payload/fields/iconPicker/fontAwesome'

-export const field = iconPickerField({ name: 'icon' })
+export const field = faIconPickerField({ name: 'icon' })

Explicit aliases, shadowed locals, property names and public export names are preserved. If faIconPickerField already exists anywhere in the file, the repair imports under an alias to avoid a collision. It supports --dry-run and is idempotent.

What's left for you: iconPickerField imported from the @systhemaui/payload/fields barrel is valid and may be intentional, so the repair reports those call sites with file and line and does not rewrite them. Compare against version control and restore faIconPickerField only where an earlier upgrade renamed it. The adopt-icon-picker warnings are also quieter: a local render helper whose first parameter is an IconDefinition from any @fortawesome/* package no longer triggers advice, and the remaining advice is conditional on the value coming from a CMS picker. Helpers imported from another file, IconProp-typed helpers, untyped JavaScript helpers and results first stored in a variable can still receive it; leave already rendered SVG and local FontAwesome definitions alone. See existing FontAwesome fields.


22. Node 20.9 is the minimum (#197 (opens in new tab))Link to this section

engines.node drops its ^18.20.2 half and reads >=20.9.0 at the monorepo root, in @systhemaui/cli and in the Payload scaffold. sharp 0.35 needs Node 20.9, and both the CLI and the scaffold depend on it directly. Node 18 left maintenance in April 2025 and the install docs already asked for Node 20+, so no supported setup changes; a Node 18 machine now gets an engines error on install instead of a failed sharp build. Payload's own template asks for Node 24.

23. The browser baseline is Chrome 111, Edge 111, Firefox 128, Safari 16.4 (#203 (opens in new tab), #201 (opens in new tab))Link to this section

Every scaffold's browserslist now declares that line, and it is written down once, in packages/cli/src/engine/browserBaseline.ts, which the templates, the 1.6.0 add-browserslist-to-package-json codemod and the new raise-browserslist-to-modern-baseline codemod all read. The numbers are the union of three floors a Systhema site already sits on: Tailwind CSS v4 (a hard requirement of @systhemaui/core) supports Safari 16.4, Chrome 111 and Firefox 128, so the CSS does not render correctly below that line whatever the JavaScript is compiled for; Next 16's own modern target is Chrome 111, Edge 111, Firefox 111, Safari 16.4; and Swiper 14, which the gallery and carousel run on, removed its code paths for anything older than Chrome/Edge/Firefox 110 and Safari 16.4. Declaring anything lower ships transpiled code and polyfills to browsers the CSS never supported.

systhema upgrade runs the codemod, which raises any >= floor in the root package.json browserslist that sits below the line and leaves higher floors, absent browsers and other query shapes (last 2 versions, defaults, > 0.5%) alone. A project that keeps its targets in a .browserslistrc raises them by hand. Swiper's snapGrid semantics, which the gallery's {current} / {total} counter depends on, were checked against 14 by source diff and by measuring a real build; they are unchanged.