What changed, release by release
Every package is versioned with Changesets. Releases that need a migration say so.
Links to easycms.dev
The admin's help links and the package READMEs point to the new website address, easycms.dev.
- The admin's help links (dashboard, jobs, backups, deliveries, email, single sign-on) and every package README link to easycms.dev. The old easy-cms-website.vercel.app links redirect, so nothing breaks either way.
A Calendar page, and statuses that say what is pending
A Calendar page across the site, moving things on calendars with Undo, and statuses that show what is scheduled.
- Calendar in the menu, right after Content: every collection with
admin.calendar, each in its own colour, and what is scheduled to publish or unpublish. Each source can be hidden; withauth.rbac, roles are given it ascalendar. - Calendars get a side column with the picked day's documents and their status. Drag a document to another day or time, or use Move to… from the keyboard; a message offers Undo for 8 seconds. A document with a pending draft moves in that draft.
- Statuses say what is pending wherever they show: Scheduled · date for a draft waiting to go live, and Unpublished changes, Publishes · date or Unpublishes · date beside Published. A list's status menu has Scheduled.
- The live preview panel names the site it opens, and says so when the page doesn't answer.
GET /admin/ui/scheduledandcms.upcomingSchedules()takefrom,to,collectionanddocs.
Dates in your time zone, and a Calendar mode for lists
The admin shows and takes dates in config.timeZone, and collection lists can be a calendar.
- With
config.timeZone, every date the admin shows or takes (lists, edit pages, scheduled publishing, filters) is in that zone, so 09:00 is 09:00 for everyone; someone in another zone sees it named. admin.calendar: { start, end?, color? }gives a collection's list a List | Calendar switch: month, week, day and agenda views with the list's filters. Click a document to open it, or a day or slot to create one. The calendar loads only when opened.
Filter lists by several conditions
A Filter panel for collection lists, kept in the URL as the REST API reads it.
- Filter builds conditions that all must match: text, number ranges, whole days (with Today and Last 7 / 30 days), select options, related documents and empty fields. Each shows as a chip above the list.
- The conditions live in the URL as the REST API reads a
where, so a link shows the same list; older?status=and?f_<field>=links still work.admin.filter: falseleaves a field out.
Admin components on Vercel with pnpm
Plugins' admin components, such as the SEO plugin's length meters, load on Vercel with pnpm again.
withEasyCMS()now traces an admin module's package folder innode_modules, not only its real path in.pnpm, so the server finds it by its package name.
Single sign-on for everyone
Require single sign-on for every account, admins included, except named break-glass accounts.
auth.password: { breakGlass: ['emergency@example.com'] }turns password sign-in off for every account but those listed. Other passwords are refused at sign-in and by the API, and can't be set; the admin hides the field.- When
auth.passwordchanges, the next start ends the sessions of accounts that may no longer use a password and recordsauth.password.policyin the audit log. - Each break-glass sign-in is recorded as
login.breakGlassand shown on the dashboard for a day.
API rate limits
Limit requests to the API per IP, signed-in user and API key, across instances.
rateLimitallows 300 requests a minute from each IP of visitors who are not signed in and 1,200 per signed-in user and per API key by default; over that,429withRetry-After.- Counts are kept in memory and added to the new
rate-limitstable every few seconds, so instances share them without a write per request. No IP is stored, only an HMAC of it. - Off by default, and needs a migration when turned on. New projects have it on.
Rotate the secret
Replace EASY_CMS_SECRET without logging everyone out.
previousSecretslists secrets used beforesecret: sessions, CSRF tokens, password and invitation links, preview tokens, links to private files, single sign-on, form tokens and audit log entries signed with them still verify. Everything new is signed withsecret.easy-cms audit:resignsigns the audit log entries made with an old secret with the current one, and reports entries no secret verifies.- New projects read
EASY_CMS_PREVIOUS_SECRETS(comma-separated). The Security guide has the steps.
Webhook signatures against replays
Each webhook attempt is signed with its own time, so a captured request can't be sent again later.
- With a
secret, each attempt sendsx-easy-cms-timestamp(its own time) andx-easy-cms-signature-v2, an HMAC-SHA256 of the delivery id, that timestamp and the body. verifyWebhook(body, headers, secret)checks it in constant time, refuses timestamps more than 5 minutes from now, accepts several secrets while one is replaced, and returns the payload.x-easy-cms-signature(the body alone) is still sent until 2.0, but is deprecated. Retries keep the body andx-easy-cms-delivery; their timestamp and signature are new.
Faster at 1M documents
Relationship filters, short searches and saves under load stay fast at 1,000,000 documents.
- In collections with drafts, indexed fields (relationships, uploads,
index: true) are indexed together withstatus, so totals read only the index. On Postgres they also get statistics on the pair: at 1M posts, a filter by author went from about 1.6 s to a few milliseconds, and an author with only drafts is found empty at once. postgres()plans each query for its own values (customPlans, on unlessprepareis false). Behind PgBouncer with prepared statements, setcustomPlans: falseandplan_cache_modeon the role.writeConnectionsonpostgres(): connections kept for creates, updates and deletes, so saves don't wait behind slow reads (at 1M posts under 200 editors, saves about 4× faster).likeon a field withindex: 'trigram'needs three characters on Postgres; the admin's searches wait for the third. The Blocks guide shows how to query inside blocks on large collections.- A job without
everyno longer runs twice when two instances run jobs at the same moment.
OpenTelemetry
Traces and metrics through OpenTelemetry, sent wherever your app's SDK sends them.
telemetry: true(or{ traces, metrics }) sends spans and metrics through@opentelemetry/api, an optional peer dependency. Without it, the package isn't loaded.- Each API request gets a span, inside the framework's when it has one, with spans for Local API and database calls, and for background jobs and outbox entries. Spans carry names, never values.
- Metrics:
http.server.request.duration,db.client.operation.duration, job runs and durations, queue sizes and late scheduled publishes. - JSON log lines carry the active span's
traceIdandspanId. New docs: Observability, with a local collector, Jaeger and Prometheus in examples/otel.
Tables in rich text
Editors can add tables to rich text, and every renderer handles them.
- The admin's editor inserts a table with a header row and, while the cursor is in one, adds and deletes rows and columns. Cells can't be merged.
renderRichTextrenders tables with their header rows in<thead>;renderMarkdownwrites GFM tables, a cell's paragraphs joined with<br>.richTextToPlainTextputs each cell on a line of its own, and search adapters index the text in tables.
Search adapters
Relevance-ranked search of published content, Thai included, through a search adapter.
search: memorySearch()in the config andsearch: { fields }on a collection index published documents as they change: withoutbox: true, as a step of the outbox, retried until it succeeds.cms.search()andGET <api>/searchreturn the best matches first, read with the reader's access. The admin's command palette lists them first, then title matches with drafts.memorySearch()splits words withIntl.Segmenter, so Thai text without spaces is found by its words. It is rebuilt when the server starts, for development and small sites.easy-cms search:reindexbuilds the index again. Adapters for search engines implementSearchAdapter. New docs: Search.
Outbox for after hooks
After hooks, webhooks and cache purges that happen whenever the save does, retried until they succeed.
outbox: truewrites an outbox entry in the same transaction as the save; a worker runsafterChangeandafterDeletehooks, webhooks andhttpCache.purgeafter it commits, and retries what fails.- Hooks then run after the response, at least once: they must be safe to run again.
cms.flushOutbox()waits for them, e.g. before a serverless function returns. - Failed entries show under Settings → Jobs → Outbox, where an admin can retry them.
- Off by default. Turning it on needs a migration for the
outboxtable.
HTTP caching for a CDN
Public reads of published content can be cached by a CDN, and purged by tag when content changes.
httpCache: { sMaxAge, staleWhileRevalidate, purge }(off by default) sendsCache-Controlwiths-maxage, a weakETagwith304answers, and cache tags asCache-TagandSurrogate-Key.- Only reads without a login, session or key, and without drafts, are cached; everything else stays
no-store. Collections and globals can set their own times orhttpCache: false. purgereceives the tags of what readers saw change.cacheTagsis exported from@easy-cms/coreand@easy-cms/next, for tagging fetches andrevalidateTag.
Trigram indexes for Thai search
Fast like searches on any part of a text on Postgres, in Thai as in English.
index: 'trigram'on text, textarea and email fields makes a GINpg_trgmindex thatlikeuses: at 100k posts, p95 25 ms instead of reading every row. Localized fields get one per locale.- The first migration with one creates the
pg_trgmextension; PGlite loads it. A warning at start says when the database'sLC_CTYPEcan't index Thai letters. - On SQLite the option does nothing.
Cursor pagination
Pages that take as long as the first however deep, for large collections.
findtakescursor(REST?cursor=):''for the first page, then each page'snextCursor. No page numbers or totals.- A cursor belongs to its query (where, sort, locale) and is refused with another.
maxPagecaps how deep API clients may page withpage; deeper requests get400, pointing tocursor. The signed-in admin keeps its pages. No limit by default.
Lists count less
Skip or reuse counts on large lists, for archives and infinite scroll.
findtakescount: false(REST?count=false):totalDocsandtotalPagesarenull, andhasNextPagecomes from one row more.- Counts of paginated lists are reused for
countCacheSeconds(default 5;0counts every time). The documents are always current; only the total can be that old.
drizzle-orm 0.45.4
The database adapters use drizzle-orm 0.45.4.
@easy-cms/drizzle,@easy-cms/db-sqliteand@easy-cms/db-postgresuse drizzle-orm 0.45.4.
Health checks
Liveness and readiness checks in every setup, for load balancers, Kubernetes and Docker.
<api>/healthzanswersokwhile the process runs, without touching the database.<api>/readyzanswers200when the CMS started, its database answers and no migration is pending, and503naming the check that failed otherwise. The error goes to the log, never to the response.- Next.js and Nuxt get both through the API route, and they answer even when the CMS can't start. The standalone server also answers
/readyzat the root. healthzandreadyzare now reserved collection slugs and endpoint paths.- The official Docker image,
ghcr.io/maritonx/easy-cms, checks/readyz. New docs: Health checks.
JSON logs and request ids
One JSON object per log line for log pipelines, and a request id on every API response.
logs: { format: 'json' }, orEASY_CMS_LOG_FORMAT=json, writes one JSON object per line: time, level, message, the request id, and errors with their stack.- Every API response has an
X-Request-Idheader; a plain one from your load balancer is kept. With JSON logs, every request gets a line with its method, path, status and duration (logs.requests: falseturns that off). - Each run of the background jobs logs with its own
runId.easy-cms servefollows the setting. - The readable default now prints an error's stack under its line, and
jsonLogger()is exported.
Postgres connection options
Tune the Postgres pool, and run behind PgBouncer and other poolers.
postgres()takesprepare,statementTimeoutMs,idleTimeoutMs,connectTimeoutMsandapplicationName.- Connections are named
easy-cmsinpg_stat_activity(orPGAPPNAME). Options left unset still come from the URL and thePG*variables. - CI runs the Postgres suites through PgBouncer in transaction mode, as a role with a schema of its own.
- New docs: pool sizing, serverless and poolers in Databases, and Database isolation.
Log out without losing your changes
Logging out asks about unsaved changes first.
- Log out leaves the page first: on a page with unsaved changes the admin asks, and choosing to stay keeps you logged in with your changes. It used to end the session before asking.
Types for every locale
Reads with locale: 'all' are typed as they return.
find,findByIdandfindGlobalwithlocale: 'all'type localized fields as{ [locale]: value }maps of the config's locales.CollectionDocumentandGlobalDocumenttake a third parameter,true, for that shape.
Settings → Jobs, complete without jobs
Settings → Jobs shows everything even when the config has no jobs of its own.
- The Jobs tab always shows, with how to add jobs when there are none.
- A Built in section lists what Easy CMS does with every run (scheduled publishing, webhook and email retries, scheduled backups, the audit log check), with links to their pages.
- The page says when jobs last ran, and warns when nothing has run them for 10 minutes, usually a missing cron on serverless hosts.
Settings → Jobs
See, run, pause and reschedule background jobs from the admin, and run jobs at a time of day in your time zone.
- Settings → Jobs: each of the config's and plugins'
jobswith its schedule, last run, result and next run, and the scheduled-publish queue in a second tab. **Run due jobs now** runs what is due. - Admins can pause and resume a job, and change the schedule of jobs with
adminSchedule: true. The dashboard warns about failed and paused jobs; changes go to the audit log. jobs[].atruns a job at a time of day ('02:00'), daily or every few days, and the newtimeZoneoption sets the zone, forbackups.attoo.- New guide: Jobs.
Background work runs once across instances
Scheduled publishes, webhook and email retries, backups and jobs are claimed before they run, so several instances (or a scheduler and a cron) never do the same work twice.
- Each piece of background work is claimed in the database first (compare and set): no double publish, webhook, email or backup. A job claimed by an instance that stopped goes back to the queue.
jobskeep their runs in a new internaljob-runstable. Apps withjobs(such as shops) need one migration:easy-cms migrate:create, theneasy-cms migrate.- The admin's Retry on a delivery sends once, even when clicked twice.
- Dropdowns in the admin show the same arrow in every browser, with room on the right.
The admin shows when it is loading
Skeletons shaped like the content and a navigation bar while pages load, and the back button on a document works again.
- Skeletons on the dashboard, lists (table and media grid), edit and global pages, the document drawer, settings pages, version history and the activity panel; a thin bar during route changes and list reloads.
- Indicators appear only after 250 ms, stay at least 300 ms, stand still with reduced motion, and are announced once to screen readers.
- The back button on a document goes back to its list, with its filters, page and scroll.
Payments rechecked, GraphQL create inputs
The shop confirms payments still pending after checkout with its provider, and GraphQL create inputs mark required fields inside groups and rows.
- Shop: a payment still pending a few minutes after checkout is confirmed with the payment adapter, for when the page was closed and no webhook arrived.
- GraphQL: required fields inside groups and array rows are non-null in create inputs, with new
…CreateInputtypes. - Pickers say "Loading…" while they wait, instead of "No files yet" or "No matches".
Test emails that went only to the log
Settings → Email no longer says a test email was sent when the adapter only prints it to the server log.
- With
consoleEmail()(or any adapter whosedescribe()saysdelivery: log), Send test email answered{ ok: true }and the admin headed it "Sent to …". It now answers{ ok: true, logged: true }, and the admin shows "Not sent" with how to set up a real adapter, plus a warning above the settings. - New recipe in the docs: Move from WordPress, with a script that imports posts, pages, categories and images and adds redirects from the old addresses.
Easy CMS 1.0
The first version meant for production use. From 1.0, breaking changes come only in a major release, with an upgrade guide; renamed APIs keep working until the next major, with a warning. The config, Local API, REST routes and error codes, CLI, adapters and your data are covered; @easy-cms/plugin-ecommerce, @easy-cms/plugin-graphql and @easy-cms/plugin-mcp stay experimental. Upgrading from 0.x: back up, upgrade every package together, and follow "From 0.x to 1.0" in the upgrade guide.
The 1.0 release candidate
The first release candidate of 1.0, on the rc npm tag (npm create easy-cms@rc). No changes since 0.68.5 beyond the version.
Shop hooks handle failed address loads
useAddresses() (React and Vue) no longer leaves an unhandled promise rejection when the addresses can't load as the customer signs in or out, e.g. with an expired session: the list stays empty. Calling reload() still throws. The React hooks and Vue composables now have tests.
Stripe client settings
stripeAdapter({ clientConfig }) passes more settings to the Stripe client the adapter makes, e.g. { maxNetworkRetries: 2 }, or the host and port of stripe-mock in tests.
Form element survives production builds
import '@easy-cms/plugin-form-builder/element' (and element.css) now survive production builds: the package declared no side effects, so Vite and other bundlers dropped the import and <easy-form> stayed empty on the site. Development servers don't tree-shake, which hid it.
MCP: accurate draft wording
In a collection or global with drafts but without versions, update_* now tells the assistant that changing a published document takes it off the site until it is published again (it said the live version stays, which is only true with versions). Every tool is now tested through the MCP SDK client, along with clients on protocol versions back to 2024-11-05; @modelcontextprotocol/sdk 1.32.1 or later is required.
graphql as a peer dependency
graphql is now a peer dependency (16 or 17) instead of a dependency, so the plugin and the graphql-js objects you pass to extend are always the same copy; with two copies graphql-js refuses the schema. npm, pnpm and Bun install it for you; with Yarn, run yarn add graphql. The tests now run with graphql 16 and with 17.
A fixed Stripe API version
The Stripe adapter asks for a fixed Stripe API version, STRIPE_API_VERSION (2026-09-30.endive), instead of the default of whichever stripe package is installed, so upgrading the package no longer changes Stripe's answers under a shop. stripeAdapter({ apiVersion }) picks another. The stripe peer range is now 17 to 23, the majors that are tested; create the webhook endpoint in Stripe with the same API version. With stripe 17 to 22 installed, a shop now talks to this newer API version; the fields the adapter uses are the same in both.
Payment methods in the page's language
Payment methods are named in the page's language: GET <api>/shop/config?locale=th (the shop client sends its locale) gives โอนเงิน instead of Bank transfer. Found testing the shop end to end with Stripe in test mode.
Large direct uploads from the browser
Direct uploads of large files work from the admin in a real browser. Tested with real Vercel Blob stores, public and private.
- The admin's Content-Security-Policy allowed requests to its own origin only, so the browser blocked sending files to the storage. Storage adapters now name where browsers send files (
uploadOrigins: the bucket for S3,https://vercel.comfor Blob), and the admin allows them inconnect-src(securityHeaders(connectSrc);adminHandlerForand Nuxt fill it in from the config). - Vercel Blob: the upload no longer sends
x-add-random-suffix, which the Blob API's CORS doesn't allow from browsers (the client token already disables the suffix).
Clearer single sign-on settings
Settings → Single sign-on says what happens to people who already have an account: they link the provider on their Account page first, or are matched by verified email with linkByEmail.
Multi-tenant hygiene
- Staff of a tenant see others' memberships in that tenant only; people still see all their own.
- Checking the audit log's integrity is for admins of the whole system (not of one tenant); the admin hides the button for others.
- A plugin's collections listed only partly per tenant (forms without their submissions, some of the shop's) log a warning on the first request.
uniqueon a field inside a group, array or block logs a config warning: it is enforced on top-level fields only.
Operations: encrypted backups, STARTTLS, unpaid orders
- GraphQL introspection is off in production by default (
introspection: trueto keep it);easy-cms generate:graphqlstill writes the schema. backups.encryptionKeyencrypts backup files (AES-256-GCM,.db.gz.enc); admin downloads are decrypted andeasy-cms backup:decrypt <file>decrypts one by hand. A backup storage with public URLs logs a warning;create-easy-cmsaddsbackups/to.gitignore.- SMTP on port 587 requires STARTTLS (
requireTLS, shown in the admin's Email settings). - Without the audit log, failed sign-ins are written to the server log; each audit check logs its last id there too.
- Forms without a client IP share one rate limit of ten times
max, instead of none. - Unpaid manual orders (bank transfer) are cancelled after 3 days and their stock put back, so unpaid orders don't hold stock.
manualAdapter({ expiresIn })sets the seconds, orfalseto wait for staff (e.g. cash on delivery). Orders get anexpiresAtfield: runeasy-cms migrate:createin production. Orders made before the upgrade aren't affected.
Browser and transport security
- Over HTTPS (and in production) the session, CSRF and SSO cookies are named
__Host-…; the old names are still read, so nobody is signed out. Logging out clears both and sendsClear-Site-Data: "cache", and the admin forgets drafts and recent documents it kept in the browser. - The REST API and the admin send HSTS (
max-age=31536000) in production. - Uploaded text files are stored and served with
charset=utf-8. - The CSRF origin check uses
X-Forwarded-Hostonly with trust-proxy (trustProxy,--trust-proxy) or on Vercel and Netlify;createRestHandlertakestrustProxy. - The SEO plugin's sitemap and robots.txt build their own addresses from
serverURL, not the request's host. - The admin's switcher cookie is
Secureover HTTPS.
Access checks
- Updating or deleting a document the user may not read answers
404, as for an id that doesn't exist; one they may read but not change still answers403. - Preview links from the admin read with the access of whoever made them (
createPreviewToken(target, { user })); they stop working when that user is deactivated. <api>/admin/ui/media-usagecounts only documents the user may read (cms.mediaUsage(ids, access)).- Shop:
POST <api>/shop/confirmis only for whoever paid: the customer, or the guest with the cart's secret (the client sends it); others get404.
Account hardening
- Passwords that are among the ~2,000 most common (from SecLists, 8 characters or more) are refused. Existing passwords keep working.
- Account lists your API keys with Revoke and Revoke all; after a password reset the admin shows your keys first and offers to revoke them.
- In production the server warns while no admin exists and no setup code is set; creating the first admin is in the audit log (
setup). cronSecretmust be at least 32 characters (a shorterCRON_SECRETis a warning), and<api>/jobs/runwith the session cookie takesPOSTonly.
Deprecation warnings
Deprecation warnings: warnDeprecated(code, message) (from @easy-cms/core and @easy-cms/core/plugin) logs a Node.js DeprecationWarning once per code and process. Old names that still work warn with codes EASY_CMS_DEP001–005: <api>/users/<action>, POST to a global, ?fallback-locale=, easy-cms create-admin and easy-cms run-scheduled. A new docs page, "Versions and deprecations", says what the version number promises and lists them. The admin saves globals with PATCH and sends ?fallbackLocale=, and a failed sign-in no longer counts as an expired session.
Names and shapes for 1.0
Names and shapes for 1.0. This release renames config options, Local API methods, CLI commands, REST routes and plugin options; the config check names the new option when an old one is used. See the upgrade guide: the Upgrading guide
- Public API separated from what packages share (
@easy-cms/core/internal, outside semver) and@easy-cms/core/pluginfor plugin authors - Error codes on every
EasyCMSErrorand REST error;Retry-Afteron 429 - Config renames (
siteURL,admin.order,admin.icon,cliCommands,keepDays,backups.frequency, …) and warnings for unknown options - Local API:
isStaff,findSchedule,upcomingSchedules,runJobs()result,increment()throws for a missing document - REST:
/auth/<action>(with/users/<action>aliases),/admin/ui/*,PATCHfor globals - Plugins and adapters declare
apiVersion;onRequestlists;flagsfor plugin commands; CLIadmin:createandjobs:run; plugin option renames
Security changes that may need a step
- SVG is no longer included in
image/*(it can carry scripts). To keep allowing it, list it:upload.mimeTypes: ['image/*', 'image/svg+xml']. After a direct upload, the type the storage serves the file with must be the one checked (S3 reports it). - Users changing their own password or email send their current password (
currentPassword); the account page asks for it. Admins changing other users don't. Accounts without a password can't change their own email. - Single sign-on no longer signs existing staff in by email until they link the provider from their account page, unless the provider has
linkByEmail: true(for providers whose emails your organization controls). Site members are still matched by email.microsoft()no longer uses the user principal name as an email. trustProxytakes the lastX-Forwarded-Foraddress (the one your proxy added), not the first, which the client can write. On Vercel and Netlify the client IP is found without it; Nuxt uses the connection's address otherwise. A warning is logged in production when logins have no client IP.- In production, a
secretthat looks like an example or a repeated pattern is refused. The Next.js starter no longer falls back to a fixed secret in production: withoutEASY_CMS_SECRET(and without a database URL) it uses a random one per process and says so. - Multi-tenant: the people of a tenant, its admins included, no longer change shared collections and globals (those not listed in
collections/globals). List the ones they may change ineditShared. The deliveries page is for system admins only.
Security fixes
- Site members never read drafts, through any API (the Local API now applies the rule REST already had).
- A sign-up for an account still waiting for its email takes the new password and sends a new link; signing in with a provider confirms such an account and drops the password set before. Email confirmation links work once.
- Only system admins unlink someone else's single sign-on account.
- Request bodies sent without a length are refused once past the limit, instead of being read whole first. Endpoints get
req.text()for raw bodies (at most 1 MB). - Upload from URL also refuses IPv4 addresses inside IPv6 (NAT64,
::a.b.c.d) that are private. - Shop: changing an order's status (paid, sent, cancelled, refunded) needs update access to the order.
- Multi-tenant: a tenant's admins can't add site members or people with access to all tenants to it; adding someone who already has an account answers as for someone new and emails them.
Security: members and multi-tenant
Security fix: site members (auth.members, such as shop customers) never get the rights of users with access to all tenants, whatever publicReads is, and see only their own user record. Update now if you use the multi-tenant plugin together with members.
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.