Changelog

What changed, release by release

Every package is versioned with Changesets. Releases that need a migration say so.

1.23.1patch

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.
@easy-cms/admin
1.23.0minor

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; with auth.rbac, roles are given it as calendar.
  • 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/scheduled and cms.upcomingSchedules() take from, to, collection and docs.
@easy-cms/core@easy-cms/admin
1.22.0minor

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.
@easy-cms/core@easy-cms/admin
1.21.0minor

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: false leaves a field out.
@easy-cms/core@easy-cms/admin
1.20.1patch

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 in node_modules, not only its real path in .pnpm, so the server finds it by its package name.
@easy-cms/next
1.20.0minor

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.password changes, the next start ends the sessions of accounts that may no longer use a password and records auth.password.policy in the audit log.
  • Each break-glass sign-in is recorded as login.breakGlass and shown on the dashboard for a day.
@easy-cms/core@easy-cms/admin
1.19.0minor

API rate limits

Limit requests to the API per IP, signed-in user and API key, across instances.

  • rateLimit allows 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, 429 with Retry-After.
  • Counts are kept in memory and added to the new rate-limits table 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.
@easy-cms/corecreate-easy-cms
1.18.0minor

Rotate the secret

Replace EASY_CMS_SECRET without logging everyone out.

  • previousSecrets lists secrets used before secret: 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 with secret.
  • easy-cms audit:resign signs 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.
@easy-cms/coreeasy-cmscreate-easy-cms@easy-cms/plugin-form-builder@easy-cms/admin
1.17.0minor

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 sends x-easy-cms-timestamp (its own time) and x-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 and x-easy-cms-delivery; their timestamp and signature are new.
@easy-cms/core
1.16.0minor

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 with status, 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 unless prepare is false). Behind PgBouncer with prepared statements, set customPlans: false and plan_cache_mode on the role.
  • writeConnections on postgres(): 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).
  • like on a field with index: '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 every no longer runs twice when two instances run jobs at the same moment.
@easy-cms/core@easy-cms/drizzle@easy-cms/db-postgres@easy-cms/adminneeds migration
1.15.0minor

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 traceId and spanId. New docs: Observability, with a local collector, Jaeger and Prometheus in examples/otel.
@easy-cms/core@easy-cms/admin
1.14.0minor

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.
  • renderRichText renders tables with their header rows in <thead>; renderMarkdown writes GFM tables, a cell's paragraphs joined with <br>.
  • richTextToPlainText puts each cell on a line of its own, and search adapters index the text in tables.
@easy-cms/admin@easy-cms/richtext
1.13.0minor

Search adapters

Relevance-ranked search of published content, Thai included, through a search adapter.

  • search: memorySearch() in the config and search: { fields } on a collection index published documents as they change: with outbox: true, as a step of the outbox, retried until it succeeds.
  • cms.search() and GET <api>/search return 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 with Intl.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:reindex builds the index again. Adapters for search engines implement SearchAdapter. New docs: Search.
@easy-cms/coreeasy-cms@easy-cms/admin
1.12.0minor

Outbox for after hooks

After hooks, webhooks and cache purges that happen whenever the save does, retried until they succeed.

  • outbox: true writes an outbox entry in the same transaction as the save; a worker runs afterChange and afterDelete hooks, webhooks and httpCache.purge after 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 outbox table.
@easy-cms/core@easy-cms/drizzle@easy-cms/admineasy-cms
1.11.0minor

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) sends Cache-Control with s-maxage, a weak ETag with 304 answers, and cache tags as Cache-Tag and Surrogate-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 or httpCache: false.
  • purge receives the tags of what readers saw change. cacheTags is exported from @easy-cms/core and @easy-cms/next, for tagging fetches and revalidateTag.
@easy-cms/core@easy-cms/next
1.10.0minor

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 GIN pg_trgm index that like uses: 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_trgm extension; PGlite loads it. A warning at start says when the database's LC_CTYPE can't index Thai letters.
  • On SQLite the option does nothing.
@easy-cms/core@easy-cms/drizzle@easy-cms/db-postgres
1.9.0minor

Cursor pagination

Pages that take as long as the first however deep, for large collections.

  • find takes cursor (REST ?cursor=): '' for the first page, then each page's nextCursor. No page numbers or totals.
  • A cursor belongs to its query (where, sort, locale) and is refused with another.
  • maxPage caps how deep API clients may page with page; deeper requests get 400, pointing to cursor. The signed-in admin keeps its pages. No limit by default.
@easy-cms/core@easy-cms/drizzle@easy-cms/db-sqlite@easy-cms/db-postgres
1.8.0minor

Lists count less

Skip or reuse counts on large lists, for archives and infinite scroll.

  • find takes count: false (REST ?count=false): totalDocs and totalPages are null, and hasNextPage comes from one row more.
  • Counts of paginated lists are reused for countCacheSeconds (default 5; 0 counts every time). The documents are always current; only the total can be that old.
@easy-cms/core@easy-cms/drizzle@easy-cms/db-sqlite@easy-cms/db-postgres
1.7.1patch

drizzle-orm 0.45.4

The database adapters use drizzle-orm 0.45.4.

  • @easy-cms/drizzle, @easy-cms/db-sqlite and @easy-cms/db-postgres use drizzle-orm 0.45.4.
@easy-cms/drizzle@easy-cms/db-sqlite@easy-cms/db-postgres
1.7.0minor

Health checks

Liveness and readiness checks in every setup, for load balancers, Kubernetes and Docker.

  • <api>/healthz answers ok while the process runs, without touching the database.
  • <api>/readyz answers 200 when the CMS started, its database answers and no migration is pending, and 503 naming 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 /readyz at the root.
  • healthz and readyz are now reserved collection slugs and endpoint paths.
  • The official Docker image, ghcr.io/maritonx/easy-cms, checks /readyz. New docs: Health checks.
@easy-cms/coreeasy-cms
1.6.0minor

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' }, or EASY_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-Id header; 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: false turns that off).
  • Each run of the background jobs logs with its own runId. easy-cms serve follows the setting.
  • The readable default now prints an error's stack under its line, and jsonLogger() is exported.
@easy-cms/coreeasy-cms@easy-cms/plugin-graphql@easy-cms/plugin-ecommerce
1.5.0minor

Postgres connection options

Tune the Postgres pool, and run behind PgBouncer and other poolers.

  • postgres() takes prepare, statementTimeoutMs, idleTimeoutMs, connectTimeoutMs and applicationName.
  • Connections are named easy-cms in pg_stat_activity (or PGAPPNAME). Options left unset still come from the URL and the PG* 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.
@easy-cms/db-postgres
1.4.3patch

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.
@easy-cms/admin
1.4.2patch

Types for every locale

Reads with locale: 'all' are typed as they return.

  • find, findById and findGlobal with locale: 'all' type localized fields as { [locale]: value } maps of the config's locales. CollectionDocument and GlobalDocument take a third parameter, true, for that shape.
@easy-cms/core
1.4.1patch

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.
@easy-cms/core@easy-cms/admin
1.4.0minor

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' jobs with 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[].at runs a job at a time of day ('02:00'), daily or every few days, and the new timeZone option sets the zone, for backups.at too.
  • New guide: Jobs.
@easy-cms/core@easy-cms/admin
1.3.0minor

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.
  • jobs keep their runs in a new internal job-runs table. Apps with jobs (such as shops) need one migration: easy-cms migrate:create, then easy-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.
@easy-cms/core@easy-cms/adminneeds migration
1.2.0minor

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.
@easy-cms/admin
1.1.0minor

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 …CreateInput types.
  • Pickers say "Loading…" while they wait, instead of "No files yet" or "No matches".
@easy-cms/plugin-ecommerce@easy-cms/plugin-graphql@easy-cms/admin
1.0.1patch

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 whose describe() says delivery: 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/core@easy-cms/admin
1.0.0major

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.

@easy-cms/core
1.0.0-rc.0major

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.

@easy-cms/core
0.68.4patch

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.

@easy-cms/plugin-ecommerce
0.68.3patch

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.

@easy-cms/plugin-ecommerce
0.68.2patch

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.

@easy-cms/plugin-form-builder
0.68.1patch

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.

@easy-cms/plugin-mcp
0.68.0minor

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.

@easy-cms/plugin-graphql
0.67.2patch

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.

@easy-cms/plugin-ecommerce
0.67.1patch

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.

@easy-cms/plugin-ecommerce
0.67.0minor

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.com for Blob), and the admin allows them in connect-src (securityHeaders(connectSrc); adminHandlerFor and 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).
@easy-cms/core@easy-cms/admin@easy-cms/storage-s3@easy-cms/storage-vercel-blob
0.66.1patch

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.

@easy-cms/admin
0.66.0minor

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.
  • unique on a field inside a group, array or block logs a config warning: it is enforced on top-level fields only.
@easy-cms/core@easy-cms/admin@easy-cms/plugin-multi-tenant
0.65.0minor

Operations: encrypted backups, STARTTLS, unpaid orders

  • GraphQL introspection is off in production by default (introspection: true to keep it); easy-cms generate:graphql still writes the schema.
  • backups.encryptionKey encrypts backup files (AES-256-GCM, .db.gz.enc); admin downloads are decrypted and easy-cms backup:decrypt <file> decrypts one by hand. A backup storage with public URLs logs a warning; create-easy-cms adds backups/ 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, or false to wait for staff (e.g. cash on delivery). Orders get an expiresAt field: run easy-cms migrate:create in production. Orders made before the upgrade aren't affected.
@easy-cms/core@easy-cms/email-smtp@easy-cms/plugin-graphql@easy-cms/plugin-ecommerceneeds migration
0.64.0minor

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 sends Clear-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-Host only with trust-proxy (trustProxy, --trust-proxy) or on Vercel and Netlify; createRestHandler takes trustProxy.
  • 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 Secure over HTTPS.
@easy-cms/core@easy-cms/admin@easy-cms/plugin-seo
0.63.0minor

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 answers 403.
  • 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-usage counts only documents the user may read (cms.mediaUsage(ids, access)).
  • Shop: POST <api>/shop/confirm is only for whoever paid: the customer, or the guest with the cart's secret (the client sends it); others get 404.
@easy-cms/core@easy-cms/plugin-ecommerce
0.62.0minor

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).
  • cronSecret must be at least 32 characters (a shorter CRON_SECRET is a warning), and <api>/jobs/run with the session cookie takes POST only.
@easy-cms/core@easy-cms/admin
0.61.0minor

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.

@easy-cms/core@easy-cms/admin
0.49.0minor

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/plugin for plugin authors
  • Error codes on every EasyCMSError and REST error; Retry-After on 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/*, PATCH for globals
  • Plugins and adapters declare apiVersion; onRequest lists; flags for plugin commands; CLI admin:create and jobs:run; plugin option renames
@easy-cms/core
0.48.0minor

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.
  • trustProxy takes the last X-Forwarded-For address (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 secret that looks like an example or a repeated pattern is refused. The Next.js starter no longer falls back to a fixed secret in production: without EASY_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 in editShared. The deliveries page is for system admins only.
@easy-cms/core@easy-cms/admin@easy-cms/auth-oauth@easy-cms/nuxt
0.47.4patch

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.
@easy-cms/core@easy-cms/plugin-ecommerce@easy-cms/plugin-multi-tenant
0.47.3patch

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.

@easy-cms/plugin-multi-tenant
0.47.2patch

MCP security update, and safer packing

  • @easy-cms/plugin-mcp needs @modelcontextprotocol/sdk 1.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.json still has workspace: ranges, which those tools would publish as they are; pnpm, Yarn 2+ and Bun rewrite them and pass.
@easy-cms/plugin-mcp@easy-cms/core
0.47.1patch

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.

@easy-cms/plugin-ecommerce
0.47.0minor

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: false keeps 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.
@easy-cms/core@easy-cms/admin@easy-cms/plugin-seo
0.46.0minor

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 (isLoggedIn no longer counts them; isSignedIn does); update() with where (compare and set) and cms.increment(); jobs that run with the scheduled jobs; events and cms.emit() for webhooks; menu headings of your own (admin.group with a label); list cells get their row's doc; plugin collections are typed in configs without collections. The form builder's spam checks moved to the core (formToken, checkFormToken, rateKeys, honeypotName, verifyTurnstile).
  • Signing up adds an emailVerified field to users: create a migration (easy-cms migrate:create) before deploying.
@easy-cms/core@easy-cms/admin@easy-cms/plugin-ecommerceneeds migration
0.45.0minor

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: filterOptions on upload fields; uniqueWithin with several fields; cms.uniqueScope(); audit.scope; admin.confirmDelete on collections; admin.defaultValue, admin.column and admin.allowCreate on fields; relationship columns in lists.
  • Projects with audit get a scope column: create a migration (easy-cms migrate:create) before deploying.
@easy-cms/core@easy-cms/admin@easy-cms/plugin-multi-tenantneeds migration
0.44.0minor

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: onRequest in the config gives each request a context (and may change its user), which access rules, hooks, filterOptions and the Local API (context, cms.forRequest(request)) receive; scoped users and isSystemAdmin(); globals with a value per scope; uniqueWithin on any unique field, with a unique index per scope; and admin.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() and llmsFullTxt() take a context.
  • Projects with the nested-docs plugin get a unique index on (parent, slug): create a migration (easy-cms migrate:create) before deploying.
@easy-cms/core@easy-cms/admin@easy-cms/plugin-multi-tenant@easy-cms/plugin-seoneeds migration
0.43.0minor

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.

@easy-cms/plugin-graphql
0.42.0minor

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.

@easy-cms/core@easy-cms/admin@easy-cms/next@easy-cms/nuxt
0.41.0minor

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.

@easy-cms/core@easy-cms/admin@easy-cms/storage-s3@easy-cms/storage-vercel-blob
0.40.0minor

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.

@easy-cms/core@easy-cms/admin
0.39.0minor

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.

@easy-cms/core@easy-cms/admin
0.38.0minor

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.

@easy-cms/core@easy-cms/admin
0.37.2patch

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").

@easy-cms/core
0.37.1patch

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.

@easy-cms/storage-vercel-blob
0.37.0minor

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: the EASY_CMS_SETUP_CODE environment 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/init says 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.
@easy-cms/core@easy-cms/admin@easy-cms/storage-vercel-blob@easy-cms/storage-netlify-blobs
0.36.1patch

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.

@easy-cms/admin
0.36.0minor

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 internal audit-logs table: 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 keep days, 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.
@easy-cms/core@easy-cms/adminneeds migration
0.35.0minor

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() and oidc({ issuer, clientId, clientSecret }), with the Authorization Code flow, PKCE, state and nonce (oauth4webapi). Client IDs and secrets come from environment variables.
  • auth.providers adds "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/ and GET <api>/admin/sso; the auth slug and endpoint root are reserved.
  • With providers, the internal user-identities table is added: create a migration (easy-cms migrate:create sso).
@easy-cms/core@easy-cms/admin@easy-cms/auth-oauthneeds migration
0.34.0minor

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 with admin.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 get createdBy from 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.read in 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> (or none); Local API: cms.delete('users', id, { transferTo }). New admin endpoint: GET <api>/admin/owned/:userId.
  • Projects with auth.rbac get a created_by column 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 keep finished backups (it briefly showed one more).
@easy-cms/core@easy-cms/adminneeds migration
0.33.0minor

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.roles always 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/schema now has permissions.publish and views; dashboard panels take an optional label, and with rbac each needs its own tag. New admin-only endpoints under <api>/admin/roles.
  • Adds the internal user-roles table when rbac is on: create a migration (easy-cms migrate:create roles). The user-roles slug is now reserved.
@easy-cms/core@easy-cms/adminneeds migration
0.32.0minor

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 every day or week at at (server time), keep the newest keep (7). Without every, 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 with backups.sqlite (sqlite from @easy-cms/db-sqlite).
  • Stored privately: backups/ by default (never the uploads folder, never served) or backups.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/run cron); 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 .gz name.
  • Adds the internal database-backups table: create a migration (easy-cms migrate:create backups).
@easy-cms/core@easy-cms/admineasy-cmsneeds migration
0.31.0minor

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).
  • EmailAdapter gets two optional methods for this: describe() (never secrets) and verify(). smtp() and consoleEmail() have them.
  • New admin-only endpoints: GET <api>/admin/email, POST <api>/admin/email/verify, POST <api>/admin/email/test.
@easy-cms/core@easy-cms/admin@easy-cms/email-smtp
0.30.0minor

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.
@easy-cms/core@easy-cms/admin
0.29.0minor

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), no email, and no serverURL in 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. EmailAdapter gets an optional name (smtp, console). Core exports VERSION.
  • In development, the admin's HTML is read on each request, so a rebuilt admin shows up without a restart.
@easy-cms/core@easy-cms/admin
0.28.0minor

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, and POST <api>/media with 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 in allowedHosts (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, unless allowPrivate: true; 15 seconds and maxFileSize at 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, full ones below.
@easy-cms/core@easy-cms/admin
0.27.0minor

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. group puts them under Content or Settings in the menu, or leaves them out (false).
  • admin.dashboard: panels on the dashboard after the built-in ones, half or full width.
  • access: ({ user }) => boolean on 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 pages route (subpath, query). They can send a navigate event to open an admin path, e.g. to keep a tab or date range in the address.
  • New menu icons: chart-column and chart-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.
@easy-cms/core@easy-cms/admin@easy-cms/plugin-form-builder
0.26.0minor

Custom field types

  • fieldTypes and defineFieldType() let packages add a field type: stored, queried and translated like a built-in base type (text, number, json…), with its own validate, 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), so type: 'color' is accepted with its options and documents get the right value type. generate:types uses the type's typescript.
  • admin.cell on 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/fields with color: #rrggbb (or #rrggbbaa with alpha: true), a picker with presets, and swatches in lists.
@easy-cms/core@easy-cms/admin@easy-cms/fields
0.25.0minor

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.path and page.breadcrumbs (nested pages, with the names you give), the redirects collection with its to_<collection> fields, and the forms and form-submissions collections.
  • findByPath() returns the page typed from your config; getTree() and rebuildNestedDocs() take your typed cms.
  • New types in core: PluginTypes, TypedPlugin, DocumentOf and SlugOf for helpers that take an EasyCMS<C>.
@easy-cms/core@easy-cms/plugin-seo@easy-cms/plugin-nested-docs
0.24.0minor

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 need email in the config, and serverURL in production (a request's Host could be forged); without them the admin doesn't offer them.
  • REST: POST /users/forgot-password, GET and POST /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 a password: a user without one can't log in until they set it from an invitation.
@easy-cms/core@easy-cms/admin
0.23.0minor

Image galleries

Upload fields with several files.

  • upload with hasMany: true keeps 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 be localized, and queries like where: { gallery: { in: [id] } } work. Needs a migration for the new field.
  • mimeTypes on upload fields, e.g. ['image/*']: the media picker offers only those files, and saving refuses others ("must be an image").
  • minRows / maxRows on upload and relationship fields with hasMany.
  • An empty list now counts as fewer than minRows (arrays and blocks too); drafts may still be incomplete.
@easy-cms/core@easy-cms/adminneeds migration
0.22.2patch

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 /admin answered 500 with "Cannot find module '@easy-cms/next/package.json'".
  • It now uses the path @easy-cms/admin reports for itself, which the build traces, and falls back to the old lookup when that package was bundled.
@easy-cms/next
0.22.1patch

Package READMEs

Every package now explains what it does, how to install it with npm, pnpm, Yarn or Bun, and links to its guide.

@easy-cms/core
0.22.0minor

npm, pnpm, Yarn and Bun

  • create-easy-cms --pm npm|pnpm|yarn|bun picks the package manager. Without it, the project's packageManager field, 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.yml with nodeLinker: node-modules.
  • The docs show every command for all four and keep the one you pick.
create-easy-cms@easy-cms/core@easy-cms/nuxt
0.21.0minor

Nested pages

  • New @easy-cms/plugin-nested-docs: pages inside pages, each with a parent, its full path and breadcrumbs, per language.
  • Moving a page updates the pages under it, and the redirects plugin redirects their old addresses.
  • Relationship filterOptions and slug uniqueWithin.
@easy-cms/plugin-nested-docs@easy-cms/coreneeds migration
0.20.0minor

Forms and email

  • email in 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.
@easy-cms/email-smtp@easy-cms/plugin-form-builderneeds migration
0.19.0minor

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 16 proxy.ts.
@easy-cms/plugin-redirectsneeds migration