0.0.2-alpha.68
- The Visual Page Builder opens as a screen of its own, at the page’s own address.
- With the rate limit on (the default), every REST request now counts against it, the admin API included.
Before you upgrade4
- 1
Run
nextly migrateagainst your production databaseafter deploying the new packages and before the new version serves traffic:
npx nextly migrate0.0.2-alpha.68 adds two core tables,
nextly_content_eventsandnextly_content_event_parts, and a production server never creates tables itself. Nothing writes to them in this release yet. A server that starts without them now logs one warning naming every missing core table and the command that creates them. - 2
MySQL, if you ever turned localization off on a collection, Single or field group:
before you start the upgraded version, save any edit made directly to that entity's main table since you turned it off. The first
nextly db:sync, dev-server start ornextly migrateafter upgrading restores the translation table's values onto the main table and replaces those edits. The entry "On MySQL, turning localization off now brings the content back" below has the query that lists the rows to check. PostgreSQL and SQLite are not affected. - 3
The REST rate limit now applies to every request, the admin API included.
Requests are counted by who is asking:
- a signed-in session gets 500 reads and 150 writes a minute by default (
rateLimit.sessions); - an API key gets its own
rateLimit; - an anonymous caller gets 100 reads and 30 writes a minute per address.
Behind a proxy or load balancer, set
security.trustProxy. Until you do, the server cannot tell anonymous callers' addresses apart, and all anonymous traffic shares one ceiling (1,000 reads and 300 writes a minute by default). An app that relied on the admin API being exempt can pass the deprecateddefaultSkipAdminApiasrateLimit.skipfor now; a later release removes it. - a signed-in session gets 500 reads and 150 writes a minute by default (
- 4
A Content Release now holds at most 200 documents
(it was 1,000). A release made before this upgrade that holds more keeps running as scheduled, but takes no new documents. Check large scheduled releases before upgrading.
What's changed29
Released 21 packages at 0.0.2-alpha.68 in lockstep. Every package below ships at this version.
Patch Changes
- A core record written inside a plugin's PostgreSQL transaction (an email delivery or an audit row naming a plugin-defined person) runs under a savepoint of its own, and a failure rolls back only to it.
More
The savepoint could open while plugin work started earlier was still running: a deletion report of several steps, or a query not yet sent. A rollback to it then undid that work while the transaction still committed. The savepoint now opens only once nothing the plugin started is in flight. A plugin read made meanwhile no longer fails the transaction, since a rollback cannot undo a read.
- On PostgreSQL, a transaction that a failed statement had aborted was reported as committed.
More
PostgreSQL does not refuse a COMMIT sent after a failure: it rolls the transaction back and answers with the ROLLBACK command tag, raising no error. So a callback that caught a failed statement and returned saw
transaction()resolve while none of its writes were kept.The PostgreSQL adapter's
transaction(), and soctx.db.transaction, now rejects in that case, with aDatabaseError(code25P02): an error now replaces a silent rollback.ctx.db.raw.transactionis Drizzle's own transaction and is unchanged. Code that catches a failed statement inside a PostgreSQL transaction and carries on will see the transaction fail where it used to appear to succeed. Nothing it wrote was ever kept, so no data changes; only the report does. A bulk create or update withoutstopOnError, in which one item's statement failed, now reports every item failed, which is what was kept, where it used to count the items before the failure as saved. A statement that may fail, and that the rest of the work should not depend on, belongs outside the transaction.One core path did exactly that. A plugin that reports a person deleted inside
ctx.db.transaction, and then sends the person an email naming them, has the email's record written inside that same transaction. The recorder never fails a send, so when that insert failed (a provider removed between the send and its record, a lock timeout), it caught the failure and carried on. On PostgreSQL the commit then rolled back the plugin's deletion, the audit erasure and the deleted-person record, while the plugin was told all three had committed. The person was never erased.A core record written inside a plugin's transaction (an email delivery or an audit row naming a plugin-defined person) now runs under a savepoint of its own on PostgreSQL. Its failure is rolled back to the savepoint and the plugin's transaction stays usable, so the deletion commits. If rolling back would also undo a write the plugin started while the record was being written, the transaction is failed instead, so no write the plugin was told about is lost unseen. MySQL keeps a transaction usable after a failed statement and has no savepoints in this adapter, so nothing changes there.
- Nextly gains a record of content events, ready for the features that write to it: what happened to a document or a release, who or what did it (a person, an API key, a plugin-defined person, a release or the system, with the name they had then), when, and optionally the state before and after, a note, the version, a small kind-specific payload and a definition hash.
More
The languages and shared fields an event involved are kept as parts, so history can be filtered and paged by language. Event kinds are registered by the core features that write them, and an event is written in the same transaction as the change it records. Nothing writes events yet. A subject's events are kept while it exists, and removed once the activity log's retention window has passed since its deletion, by the write paths' retention pass and the scheduled drain alike. Deleting a staff account removes its name from the events it performed and keeps its id as an opaque reference, as the audit trail does; a plugin reporting one of its people deleted removes their name and replaces their id with the keyed digest the audit trail names them by. Two new core tables,
nextly_content_eventsandnextly_content_event_parts, are created bynextly migrate. - A release now holds at most 200 documents (
RELEASE_MEMBER_LIMIT, exported fromnextly/schemas), down from 1,000: a full release on its own publishes within one pass of the job runner on every supported database.More
The limit is per release; releases due together that share a document are settled as one piece and take as long as all of their documents. Adding to a full release is refused with a conflict that says no more fit, and
can.addMemberis false for it. A release made before the limit and holding more keeps running as scheduled and can lose documents, but takes no new one. - A save no longer leaves an autosave behind it.
More
An edit schedules a recovery point on a debounce, and a save made before that debounce ran used to be followed by the recording anyway, writing a copy of what the save had just stored. A recording waiting for an edit is now dropped once the editor is clean again (the edit was saved, or undone), and the recording checks at the moment it would run that there is still unsaved work to protect.
- A save that changes nothing records no version, on every path, not only on a working draft: an unpublished document, a collection with history and no status, a Single, and each language of a translated document.
More
The comparison is the one a working draft's save already used, so framework fields such as timestamps don't count as a change, and now neither do a component's own row timestamps, which made an unchanged save of a document with a component look changed whenever it landed in a later second (for a working draft too). A save that changes only a password records none either: a version never holds a secret, so it would show nothing. A save that changes the status still records one, and so do a create, a restore, a named version, and publishing or unpublishing every language. A save is compared with the document's newest version, and only when that version is in the save's own language; an unchanged save in another language still records one, since its own newest version can predate a change to the shared fields. This takes one extra read of the document's newest version per save of a document with history.
- The localization archive records which kind of entity each value came from, so a field group that shares its slug with a collection or a Single keeps its archived translations to itself.
More
A field group's slug can equal a collection's or a Single's. Until now the archive kept only the slug, so restoring one entity read the other's archived values as well (the restore failed part-way when it tried to write them into the wrong table), and deleting one entity removed the other's archived values too. Each row now records
collection,singleorfieldGroupin a newentity_kindcolumn, and restore and delete act only on rows of their own kind.nextly migrate, and boot, add the column to an existing archive, and a Schema Builder save that archives brings an older archive up to date on its own. Rows written before the column existed are matched to their entity when exactly one holds their slug, counting only rows archived since that entity was created. The rest are kept, never deleted, and a restore of their slug reports them;--kindclaims them.nextly i18n:restoretakes--kind collection,--kind singleor--kind field-group. It is needed only when the slug names more than one kind; without it, such a restore is refused and the kinds are named.
- A boot on a database that lacks one of Nextly's core tables now says so.
More
A release can add core tables, and an existing database gets them only through
nextly migrate; a production boot never creates them. The boot used to start in silence, and the first write that needed a missing table failed far from the cause (deleting a document, for one, rolls back). It now logs one warning naming every missing core table and the command that creates them, beside the existing warning for missing columns, and keeps booting. - The record of content events writes an event by a plugin-defined person already reported deleted as their erasure left their earlier events: named by the keyed digest, with no name.
More
Writing an event for such a person now requires the deleted-people check, read on the event's own transaction. Retention deletes each batch of events and their parts in one transaction, so a failed batch never leaves events without their parts.
- The dashboard summary (
GET /api/dashboard/stats) now gives each installation-wide count only to a caller who may read that resource: users toread-users, roles and permissions toread-roles, media toread-media, field groups to a settings read, and active API keys to the API-key read policy.More
For anyone else the field is left out of the response rather than sent as zero, so the answer no longer reveals how many users, roles, keys or media items exist. An API key is judged on its own grant. The admin's Team card and stats grids draw nothing for a missing count instead of 0, and the Team card shows its unavailable state when neither count is given.
- A field-type document view, such as the Page Builder's screen, now opens only for a field the edit form would let the author edit.
More
Opened at its address for a field that is read-only, disabled, hidden, or hidden by its condition (its own, or an unnamed group's around it), the admin says why and offers the way back to the document, instead of drawing an editor that could save into the field.
Where a document has several such fields and the address names none, the admin offers only the ones the author may edit, and opens the only one directly.
The form also no longer draws a field marked
admin.hiddenthat has its own editor (admin.component). Before, only fields drawn through the standard wrapper were hidden.The Page Builder's screen also starts afresh when its address moves to another blocks field of the same document. Before, it kept showing the first field's page and could save it into the second.
- In development, a page opened at
localhost,127.0.0.1or[::1]can now sign in, set up the first admin and submit the other auth forms when the app's own address names another of those, as long as the request is sent to exactly the address the page is on.More
Before, opening
127.0.0.1for an app atlocalhostwas refused with "Invalid request origin". Another loopback name of the same port, any other host, and everything in production are refused exactly as before.
All 29 changes
- On MySQL, removing a field in the Schema Builder now drops its column.
More
The Builder's ALTER generator wrote
ALTER TABLE … DROP COLUMN IF EXISTS, a form MySQL does not accept (MariaDB does). On MySQL the drop failed, the column stayed, and a Required field's column, NOT NULL, refused every later entry, both for collections and for Singles. The same statement went into the migration file, wherenextly migratefailed on it. PostgreSQL keepsIF EXISTS; MySQL and SQLite get the plainDROP COLUMN.Columns that an existing MySQL install kept from fields removed before this fix are not dropped by it: a save drops only the columns of the fields it removes. They stay, with their data, until removed on purpose.
A collection save in the Schema Builder now applies whole or fails whole on PostgreSQL and SQLite, and says why on every database.
- Before anything runs, the save checks the entries: a field made Unique over repeated values, a new Unique field whose backfill would repeat, and a text field narrowed below a value an entry holds are refused, naming the field. A refusal never shows a stored value.
- The migration runs before the file and the registry are written. On PostgreSQL and SQLite it runs in one transaction, so a refusal changes nothing. On MySQL, which commits each schema statement, the refusal says which statement stopped it. Saving again finishes the change for what such a refusal most often leaves behind: a column already added or already removed, Draft/Published already turned on or off, a plain or unique index already created or dropped, and a translated field's companion column already added.
- Turning Unique on or off for an existing field now creates or drops its unique index. Before, only the registry changed, so a field could be recorded unique while the table accepted repeats.
- A new field whose name matches a column of another type already on the table is refused, naming the column, instead of being recorded over it.
- On MySQL, turning localization off now brings the content back. On MySQL,
nextly_meta's compare-and-set, and every statement guarded on one of its rows, never matched a stored value: the column is native JSON and the stored text was compared encoded twice.More
Postgres and SQLite were not affected.
What a MySQL install saw, in every release since localization transitions were recorded (2026-07-31):
Turning localization off on an entity copied nothing back onto its main table, during
db:sync, dev reloads andnextly migrate's companion pass. The site went back to the values the entity had before it was localized, and edits made while it was localized looked lost. They were not: they are intact in the entity's translation table (dc_<slug>_localesfor a collection), which is left in place.Re-enabling localization after a disable did not refresh the translation table, so edits made while it was off stayed hidden.
A first enable copied its content, but every later pass reported that the transition was held by another process. Writing translations was not blocked.
Before you start the upgraded version, save any edit made directly to an affected entity's main table since you turned its localization off. The first
nextly db:sync, dev-server start ornextly migrateafter upgrading restores the translation table's values onto the main table, replacing those edits. To list the rows where the two differ, run this for each entity whose localization you turned off, with its slug, its default locale and one comparison per translated field:SELECT m.id, m.updated_at, m.title AS on_main, l.title AS in_translations FROM dc_<slug> AS m JOIN dc_<slug>_locales AS l ON l._parent = m.id AND l._locale = '<default locale>' WHERE NOT (CAST(m.title AS BINARY) <=> CAST(l.title AS BINARY));The binary cast on both sides makes the comparison exact and keeps it null-safe: under MySQL's default collation,
<=>alone takesFooandfoofor the same value, so an edit that changed only letter case or accents would not be listed. For an entity defined withtimestamps: false, leave outm.updated_at: it has no such column.A row whose
updated_atis later than the disable was edited on the main table since, and the upgrade replaces it within_translations. Every other listed row is an edit made while the entity was localized, which the upgrade brings back.updated_atmoves only with saves made through Nextly: a row changed outside it (from a SQL client or a script) can keep an olderupdated_at, so if anything wrote to the main table that way, judge its rows by their values, not by the date.Recovery, after upgrading: run
nextly db:sync(or start the dev server, or runnextly migrate). For an entity whose localization was turned off, the earlier pass never recorded the restore as done, so this pass restores the default language's values from the translation table onto the main table. A stuck first enable settles on the same pass.A migration file that turns localization off was never affected: its own statements restore and archive before the translation table is dropped.
- A plugin route declared with
rateLimit: "auth"can no longer be closed to everyone by one visitor when no client address can be read (the default, withoutsecurity.trustProxy).More
Before, every such client shared one bucket per route, so one visitor spending the auth budget refused the route to all. Now each request counts against the identity the route's new, optional
limitKeynames, at the auth budget, under a ceiling of 100 times that budget that only the plugin's own auth routes share. A route without alimitKeyis bounded by that ceiling alone, and the server names it at startup. Core's sign-in and other plugins keep their own ceilings. Auth routes are no longer listed in the startup warning about routes sharing one limit. - Requests from clients whose address cannot be read are now charged to the identity they name at the time they are admitted, with the shared ceiling still spent when they arrive.
More
On a plugin's
rateLimit: "auth"route, the body was read after the ceiling but charged at the time the request began, so a caller holding several bodies open longer than the window could make each charge land at a time already expired, and spend an identity's budget more than once. The route now spends the plugin's ceiling on arrival, before its body is read, so the ceiling also bounds how many requests a caller can hold open, and charges the identity once the body is in. Without a shared store, the ceiling place a request spent moves with its charge, so the two still expire together; core's token routes and strategy-keyed sign-ins are charged the same way.The once-per-process warning about proxy client-address headers that could not be used also now says how a plugin's auth route counts such requests: by the identity its
limitKeynames, or against one budget its requests naming no identity share, under that plugin's own ceiling, or by the ceiling alone without alimitKey. - A sign-in challenge (a second step, such as a one-time code) whose answer the server refuses because the page is open at an address the app does not accept now shows the same notice as the sign-in form: both addresses, and where to go.
More
Before, the challenge view showed only a short message.
- A sign-in, first-run setup or other signed-out form whose submission is refused now says so in the form.
More
When the page is open at an address the app does not accept, for example
127.0.0.1for an app atlocalhost, it names both addresses and links to the same page at the app's own. Before, the form looked unchanged. The auth routes' 403CSRF_FAILEDanswer now carriesdata.reason("origin"or"token") and, for an origin,data.appOrigin; what they accept is unchanged. - A release's documents are checked before the moment, and a hard problem stops scheduling.
More
GET /api/releases/:id/readinessandnextly.releases.readiness()report each document's hard problems (no author, an author who is gone or may no longer publish or unpublish it, a document that is gone, a collection or Single without a draft/published lifecycle or whose translations lack a per-language status, unreleased work held in another language, a document going live that fails validation or edits a field its author may no longer write) and soft ones (another scheduled release holds it, and which one wins). Every document is checked, however many the release holds. Scheduling, Publish now and adding a document to a scheduled release are refused while a hard problem stands, as a409 CONFLICTnaming each problem indata.problems, alike over REST and the Direct API. A document coming down is never validated.PATCH /api/releases/:id/members/:memberIdandnextly.releases.changeMemberAction()switch a document between going live and coming down, checking the new action as the document's author in a scheduled release. The blocking reasons are now derived from the same checks. All of this is experimental. Publish now judges each member by its author, as the scheduled run does. nextly.releases.find()andnextly.releases.count()take their query as optional again, which restores the alpha.67 signature: a call with no argument compiles and lists or counts every release the caller can see.More
A release's readiness check now judges each document's own write before the moment. A status the author's field rules would not let them set is reported as
FIELD_NOT_PERMITTEDrather than refused when the release runs, and a document taken down with a pending change is judged on that change, as the takedown folds it in. The checks also read each collection's stored documents and translations once per batch.- A release's readiness check no longer blocks a release whose run would succeed over a protected field the pending change left alone. - A many-to-many relationship (a Schema Builder collection's) is compared using every id the document stores, as the release's write reads it.
More
A related document unpublished or removed since no longer makes an unchanged relationship read as edited, and a failed read of the stored ids fails the check instead of reading as an empty relationship. - For a localized collection, the live document the pending change is compared with now carries the default language's stored translations, as the write's does. An unchanged protected translation no longer reads as edited.
- A content release can now be renamed and deleted, under two new permissions.
More
update-content-releaseslets a person change a release's title and description in any state, throughPATCH /api/releases/:idornextly.releases.update().delete-content-releaseslets a person delete a release that never ran, with its documents, throughDELETE /api/releases/:idornextly.releases.delete(): a draft, or a release cancelled before it was ever scheduled. Every other release is refused with a conflict whosedata.reasonsays why: a scheduled or stopped release (committed) can be cancelled to call it off, and is then kept; a published one (published) stays as the record of what went live; and a cancelled release that was ever scheduled (was-scheduled) is kept as a record too, because nothing stored says whether it began to run before it was called off. The delete answers with the release as it was. Each rename and deletion is recorded in the activity log, and the capabilities sent with every release gainupdateanddelete, so the admin can offer only what the caller may do. All of this is experimental until the releases redesign is complete.An existing installation receives both permissions the first time it boots, granted so that nobody gains or loses anything: super-admins hold both, every role holding
create-content-releasesgainsupdate-content-releases, and every role holdingpublish-content-releasesgainsdelete-content-releases. The grants are made once, when the permissions are created, so a permission an administrator later takes from a role is not given back on the next restart. - A REST request's per-address budget is no longer split by a collection name the caller chooses.
More
The collection was read from anywhere in the URL, query included, and every name opened a counter of its own, so one address could rotate a query value or an invented collection slug and never meet its budget, and invalid API keys could be looked up past their bound the same way. The collection now comes from the path alone, and only a collection with limits of its own in
rateLimit.collectionskeeps a separate budget; every other request counts against the address's one budget. - The REST rate limit now applies to every request, the admin API included, and counts each one by who is asking.
More
A signed-in session spends a budget of its own, five times the per-address budgets by default (
rateLimit.sessions), so the admin's parallel page loads no longer need an exemption. An API key that resolves spends its ownapiKeys.rateLimitbudget, counted once per request, and a plugin's signed-in people each spend a budget the size of a session's. Requests carrying neither from an address the server cannot resolve share a ceiling of ten times the per-address budgets (rateLimit.unresolvedCeiling), which never refuses a session, a key or a sign-in. Invalid API keys and unresolvable plugin people are bounded before the next lookup. An app that relied on the old exemption can pass the deprecateddefaultSkipAdminApiasrateLimit.skip; a later release removes it.The limit's defaults now have one source, 100 reads and 30 writes a minute per address, which is what every app configured through
defineConfigalready ran on. A limiter built with no values used a second copy of 600 and 120; it now uses the same 100 and 30. That narrows only code that reaches the limiter without a sanitized config:createRateLimiter()called withoutreadLimitorwriteLimit, andcreateDynamicHandlers()created without a config. Pass the limits explicitly to keep the old numbers. - The Schema Builder's change dialog says what happens to a field's translations when it stops being translated. - Before you save, the dialog lists each field leaving translation, how many translated values move to the archive and in which languages, and that the default language's value stays as the shared one.
More
A field badge reads "no longer localized". - A change that can't be made in one save is refused with the steps to take: a Required field can't stop being translated in the same change, so the dialog says to turn off Required and save, turn off Localized and save, then turn Required back on. - The command that restores the archived values (
nextly i18n:restore) is shown on one line, with a copy button. - The dialog's Save button now uses the primary style rather than the destructive red, which it used for every change. - An auth strategy can now name the account a sign-in targets, with an optional
limitKey(@experimental, exported from@nextlyhq/plugin-sdkwith itsAuthLimitAnswertype).More
When the client's address cannot be read, the sign-in rate limit then keys that strategy's sign-ins on what it reads, such as a username or an OAuth code, so rotating the
emailin the body no longer buys a fresh budget. A strategy withoutlimitKeykeeps sign-ins keyed on theemailthey carry, as before, and the server names it at startup while the limit is on. - The editor header is rearranged.
More
The top row holds the way back to the collection's list at the left and every action at the right, with History first. The title has a line of its own below at full width, so it is no longer cut to a few words wherever the actions take the width, and the save state moves to a status line under it. Save draft, Unpublish and the other secondary document verbs keep their words at every width: they draw no icon, so at narrow widths they used to leave an empty bordered button. On a phone, History and the preview move into the document's menu and the panel toggle goes, so the actions keep to one row. What each control does is unchanged.
- The Page Builder opens as a screen of its own, at the page's own address:
/admin/collections/<slug>/<id>/builder, or/admin/singles/<slug>/builderfor a Single.More
Before, it opened over the entry form. A reload keeps the author in the builder, a link to it can be shared, and Exit editor goes back to the page's form. The admin holds the page's soft lock, the offer to restore unsaved work, and the question before leaving with unsaved changes. Saving a pattern no longer saves the page as well. If a colleague takes the page over, the author's unsaved edits stay in their form until they take it back.
A page that has never been saved has no address yet, so its Page Builder card asks the author to save it first. The Page Builder edits the default language: opened from another language's edit screen, it opens the default one, and the card says so.
Compatibility. The Page Builder now opens only for a blocks field at the top level of a collection or a Single, including one inside an unnamed group.
Who is affected: a site that declares a blocks field inside a named group or a repeater. That field's card still shows the page, but offers no way into the builder, and startup logs a warning naming the collection and the field. The warning covers fields declared in code; for a collection made in the Schema Builder, the card is what says it.
What to do: move the field to the top level of its collection or Single to edit it visually, or keep it where it is until nested fields are supported.
The builder is a document view at the
builderaddress on every document with a top-level blocks field, so another plugin declaring a document view atbuilderis refused at startup, naming both plugins. - Two follow-ups to recent schema-save and localization-archive fixes. - A collection save that makes a field unique and shortens it at once checks both: a value longer than the new length is refused, naming the field, rather than truncated by PostgreSQL's cast while the save succeeds. - On MySQL, an archive row from before the archive recorded its entity's kind is matched to an entity only when it was archived more than 42 hours after the entity's recorded creation (14 on PostgreSQL).
More
On MySQL the creation time, the archive time and the reading of it are all in the session's time zone, which may differ each time, so a narrower margin could give an older entity's translations to a newer one with the same slug. Rows inside the window stay unresolved: kept, reported by a restore and restored by naming their kind.
Packages
@nextlyhq/adapter-drizzle@nextlyhq/adapter-mysql@nextlyhq/adapter-postgres@nextlyhq/adapter-sqlite@nextlyhq/admin@nextlyhq/admin-css@nextlyhq/blocks-engine@nextlyhq/blocks-react@nextlyhq/builder@nextlyhq/eslint-plugin@nextlyhq/plugin-form-builder@nextlyhq/plugin-mcp@nextlyhq/plugin-page-builder@nextlyhq/plugin-sdk@nextlyhq/plugin-seo@nextlyhq/storage-s3@nextlyhq/storage-uploadthing@nextlyhq/storage-vercel-blob@nextlyhq/uicreate-nextly-appnextly
Earlier releases
Releases before 0.0.2-alpha.66 are listed in Nextly's changelog on GitHub.