0.0.2-alpha.69
- In production, the web setup asks for a one-time setup token, printed in the server log, before it creates the first admin.
- Role-based permissions for each collection and action, plus field-level read and write rules in code, on a collection’s or single’s fields, fields inside field groups included.
Before you upgrade30
- 1
Run
nextly migrateafter upgrading, before content can be saved.npx nextly migrateEvery save now records its history, and until the history table exists a save is refused with "The database needs updating before content can be saved. Run
nextly migrate." A production server never creates tables itself. The same run also creates the table your user fields are stored in (user_ext) on a database set up bymigratealone, creates thenextly_preview_linkstable (until it exists, sharing a new preview link is refused with the same remedy; links made before the upgrade keep working until they expire), adds the releases table's two new indexes, and adds the email templates table's newcc,bccandcarries_secretcolumns. It also adds theemail_send_claimstable, the email delivery log'skey_digestcolumn, and theemail_brandtable that holds the email brand settings (until it exists, emails send as before but saving the brand settings fails). It also creates thenextly_languagestable that holds languages managed through/api/languages; until it exists the site reads as having none stored, and changing them fails. Until these exist, templated email (password reset, verification, welcome, plugin emails) and the admin's email templates fail, deleting a user fails, and the email delivery log is not written, so runnextly migratebefore the new version serves traffic, or setdb.runMigrationsOnBoot. - 2
Self-registration is now off by default.
If your users sign themselves up, add this to your Nextly config before upgrading, or registration answers 403 "Registration is not open.":
auth: { allowRegistration: true }An account already registered and waiting for verification can still finish by its link. No migration is needed.
- 3
Make sure someone holds the Super Admin role.
Setup now stays open until an account holds Super Admin, instead of closing at the first account, and a registered account never becomes the first administrator. On an install that has accounts but where nobody holds Super Admin, give one of them the role before upgrading. An administrator whose role inherits Super Admin counts.
- 4
In production, creating the first administrator needs a one-time setup token.
An install that already has an administrator sees no change. On an empty production install, the next start prints one line beginning
[nextly] SETUP TOKENwith the token and the setup address (valid for 24 hours and one setup), and the setup form asks for it. Automation that creates the first administrator over HTTP in production must setNEXTLY_SETUP_TOKEN(at least 43 characters;nextly setup-token --generateprints one). Or create the administrator from the command line with the newnextly users:create-admin. No migration is needed. - 5
App tables can no longer be named
accountsorsessions.These are retired auth table names, and
db.schema.extendrefuses them. Rename such a table before upgrading. - 6
MySQL, unique custom user fields.
A unique text user field without
maxLength, built-in or from a plugin, is now stored asvarchar(255)so its unique index can be made, and a longer value is refused before it is written. On an install where such a column was created as TEXT,nextly migratestops and names the field: change the column toVARCHAR(255)once no value is longer, or removeuniquefrom the field, then run it again. Values are never truncated. - 7
PostgreSQL and MySQL, components with a many-to-many relationship.
That column is now the JSON list column a
hasManyrelationship uses. On an existing install, the next schema apply offers a one-time conversion of a component table holding it asuuidortext(PostgreSQL) orvarchar(36)(MySQL), andnextly migrate:createwrites the same conversion into a migration file. Every stored id is kept. Until the conversion is accepted, saves to such a column may be refused. SQLite needs no conversion. - 8
Email templates now have owners.
Nothing an existing site sends changes on upgrade, with one exception (below): a stored core or plugin template that differs from its owner's version is kept as the site's override, and a site that configures
email.templateskeeps sending the template it sends today until it resets it. The exception: a site that sends itsemail.templatesversion because it deactivated the stored template now sends it with that stored template's provider, From, Reply-To and attachments, where it had set them. Core templates and an installed plugin's can no longer be deleted; reset them instead. Code that builds anEmailTemplateRecorditself (a test fixture, a mock of the template service) must now setowner,overridden,ownerChanged,setInCode,description,cc,bccandcarriesSecret. A stored From or Reply-To that contains{{or{%is now rendered as Liquid rather than sent as written. - 9
Outstanding password-reset links stop working after
nextly migrate.Reset and verification links are now bound to the account they were issued for. The migration removes every outstanding password-reset link, and every email-verification link it cannot safely tie to its account (expired ones, ones for an address no account holds, and ones whose account was created or changed after the link was issued). Anyone holding one requests a new link.
- 10
Code-defined access rules: write permissions as
action-resource.Every access rule (collection, Single and field rules) now gets the same context, and its permission list spells each permission the way it is stored (
update-posts). For this release the list also carries the oldresource:actionspelling (posts:update), so a rule that checksposts:updatekeeps working but logs a warning naming the spelling to use; a later release drops the old spelling. A rule that reads the list's order or length now sees the stored spellings first. In TypeScript,AccessRuleContext(and its aliasAccessControlContext) now requiresreq,targetandlocales, so a context you build by hand, as in a unit test of a rule, must set them. Rules are now told the languages they judge (localeandlocales); to restrict by language, checklocales, sincelocaleis absent for an operation on every language. A project made from the blog template before this release still has the old check inhas-permission.ts: change${collection}:${operation}to${operation}-${collection}. - 11
Password rules, and custom password hashers.
A new staff password must now be at least 8 characters (set
auth.minPasswordLength, 8 to 15, for more), counted as characters, not code units; must not be on a list of 3,000 common passwords (compared without regard to case); and must fit what the hasher reads: 72 UTF-8 bytes for the default bcrypt. The old rule's required mix of character kinds is gone. Some passwords the old rule accepted are now refused when set, such as a common password with symbols swapped in, or a long passphrase over 72 bytes. Existing passwords keep signing in; only new ones are judged. If your app passes its ownpasswordHasher, give it areach(how many bytes it reads) or avalidatefunction before upgrading: without one, the server refuses to start and names the hasher. - 12
MySQL: a request no longer waits without end for a database connection.
When every connection in the pool is busy, a request now fails after
pool.connectionTimeoutMs(15 seconds by default; set0to wait without limit as before, or raise it). On SQLite, a transaction queued behind others gives up after the same time. The request answers 503DATABASE_BUSYwithRetry-After: 5, entry reads and writes included (a few paths still answer 500; the changelog names them). A collection or Single write, or a registration, that saved before a later step waited answers that the change was saved, without a retry hint. A locked SQLite file (SQLITE_BUSY) answersDATABASE_BUSYinstead of 409CONFLICT. - 13
Code-defined component validators now run.
A component field's own
validate, fromfieldGroupsin your config, now runs when a write carries that component: at the top level of a collection or Single, inside a group or repeater, or inside a component held in one, and in a group or repeater inside another component. A component placed directly in another component's fields is validated only where it is stored (see Known limitations). Content that fails it is refused when a save or publish next carries it, and a held draft saved before this release is judged in full when it is published. A Single with drafts on, whose only localized component is reached through virtual fields, used to save live; its saves without a status are now held as drafts. - 14
Direct API release writes that act for an API key must name the key.
A release write (
create,addMember,removeMember,changeMemberAction,schedule,cancel,publishNow,update,delete) that passes an API key'sauthenticatedScopemust now pass the key'sapiKeyIdon the same call, so the release's history can name the key; without it the write is refused withINVALID_INPUTbefore anything is written. REST requests already carry it, and calls without an API key's scope are unchanged. Release history is experimental. - 15
Plugin migration lists must be in name order, and a plugin's applied migrations must stay as they ran.
nextly migratenow checks every plugin's migration history before it changes anything. A plugin that lists its migrations in an order other than their names now stops the app from starting, not onlynextly migrate; lists written bynextly migrate:create --pluginare already in order, so only a hand-edited list is affected. A migration the database records as applied must not be edited, removed or renamed, and a new one must sort after the applied ones. To undo a migration rolled back by hand from the middle of a plugin's history, roll back the migrations after it withnextly migrate:down --plugin, then runnextly migrate. - 16
The schema guard reads more of a migration, and refuses some hand-written shapes it used to run.
It judges each app and plugin migration file before it runs (migrations already applied are not run again, so they are unaffected). Newly refused, with nothing applied: on PostgreSQL, a statement that names a table or index after an earlier statement in the same file dropped or renamed it, such as
DROP TABLE IF EXISTS t; CREATE TABLE IF NOT EXISTS t (...); ALTER TABLE t ...(move the later statement to a migration of its own),ALTER TYPE ... CASCADE, and an index named after aSET search_pathorCREATE SCHEMA; on MySQL,ALTER TABLE ... ADD name typewithout theCOLUMNkeyword on a table another owner has an element on, including core's tables (writeADD COLUMN). Migrations written bynextly migrate:createare not affected. - 17
MySQL: migrations run in strict mode, and a column is narrowed only when its values fit.
nextly migrate(andmigrate:down,migrate:resolveand plugin migrations) runs each migration on MySQL withSTRICT_ALL_TABLES, so a statement that would cut a value short or coerce it now fails where a server without strict mode truncated it with a warning; schema statements before it in the same file stay applied. A hand-written migration that relied on that coercion needs fixing first. Narrowing a text column tovarchar(N)(bydb:sync, the dev reload or an admin schema save) now counts the stored values longer than N first and stops, naming the table, column and row count, when there are some: on an older MySQL install whose Single or field-group tables hold atextfield asTEXT, shorten values over 255 characters or give the field a type that holds them. - 18
Role changes through the REST API answer differently.
Only a super-admin can assign the super-admin role, or a role that inherits it, through the REST API, and an API key cannot, even a super-admin's.
POST /api/roleswithisSystem: truenow answers 400.POST /api/users/:id/rolesnow answers with what happened instead of always 200:404for a user or role that doesn't exist,409for a role already held,400for anexpiresAtthat isn't an ISO date and time, and403when the caller may not assign that role. A client that sent a date without a time, or ignored the answer, needs updating. - 19
An
accesskey that doesn't apply now stops the app from starting.A collection's
accessacceptscreate,read,update,delete,publishandunpublish; a Single's acceptsread,update,publishandunpublish(a Single has nocreateordelete); a field's acceptscreate,readandupdate. Any other key, such as a misspeltreedor a Single'sdelete, used to be accepted and govern nothing; nowdefineCollection,defineSingleanddefineFieldGroup(andnextly build) refuse it by name, so an app that defines it won't start. A field'saccessmust also be an object whose rules are functions or booleans. Remove or correct such keys before upgrading. Every rule may now also be written astrueorfalse, and a field rule written asfalsewithholds the field from every checked read and write (REST and admin requests), as a function returningfalsedoes. - 20
Media writes are checked when the caller asks to be checked.
A Direct API
mediawrite (upload,update,delete,bulkDelete,folders.create) on an instance or call withoverrideAccess: falsenow needscreate-media,update-mediaordelete-media, as the REST media routes do, and one that names no user is refused. A plugin'sctx.services.mediawrite given a request context with auseris now checked as that user, whatever the context'soverrideAccesssays, where every plugin media write used to run trusted: pass{ as: "system", user }to keep it trusted and attributed to that user. Under{ as: "system", user }, collection hooks now receive that user, with no roles, where they received no user; a hook that branches on whether a user is present may act differently. A trusted Direct API media write on an instance made with an API key is now recorded as the key, not its owner. - 21
Upgrade every instance together, and don't roll back to alpha.68 once alpha.69 has started.
On its first start, alpha.69 rewrites the default email layout and the built-in password reset, verification and welcome emails your site hasn't edited, so they read the new email brand settings. An alpha.68 instance on the same database renders the reset, verification and welcome buttons without a background.
- 22
A plugin's history autosave is stored as REST stores one.
ctx.services.versions.autosavenow leaves password values out of the stored snapshot (rows of a list included), whoever the call is made as, and is refused with an internal error when the collection or Single it names doesn't exist or its fields can't be read, where it used to store the snapshot as given.setLabel,deleteWorkingDraftandautosavealso take an optionalServiceOpts: pass the person the plugin acts for to have the write judged as REST judges them. - 23
The REST email send routes are removed.
POST /admin/api/email/sendandPOST /admin/api/email/send-with-templateare gone, with their package exportsnextly/api/email-sendandnextly/api/email-send-template. Send from server code withnextly.email.send()ornextly.email.sendWithTemplate(); to send from a browser or another service, add a route of your own that checks who may send, and to whom, before it calls them. The admin's Send test on an email template now sends the saved template only to the signed-in user's own address, needs theupdate-email-templatespermission, and refuses API keys. - 24
Direct API reads of users, roles, media and field groups are checked when the call asks to be.
With
overrideAccess: false, on the instance or on the call,users,roles,permissions,mediaandfieldGroupsreads now ask the grant REST asks for the same read (read-users,read-rolesorread-permissions,read-media; field groups need a named user), and a checked read that names no user is refused. Before, these reads ran unchecked even withoverrideAccess: false. A refused read throws, even withdisableErrors. Calls left trusted, the default, are unchanged. - 25
Translated documents: publishing a language no longer puts back a newer shared value, and pending changes begun before this release may be refused.
A language's publish is now refused with a 409 (
shared-fields-out-of-date) when a shared field has been published by someone else since its pending change began; taking the live values (POST …/versions/working-draft/take-live?locale=, or "Take the live values" in the admin) brings it up to date. A pending change begun before you upgrade has no recorded starting point, so its publish is refused whenever a shared field it holds differs from the live value, its own edits included. Taking the live values replaces those edits in the fields you may write (a shared part of a translated component is copied by hand): re-enter them, save, and publish, or publish such changes before upgrading. A scheduled release holding one is refused for that document when it runs. Publishing every language of a Single at once can also be refused (shared-edits-unordered,shared-edits-unmergeable); publish one language at a time. - 26
Node 22.12 or later.
Every package now requires Node
^22.12.0or>=24.0.0, and Node 24 LTS is recommended; Node 20 is no longer supported. Upgrade Node before you upgrade Nextly. - 27
Next.js 16.4.1 or later (or 15.5.28 or later), and sharp 0.35.5 or later.
Every Nextly package that peers on
nextnow asks for^15.5.28 || ^16.4.1, andnextly's optionalsharppeer for^0.35.5; earlier versions carry critical advisories, including unauthenticated remote code execution. Next.js 14 is no longer supported. Upgrade both in the same step:pnpm add next@latest sharp@latest, or your package manager's equivalent. With npm, an oldernextstops the install with a peer conflict. React is unchanged. - 28
Date filters take an instant with its offset, or a calendar date.
A filter on
createdAt,updatedAtor a date field must be an ISO 8601 instant with its offset (2026-09-01T09:00:00Z) or a date (2026-09-01, the start of that day in UTC). Any other value, including a time with no offset, is refused with 400 (FILTER_INVALID_OPERAND), where PostgreSQL and MySQL used to pass it to the database. - 29
A localization fallback list that loops no longer boots.
If the
fallbackLocalelists lead from a language back to itself (fr → es → fr), the app refuses to start and names the loop. Remove one of the codes it names from the list that leads back. - 30
A code access rule allows only by returning
true.A rule that returns
null,undefined, a string or any other value now refuses, asfalsedoes, for every caller and at every way in. Before, a visitor was refused only byfalse, and a signed-in user was let in by any truthy value. Make every rule returntrueto allow. For this release, each request refused this way that a rule allowed before is logged once, naming the collection or Single, the action and the kind of caller.ctx.caller.canandnextly.access.checknow return a boolean.
Security3
An access check whose role or permission read fails now refuses the request
(
DATABASE_BUSYwhen the database was busy, otherwise an internal error) instead of judging the caller as holding no roles or permissions. This covers code-defined collection, Single and field rules; a field rule whose caller's grants cannot be read is denied.listRoleSlugsForUserandlistEffectiveGrantsthrow aNextlyErrorwhen the read fails, where they returned an empty list; a caller with no roles or permissions still gets an empty list.A Single call made while serving an API key's request is judged as the key
, not as the key's owner, as collection calls already were. A hook's Single read or update may now be refused where the key lacks the permission.
Field rules now withhold fields inside field groups on live reads
: lists, reads by id, Single reads, populated relations, and create and update responses. Trusted (
overrideAccess) reads are unchanged.
Known limitations2
A component placed directly inside another component's fields is stored only where that outer component is held in a named group or a repeater.
There it is saved with the outer component's value, shown in the admin and validated. Where the outer component is a field of its own (at the top level of a collection or Single, or under rows, tabs, collapsibles or an unnamed group), it is not stored: the admin doesn't show it, it isn't required, and a save, draft save or publish that carries a value for it is refused with
NOT_STORED(an empty value passes). To store it everywhere, put it in a group or repeater inside the outer component.Rename one translated field at a time.
A translated field's values move with it when it is renamed in a save that removes only that field and adds only its new name, with a compatible type. A Schema Builder save that removes a translated field while it adds another field (several renames in one save, a rename beside an added field, a rename to an incompatible type) is now refused before anything changes, with each such field named (
TRANSLATED_FIELD_REMOVED_BESIDE_ADDED) and the two saves that work: rename or remove one translated field and save, then add or change the others. The same goes for a collection's or Single's settings save that turns translation on (turn it on with the fields as they are, then rename or add), and for a field group's settings save that removes a translated field while it adds a nested field group. A schema dialog's explicit "drop and add" answer, offered only for a single removed and added pair, still drops the old field's values in every language. A rename made in code and applied bynextly db:syncor a dev reload is not refused yet: the old translations column is kept, with its values, and the renamed field starts empty.
What's changed94
Released 21 packages at 0.0.2-alpha.69 in lockstep. Every package below ships at this version.
Patch Changes
- A batch save that cannot record its history now keeps the versions it would have pruned.
More
When the history table is missing (an install not yet migrated), a batch update with
stopOnError: falseno longer commits a held edit or removes older versions with no record of it: the whole batch rolls back. - A card's settings sheet keeps a change where the reader is, shows the card beside the form, and reopens on what is stored after Cancel. - Done while customising holds the change with the dashboard's other unsaved changes and closes the sheet.
More
At rest it saves the change at once: the sheet stays open while saving, closes when the save lands, and says why in place when it does not. - A live preview draws the card as the form's values would, with the dashboard's own renderer, beside the form on a wide screen and above it on a narrow one. A change that alters what the card asks, such as its number of rows, is asked once for the preview alone after the values settle. The card keeps its own answer until Done. - Cancel now discards the change. The sheet used to reopen on the value the reader cancelled, although nothing had been saved: the form was given the sheet's own starting values rather than a copy, and wrote into them. - A quick setting whose new answer the card could not load is announced as saved, with the card still showing its earlier data, rather than not announced at all.
- An API key is refused while the person who created it is deactivated, and works again once they are reactivated.
More
The instance that deactivates the person refuses the key at once, and every other instance within ten seconds, the bound that already applies to that person's sessions. An administrator setting the person's password does not stop their keys.
- A declared widget's card is now held to what its display draws for the card's own settings, not only the starting ones, so a query function that writes a different operation for a setting a reader chose fails that card alone rather than drawing an answer it misreads.
More
And the read decisions a declared widget's data function needs, for collections the batch did not name in advance, now take the same bounded turns as the rest of the batch's, instead of starting all at once.
- A widget declared with
defineWidgetand drawn by a core display (metric,list,tableand the others) now has its cards answered on the server, as the person asking, and drawn in the admin, in the widget's own words when it has nothing to show.More
A widget drawn by its own component (
display: { component }) is checked at startup but not offered yet: the admin does not yet hand a component the props its declaration types, and development says so once per widget. - A card is asked for by its id, and core builds its data from the widget's declaration: itsdatafunction run with the card's settings, the person and the batch's one instant, or its query written from the settings. Only a card the reader was handed is answered; every other card gets the one "unavailable" answer. While the dashboard is being customised, a card is asked with the settings it is drawn with. - Reads are the person's. A data function'sservices.countandservices.findread as the person asking, through the same check a card's own query passes, and read nothing once the card has given up. Options a plain JavaScript caller passes beside them (user,as,overrideAccess) are never read. -WidgetDataInputgainsnow(@experimental): the batch's one instant, so every card asking for the last 30 days asks for the same days.ResolverOptionsgains the samenow(@experimental). -WidgetResultgains{ op: "data", data }, a declared widget's own data for its own component, and a slot may carryempty: true, the verdict of the widget's declared empty test. The admin then draws the declared empty message and action, whose link is only ever a path under the admin. - Definitions gainserverData,allowedWidths,defaultWidth,allowsSeveral,sketchandempty. Core publishes these for a declared widget.registerWidgetrefusesserverData, and an empty state's action is withheld from a reader without its permission. - Default dashboards never place a declared widget; it is offered in the add panel instead. - Deleting media no longer removes a file another media item still refers to, however the other item refers to it: as its original, through one of its image sizes, as its thumbnail, or under another spelling of the same path.
More
Two items can share a stored file after an import that copies a record, or on a storage adapter that keys files by their content. A crop keeps such files too, including when its write fails. - When the database cannot be read to check this, nothing is removed and the delete or crop still succeeds; the log names the item whose files were left. - Failure logs from media cleanup no longer show credential query parameters (
X-Amz-Credential=…,token=…) wherever they appear in a storage error, nor URLs written without a scheme. - Deleting a staff user now erases every email delivery record that names them, under every address they used, and any record written for them afterwards is written erased.
More
Another person at the same address keeps their records.
- A chart's dismissed tooltip stays dismissed until the pointer moves.
More
Escape hides a hovered tooltip on a dashboard chart, but the bar under a pointer that had not moved could bring a tooltip straight back, because hiding the tooltip uncovered that bar and the browser reported it as entered. Now only a move of the pointer, or of the keyboard's point, shows a tooltip again.
- An email the provider fails or refuses now says whether sending it again can help:
nextly.emailand a plugin'sctx.services.emailreturnfailurewithretryable, the provider's status where it gave one, and a short reason, and list any recipients the provider refused.More
A send refused before any provider is reached (a bad recipient, a provider named but not found, a missing template) still throws, as before. Form Builder logs a notification that was not sent as failed instead of sent, and no longer writes recipient addresses into its logs.
- Several processes starting Nextly against one fresh database at once, as
next build's workers and a fleet of instances do, now seed each default row once without reporting the ones another process wrote first.More
The process that lost the race used to log the duplicate as a warning or an error with a stack trace (an email template, a role preset, a plugin's role), report the whole permission step as failed and skip the plugin roles that wait for it, or fail its sync of the code-defined user fields. A row another process wrote is now taken as seeded and logged at debug; any other failure is still reported as before. The same holds for a Single's default document, which its first read creates: concurrent first reads, such as a fresh build's pages, used to fail every read but one with a
UNIQUE constraint failederror logged at error level, and each now gets the one document that was stored. On PostgreSQL and SQLite, a permission another process created at the same moment was also reported as created under an id that was never stored, so a grant made for it could point at no permission: the permission is now read back and reported as found. - A send can now carry an
idempotencyKey, and the email then goes out once on every provider: onnextly.email.sendandsendWithTemplate, the same two on a plugin'sctx.services.email.More
A repeat of a send that succeeded returns its result with
repeat: trueand sends nothing; a repeat after a failure sends again; the same key with a different message is refused. Keys are scoped to their caller, so two plugins never share one, and Resend is also handed a key derived from the caller's.Nextly stores a keyed digest of each key in a new
email_send_claimstable and a newkey_digestcolumn ofemail_deliveries, never the key or an address, and erasing a person clears the digest from every record of their sends.nextly migrateand boot add both on all three databases, and rewrite no row. Sends without a key are unchanged. - A document-lock claim is now judged as an update of that document is.
More
Claiming or renewing a lock asks the same question a REST update of the document asks, so an API key must hold the update grant and pass the collection's or Single's own
updaterule. Releasing a claim still needs the grant alone, since its claim token fences it. Sessions are judged as before.
All 94 changes
- A column narrowed to
varchar(N)is judged by the values it holds, andnextly migrateruns in strict mode on MySQL.More
On MySQL, Single and field-group tables made before their tables came from the schema pipeline hold a
textfield asTEXT, where the schema declaresVARCHAR(255). Everydb:syncand dev reload asked to narrow them in a terminal, and failed without one.db:sync, the dev reload and an admin schema save now count the stored values longer than N before narrowing a string column tovarchar(N). When there are none, the change loses nothing and applies without a prompt. When there are some, the apply stops before any DDL and names the table, the column and the number of rows; shorten those values, or give the field a type that holds them, and apply again.nextly migrate,migrate:down,migrate:resolveand plugin migrations run each migration not marked-- nextly:no-transactionon MySQL withSTRICT_ALL_TABLESadded to the session's mode, and puts the session's mode back afterwards. A file that would cut a value short — such as theMODIFY COLUMN … varchar(255)thatmigrate:createwrites aftermigrate:baselineadopts one of these tables — now fails with "Data too long" at that statement, which changes nothing (schema statements before it in the same file stay applied, as MySQL commits them), where a server without strict mode used to truncate the value with only a warning. A hand-written migration that relied on MySQL's non-strict coercion now fails the same way.- The MySQL adapter's
transaction()takes a newstrictSqlModeoption, which does this for any transaction. PostgreSQL and SQLite ignore it.
- A past version now reads in the editor's own layout with its changes marked.
More
One comparison bar above it says which version is shown, what it is compared with (the version before it in its language) and how many fields changed, with Previous and Next change. Each changed field is marked with what changed; unchanged fields fold behind "Show N unchanged fields". While a version is shown, Back to current and Restore… replace Save and Publish in the title bar, and on a phone they sit in a bar at the bottom. The banner above a past version is gone.
The admin's unused localStorage autosave and draft recovery (
useAutoSave,useDraftRecovery,DraftRecoveryDialog) are removed. Nothing called them since autosave moved to the server, and none was exported from the package. - A person's standing is read again as soon as the change that deactivates them, or sets their password, commits, however long that change's transaction stays open, on every database.
- A project gets the release of Nextly its create-nextly-app belongs to. - A new project declares
nextlyand every@nextlyhq/*package at exactly the CLI's version, from any template, so the templates, the agent guide and the skills match what is installed. npm'slatesttag no longer decides anything, and the registry is not asked. - The blog template is downloaded at the CLI's own release tag instead ofmain.More
If GitHub has no template for that release, the run stops and names
--branch; if GitHub cannot be reached, it names--local-template. A branch name with a/now downloads too. - Adding Nextly to an existing app installs its packages at exactly that version, recorded exactly, after the packages they require alongside them, so pnpm with strict peers accepts the install. An app that already has a Nextly package at another version stops before anything is written or installed, and is told what to set: one declared as a range or at another version in any dependency field, or one it loads at another version, from its ownnode_modulesor a workspace folder above it. ---branchno longer defaults tomain: given, it takes the template from that branch, and the packages from thealphatag, as--local-templatedoes.--use-yalcstill links local builds.A project made before this release is not changed by it, and keeps the versions it declares. Before adding Nextly to an existing app, set every Nextly package it has to exactly the CLI's version.
- A new project created outside a workspace records the pnpm or Yarn version that created it as
packageManager, so Corepack installs with that version instead of its own default.More
Adding Nextly to an existing app records it too, but only when the app chooses no package manager yet and isn't part of a workspace.
- Publishing or unpublishing every language of a collection entry now clears its cached pages even when the request fails after the change is saved, and is reported saved only once a transaction around it has committed.
- A refusal now says what was refused, in the site's own words.
More
A refused request's
error.messagenames the action and the thing as the site names them: "You can't edit this post.", "You can't create posts.", "You can't edit Site Settings.", "You can't see API keys." The thing is a collection's label (one entry of it where the request names one), a Single's label, or a system resource in words. It never names a permission as stored (update-posts), a role, a rule or which check refused, and never repeats a slug the site does not know, so a URL cannot make a refusal echo it. The REST route gate, the Direct API, plugin services and the routes built on them answer this way.Which check refused, and on what grant, is logged on the server at
info([nextly] Refused a request, with the reason and the request id the caller was answered with), and never sent.A request that is not signed in, or whose session has expired, or whose API key is not valid, is answered "Authentication required." in every case; the code still tells an expired session (
TOKEN_EXPIRED) apart, so the admin refreshes it as before.One status changes. The standalone route handlers exported from
nextly/api/*now answer a request presenting an API key while the key service is unavailable with 503SERVICE_UNAVAILABLE, as the main REST handler already did; they answered 403. Every other refusal keeps its status.A client that matched the old wording ("You do not have permission to …", "Access denied: …") reads
error.codeinstead, which is unchanged. - Add a calendar read for releases.
More
GET /api/releases/calendarandnextly.releases.calendar()take a window of days (fromandto, at most six weeks) in a named time zone and answer every day of it with the complete count of its releases by state and its first three releases in time order, so a calendar's "+N more" is always true. Days follow the zone's own clock, daylight saving included: a release at 23:30 in New York is on the next day in London. The list gains a day filter (dayandtimezoneover REST,day: { date, timezone }from the Direct API) that returns one such day in full, paged, in the same order. A published release is on the day it went out, any other release on the day of its scheduled time. Both are experimental.The releases table gains two indexes, on
scheduled_atand onpublished_at, which the calendar and the day filter read by.nextly migrateadds them to an existing database. Index only, no data change. - A renamed collection or Single keeps its history, who may use it and where its webhooks go: its versions, pins, "last looked at" markers, content events, release membership and each release's history of its documents, schema events, activity, archived translations, permissions (with every role's grants of them), live webhook filters, webhook events not yet sent and the webhook recording choice move to the new slug in the same transaction as the rename, instead of staying behind for the next entity under the old slug.
More
A renamed Single's document reads its new slug, and content cached under either slug is refreshed. A rename onto a slug that still holds an earlier entity's records, or onto one longer than 50 characters, is refused, and document locks, preview links and kept permission answers end with the old slug, as do any an earlier entity left under the new one.
- A request now waits a bounded time for a database connection on every dialect, and past the bound fails instead of waiting without end, as "database busy" where the error reaches the request boundary (see "Where it shows" below). - The setting:
pool.connectionTimeoutMs, default 15 seconds (20 on Neon, from the PostgreSQL adapter's provider defaults), with the same meaning on PostgreSQL, MySQL and SQLite.More
0waits without limit. - Where it shows: route handlers, server actions, sign-in and the REST dispatcher answerDATABASE_BUSY. Collection and Single entry reads and writes answerDATABASE_BUSYtoo. A few entry paths still answer 500INTERNAL_ERRORwhen it is busy: publishing every language of a Single, duplicating an entry, and a read whose locale, component or media step is the one that waits. - The error:DATABASE_BUSY, status 503, withdata.retryAfterSecondsof 5 and aRetry-After: 5header on REST. The statement or transaction that waited did not run. Anything the request committed before it stays committed, so a request that writes in several steps is safe to repeat only where those steps are. A collection or Single write, or a registration, that committed and then hit a busy database in a later step answers that the change was saved, without a retry hint. - PostgreSQL: the timing is unchanged; the pool already gave up after 15 seconds. What changes is the answer: it was a 500INTERNAL_ERROR, and is nowDATABASE_BUSY. - MySQL (behaviour change): a request that found every connection in use used to wait in mysql2's queue without limit. It now fails afterpool.connectionTimeoutMs, unless you raise it. The time a new connection gets to open (connectTimeout) also moves from 10 to 15 seconds, so it matches PostgreSQL; a connection that cannot open in time is still a connection error, notDATABASE_BUSY. - SQLite: a transaction waits at mostpool.connectionTimeoutMsfor the one connection behind the transactions ahead of it. A write blocked by another process holding the database file pastbusyTimeout(SQLITE_BUSY) is nowDATABASE_BUSYtoo, where it was a 409CONFLICT.SQLITE_LOCKEDstays aCONFLICT. - Retry-After: it is now set for any error carryingdata.retryAfterSeconds, not onlyRATE_LIMITED. - For adapter authors: the adapters raise the busy error with the codeNEXTLY_POOL_WAIT_TIMEOUT, exported from@nextlyhq/adapter-drizzle/typesasPOOL_WAIT_TIMEOUT_CODE, withpoolWaitTimeoutError()to raise it.See When the database is busy in the production guide.
- Password-reset and email-verification links now act only on the account they were issued for, and only while it still holds the address they were sent to.
More
Each link records its account (a new
user_idcolumn onpassword_reset_tokensandemail_verification_tokens, with a foreign key tousers). Changing an account's address revokes its links, so one sent to the old address stays dead even after the account moves back. Deleting an account removes its links, whatever address they went to. Someone later given an address is never reset or verified by a link sent to an earlier holder.Issuing a link, following one, changing an address and deleting an account each hold the account's row for their transaction, so a link issued or followed while the account moves or is deleted is either revoked afterwards or refused. Following a link now checks it, applies it and spends it in one transaction, so two redemptions of one reset link cannot both set a password.
nextly migrateadds the column and removes every outstanding password reset link: a reset link may have been given any lifetime, and its stored times cannot show reliably when it was issued. It ties each outstanding email verification link to the account holding its address when that account was created and last changed before the link was issued, so those links keep working. A verification link whose account was created or changed at or after the time it was issued (on MySQL and SQLite, which store whole seconds, also in the same second) is removed, since it may have been sent to an earlier holder of the address, and so are expired links and links to an address no account holds. Their owners request a new link. - Editing a role (its name, its permissions or its child roles) works on SQLite.
More
The edit ran in a transaction the SQLite driver refuses for asynchronous work, so it failed there with a server error before changing anything, while PostgreSQL and MySQL were unaffected.
- A collection or Single write, or a registration, that saved and then failed answers that the change was saved, without a retry hint, and the admin retries a failed write only when the server refused it before running it, so a saved change is no longer made twice.
- Looking up a collection's schema no longer writes to the console.
More
Every query against a collection resolved its schema through a lookup that logged three lines, one of them the name of every registered collection, so a busy app wrote thousands of identical lines to its server log per burst of requests. A lookup that has to build the schema, or finds none, still logs as before.
- Unsaved work gets a recovery point after a slow save.
More
A save slower than the autosave debounce let the edit's recording fire while recording was off, where it was refused, and nothing asked again; when recording comes back on and the editor still holds unsaved changes, a recording is now asked for again.
- A value a unique custom user field already holds for another user is now refused with a conflict (
DUPLICATE), when creating or updating a user, and a refused update changes nothing: the account's other fields, its roles and its custom fields are written together or not at all.More
Before, creating such a user took the refusal for a missing table: it created the user without any custom fields and stopped writing custom fields for the rest of the process, and an update logged it and carried on. On MySQL, a unique field of a plugin type stored as text with no
maxLengthis now aVARCHAR(255)column carrying its unique index, as a built-in text field is, rather than a TEXT column (which MySQL cannot index without a key length) that stoppednextly migrate. A value longer than such a column holds is refused, naming the field, before it is written: before, it failed at the database and was taken for a missing table. An install where that column is already TEXT keeps it, with every value whole:nextly migratestops, naming the field, until the column is changed toVARCHAR(255)oruniqueis removed. A plugin type stored as long text or JSON cannot be unique: the config is refused at the field, as a textarea's is, including when the plugin declaring the type is in the same config. - A user's roles and permissions are now read once and kept in memory until a role or permission changes, in place of the separate per-answer caches.
More
Every change that affects access retires what was kept, in every instance sharing the database, so the next request reads afresh: editing a role's permissions, assigning or removing a role, changing role inheritance, deleting a role, deactivating or deleting a user, and a role assignment expiring. A request that asks about a user again reads only the role version, and a request that asks several permissions reads the user's roles once.
Permission answers are no longer stored in the
user_permission_cachetable, and theuserPermissionCacheschema export is deprecated; the table is removed in a later release.PERMISSION_CACHE_ENABLED,PERMISSION_CACHE_TTL_SECONDSandPERMISSION_CACHE_MEMORY_SIZEno longer change how access is cached, and a warning says so when one is set.PERMISSION_CACHE_ENABLED=falsestill tells Nextly that no instance reads the stored answers, so they are not emptied when access changes. - A Direct API call made as a user (
overrideAccess: false), a plugin service call madeas: "user", and a page render now each look up the caller's roles and permissions once, however many rows, field rules and permission checks they read.More
That covers every Direct API method and namespace method, and both collection and Single routes, including the reads their
renderandbuildMetadatamake. A 50-entry list read through the Direct API or a plugin service costs the same access queries as a 1-entry list. A call made inside a request shares that request's lookups. - A request now looks up its caller's roles, permissions and super-admin authority once, however many gates, rules and rows ask about them.
More
A list read whose collection has field-level read rules no longer repeats those lookups for every row it returns; a 50-entry list costs the same access queries as a 1-entry list. Any role or permission change the request makes is seen by its later checks. Work outside a request, such as a job, looks the caller up as before.
- Every access rule may be
true,falseor a function: a collection'screate,read,update,delete,publishandunpublish; a Single'sread,update,publishandunpublish; and a field'screate,readandupdate, in a collection, a Single or a field group.More
On a collection's or a Single's own fields, a field rule written as
falsenow withholds the field from every checked read, and keeps it from being written by a checked write, like a function returningfalse. A checked call is one made withoverrideAccess: false, as every REST and admin request is. Trusted server code, the Direct API's defaultoverrideAccess: true, skips field rules, whether they are written as booleans or as functions.Breaking at startup: an
accesskey that its kind doesn't have now stops an app from starting when the collection, Single or field group is defined withdefineCollection,defineSingleordefineFieldGroup, and failsnextly build. Entities written as plain objects, and those the Schema Builder stores, are not checked. Before, such a key was accepted in silence and governed nothing: the operation it meant to rule fell back to stored permissions.The keys each kind accepts:
- A collection:
create,read,update,delete,publish,unpublish. - A Single:
read,update,publish,unpublish. A Single has nocreateordelete, so a Single carrying either now fails. - A field, in a collection, a Single or a field group:
create,read,update.
Any other key, such as a misspelt
reedor a Single'sdelete, is refused when the collection, Single or field group is defined, withACCESS_KEY_UNKNOWNnaming the key, where it sits, and the keys that kind accepts. To fix it, remove the key, or correct its spelling if it was meant to be one of the keys above. A field'saccessmust also be an object whose rules are functions or booleans; any other value is refused at definition in the same way. - A collection:
- Every access rule is told the languages it judges.
More
A collection's, a Single's and a field's rule now get
locale, the one language the operation is in, andlocales, every language it involves:a read or a write in one language gets that language; a read with an unknown or missing
?locale=is judged as the default it resolves to, and a write naming an unknown language is judged as the default and then refused with 400; a read'slocalesalso lists each language a missing value falls back to, in the order it tries them; a bulk or transactional create or update writes the default language, and gets it; a duplicate gets the default language and each it falls back to, since it copies what a read of the source shows;a read that also reads each language's translation state (
?translation-status=1, or a_translatedfilter) gets every configured language, keeping its own aslocale, and so does an autosave, or a working-draft discard, naming a language the site does not configure; a bulk update by query, whose filter may choose rows by any language, gets every configured language too;a read of every translation (
locale=all), a write to every language (locale=*), and an operation on the whole entry (a delete, a bulk delete, publishing or unpublishing every language, a release) get every configured language, with no singlelocale;content a collection or Single stores the same in every language (it is not localized and has no localized field group) gets every configured language, with no single
locale, whatever?locale=names;- a site with no languages gets none.
So a rule can answer by language, such as
update: ({ locales }) => !locales.includes("fr"), and a rule that limits someone to some languages refuses an operation that reaches beyond them. Write language rules as restrictions like that one, not as admissions by inclusion (locales.includes("fr")): where the languages an operation involves can't be known exactly, such as an operation that names none or a collection built in the admin, the rule is told every configured language, which a restriction refuses and an admission would allow. Restrict withlocales, notlocale:localeis absent for an operation on every language, and leaves out the languages a read falls back to.The REST route gate is told the same languages as the service gate, from the request's
?locale=for an operation on one language's content (a history list filtered to one language among them), the default for a bulk write, and every language for any other. It reads them from the parameters the request is dispatched with, so a language named twice is judged as dispatched.A collection preview link names no language, so minting or opening one asks the read rule in every language: a collection whose read rule closes any language can't mint entry previews until the preview path binds the link's language.
A rule that does not read
localeorlocalesanswers as before. Code that builds a rule's context by hand, such as a unit test of a rule, now giveslocalestoo:[]where the site has no languages. - The PostgreSQL and MySQL adapters can now tell a test harness when a statement escapes an open transaction: a query or connection taken from the pool while the same flow's transaction already holds its own connection, which runs outside that transaction and is kept when it rolls back.
More
Nextly's own integration suites fail on such a statement. Nothing changes in a running application; with no harness listening, the check is a single property read.
- The admin's build-mode comment names what its
NODE_ENVfold decides — the error screens' technical-details box, the caught error's message and stack trace, stays out of what production users are served — and leaves whether the development reload stream opens to the running server's answer.More
The route-table comment keeps the rule and the runtime answers without the record of what an older published bundle did. The settings-sync component's explanation of its sibling shape is stated once, on the component, where the layout points at it, and the component's file is named after what it exports.
@nextlyhq/adminbuilds its declarations with oxc, from the type annotations in its own source.More
Its published types and JavaScript are unchanged.
@nextlyhq/admin's feature components declare their own types: each export states what it returns and accepts, so its published declarations no longer depend on inference.More
What the components render and accept is unchanged.
- The dashboard a new reader is given is now the agreed one: Needs attention, Create, Recently edited, Recent activity, Content, Upcoming releases and Team, in that order, each at its agreed width and offered only within its agreed widths. - Each card only when it has something to show. Create needs a collection the reader may create in; Needs attention needs something it counts that the reader may read; Upcoming releases needs a release the reader may read; Content needs a collection or a Single the reader may see.
More
Four new widget conditions say so:
create:available,attention:readable,releases:presentandcontent:present. Each condition's reads happen at most once per request. - One Content card,core/content, lists both collections (with their entries and drafts) and Singles. It replaces the Collections and Singles cards. A saved Collections, Singles or per-Single card becomes Content, at the first one's place, and the per-Single cards are no longer offered. -system:contentanswers Singles too, after the collections, each row saying itskind. - Saved dashboards convert through one retirement map, which now also covers ids minted per Single (single/<slug>-status). - New,@experimental:ReleasesService.anyReadable, whether a release exists that a caller may read, one row read through the list's own rules. - An access check whose role or permission read fails now refuses, instead of judging the caller as holding no roles or permissions. - Code-defined access rules (collection and Single
access, and field-levelaccess) are handed the caller's roles and permissions.More
When those could not be read, a rule was handed empty lists. It is now not run: the request is refused with
DATABASE_BUSY(503, retryable) when the read waited outpool.connectionTimeoutMs, and an internal error otherwise. A field rule whose caller's grants cannot be read is denied. -listRoleSlugsForUserandlistEffectiveGrantsthrow aNextlyErrorwhen the read fails, where they returned an empty list. A caller with no roles or permissions still gets an empty list. - A request with an API key on a server that has just started no longer answers 503; it waits for Nextly to start, as a signed-in request does.
- Saving an entry or a Single in the admin keeps a related item or upload inside a field group or a dynamic zone that the editor may not read, or that no longer exists.
More
The editor sees it as a "Restricted or missing item" with its id, which can be removed but not opened, as at the top level; before, a save removed such links. A read at
depth=0now returns a to-many relationship inside a field group as an array of ids, as for every other field, rather than as its stored JSON text when the field's name is camelCase. - Saving an entry or a Single in the admin keeps a related item or upload the editor may not read, or one that no longer exists.
More
The edit page shows it as a "Restricted or missing item" with its id, which can be removed but not opened; before, a save removed such links. Populated reads are unchanged.
- Code and plugins can now queue an email (
@experimental):nextly.email.queueandqueueWithTemplate, and the same two on a plugin'sctx.services.email, take every optionsendandsendWithTemplatetake.More
A queued email is stored, sent right away, and retried through the job queue when the provider fails temporarily, with backoff for up to about three hours (15 attempts); a failure that is not temporary fails it at once. Every attempt sends with the caller's
idempotencyKey, or with a key of the queued email's own, so a retry after a lost response is not delivered twice where the provider takes a key. A template is rendered at each attempt.A plugin can pass its transaction as
tx: the email is then stored in that transaction, exists only if it commits, and is attempted once it has. An email stored but never attempted, because the process stopped, is sent the next time the jobs trigger (/api/jobs/run) runs, and the server log warns at startup and on each queued send when a queued email is more than 15 minutes past due because the trigger isn't running.The message is held in a new
email_queuetable, sealed underNEXTLY_SECRET; the job carries only its id.nextly migrateand boot add the table on all three databases. Sends that are not queued are unchanged. - An email sent to several
toaddresses now reaches every one of them, throughnextly.emailand a plugin'sctx.services.email; before, only the first address received it.More
fromand a newreplyTotake effect on every way of sending. - Sign-in and the other auth routes, for a client whose address cannot be resolved, now count a request body larger than the limiter reads among the bodies being read until its handler has finished with it.
More
The limiter stops reading such a body early and the handler reads the rest, which the client could hold open, so these bodies escaped the bound on how many are read at once. A request the limit refuses still gives its place back at once.
nextly plugins installandnextly plugins uninstallwait for the event handlers a plugin'sonInstalloronUninstallset off, and now for the follow-up events those handlers emit in turn, before shutting the runtime down.More
A producer a plugin started in
init()is not waited for, so it never holds the command open.When that work is still running after 30 seconds, the command now stops instead of carrying on. An uninstall stops before any DOWN migration drops the plugin's tables, which that work may still be reading, and the plugin stays
uninstalling, so running the command again finishes it. Before, it warned and dropped the tables anyway.nextly plugins installandnextly plugins uninstallnow let the event handlers a plugin'sonInstalloronUninstallset off finish before they shut the runtime down.More
Emitting an event doesn't wait for its handlers, so another plugin's handler (removing what it kept about each account an uninstall reports deleted, for instance) could be stopped part way. The uninstall's DOWN migrations then dropped the tables that handler was still working through, leaving its cleanup unfinished.
@nextlyhq/blocks-enginebuilds its declarations with oxc underisolatedDeclarations, from the type annotations in its own source.More
The published types and JavaScript are unchanged.
@nextlyhq/blocks-reactbuilds its declarations with oxc underisolatedDeclarations, from the type annotations in its own source.More
The published types and JavaScript are unchanged.
- A tenant's brand colours no longer make a dashboard chart unreadable.
More
Where branding sets the chart colours, the admin checks each against the card, a bar's track and the tooltip, in light and in dark, and moves the lightness of one below 3:1 until it reads, keeping the brand's hue and never refusing it. The chart colours are now written per mode.
@nextlyhq/ui/colorgainsreadableOn(@experimental), the adjustment itself, with the target ratio as a parameter. - The adjusted brand chart colours now reach the chart.
More
The server-rendered branding stylesheet sits later on the page and wrote the unadjusted colours at the same specificity, so it won; the admin's per-mode chart rules now outrank it. The content status widget draws its Published segment and dot in the brand's chart slot, held to 3:1 on the card, rather than in the brand's primary as given.
@nextlyhq/admin,@nextlyhq/uiand@nextlyhq/builderbuild with tsdown, which completes the move from tsup.More
What they publish is unchanged: the same entry points, exports conditions and declarations. The plugin template that
create-nextly-appscaffolds now builds with tsdown too, through atsdown.config.tsin place of the tsup config. A plugin project now declaresengines.nodeas^22.18.0 || ^24.11.0 || >=26.0.0, where both tsdown and Vitest run, andcreate-nextly-app --template pluginrefuses to scaffold on an older Node, with a message naming the floor.@nextlyhq/builderbuilds its declarations with oxc underisolatedDeclarations, from the type annotations in its own source.More
The published types and JavaScript are unchanged.
- A translated field named with capitals in a Schema Builder collection (
subTitle) takes writes in every language straight after the collection is created or the field is added.More
The Builder stores field names lowercased, and the database's columns are named from the stored name, but the tables the running process reads and writes through were built from the name as typed, so every write in a language other than the default one failed with an unknown column until a restart. They are now built from the stored names, as a restart builds them. Nothing stored was wrong, so existing installs need no change.
- Reading an entry or a page of entries now makes fewer round trips to the database.
More
A read looked up its collection's registry row several times, each a query of its own. The row is now read once and reused for up to a second, so a read of one entry sends 4 statements instead of 11, and a page of entries 5 instead of 13. That is a few milliseconds off every read on PostgreSQL and MySQL.
Reads return exactly what they did before. A change to a collection made in this process is seen at once. Changes to a collection made elsewhere (another instance,
nextly migrate, a direct database write) reach every instance within a second. A read inside a transaction always goes to the database. - Nextly does not support Next.js Cache Components yet, so it keeps it off.
More
create-next-app16.4 turns Cache Components on by default (cacheComponents: truewithpartialPrefetching: true). - A projectcreate-nextly-appcreates setscacheComponents: falsein itsnext.config, with a comment saying why. - Whencreate-nextly-appadds Nextly to an existing app, it turns Cache Components off in that app's config and removespartialPrefetching, which Next.js accepts only with Cache Components on. Its report says what it changed and why. A setting it cannot read safely is left as it was, with what to change by hand. -withNextly()and Nextly's start print a warning when Cache Components is on, with how to turn it off. They don't refuse: an app with it on is untested with Nextly, not known to be broken. The start check reads the setting from the app'sinstrumentation.ts, which now passescacheComponents: process.env.__NEXT_CACHE_COMPONENTStocreateRegister;createRegistertakes it as an optional second argument.Why, and the plan to support Cache Components before Next.js 17: https://nextlyhq.com/docs/getting-started/installation#cache-components
- A plugin route's
ctx.caller.can(), the held permission that decides a dashboard widget'srequiredPermissionand a document lock's release, and the schema builder's collection list now ask the one access decision.More
Every answer they give is unchanged: where one still answers differently from the decision, it keeps its answer through a named difference, so a later change can remove each one on its own.
The schema builder's collection list now reads the caller's access once for the whole list, rather than once for each collection.
- A card's quick setting (a
selectorcheckboxits widget marksquick) is drawn in its header and offered in a new card menu at rest, and a change there is saved at once for the reader.More
Only that card is asked again, and the change is announced once the card shows it. Settings saved outside customise mode are now kept for a reader who has never arranged their dashboard, who goes on following the default. Changing one card's settings no longer reloads every card on the dashboard.
- A dashboard card's settings sheet can now draw Collection, Filter, Sort and Group by settings, offering only the collections and fields the reader may read, for widgets whose card query is built from them.
More
Registered widgets cannot declare these settings yet: a registered widget's query applies only
limit, so registration refuses them, as before. A stored choice the reader can no longer make opens at its default, with a line saying so, and "Reset to defaults" leaves a card's Collection as it is. A widget may mark one setting as quick; the mark is validated now, and the card header that shows it comes later. The workspace payload gainswidgetSources, and widget source fields gain an optionalgroupable: falsemark. New experimental type exports onnextly/config:AnyWidgetSetting,ContentSettingandPublishedContentSource. - A dashboard chart is reachable by keyboard and pointer.
More
It takes one tab stop; the arrow keys move through its bars or intervals, a polite live region reads the current point, and a tooltip shows it. Escape dismisses the tooltip wherever focus is, and a tooltip stays while the pointer is over it.
- The collection access gate now asks the one access decision, keeping every answer it gives today.
More
Where today's gate still differs from that decision, a named deviation keeps today's answer, so nothing a caller is allowed or refused changes.
@nextlyhq/plugin-commerceand@nextlyhq/plugin-accountsbuild their declarations with oxc underisolatedDeclarations, from the type annotations in their own source.More
Their published types and JavaScript are unchanged.
nextly migrate:create --pluginnow writes the migrations index with its type stated (PluginMigration[]), so a plugin built the same way can publish it.- A component's many-to-many now reads back as the list of ids it was saved with on every dialect.
More
Before, PostgreSQL returned the list as a string, SQLite returned it as an undecoded JSON string, and MySQL refused a save of more than one id. The column is now the JSON list column a
hasManyrelationship uses (jsonbon PostgreSQL,jsonon MySQL,texton SQLite), on every route that creates a component table. A single stored id reads as a one-item list.Existing installs: a PostgreSQL component table holding the many-to-many as
uuidortext, or a MySQL one holding it asvarchar(36), is offered a one-time conversion to the JSON list column on its next schema apply, andmigrate:createwrites the same conversion into a migration file. Every stored id is kept: a bare id becomes a one-item list, a PostgreSQL array literal becomes the list it holds, and a JSON list stays as it is. On PostgreSQL a value that cannot be read (a malformed array literal) refuses the conversion before anything changes. A generated DOWN reads each list back as its id, and refuses a row holding more than one id rather than drop any. Until the conversion is accepted, saves to such a column may be refused. SQLite needs no conversion. - A component's many-to-many column is text on PostgreSQL on every creation route, so the schema planner proposes no type change between them.
More
Compatibility: a Postgres component table already created with uuid gets a one-time uuid-to-text change on its next preview. Converting existing data is a follow-up for Mobeen; this release does not convert it.
- A component held in a group or repeater of another component is now validated against its own fields on every write, its own
validateincluded; until now nothing judged it, so existing content that fails it is refused on its next save or publish.More
A component placed directly in another component's fields is not stored where the outer component is stored on its own (at the top level of a collection or Single, or under rows, tabs, collapsibles or an unnamed group), and since alpha.67 a value for it there was dropped without a word. Where the outer component is held in a named group or a repeater, its whole value, the directly placed component included, is stored there and validated. Where it is not stored, now: - the admin no longer shows such a field in its forms; - a write that carries a value for one is refused with
NOT_STORED(only an empty value,null,{}or[], passes); -defineConfigwarns about each such field; - the Schema Builder no longer offers one there, and names any an existing field group holds. Put such a component in a group or repeater instead, where it's stored and validated. - Outside development and test, more of what Nextly writes to the console is masked of email addresses: post-commit hook failures and rejected forgot-password redirects (now written through
consoleLogger, so with its[WARN]/[ERROR]prefix), failed user searches, and the default auth and database loggers.More
Addresses written with combining marks (Devanagari, for example) and punycode labels in upper case are masked too.
- The dashboard's Content card counts only the collections it shows, and shows every collection it counts. -
system:contenttakes the page of collections first, in slug order, and counts only that page, so an install with many collections no longer counts every one of them on each dashboard load to show fifty. - The card draws every collection in the answer: those the navigation lists in its order, then any it does not.More
Past a hundred collections the two lists cover different collections, and a counted one used to be dropped.
- The Count card in the add panel, and content widgets that ask only what they draw. - The add panel's Content group is shown. It lists Count once for each collection the reader may read, and its rows carry the widget's category, so a search by category finds them. - A card set to a collection names it in its title ("Count: Posts"), and Count names it in its number's accessible name, so two cards for different collections can be told apart. - Count's Retry asks a failed card again. - A custom card's cells: - A cell may declare
drawnFrom, the narrowest tier that draws it.More
The admin asks only the cells a card's tier draws, and asks the rest when the card widens. - A cell's
whenis judged by the collection that cell reads once the settings apply. - Each cell is keyed by its declared query, so changing one card's settings asks that card again alone. - The settings preview asks the cells under the sheet's values. - A component's settings are the ones its query was asked with. A stored Filter, Sort or Group by that the query set aside is not handed on.effectiveWidgetSettingsgives that reading, andKnobFieldis exported besideKnobContext. - Registration refuses: - a custom card declaring both a query and cells; - a widget offered per collection with no Collection setting; - anofferedvalue outside its closed list ("per-collection","on-request"); - a content setting that no cell's source lets a knob drive. - A timeseries withuntildraws its intervals up to that boundary, where its rows end. - Whether a caller has a release to read is asked once per batch, not once per collection.All of it is
@experimentalwhile the content widgets are built. - A contributed widget's text setting is now held to its
maxLengthwhen the dashboard layout is saved.More
Before, only widgets in the registry were checked, so a direct layout write could store text of any length on a widget a plugin contributed through its manifest. The settings sheet already refused it. The bound now comes from either channel, and where a registration and a contribution share an id it follows the admin's rule: the registration's settings wherever it states any, else the contribution's.
- Core's welcome, password-reset and email-verification emails now name the staff user on their delivery records, so deleting the user can erase them under any address.
More
persononnextly.email.send,sendWithTemplateand a plugin'sctx.services.emailaccepts a staff user as{ kind: "staff", id }(experimental). - Core's own rate limits now count in the limit store the site selected with
rateLimit.store: the sign-in limit, the limits plugin routes ask for, the REST API limit and each API key's limit.More
On
"database", every instance of the site counts one budget, where each used to count its own. - Keys are stored only as keyed hashes in the selected store. Client addresses, account ids and API key ids no longer reach the memory store, the database store or an app'sLimitStoreas given. An app's store of the earlierRateLimitStorecontract still receives them as before. - Counts roll in 32 buckets per window. An attempt counts for one window from when it was made, and can be forgotten up to 1/32 of the window early. The sign-in limit used to slide exactly, and the REST limit reset its whole window at once. - An app's store of the earlierRateLimitStorecontract keeps serving core's own limits, as before.A store outage after a good start now refuses core's sign-in requests (
503, "Please try again shortly."), on core's/auth/*endpoints and on plugin routes withrateLimit: "auth", while the REST API, API key and other plugin route limits let requests through. A store call that takes more than 2 seconds counts as an outage, and a call that never settles stops holding a place after 20 seconds, so once the store answers again its limits count again without a restart. Each outage is logged once when it begins and once when the store answers again. Before, a store failure failed the request. With an app's own store, an outage stops staff sign-in until the store answers, so monitor that store.Existing installs see one new production warning, until they select the database store (
rateLimit.store: "database") or say they run one instance (rateLimit.singleInstance: true). With only a store of the earlier contract, the warning says that plugin limits count in memory.The memory store holds at most 100,000 live entries (
rateLimit.maxEntries). When it is full and nothing has expired, a new key is answered as unavailable, so a sign-in from a new address is refused and an API request is let through. - The Count card, and the content settings that let one widget read the collection each card is set to. - Count (
core/count) states how many entries a collection holds.More
From medium width it adds the published and draft split, and what scheduled releases will publish; at large width, the last 30 days against the 30 before. Each part links to the list that holds exactly it. - Offered per collection: a widget may declare
offered: "per-collection". It is then never placed on the default dashboard, and any number of its cards may be placed, each starting with its collection. - Its query may name no source when a Collection setting supplies one. A custom card may declare several keyed reads (cells), each answered on its own, and handed to its component ascells. - Content settings reach the card's query: Filter (joined with any condition the widget declares), Sort, Group by and a Date range (since), each on a collection source. A stored Filter, Sort or Group by naming a field the reader may no longer use is asked with its default instead. - Widget queries takesinceanduntil(spans from a closed list, measured in whole UTC days or months from the batch's one instant). On a collection source, they take the entry list's workflow conditions_pendingChangeand_inReleaseat the top level. A release condition is answered "not available" for a reader with no release to read.All of it is
@experimentalwhile the content widgets are built. create-nextly-appbuilds its declarations with oxc underisolatedDeclarations, from the type annotations in its own source.More
The published types and JavaScript are unchanged.
- The dashboard's Add widget now opens a panel beside the dashboard.
More
It lists every widget you may add in core's groups (Overview, Team, Plugins), with the cards you removed first so they can be put back where they were. You can search it by name, description, category, group or plugin (a removed card by its name or by its Removed section's text), and add several cards in one visit: each new card goes to the end of the dashboard and is ringed so you can see where it landed. A one-card widget already placed shows as on the dashboard. At the card limit, adding is turned off and putting a removed card back still works. The panel announces search counts and each card it adds. When it closes, focus goes to the last card added, or back to Add widget.
The layout read now gives each widget the panel offers a
rank, its place in that order, so the panel can list a placed widget where it would be offered. - Dashboard cards now arrive one at a time.
More
A card whose data is slow, or fails, no longer holds back the cards that read other sources. -
POST /api/dashboard/querystreams its answer when the request sendsAccept: application/x-ndjson: one{"index", "slot"}line per card, written as each settles. Cards one source answers with a single shared call arrive together, as that call ends. Each line is the slot exactly as the JSON answer holds it, with its dates formatted the same way. A request without that header gets the same JSON answer as before. Both responses now sendVary: Accept. Anything that fails the whole batch (authentication, the body, the caller) still answers with the usual error before the stream starts. If the reader leaves mid-stream, the cards still running are told to stop through their signal. - The admin dashboard asks for the stream and draws each card as its slot arrives. A refresh keeps each card's numbers until its new answer lands, and only the cards still waiting show as loading. - The "is there content" check behind thecontent:emptycondition and the onboarding "first entry" step now counts at most 8 collections at once and stops at the first one with an entry. It has a 5-second budget. When the budget runs out it answers "unknown", which is never read as empty: the empty-install cards are not offered, and the first-entry step stays open. Counts already running when it stops cannot be cancelled; they finish and are ignored. - A dashboard card lays itself out by its own width, small, medium or large, never by the window's.
More
A card below 340 px is small and from 700 px large, and the dashboard predicts each card's tier from its own width and the card's span before the first query, so each card asks only for what its tier draws. - A list or table card draws three rows, titles only, when small; its number of items with a line of detail when medium; and a table with a heading per column when large. When its answer holds more rows than it draws, it says "More exist", and for a list from one collection that is a link to the collection's list view with the card's filter and sort. - A breakdown draws its 3, 5 or 8 largest groups and says how many more there are, or that more exist when the answer itself was cut short. - An over-time card shows the latest value and the total over its range when small, dates its line's two ends when medium, and adds dates between them when large. - The Create card offers 2, 3 or 6 shortcuts and the rest in a More menu; the Team card shows the number of people alone when small; the Collections and Singles tiles take as many columns as the card fits. - A card whose width grows into a tier that draws more asks again on its own; a card that shrinks asks nothing. Nothing in a card is clipped and nothing scrolls inside it. - The entry list honours a
sortin its address for a column its sort menu can order by. - Dashboard charts show their values and colour their groups.
More
A chart over time labels its value axis at zero and its highest value on medium and large cards, circles its latest point at every size and labels its value from medium up, and names several series in a legend. A chart of groups orders its groups by count, ties by label in the admin's locale, before a card's size cuts them, and colours each bar by its group's value, so a status is the same colour on every card. Each chart's text alternative states its range, total, latest and highest values, or its groups, total and the group with the most, saying when counts are lower bounds. The light theme's third chart colour is a step darker, so every chart colour holds 3:1 on a bar's track and on a tooltip as well as on the card.
- The dashboard's Needs attention and Create cards are offered exactly when they would have something to show, and the Content card's rows always say what they are. - Needs attention is offered on content it could count: a collection or Single with a published source, or releases.
More
A reader whose only collection is mid-migration, and who may not read releases, is no longer given a card that can only say "not available". - Create is offered only when it would offer a collection: one the reader may create in that does not turn creating off (
admin.disableCreate, as form submissions do). A site whose only writable collection is form submissions no longer gets an empty Create card. -system:contentkeeps each row'skindwhatever a query selects, so a collection and a Single that share a slug always stay apart. - On the dashboard, a keyboard drag cut short when the dashboard narrows to one column now leaves focus on that card's menu, where the move goes on, instead of on the page.
More
Resetting the dashboard to its default also clears the outline on the card placed last, so a card the default reuses no longer looks freshly placed.
- Dashboard cards are now dragged by their handle with a drop line, and the dashboard can be customized on phones.
More
While a card is held, the other cards stay where they are, the held card dims, and a line shows where it will land: beside the card it will follow, or across the dashboard where it will start a row. The order changes once, on release. The keyboard works the same way: Space or Enter picks a card up, the arrow keys move it one place at a time, Space or Enter puts it down, and Escape cancels. Each step is announced, and so is the move with both positions. A moved, resized, added or restored card is outlined until the next change, and without animation for anyone who asks for reduced motion. Customize is now offered at every width. Where the dashboard shows one column, the handles are hidden and the card menu moves, resizes and removes cards, and the bar fits a phone. A drag in progress when the dashboard narrows to one column is cancelled, and says so.
In the add panel, a removed card is found by its widget's description, category, group and plugin, as the widget's own row is, and each visit to the panel starts with nothing announced from the last one.
- A reader can put a Note on their dashboard: Markdown of their own, private to their layout, drawn read-only with no HTML and links held to http, https and mailto.
More
Widget settings gain an experimental
textareatype with amaxLengthcounted in characters (code points) by one counter,textLengthonnextly/config, used by the settings sheet and the layout write alike.offeredgains an experimental"on-request": a card added from the add panel only, never placed by default. A dashboard over its size limit is told "This dashboard is full: remove a card or shorten a Note". - The "database busy" code core reads is now held in core, so the error module reached from every client-safe entry no longer imports the database adapter, and the admin's browser bundle no longer reaches a database package.
More
The code is unchanged, and a test holds it equal to the adapters'.
- Filtering by a date works on SQLite.
More
A filter on
createdAt,updatedAtor a date field of the collection itself with an ISO 8601 value used to answer 500 on SQLite, the admin's Created and Updated filters on every list among them. It now narrows the list and its count, as it does on PostgreSQL and MySQL. A bound with a fraction of a second is compared exactly on SQLite, which used to floor it to the second. On PostgreSQL, an instant written with an offset other thanZis compared as the instant it names, where its offset used to be dropped.A date is compared with an instant with its offset (
2026-09-01T09:00:00Z) or a calendar date (2026-09-01, the start of that day in UTC), read the same way on every database. Any other value, including a time with no offset, is now refused with a 400 validation error (FILTER_INVALID_OPERANDin its field errors) on every database, where PostgreSQL and MySQL used to pass it to the database to interpret. - Experimental: a workflow definition can now be declared in code and attached to collections and Singles.
More
This release only declares, validates, registers and types a definition. Nothing enforces it yet: content is not tracked through stages, no step can be taken, and publishing works exactly as before.
defineWorkflow, fromnextly/configornextly, declares:- stages, one or more of them allowing publishing, but never the first, nor one the system moves content to without an approval;
- steps between stages, of kind
submit,approve,request-changesormove; - settings kept for later use: what an edit to approved content does, where the next draft starts, where removed stages go and whether approvals stand.
reviewWorkflow()is a ready-made Drafting → In review → Approved definition. Each definition is checked when declared. An error names its place and the known names, for example `steps[1].to:aprovedis not a stage. Known stages: drafting, inReview, approved.`. A misspelt stage in a step fails to compile.The
workflowoption on a collection or Single attaches a definition. It needsstatus: trueand drafts enabled. An attachment that cannot keep a draft beside the live version is refused at boot, before anything is written, on hot reload, and innextly buildandgenerate:types. The refusal says which condition is missing.Each attached workflow is registered with:
- a definition hash: sha256 of its structure, which labels and attachments don't change;
- the path of its
nextly.config.ts.
generate:typesandnextly buildadd a<Name>WorkflowStageand<Name>WorkflowStepunion for each attached collection and Single, and aworkflowsentry inConfig.An internal, never-exported
defineWorkflowfor content states inlib/content-statesis renameddefineContentStates. - Deleting a media item now removes every file stored for it: the original, the thumbnail made at upload, and every image size, on local disk, S3, Vercel Blob and UploadThing.
More
Privacy note. Until this release, deleting a media item left its upload-time thumbnail stored, on local disk under
public/uploadsand on S3 and Vercel Blob, where it stayed publicly reachable at its old URL. (UploadThing was not affected.) To find those thumbnails, runnpx nextly media orphans: it lists them by stored path and changes nothing, and you remove them by hand from the folder, bucket or dashboard they are in. Run it again with--applyto have it remove the ones whose place shows they are media's: it asks about each batch of files just before removing it, keeps any an item has come to refer to, and stops if it cannot check. On S3 it matches keys as written, so/media/...ormedia//...keys are never taken formedia/...ones. Run it from the project's own directory to remove files: with--cwdnaming another project,--applyrefuses, since the database settings were read before it. It lists a file as left behind only under aprefixthe media collection has to itself, one no other collection on any storage shares, contains, sits inside or begins. Without one, as by default, another collection or upload can store a file of the same form in the same place, so it lists what it finds there as files nothing shows are media's, and names any collection that shares media's place: check those with more care. The command looks only under media's prefix as it is now set, so run it before you give media a prefix: the thumbnails earlier deletes left stay where they were, outside it. It looks only at the storage the media collection uses now. If your site used another adapter, bucket or folder before, point the media storage at that one and run the command there too. The command leaves out thumbnails stored in the last hour, which may belong to an upload still in progress: if you deleted, before upgrading, an item uploaded less than an hour before you run it, run it again an hour later. On UploadThing, whose apps can hold other uploaders' files, it lists what it finds by file key and uploaded name: remove the ones that are yours from the UploadThing dashboard. Run it again after a delete whose log names a file it could not remove, or whose process stopped before it finished.- Deleting a folder with its contents removes every file of every item in it and in its subfolders, once their records are gone. It removed none before.
- A file the storage refuses to remove is logged with its path and the media item, so it can be found.
@nextlyhq/storage-uploadthing: a delete UploadThing refuses (an outage, a refused token, a rate limit) now fails, so it is retried and logged like any other, rather than read as done while the file stays stored. A file already gone is still not an error.- A crop inside a plugin's
ctx.db.transactionon SQLite removes the sizes it replaced once that transaction commits. They were kept before, stored indefinitely. - A crop or rotation whose new sizes include
thumbnailremoves the thumbnail made at upload, which nothing refers to any more. - A crop on SQLite saves its regenerated sizes. It failed before, because the sizes were written as an object the driver cannot store.
- A delete and a crop of the same item at once leave no file of the item behind, and two crops at once leave no file behind that nothing refers to.
- Storage adapters can now list their files (
list(), optional).@nextlyhq/storage-s3,@nextlyhq/storage-vercel-bloband@nextlyhq/storage-uploadthingimplement it; an adapter without it is reported as one that cannot be searched.
- A read at
depth=0returns stored ids for a Single's upload fields and for many-to-many fields kept in a junction table, as it already did for every other relationship.More
A Single's uploads were populated at every depth, and a junction field had no value at
depth=0. Populated reads are unchanged. - Who may read, change and revoke API keys follows the permission, not the key's owner, in the admin, the REST routes and the Direct API alike. - Anyone who may read keys (
read-api-keys, orupdate-api-keys) sees every key, not only their own.More
A key still shows only its prefix after it is created, so none can be copied. - A key's owner (name and email) is shown only to someone who may also read users. A person always sees themselves on their own keys, and the admin's key list says "Hidden" for an owner it may not show. -
update-api-keysrenames any key, anddelete-api-keys(orupdate-api-keys) revokes any key. Creating a key is still only for yourself. - The dashboard's key count covers every key, as the list does.The Direct API's
apiKeysnamespace now honoursoverrideAccess: false, set on the call or on the instance, and judges itsuserby these same rules. It is now onawait getNextly()'s instance too, as on the module-levelnextly.- Making or changing keys as an API key is refused.
- A checked call that names no
useris refused withAUTH_REQUIRED. - A call that names a different
useris judged as that person, not by the instance's API key.
Compatibility: over REST, a person holding
read-api-keysbut not super-admin now lists every key where they listed only their own. A holder ofupdate-api-keysordelete-api-keyscan now change or revoke keys they do not own, where before they got 404. Through the Direct API, only calls that ask to be checked change; trusted calls, the default, behave as before. - The Direct API's role, permission and field-group writes honour
overrideAccess: false, set on the call or on the instance, as the REST routes do.More
nextly.roles.create,update,deleteandsetPermissions,nextly.permissions.createanddelete, andnextly.fieldGroups.create,updateanddeleteask the permission the matching route asks: - roles:create-roles,update-roles(also forsetPermissions) ordelete-roles; - permissions: their own action, ormanage-permissions; - field groups: their own action onsettings, ormanage-settings.A super-admin passes. An API key is judged by its own grants. A checked call that names no
useris refused withAUTH_REQUIRED.Compatibility: only calls that ask to be checked change. Trusted calls, the default, behave as before.
- The Direct API's user and role writes (
users.create,users.update,users.delete, androles.create,roles.update,roles.delete) now honouroverrideAccess: false, set on the call or on the instance.More
Each is checked as the named caller against the same permission the matching REST route asks for, and refused with
FORBIDDENwhen the caller lacks it, before anything is written. A checked call that names no user is refused withAUTH_REQUIRED. A role change made this way follows the same super-admin rule as REST: only a super-admin may give anyone a role that is, or inherits, super-admin. An API key is judged by its own grants. Trusted calls, the default, are unchanged. - Mod+S now runs the editor's Save action, so the shortcut always saves the way the Save button does.
More
Two cases change. On a Single with Draft/Published, Mod+S used to save without naming a status; it now saves a draft, or stores changes to a published Single the way Save changes does. Creating an entry, Mod+S now saves a draft, as the Save draft button does, instead of saving with no status.
Submitting the full-page entry or Single form with Enter now saves the way Mod+S does: a draft, or the change to a published document. It never publishes, even where Publish is the leading button; before, it saved without naming a status. Whether Enter submits in a given field is unchanged.
useDocumentStatus()now reports the document's status inside the full-page entry editor, including from a plugin's document view. It returnednullthere before; the quick-edit modal and the Single editor already reported it.The header's save indicator now reports the document's save; recovery points move into its tooltip. A new entry shows 'Unsaved changes' once edited.
After a save, an entry or Single shows what the server stored, such as a trimmed title or a rewritten page layout, rather than what was sent. Anything typed while the save was in flight stays on screen, still unsaved. A save that sent a link the editor cannot see keeps showing the form as sent, so the link is not dropped from the next save.
The Page Builder's screen now offers the document's Save and Publish (or Save draft) in its own top bar, the same actions the editor's header offers. And a passage still being typed in the builder is finished before any save, whether it was started from the keyboard, a button or a menu; before, only Mod+S finished it. Once its page is saved, the builder shows it as saved, and leaving no longer warns, until it is edited again. Its Save and Publish wait for a change the page depends on that is still being written, as the header's do, instead of ignoring the press.
A failed save's Retry belongs to the document and language it failed in: switching language, or to another document, clears it, so it can never store or publish a different translation. The Discard draft confirmation can no longer be dismissed while the discard runs. Confirming it twice before the dialog updates discards once. After a save, the editor stops offering a recovery point from before it. A save waits for every change the page depends on, including one started while it waits, and says so if changes keep arriving. A change that failed to save names itself and where to make it again, rather than offering a Retry that would fail the same way. For plugins,
useDocumentActions().run("delete")now resolves once the delete is answered, with what it came to, anduseDocumentActions()reportsstoredAt, when the document was last stored in this session. - Outside development and test, the log lines written by Nextly's collection, relationship, media, auth, schema and other domain services are masked of email addresses: they now go through the service's logger, or
consoleLogger, rather than straight to the console, with errors and values as fields.More
A line
consoleLoggerwrites without fields no longer carries an empty trailing argument. - Outside development and test (an unset
NODE_ENVincluded), email addresses that Nextly logs through its loggers,consoleLoggerand aloggerthe app passes it included, are masked to their first character and domain (j***@example.com), and the log transport leaves message bodies out.More
Search production logs by domain and first character instead of the full address.
- Emails can carry the site's brand without editing HTML.
More
Brand settings (a logo and its width, an accent colour, the background and content colours, a font from an email-safe list, and footer text and links) are stored by core and read by
GETandPATCH /api/email-templates/brand, which take the permissions to read and to update email templates. Core's default layout is now written in them, the buttons of core's built-in emails take the accent, and every layout and template can read them as thebrandvariable (brand.htmlholds the logo, footer and link markup they make). A background or content colour a site chooses is kept in dark mode too, where the default layout used to swap in fixed dark colours.Nothing changes for an existing site until someone changes a setting: each setting starts from what the default layout showed, which renders every email byte for byte as before. On upgrade, the default layout and built-in emails still as core shipped them move to the new versions; ones a site edited stay as they are, as do the site's own layouts.
GET /api/email-templates/brandsays which emails the settings reach and why the others are not reached. In a layout's data,brandis always the settings: a send's ownbrandvalue still reaches the template body, but no longer a layout, and a template whose send supplies nobrandreads the settings. The settings are also always available asnextly_brand, which core's built-in emails read, so a send carrying abrandof its own changes nothing in them; a send's ownnextly_brandvalue is ignored. A template kept on the engine before Liquid now writes HTML marked as trusted unescaped, as Liquid does. - The email brand settings table is named
email_brand, like the other email tables.
…and 15 more changes. The full list of all 109 is in packages/nextly/CHANGELOG.md.
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.