What changed, release by release
Every package is versioned with Changesets. Releases that need a migration say so.
MCP security update, and safer packing
@easy-cms/plugin-mcpneeds@modelcontextprotocol/sdk1.31.0 or newer, which fixes an advisory where the OAuth client could send credentials to an authorization server chosen by the MCP server (GHSA-6qxp-vccf-f47h). The Next.js starter template now asks for Next.js 16.3.8 or newer.- Packing a package with npm or Yarn 1 now stops with a message when its
package.jsonstill hasworkspace:ranges, which those tools would publish as they are; pnpm, Yarn 2+ and Bun rewrite them and pass.
Ecommerce plugin installs again
Republished so its peer dependency on @easy-cms/core is a version range: 0.45.0 and 0.47.0 went out with workspace:^ and can't be installed.
The admin grows with your plugins
The admin grows with your plugins. The menu has groups that fold, two levels deep (admin.nav, and admin.group on collections, globals and pages): Content, the media library, each plugin's group (Shop › Catalog, Sales, Customers; Forms) and Settings › Site, Users & access, System. Each person can fold groups, pin items and collapse the menu to icons. Badges show what needs attention (admin.badge, e.g. orders to send), and a + creates a document from the menu.
- ⌘K opens search and commands: go anywhere, create, find documents by title in every collection, reopen recent ones, and run commands, plugins' too (
admin.commands). Shortcuts: ⌘S saves, ⌘⇧P publishes,[collapses the menu,?lists them. - Edit pages take tabs, sections that fold and rows (
admin.layout, by field name), help below fields (admin.description), widths (admin.width) and fields shown only when they apply (admin.condition, which the server reads too: a hidden field isn't required). A failed save lists what to fix and opens the right tab; unsaved changes are kept in the browser and offered back. The SEO plugin puts its fields in an SEO tab (tab: falsekeeps them below). Lists remember their filters and scroll, and say what a collection is for while empty (admin.empty). - Configs keep working as they are; nothing changes in the database.
A shop: the ecommerce plugin
A shop: @easy-cms/plugin-ecommerce. Products with variants and prices in several currencies, carts that guests keep too, checkout with Stripe (card, PromptPay) or bank transfer, orders made exactly once however often a payment is confirmed, stock taken safely and put back on cancel or refund, customer accounts, order emails, order.* events, a dashboard panel, and a client for pages with React hooks and Vue composables. With the multi-tenant plugin, each tenant is a shop of its own.
- New in the core, for other uses too: site members (
auth.members) who sign in on the site and never get into the admin, with sign-up, email confirmation and the site's own pages for email links (isLoggedInno longer counts them;isSignedIndoes);update()withwhere(compare and set) andcms.increment();jobsthat run with the scheduled jobs;eventsandcms.emit()for webhooks; menu headings of your own (admin.groupwith a label); list cells get their row'sdoc; plugin collections are typed in configs withoutcollections. The form builder's spam checks moved to the core (formToken,checkFormToken,rateKeys,honeypotName,verifyTurnstile). - Signing up adds an
emailVerifiedfield to users: create a migration (easy-cms migrate:create) before deploying.
Multi-tenant, more complete
Multi-tenant, more complete. Uploads accept only the tenant's files, and media folders opened by an upload field's folder key are each tenant's own. Nested pages, redirects and forms work per tenant: paths, redirects and form slugs only differ within a tenant, findByPath(), getTree() and resolveRedirect() take a context, and documents a plugin writes take the tenant of what they point to. Admins of a tenant see its audit log. Deleting a tenant shows what goes with it and asks for its name. New documents start in the chosen tenant, and lists show a Tenant column while all tenants are shown.
- New in the core, for other uses too:
filterOptionson upload fields;uniqueWithinwith several fields;cms.uniqueScope();audit.scope;admin.confirmDeleteon collections;admin.defaultValue,admin.columnandadmin.allowCreateon fields; relationship columns in lists. - Projects with
auditget ascopecolumn: create a migration (easy-cms migrate:create) before deploying.
Multi-tenant
New plugin: @easy-cms/plugin-multi-tenant, several sites or clients in one CMS. A tenants collection, a tenant on the documents of the collections you name, members with a role in each tenant (Settings → Members), globals with a value per tenant, a tenant switcher in the admin, frontends that name their tenant by header, ?tenant= or domain, API keys that keep their tenant, and easy-cms tenants:assign. New guide: Multi-tenant.
- The core gains what it is built on, for other uses too:
onRequestin the config gives each request acontext(and may change its user), which access rules, hooks,filterOptionsand the Local API (context,cms.forRequest(request)) receive;scopedusers andisSystemAdmin(); globals with a value perscope;uniqueWithinon any unique field, with a unique index per scope; andadmin.switcher. - The SEO plugin's sitemap and llms.txt read in the request's context (e.g. the tenant of the domain);
sitemap(),sitemapXml(),llmsTxt()andllmsFullTxt()take acontext. - Projects with the nested-docs plugin get a unique index on
(parent, slug): create a migration (easy-cms migrate:create) before deploying.
GraphQL API
New plugin: a GraphQL API at /api/cms/graphql. A type, queries and mutations for every collection and global, through the Local API with the same access rules as REST (sessions, Bearer tokens, API keys); typed where and sort, locales and drafts; relationships loaded in batches; limits on depth (7) and documents per request (2000); extend for your own queries, mutations and fields; easy-cms generate:graphql for codegen; GraphiQL outside production. New guide: GraphQL.
A kit for other frameworks
A kit for running Easy CMS inside any framework that handles Web Requests: sharedEasyCMS(config) (one instance per server, compared by structure, kept across hot reloads), createApiHandler(config, { trustProxy }) (the REST API on it), cms.auth.userFromHeaders(headers), and adminHandlerFor(resolvedConfig) in @easy-cms/admin. The Next.js and Nuxt packages and the standalone server now use it; Nuxt compares configs by structure like Next.js. New guide: Other frameworks.
Large files straight to the storage
Large files straight to the storage: with S3 (R2, MinIO) or Vercel Blob, the admin sends files over 4 MB from the browser to the storage (POST <api>/media/uploads for a signed ticket and a presigned URL or client token, then /media/uploads/complete), past hosts' request limits (about 4.5 MB on Vercel); the server still checks each file's size and type from its contents, and deletes what fails. Storage adapters can add uploadURL() and getStart(). cms.createUpload() and cms.completeUpload() do the same from code. Making a media folder private or public (moving or deleting it) now refuses to move more than 200 files at once.
Private folders
Private folders: admins tick Private on a media folder, and its files (and subfolders') are kept apart (upload.privateStorage, or the storage itself when it has no public URLs), served only at <api>/media/private/<name> to users who may see them or with cms.signedMediaURL(doc, { expiresIn }) (at most 7 days), and hidden from requests that are not signed in. Files move between storages, renamed, when they or their folders change; the admin asks first, with how many documents use them. Upload fields take a folder by key (folder: 'banners', made when first needed), with folderOnly to allow no other. API keys can be limited to some media folders.
Upload several files at once
Media: upload several files at once (three at a time, each with its progress; cancel or retry a file; files over maxFileSize refused before sending; the new files stay selected) — the Media page only uploaded the first of several dropped files before. New file types detected from their contents: Word, Excel, PowerPoint, OpenDocument, zip, MP3, WAV, Ogg, M4A, WebM, MOV and CSV, with mimeTypes groups documents, office and archives. Files show an icon in their type's color; the Media page has a grid view; a file's page previews it by type (players for audio and video, the browser's PDF viewer, the start of text and CSV files). PDFs are served without the sandbox CSP so browsers can show them.
Media folders
Media folders (upload.folders: true): nested folders in the media library (media-folders, each file in one folder), a folder tree, path and subfolders on the Media page, drag files onto folders or "Move to…", uploads into the open folder, and folders in upload fields' pickers. Deleting a folder moves what it holds up to its parent. With auth.rbac, admins choose which roles may view, edit or manage each folder (inherited by subfolders; it only narrows the role's Media permissions; recorded in the audit log). Files stay public: folders sort the team's work.
Admin writes behind proxies
The CSRF check also accepts an Origin matching the request's public host (x-forwarded-host or host), so admin writes work behind proxies whose request URL names an internal host, like Netlify's (the first-admin setup said "CSRF check failed: untrusted origin").
Clearer errors without a Blob store
Without a Blob store (BLOB_READ_WRITE_TOKEN), uploads now fail with a message the admin shows (connect a Blob store, then redeploy) instead of an error in the server log, and pages that show images still render. The starter template uses Vercel Blob whenever it runs on Vercel, instead of falling back to the disk, which Vercel doesn't keep.
One-click deploy
To Vercel and Netlify, with a setup code for the first admin.
- Deploy buttons (README, docs): a Next.js blog with its admin, from
templates/next-starter, with a Neon Postgres database and file storage made by the platform, and sample posts. The only thing to fill in is a setup code. auth.setupCode(default: theEASY_CMS_SETUP_CODEenvironment variable): creating the first admin needs this code, so nobody else can claim a freshly deployed site. Wrong codes are rate-limited.GET <api>/users/initsays whether it is asked (setupCode), and the admin's setup page asks for it.- New package
@easy-cms/storage-vercel-blob:vercelBlobStorage()keeps uploads in Vercel Blob (BLOB_READ_WRITE_TOKEN), public on the CDN, or private behind the API (access: 'private', e.g. for backups). - New package
@easy-cms/storage-netlify-blobs:netlifyBlobsStorage()keeps uploads in Netlify Blobs, served through the API.
Audit log: one row per language
Audit log: a translated field's change shows one row per language that changed (title · en: — → Contact), not the raw values of every language.
Audit log
Who changed what and when, sign-ins, and admin actions.
- Turn it on with
audit: true(or{ keep: 365, values: true, failedLogins: 20 }). It adds the internalaudit-logstable: create a migration (easy-cms migrate:create audit). - Recorded: creating, changing, publishing, unpublishing, restoring and deleting documents and globals (with each changed field's value before and after; hidden fields never, the password only as changed), scheduling; logins, failed logins, lock-outs, logouts, password links and single sign-on; roles, backups, test emails and deliveries. Each entry has who (their email then), how (signed in, API key, code, scheduled job), the IP address and the browser.
- Settings → Audit log (admins, and roles given it): filters, each change's fields, CSV export and an integrity check. A document's page shows its Activity.
- Tamper evidence: entries can't be changed or deleted through Easy CMS (only older than
keepdays, oldest first), and each is signed with the secret; the check finds edited and missing entries, daily with scheduled jobs. - The dashboard warns about many failed logins within an hour, entries that couldn't be written, and a failed check.
cms.audit.record({ action, target, doc })writes your own entries. New endpoints:GET <api>/admin/audit,/admin/audit.csv,POST /admin/audit/verify.
Single sign-on
Google, Microsoft (Entra ID), GitHub or any OpenID Connect provider for the admin.
- New package
@easy-cms/auth-oauth:google(),microsoft(),github()andoidc({ issuer, clientId, clientSecret }), with the Authorization Code flow, PKCE,stateandnonce(oauth4webapi). Client IDs and secrets come from environment variables. auth.providersadds "Sign in with …" to the login page. People are matched to users by the provider's id for them, or the first time by their verified email.auth.allowSignUp: { domains, role }gives people from your domains an account on their first sign-in; nobody else gets in without one.auth.password: false: only admins sign in with a password (a way in if the provider is down). Invitations then tell people to sign in with the provider.- Account lists the outside accounts you sign in with: link another, unlink one (never the last way in). Admins see a user's on their page. Settings → Single sign-on shows each provider's callback URL to give it.
- New endpoints under
<api>/auth/andGET <api>/admin/sso; theauthslug and endpoint root are reserved. - With providers, the internal
user-identitiestable is added: create a migration (easy-cms migrate:create sso).
Own documents and field permissions
Roles (auth.rbac): own documents only, field permissions, and who gets a deleted user's documents.
- Own documents only: next to a ticked Read, Update, Delete or Publish, Settings → Roles can limit it to the role's own documents, e.g. writers read every post but change, publish and delete only theirs.
- Who owns a document: with roles on, every collection but Users gets
createdBy, set when a document is created (requests can't set it; trusted Local API calls can, for imports). Or name an owner field withadmin.ownerField, e.g.'author': filled with the creator when empty, and read-only for roles limited to their own documents. Lists get a Mine filter. Documents from before getcreatedByfrom their first version's author, where there is history. - Field permissions: per role, each top-level field can be Can edit, Read only or Hidden; others follow the row. Required fields stay editable for roles that create documents.
- Hidden fields can't be used to filter or sort (403), for role rules and for
access.readin the code, which before only hid the value. The admin schema leaves out fields the user can't read. - Deleting a user in the admin asks who gets the documents they own. REST:
DELETE <api>/users/:id?transferTo=<id>(ornone); Local API:cms.delete('users', id, { transferTo }). New admin endpoint:GET <api>/admin/owned/:userId. - Projects with
auth.rbacget acreated_bycolumn in every collection: create a migration (easy-cms migrate:create owners). - Backups: older backups are removed before a new one is marked done, so the list never shows more than
keepfinished backups (it briefly showed one more).
Roles from the admin (RBAC)
Admins add roles and tick what each may do, without code.
- Turn it on with
auth: { rbac: true }. Admins get Settings → Roles: add, rename, copy and delete roles, and tick Read, Create, Update, Delete and Publish per collection and global, plus admin pages (the system status, Deliveries, and plugin pages and dashboard panels). Each role keeps a history of who changed it. - Permissions are checked on the server on top of access rules: both must allow. Admins may always do everything; requests that aren't logged in are left to access rules; everyone keeps their own account; API keys can do no more than their owner's role.
- The roles in
auth.rolesalways exist and can't be deleted. When roles are first turned on they get everything they could do before, so nothing changes until an admin unticks something. Collections added later are given to no role until ticked (marked New). - The admin hides what a role can't do: menus, buttons, Publish (the main button saves a draft instead), and relationship and upload fields pointing to collections the role can't read (shown as they are, kept on save). The API key permissions table only offers what you may do yourself.
/admin/schemanow haspermissions.publishandviews; dashboard panels take an optionallabel, and withrbaceach needs its own tag. New admin-only endpoints under<api>/admin/roles.- Adds the internal
user-rolestable whenrbacis on: create a migration (easy-cms migrate:create roles). Theuser-rolesslug is now reserved.
Database backups from the admin
- Settings → Backups (admins): Back up now, the list of backups with who made and last downloaded each, Download and Delete.
backups: { every, at, keep, dir, storage, sqlite }in the config: back up everydayorweekatat(server time), keep the newestkeep(7). Withoutevery, back up by hand.- Each backup is one compressed SQLite file (
<site>-YYYY-MM-DD-HHmm.db.gz) of the whole database. SQLite copies itself; Postgres is copied into a SQLite file withbackups.sqlite(sqlitefrom@easy-cms/db-sqlite). - Stored privately:
backups/by default (never the uploads folder, never served) orbackups.storage, e.g. a private S3 bucket. Downloads go through the server, for admins only. - Scheduled backups run with scheduled jobs (every minute on a server, or from the
jobs/runcron); one at a time. The dashboard's "Needs attention" warns when the last scheduled backup failed or none finished in two periods. easy-cms backup <file>uses the same engine: it now backs up Postgres too, and compresses a.gzname.- Adds the internal
database-backupstable: create a migration (easy-cms migrate:create backups).
Settings → Email
See the email settings in use, check the connection, send a test email.
- Admins see the email adapter and its settings with where each comes from (
SMTP_HOST,smtp({ from })…). Passwords only show as set or not. - Check the connection logs in to the SMTP server without sending; Send test email sends one now, not through the queue, to you or another address (5 in 10 minutes). Both show the server's answer with a hint for the usual mistakes (user or password, port and TLS, an unreachable server, a refused sender).
EmailAdaptergets two optional methods for this:describe()(never secrets) andverify().smtp()andconsoleEmail()have them.- New admin-only endpoints:
GET <api>/admin/email,POST <api>/admin/email/verify,POST <api>/admin/email/test.
Failed webhooks and emails in the admin
- Settings → Deliveries (admins): webhook deliveries and emails that failed or wait for another attempt. Webhooks show what changed, the address, the last error and the body sent; emails show who to and the subject, not the content.
- Retry sends once now (removed when it goes through, kept with the new error when not), one at a time or all failed at once; Delete one or all failed.
- Failed deliveries are deleted after 30 days, when scheduled jobs run.
- The dashboard's "Needs attention" links failed webhooks and emails to this page.
- New admin-only endpoints:
GET <api>/admin/deliveries,POST …/:kind/:id/retry,POST …/:kind/retry,DELETE …/:kind/:id,DELETE …/:kind.
What needs attention, on the dashboard
The dashboard tells admins what needs attention, and what the system is.
- Needs attention (admins only, shown only when something is wrong): webhook deliveries that failed in the last 7 days, emails waiting over an hour or failed, scheduled publishing over 10 minutes late (nothing calls
jobs/run), noemail, and noserverURLin production. Each links to the new Health checks guide. - System (admins only): the Easy CMS version, database, file storage, email adapter, plugins with their versions, and field types.
- Both come from the new
GET <api>/admin/status, for admins only. Easy CMS doesn't check for new versions. definePlugin(plugin, { name, version })names a plugin for the dashboard; the official plugins name themselves.EmailAdaptergets an optionalname(smtp,console). Core exportsVERSION.- In development, the admin's HTML is read on each request, so a rebuilt admin shows up without a restart.
Uploads from links
upload.fromURL: { allowedHosts, allowPrivate? }lets users upload a file by its link: the server downloads it, then checks it like any upload (type from the contents,maxFileSize, the right to create media). Off by default.cms.uploadFromURL(url, fields?, options?)in code, andPOST <api>/mediawith JSON{ url, ...fields }over REST.- Admin: a "From a link" field in the media library, the media picker and galleries. Links (or files) can also be pasted into the upload area, and images dragged from other pages dropped on it.
- Safe by default: only
http(s); hosts must be inallowedHosts(also after redirects, three at most); private network addresses (localhost,10.x,192.168.x,169.254.x, IPv6 ones too) are refused after DNS resolution, unlessallowPrivate: true; 15 seconds andmaxFileSizeat most. - Fix: a media document's page cut off large images; the whole image now shows, as big as fits (up to 70% of the screen's height), and a click opens the original.
- Dashboard, regrouped: number tiles show content only, in menu order, each opening its list with a + to create; drafts to review come first, then recent edits; plugin panels (
width: 'half') sit in the side column beside them,fullones below.
Plugin pages and dashboard panels
admin.pages: pages of their own at<admin>/p/<path>, with a Web Component as the body. The admin draws the header.groupputs them under Content or Settings in the menu, or leaves them out (false).admin.dashboard: panels on the dashboard after the built-in ones,halforfullwidth.access: ({ user }) => booleanon both is checked on the server; what a user may not see is left out of their admin.- Admin components now also get
user, and on pagesroute(subpath,query). They can send anavigateevent to open an admin path, e.g. to keep a tab or date range in the address. - New menu icons:
chart-columnandchart-line. - Form builder: a "Form overview" page with submissions per day and per form over 7 or 30 days, and a dashboard panel for the last 7 days. Both read the new
GET <api>/form/stats.json, which counts days in the editor's time zone.
Custom field types
fieldTypesanddefineFieldType()let packages add a fieldtype: stored, queried and translated like a built-in base type (text,number,json…), with its ownvalidate,checkOptions, input and list cell. Names that clash with built-in types or each other are config errors.- Types: packages extend
interface CustomFieldTypes(module augmentation), sotype: 'color'is accepted with its options and documents get the right value type.generate:typesuses the type'stypescript. admin.cellon any field shows its value in the list's column with a Web Component; columns with a cell are shown by default.- New package
@easy-cms/fieldswithcolor:#rrggbb(or#rrggbbaawithalpha: true), a picker withpresets, and swatches in lists.
Typed plugin fields
definePlugin<T>(plugin)tells the inferred document types what a plugin adds: fields on collections and globals, and whole collections.CollectionDocument,cms.find()and the rest then know them, with no command to run.- The official plugins declare theirs:
post.meta(SEO),page.parent,page.pathandpage.breadcrumbs(nested pages, with the names you give), theredirectscollection with itsto_<collection>fields, and theformsandform-submissionscollections. findByPath()returns the page typed from your config;getTree()andrebuildNestedDocs()take your typedcms.- New types in core:
PluginTypes,TypedPlugin,DocumentOfandSlugOffor helpers that take anEasyCMS<C>.
Forgotten passwords and invitations
Password reset and invitation emails from the login page and the admin.
- Forgot your password? on the login page emails a link that sets a new password: it works once, within an hour (
auth.resetPasswordExpiration). The answer is the same whether or not the email has an account, and requests per email and IP are limited. Setting the password signs the account out everywhere, logs in on this browser and emails a "your password was changed" notice. - Invitations: admins create users without a password and they get an email to choose one (the link works for 7 days,
auth.inviteExpiration). "Email a link to set the password" on a user's page sends an invitation or a reset link. - Emails are in English or Thai, or your own text with
auth.emails. Links needemailin the config, andserverURLin production (a request's Host could be forged); without them the admin doesn't offer them. - REST:
POST /users/forgot-password,GETandPOST /users/reset-password,POST /users/:id/password-link. Local API:cms.auth.requestPasswordReset(),cms.auth.sendPasswordLink(),cms.auth.resetPassword(). cms.create('users', …)no longer requires apassword: a user without one can't log in until they set it from an invitation.
Image galleries
Upload fields with several files.
uploadwithhasMany: truekeeps several files in the order editors arrange them, e.g. a gallery on a post. In the admin editors drop several files at once, pick several from the media library, drag them into order (or use the arrow buttons) and remove them. Reads return the media documents in order; it can belocalized, and queries likewhere: { gallery: { in: [id] } }work. Needs a migration for the new field.mimeTypeson upload fields, e.g.['image/*']: the media picker offers only those files, and saving refuses others ("must be an image").minRows/maxRowson upload and relationship fields withhasMany.- An empty list now counts as fewer than
minRows(arrays and blocks too); drafts may still be incomplete.
The admin on Vercel
The admin works on Vercel and other hosts that deploy only the files the build traces.
createAdminRouteHandlers()looked for the admin app through@easy-cms/next/package.json, which Vercel doesn't deploy, so/adminanswered 500 with "Cannot find module '@easy-cms/next/package.json'".- It now uses the path
@easy-cms/adminreports for itself, which the build traces, and falls back to the old lookup when that package was bundled.
Package READMEs
Every package now explains what it does, how to install it with npm, pnpm, Yarn or Bun, and links to its guide.
npm, pnpm, Yarn and Bun
create-easy-cms --pm npm|pnpm|yarn|bunpicks the package manager. Without it, the project'spackageManagerfield, then its lockfile, then the one you ran it with.- The next steps it prints use that package manager, such as
bunx easy-cms migrate. - With Yarn 2 or later it writes
.yarnrc.ymlwithnodeLinker: node-modules. - The docs show every command for all four and keep the one you pick.
Nested pages
- New
@easy-cms/plugin-nested-docs: pages inside pages, each with aparent, its fullpathandbreadcrumbs, per language. - Moving a page updates the pages under it, and the redirects plugin redirects their old addresses.
- Relationship
filterOptionsand sluguniqueWithin.
Forms and email
emailin the config:smtp()from the new@easy-cms/email-smtp,consoleEmail()for development, or your own adapter. Failed sends retry for about a day.- New
@easy-cms/plugin-form-builder: forms built in the admin, notification emails, CSV export, and bot protection with a honeypot, rate limits and optional Turnstile.
Redirects
- New
@easy-cms/plugin-redirects: editors manage 301, 302, 307 and 308 redirects under Settings → Redirects. - Automatic redirects when a published page's address changes, in every locale, without chains or loops.
resolveRedirect(cms, url)serves them from memory in a Next.js 16proxy.ts.