<!-- https://easy-cms-website.vercel.app/docs.md -->

# What is Easy CMS?

::: info What you'll learn
What Easy CMS is, how its parts fit together, the words the rest of the guide uses, and when
it is (and isn't) a good fit.
:::

Easy CMS is a headless CMS you install into your Nuxt or Next.js app with npm. It runs inside
your app's server, so there is no separate CMS service to host, pay for or keep in sync:

- **You define content in code.** Collections, fields, access rules and hooks go in
  `easy-cms.config.ts`, which you review and version like any other code.
- **Editors get an admin** at `/admin`: lists, generated forms, rich text, a media library with
  folders and previews, drafts, history, live preview, in Thai and English.
- **Teams stay in control**: [roles and permissions](./roles) set in the admin,
  [single sign-on](./sso) and an [audit log](./audit-log) of who changed what.
- **Your pages read content** with the typed [Local API](./local-api) on the server
  (`useEasyCMS()` in Nuxt, `getEasyCMS(config)` in Next.js), and any client can use the
  [REST API](./rest-api) at `/api/cms`.
- **Data lives in your database**: SQLite (a file, or Turso) or Postgres (a server, or PGlite
  for local development). Easy CMS only touches tables with its prefix (`ecms_`).

<Screenshot name="edit" alt="Editing a post in the Easy CMS admin" />

## How it fits together

```
┌────────────────── your Nuxt / Next.js app ──────────────────┐
│  pages ──► Local API (typed)      browsers ──► /api/cms      │
│  editors ──► /admin                                          │
│                  @easy-cms/nuxt | @easy-cms/next             │
│                        @easy-cms/core                        │
│          SQLite / Postgres (ecms_ tables) · uploads          │
└──────────────────────────────────────────────────────────────┘
```

- `@easy-cms/core` holds everything that matters: the config, validation, access control,
  hooks, drafts, the Local API and the REST API. It is plain TypeScript on Web-standard
  `Request`/`Response`, with no framework inside.
- An **adapter** mounts it in your framework: the [Nuxt module](./nuxt), the
  [Next.js route handlers](./next), or the [standalone server](./standalone) for any other
  frontend.
- A **database adapter** (`@easy-cms/db-sqlite` or `@easy-cms/db-postgres`) stores documents in
  real tables and columns, so you can still query them with SQL.
- The **admin** (`@easy-cms/admin`) is a prebuilt Vue app. You don't build it; it reads your
  config from the API and draws the forms.

## Words used in this guide

| Word | Meaning |
|---|---|
| **Collection** | A type of content with many documents: posts, products, pages. |
| **Global** | A single document: site settings, a menu, a footer. |
| **Field** | One value of a document: a title, a date, an image. See [Fields](./fields). |
| **Document** | One item in a collection, with an `id`, `createdAt` and `updatedAt`. |
| **Draft** | An unpublished version editors can keep working on. See [Drafts](./drafts). |
| **Local API** | Functions like `cms.find()` you call in server code. No HTTP involved. |
| **REST API** | The same operations over HTTP at `/api/cms`, for browsers and other apps. |
| **GraphQL** | The same again as a GraphQL API at `/api/cms/graphql`, from a plugin. See [GraphQL](./graphql). |
| **Access rule** | A function deciding who may read or change what. See [Access control](./access-control). |
| **Hook** | Your code that runs when documents change. See [Hooks](./hooks). |
| **Plugin** | A function that adds fields, endpoints or admin UI. See [Plugins](./plugins). |

## When it fits

- You build sites with Nuxt or Next.js and want content editing without another service.
- You build with Vite, React, Vue or a static site generator and want a small, self-hosted CMS
  backend: run it as a [standalone server](./standalone).
- You like your schema in TypeScript and in git, reviewed in pull requests.
- You deploy one app server with a database (a VPS, a container, a platform like Railway or
  Render, or serverless with Postgres).
- Your editors work in Thai, English or both.
- You want to try it on Vercel or Netlify first: [deploy a starter in one click](./one-click-deploy).

## When it doesn't

- Editors must change the content model themselves without a developer: here the model is code.
- You need edge runtimes: Easy CMS needs Node.js 22.12 or newer.
- You need a large marketplace of ready-made integrations today.

## Next steps

- [Getting started](./getting-started): add Easy CMS to a project in a few minutes.
- [Configuration](./configuration): every option of `easy-cms.config.ts`.

---

<!-- https://easy-cms-website.vercel.app/docs/one-click-deploy.md -->

# One-click deploy

::: info What you'll learn
How to get a site with its admin running on Vercel or Netlify in a few clicks, and what to do
next.

**Before this page:** nothing. To add Easy CMS to your own app instead, see
[Getting started](./getting-started).
:::

[![Deploy with Vercel](https://vercel.com/button)](https://vercel.com/new/clone?repository-url=https%3A%2F%2Fgithub.com%2Fmaritonx%2Feasy-cms%2Ftree%2Fmain%2Ftemplates%2Fnext-starter&project-name=easy-cms&repository-name=easy-cms&env=EASY_CMS_SETUP_CODE&envDescription=A+code+you+choose%3A+you+type+it+to+create+the+first+admin+at+%2Fadmin&envLink=https%3A%2F%2Feasy-cms-website.vercel.app%2Fdocs%2Fone-click-deploy&stores=%5B%7B%22type%22%3A%22integration%22%2C%22integrationSlug%22%3A%22neon%22%2C%22productSlug%22%3A%22neon%22%2C%22protocol%22%3A%22storage%22%7D%2C%7B%22type%22%3A%22blob%22%2C%22access%22%3A%22public%22%7D%5D)
[![Deploy to Netlify](https://www.netlify.com/img/deploy/button.svg)](https://app.netlify.com/start/deploy?repository=https://github.com/maritonx/easy-cms&create_from_path=templates/next-starter)

Either button copies the [starter](https://github.com/maritonx/easy-cms/tree/main/templates/next-starter)
to a new repository of yours and deploys it: a Next.js blog with its admin (posts, categories,
media, SEO, [roles](./roles) and an [audit log](./audit-log)), with sample posts.

## What you get

| | Vercel | Netlify |
|---|---|---|
| Database | Neon Postgres, created by the button | Netlify Database (Postgres), created at the first deploy |
| Uploads | Vercel Blob, created by the button | Netlify Blobs, created on first use |
| You type | a setup code | a setup code |

The only thing to fill in is the **setup code** (`EASY_CMS_SETUP_CODE`): any phrase you choose.
On a fresh site, whoever opens `/admin` first could otherwise make themselves admin; with the code,
only you can.

## After deploying

1. Open `/admin` on the new site.
2. Create the first admin: your name, email, a password, and the setup code.
3. Edit or delete the sample posts, and write your own. Invite your team from **Settings → Users**.

## If something doesn't work

**Uploads fail, saying to connect a Blob store (Vercel).** The deploy has no `BLOB_READ_WRITE_TOKEN`:
the Blob store wasn't added in the deploy form, or it was connected after the deploy started.

1. In the project on Vercel, open **Storage**. If there is no Blob store, **Create** one: **Blob**,
   with **Public** access.
2. On the store, **Connect Project**, choose this project and every environment.
3. **Settings → Environment Variables** now lists `BLOB_READ_WRITE_TOKEN`. Don't copy it anywhere
   else: anyone with it can change your files.
4. **Deployments** → **⋯** → **Redeploy**. New environment variables only reach new deployments.

**The site fails with "No database".** The platform gave no database URL. On Netlify, Netlify
Database is created at the first deploy because the starter installs `@netlify/database`; it needs
a credit-based plan, and **Data & Storage → Database** shows it. Older sites made from the starter
used Netlify DB (beta), which can no longer be created: update `package.json` and
`easy-cms.config.ts` from the [starter](https://github.com/maritonx/easy-cms/tree/main/templates/next-starter),
or set `DATABASE_URL` to any Postgres, then redeploy.

**`/admin` doesn't ask for a setup code.** `EASY_CMS_SETUP_CODE` isn't set: add it in the project's
environment variables and redeploy before anyone creates the first admin.

## For real use

- **Set `EASY_CMS_SECRET`** in the project's environment variables (`openssl rand -hex 32`), then
  redeploy. Until then the starter makes one from the database URL, so a one-click deploy works at
  once; it is as secret as the database password, and changes if that does.
- **Change the content model** in `easy-cms.config.ts` in your new repository, create a migration
  (`npx easy-cms migrate:create <name>`), and push: each deploy runs `easy-cms migrate` first.
- **Email** for invitations and forgotten passwords: see [Email](./email).
- **Your domain:** set it in the platform, and `serverURL` in the config.

## How it works

The build runs `easy-cms migrate`, adds the sample posts while the database has none, then
`next build`. The config picks the database from `DATABASE_URL` (Vercel) or `NETLIFY_DB_URL`
(Netlify Database, which Netlify creates because the starter installs `@netlify/database`; it
needs a credit-based Netlify plan), and uploads from `BLOB_READ_WRITE_TOKEN`
([`@easy-cms/storage-vercel-blob`](./uploads#vercel-blob)) or Netlify
([`@easy-cms/storage-netlify-blobs`](./uploads#netlify-blobs)); without them, it uses PGlite and the
`uploads` folder, for developing.

Other databases work too: Supabase or any Postgres through `DATABASE_URL`, or Turso with
`@easy-cms/db-sqlite`. See [Databases](./databases).

## Next steps

- [Deploy](./deploy/): set up Vercel, Netlify, Docker or a VPS yourself.
- [Migrations & deployment](./deployment): migrations and environment variables.
- [Tutorial](./tutorial): build a site with Easy CMS step by step.

---

<!-- https://easy-cms-website.vercel.app/docs/getting-started.md -->

# Getting started

::: info What you'll learn
Add Easy CMS to a Nuxt or Next.js project (or run it on its own), create the first admin and read content in a page.

**Before this page:** [What is Easy CMS?](./what-is-easy-cms).
:::

<Screenshot name="dashboard" alt="The admin dashboard after logging in" />

You need a Nuxt 4 or Next.js 15+ project and Node.js 22.12 or newer (22.19 or newer with Nuxt). Using another framework, or none?
Run Easy CMS as a [standalone server](./standalone).

::: warning npm 9 can't create a Nuxt app
Check `npm --version` first: with npm 9, as Ubuntu 26.04's own `nodejs` package has (npm 9.2),
`npm create nuxt` stops with `Cannot read properties of null (reading 'edgesOut')`. Update npm
with `sudo npm install -g npm`, or install Node from
[NodeSource](https://github.com/nodesource/distributions) or [nvm](https://github.com/nvm-sh/nvm),
then try again. Next.js apps and the standalone server install fine with npm 9.
:::

No project yet? Create one with `npx create-next-app@latest my-app --yes` or
`npm create nuxt@latest my-app`.

## Add Easy CMS

In your project directory:

```bash [pm]
npx create-easy-cms
```

It detects Nuxt or Next.js, asks which database to use, installs the packages and:

- creates `easy-cms.config.ts` with a sample `posts` collection and a `site` global
- adds a random `EASY_CMS_SECRET` to `.env`
- ignores `cms.db`, `uploads/`, `backups/` and `.pglite/` in `.gitignore`
- **Nuxt:** adds `@easy-cms/nuxt` to `modules`
- **Next.js:** creates `app/api/cms/[[...path]]/route.ts` and `app/admin/[[...path]]/route.ts`
  and wraps `next.config.ts` in `withEasyCMS()`

Options: `--db sqlite|postgres`, `--pm npm|pnpm|yarn|bun`, `--yes` (accept defaults), `--skip-install`. Running it again is safe. Commands on these pages have a tab for each package manager; the one you pick is kept.

## Run it

```bash [pm]
npm run dev
```

Open `http://localhost:3000/admin`. With no users yet, the admin
asks you to create the first admin. You can also run `npx easy-cms admin:create`.

In development the database schema follows your config automatically. Edit
`easy-cms.config.ts`, save, and the admin shows the new fields.

## Read content

::: code-group

```ts [Nuxt: server/api/posts.get.ts]
export default defineEventHandler(async () => {
  const cms = await useEasyCMS()
  return cms.find('posts', { sort: '-createdAt', limit: 10 })
})
```

```tsx [Next.js: app/page.tsx]
import { connection } from 'next/server'
import { Suspense } from 'react'
import { getEasyCMS } from '@easy-cms/next'
import config from '@/easy-cms.config'

export default function Home() {
  return (
    <Suspense fallback={<p>Loading…</p>}>
      <Posts />
    </Suspense>
  )
}

async function Posts() {
  // Read on each request, so the page shows what was just published.
  await connection()
  const cms = await getEasyCMS(config)
  const { docs } = await cms.find('posts', { sort: '-createdAt', limit: 10 })
  return <ul>{docs.map((post) => <li key={post.id}>{post.title}</li>)}</ul>
}
```

:::

**Nuxt:** if `server/` is a new folder, restart `npm run dev` so Nuxt picks it up.

**Next.js:** the page reads the CMS on each request: `await connection()` in a component inside
`<Suspense>`. This works whether or not `cacheComponents` is on in `next.config.ts` (new apps from
`create-next-app` have it on, and there `export const dynamic` is not allowed).

`find` returns only published documents by default, and `post.title` is typed as `string`
because the config says the field is required text.

## Next steps

- Model your content: [Configuration](./configuration) and [Fields](./fields)
- Decide who can do what: [Access control](./access-control)
- Before your first deploy: [Migrations & deployment](./deployment)

---

<!-- https://easy-cms-website.vercel.app/docs/tutorial.md -->

# Tutorial: build a blog

::: info What you'll learn
Build a small blog from an empty project to production: content model, admin, pages, drafts,
live preview, SEO and deployment. It takes about 30 minutes. Pick Nuxt or Next.js in the code
tabs; everything else is the same.

**Before this page:** nothing. You need Node.js 22.12 or newer.
:::

## 1. Create the project

::: code-group

```bash [Nuxt]
npx nuxi@latest init my-blog
cd my-blog
npx create-easy-cms --db sqlite --yes
```

```bash [Next.js]
npx create-next-app@latest my-blog --ts --app --no-src-dir
cd my-blog
npx create-easy-cms --db sqlite --yes
```

:::

`create-easy-cms` installs the packages, writes `easy-cms.config.ts`, puts a random
`EASY_CMS_SECRET` in `.env` and connects the admin and the API to your app. SQLite keeps
everything in one file (`cms.db`), which is all you need until you deploy.

## 2. Describe the content

Replace `easy-cms.config.ts` with a blog: categories, and posts with drafts and history.

```ts [easy-cms.config.ts]
import { defineConfig } from '@easy-cms/core'
import { sqlite } from '@easy-cms/db-sqlite'

export default defineConfig({
  secret: process.env.EASY_CMS_SECRET ?? '',
  db: sqlite({ url: process.env.DATABASE_URL ?? 'file:./cms.db' }),
  collections: [
    {
      slug: 'categories',
      admin: { order: 2, editIn: 'drawer' }, // small: edit in a panel over the list
      useAsTitle: 'name',
      access: { read: () => true },
      fields: [
        { name: 'name', type: 'text', required: true },
        { name: 'slug', type: 'slug', from: 'name' },
      ],
    },
    {
      slug: 'posts',
      admin: { order: 1 }, // first in the menu
      drafts: true, // draft / published
      versions: true, // history and restore
      useAsTitle: 'title',
      access: {
        // Visitors see published posts; logged-in editors see drafts too.
        read: ({ user }) => (user ? true : { status: { equals: 'published' } }),
      },
      fields: [
        { name: 'title', type: 'text', required: true, maxLength: 120 },
        { name: 'slug', type: 'slug', from: 'title' },
        { name: 'excerpt', type: 'textarea', maxLength: 300 },
        { name: 'cover', type: 'upload' },
        { name: 'body', type: 'richText' },
        { name: 'category', type: 'relationship', to: 'categories', admin: { position: 'sidebar' } },
        { name: 'publishedAt', type: 'date', admin: { position: 'sidebar' } },
      ],
    },
  ],
})
```

In development the database follows the config: save the file and the tables change.

## 3. Open the admin

```bash [pm]
npm run dev
```

Go to `http://localhost:3000/admin`. There are no users yet, so the admin asks for the first
admin account. Then you land on the dashboard.

<Screenshot name="dashboard" alt="The dashboard after logging in" />

Create a category or two (they open in a panel), then a post: write a title (the slug fills in
by itself), an excerpt, some body text, pick a cover and a category, and press **Publish**.

<Screenshot name="edit" alt="Writing a post" />

## 4. List the posts

::: code-group

```ts [Nuxt: server/api/posts.get.ts]
export default defineEventHandler(async () => {
  const cms = await useEasyCMS()
  const { docs } = await cms.find('posts', { sort: '-publishedAt', limit: 20 })
  return docs
})
```

```tsx [Next.js: app/page.tsx]
import { getEasyCMS } from '@easy-cms/next'
import Link from 'next/link'
import { connection } from 'next/server'
import { Suspense } from 'react'
import config from '@/easy-cms.config'

export default function Home() {
  return (
    <Suspense>
      <Posts />
    </Suspense>
  )
}

async function Posts() {
  await connection() // read on each request
  const cms = await getEasyCMS(config)
  const { docs } = await cms.find('posts', { sort: '-publishedAt', limit: 20 })
  return (
    <ul>
      {docs.map((post) => (
        <li key={post.id}>
          <Link href={`/posts/${post.slug}`}>{post.title}</Link>
        </li>
      ))}
    </ul>
  )
}
```

:::

For Nuxt, show them in `app/pages/index.vue`:

```vue
<script setup lang="ts">
const { data: posts } = await useFetch('/api/posts')
</script>

<template>
  <ul>
    <li v-for="post in posts" :key="post.id">
      <NuxtLink :to="`/posts/${post.slug}`">{{ post.title }}</NuxtLink>
    </li>
  </ul>
</template>
```

`find` returns published posts only, and `post.title` is typed as `string` straight from the
config: try misspelling it.

## 5. Show one post

Install the rich text renderer:

```bash [pm]
npm install @easy-cms/richtext
```

::: code-group

```ts [Nuxt: server/api/posts/[slug].get.ts]
export default defineEventHandler(async (event) => {
  const cms = await useEasyCMS()
  const user = await useEasyCMSUser(event)
  const { docs } = await cms.find('posts', {
    where: { slug: { equals: getRouterParam(event, 'slug') } },
    limit: 1,
    user, // editors see drafts, visitors only published posts
    overrideAccess: false,
    draft: user !== null,
  })
  if (!docs[0]) throw createError({ statusCode: 404 })
  return docs[0]
})
```

```tsx [Next.js: app/posts/[slug]/page.tsx]
import { getEasyCMS, getEasyCMSUser } from '@easy-cms/next'
import { renderRichText } from '@easy-cms/richtext'
import { notFound } from 'next/navigation'
import { connection } from 'next/server'
import { Suspense } from 'react'
import config from '@/easy-cms.config'

type Params = Promise<{ slug: string }>

export default function PostPage({ params }: { params: Params }) {
  return (
    <Suspense>
      <Post params={params} />
    </Suspense>
  )
}

async function Post({ params }: { params: Params }) {
  await connection() // read on each request
  const { slug } = await params
  const cms = await getEasyCMS(config)
  const user = await getEasyCMSUser(config)
  const { docs } = await cms.find('posts', {
    where: { slug: { equals: decodeURIComponent(slug) } },
    limit: 1,
    user, // editors see drafts, visitors only published posts
    overrideAccess: false,
    draft: user !== null,
  })
  const post = docs[0]
  if (!post) notFound()
  return (
    <article>
      <h1>{post.title}</h1>
      <div dangerouslySetInnerHTML={{ __html: renderRichText(post.body) }} />
    </article>
  )
}
```

:::

For Nuxt, the page `app/pages/posts/[slug].vue`:

```vue
<script setup lang="ts">
import { renderRichText } from '@easy-cms/richtext'

const route = useRoute()
const { data: post } = await useFetch(`/api/posts/${route.params.slug}`, {
  headers: useRequestHeaders(['cookie']), // so editors see their drafts
})
const html = computed(() => renderRichText(post.value?.body))
</script>

<template>
  <article v-if="post">
    <h1>{{ post.title }}</h1>
    <div v-html="html" />
  </article>
</template>
```

`renderRichText` escapes everything, so its HTML is safe to insert.

## 6. Drafts and history

Open your post, change the title and press **Save draft**. The site still shows the published
version; the admin marks the post as having unpublished changes. Press **Publish** when ready.
Every save is kept in **History** on the right: open an older version and restore it.

Want it live at 9:00 tomorrow? Add `schedule: true` to the posts collection, and use
**Schedule** next to Publish. See [Drafts, versions & scheduling](./drafts).

## 7. Live preview

Tell Easy CMS where a post is shown, and let the page follow the form:

```ts
// in the posts collection
preview: ({ doc }) => (doc.slug ? `/posts/${doc.slug}` : null),
```

::: code-group

```vue [Nuxt: app/pages/posts/[slug].vue]
<script setup lang="ts">
// …after useFetch
useLivePreview(post) // auto-imported
</script>
```

```tsx [Next.js: app/posts/[slug]/post-view.tsx]
'use client'
import { useLivePreview } from '@easy-cms/next/live-preview'

// Render the post here, and use <PostView post={post} /> in the page.
export function PostView({ post: initial }: { post: { title: string } }) {
  const post = useLivePreview(initial)
  return <h1>{post.title}</h1>
}
```

:::

Press **Preview** in the editor: the real page appears next to the form and changes as you type.

<Screenshot name="preview" alt="Live preview next to the form" />

## 8. SEO

```bash [pm]
npm install @easy-cms/plugin-seo
```

```ts [easy-cms.config.ts]
import { seoPlugin } from '@easy-cms/plugin-seo'

export default defineConfig({
  // …
  plugins: [
    seoPlugin({
      collections: ['posts'],
      generateTitle: ({ doc }) => `${doc.title} | My Blog`,
      generateDescription: ({ doc }) => doc.excerpt as string,
    }),
  ],
})
```

Posts now have an SEO group with length meters, a search preview and Generate buttons. Use
`seoMeta(post)` in the post page for the `<title>`, description and share tags; see
[SEO](./seo#on-your-pages).

## 9. Deploy

1. **Create the first migration.** Production never changes the database on its own:

   ```bash [pm]
   npx easy-cms migrate:create init
   git add easy-cms/migrations && git commit -m "CMS schema"
   ```

2. **Pick a database.** SQLite works on a server with a persistent disk. On serverless
   platforms, switch to Postgres: `npm install @easy-cms/db-postgres` and
   `db: postgres({ url: process.env.DATABASE_URL })`. See [Databases](./databases).

3. **Set the environment** on the server: `EASY_CMS_SECRET` (a new random value, not the one
   from your laptop), `DATABASE_URL`, and `NODE_ENV=production`.

4. **Migrate, then start**, on every deploy:

   ```bash [pm]
   npx easy-cms migrate
   npm run build && npm start
   ```

5. **Store uploads safely.** Files go to `uploads/` by default; on serverless or several
   servers, use S3 or Cloudflare R2 (see [Uploads](./uploads)).

Go through the [checklist before going live](./security#checklist-before-going-live), and set up
[backups](./backups).

## Next steps

- [Recipes](./recipes/): short answers for common tasks.
- [Access control](./access-control): authors who edit only their own posts.
- [Localization](./localization): the blog in Thai and English.

---

<!-- https://easy-cms-website.vercel.app/docs/nuxt.md -->

# Nuxt

::: info What you'll learn
How the Nuxt module mounts the admin and the API, its options, and how to read content in server routes and pages.

**Before this page:** [Getting started](./getting-started).
:::

`@easy-cms/nuxt` is a Nuxt 4 module.

```ts
// nuxt.config.ts
export default defineNuxtConfig({
  modules: ['@easy-cms/nuxt'],
  easyCms: {
    // configPath: 'easy-cms.config.ts',
    // trustProxy: false,
  },
})
```

With `easy-cms.config.ts` in the project root the module:

- serves the REST API at `routes.api` (default `/api/cms`) and the admin at `admin.path` (`/admin`)
- auto-imports two server utilities, typed from your config:

```ts
// server/api/posts/[slug].get.ts
export default defineEventHandler(async (event) => {
  const cms = await useEasyCMS()
  const user = await useEasyCMSUser(event) // the logged-in Easy CMS user, or null
  const { docs } = await cms.find('posts', {
    where: { slug: { equals: getRouterParam(event, 'slug') } },
    // Let access rules decide, and show drafts to logged-in editors.
    overrideAccess: false,
    user,
    draft: user !== null,
    limit: 1,
  })
  return docs[0] ?? null
})
```

## Options

| Option | Default | |
|---|---|---|
| `configPath` | `easy-cms.config.ts` | Path to the Easy CMS config |
| `trustProxy` | `false` | The client IP for login rate limiting from `X-Forwarded-For`, its last address (the one your proxy added). The CSRF origin check then also trusts `X-Forwarded-Host`. Only behind a proxy you control; Vercel and Netlify are recognized without it. |

## Notes

- The module connects at server start: schema problems (pending migrations) show up in the log
  immediately.
- The admin's static files are Nitro public assets, so on platforms with a CDN they are served
  from it.
- **Nuxt's production server does not read `.env`.** Set `EASY_CMS_SECRET` on the host, or start
  with `node --env-file=.env .output/server/index.mjs`. See
  [Environment variables](./deployment#environment-variables).
- The production build ships the database driver's native files (libSQL). Build on the same OS
  and architecture you deploy to, and start the server from the project root.

See the [Nuxt example](https://github.com/maritonx/easy-cms/tree/main/examples/nuxt-blog).

## Next steps

- [Local API](./local-api): read and write content in server routes.
- [Live preview](./live-preview): show unsaved changes on your pages.

---

<!-- https://easy-cms-website.vercel.app/docs/next.md -->

# Next.js

::: info What you'll learn
How the Next.js route handlers mount the admin and the API, and how to read content in server components.

**Before this page:** [Getting started](./getting-started).
:::

`@easy-cms/next` supports the App Router in Next.js 15 and later.

## Setup

`npx create-easy-cms` does these steps for you.

**1. Wrap `next.config.ts`**, so the server build keeps the admin files, migrations and database
drivers (it sets `serverExternalPackages` and `outputFileTracingIncludes`):

```ts
import type { NextConfig } from 'next'
import { withEasyCMS } from '@easy-cms/next/config'

const nextConfig: NextConfig = {}
export default withEasyCMS(nextConfig)
```

**2. Add the route handlers.** Their paths must match `routes.api` and `admin.path`:

```ts
// app/api/cms/[[...path]]/route.ts
import { createRouteHandlers } from '@easy-cms/next'
import config from '@/easy-cms.config'
export const { GET, HEAD, POST, PATCH, PUT, DELETE, OPTIONS } = createRouteHandlers(config)
```

```ts
// app/admin/[[...path]]/route.ts
import { createAdminRouteHandlers } from '@easy-cms/next'
import config from '@/easy-cms.config'
export const { GET, HEAD } = createAdminRouteHandlers(config)
```

`createRouteHandlers(config, options)` takes these options (`RouteHandlerOptions`, the same as
`ApiHandlerOptions`; both are exported):

| Option | Default | |
|---|---|---|
| `basePath` | `routes.api` | Where the API is mounted. |
| `getClientIp` | — | `(request) => string \| undefined`: the client's IP, for login rate limiting and the audit log. Wins over `trustProxy`. |
| `trustProxy` | `false` | Use the last address in `X-Forwarded-For` (see below), and trust `X-Forwarded-Host` in the CSRF origin check. Vercel and Netlify need nothing. |

## Reading content

```tsx
import { connection } from 'next/server'
import { Suspense } from 'react'
import { getEasyCMS, getEasyCMSUser } from '@easy-cms/next'
import config from '@/easy-cms.config'

type Params = Promise<{ slug: string }>

export default function PostPage({ params }: { params: Params }) {
  return (
    <Suspense>
      <Post params={params} />
    </Suspense>
  )
}

async function Post({ params }: { params: Params }) {
  await connection() // read on each request
  const { slug } = await params
  const cms = await getEasyCMS(config)
  const user = await getEasyCMSUser(config)
  const { docs } = await cms.find('posts', {
    where: { slug: { equals: slug } },
    overrideAccess: false,
    user,
    draft: user !== null,
    limit: 1,
  })
  // …
}
```

`getEasyCMS(config)` returns one instance per server that survives hot reloads. It can be used
in Server Components, Route Handlers and Server Actions.

## Things to know

- **Render CMS-backed pages per request**: call `await connection()` (from `next/server`) in an
  async component inside `<Suspense>`, as above. This works whether or not `cacheComponents` is on
  in `next.config.ts`; new apps from `create-next-app` have it on, and there `export const dynamic`
  is not allowed. Or cache on purpose with revalidation. Otherwise `next build` prerenders the pages
  and queries the database at build time.
- `notFound()` inside `<Suspense>` shows the not-found page with `noindex`, but the status stays
  `200`: Next.js has already started sending the page. `generateMetadata` can stay outside.
- Login rate limiting needs the client's IP. On Vercel and Netlify it is found by itself.
  Behind another proxy that adds to `X-Forwarded-For`, use `createRouteHandlers(config,
  { trustProxy: true })`: the last address in the header counts (the one your proxy added).
- Next.js removes trailing slashes, so the admin lives at `/admin` (not `/admin/`).
- In `next dev`, changing fields or options reloads the CMS; after changing only the code of a
  hook, access rule or other function in the config, restart the dev server.

See the [Next.js example](https://github.com/maritonx/easy-cms/tree/main/examples/next-blog).

## Next steps

- [Local API](./local-api): read and write content in server code.
- [Live preview](./live-preview): show unsaved changes on your pages.

---

<!-- https://easy-cms-website.vercel.app/docs/standalone.md -->

# Standalone server

::: info What you'll learn
Run Easy CMS as its own server for a Vite, React, Vue or static frontend, and deploy it.

**Before this page:** [Getting started](./getting-started).
:::

Easy CMS can run as its own server instead of inside Nuxt or Next.js. Use it as the backend for
a Vite, React, Vue, Svelte or static frontend, or when the CMS should live on its own domain.

## Create a project

```bash [pm]
npm create easy-cms@latest my-cms
cd my-cms
npm run dev
```

In a new or empty directory `create-easy-cms` sets up a standalone server: `package.json` with
scripts, `easy-cms.config.ts`, and a secret in `.env`. In an existing project that is not Nuxt
or Next.js, pass `--standalone`.

Open `http://localhost:4000/admin` and create the first admin. `npm run dev` runs
`easy-cms serve --watch`, which reloads when the config or files it imports change; a config
with an error is reported and the previous one keeps serving.

## Routes

| Path | |
|---|---|
| `/admin` | Admin UI (`admin.path`) |
| `/api/cms` | [REST API](./rest-api) (`routes.api`) |
| `/healthz` | `ok`, for load balancers and container health checks |
| `/` | Redirects to the admin |

## Calling it from a frontend

List the frontend's origin in `cors`, then fetch from the browser:

```ts
// easy-cms.config.ts
export default defineConfig({
  // …
  cors: ['http://localhost:5173', 'https://www.example.com'],
})
```

```ts
// in the frontend
const { docs } = await fetch('https://cms.example.com/api/cms/posts?depth=1').then((r) => r.json())
```

Anonymous requests see what read access allows (typically published content). For typed
responses, generate types with `npx easy-cms generate:types` and copy the file into the
frontend.

Logged-in requests from another origin need either a bearer token
(`Authorization: Bearer <token>`) or cookies. Cookies work when the frontend and the CMS are on
the same site (e.g. `www.example.com` and `cms.example.com`) and the frontend's origin is in
`auth.trustedOrigins`; send them with `fetch(url, { credentials: 'include' })`.

## Options

```bash
easy-cms serve [--port <n>] [--host <host>] [--watch] [--trust-proxy]
```

| Option | Default | |
|---|---|---|
| `--port` | `PORT`, then `4000` | |
| `--host` | `HOST`, then all interfaces | |
| `--watch` | off | Reload on changes (development) |
| `--trust-proxy` | off | Use `X-Forwarded-For` (login rate limiting) and `X-Forwarded-Proto` (secure cookies) from your reverse proxy |

`easy-cms serve` reads `.env` from the project root, like every CLI command.

## Deploying

Same as the adapters: create and apply migrations, then start with `NODE_ENV=production`, which
refuses to start while migrations are pending. See [Migrations & deployment](./deployment).

```bash [pm]
npx easy-cms migrate
NODE_ENV=production npx easy-cms serve --trust-proxy
```

A Dockerfile, Compose with Postgres or SQLite, a VPS with systemd, and other hosts:
see [Deploy](./deploy/).

## Use your own server

`createStandaloneHandler(cms)` from `easy-cms` returns the same `(Request) => Response` handler,
for Hono, Bun, Deno or any server that speaks Web requests:

```ts
import { createEasyCMS, loadConfig } from '@easy-cms/core'
import { createStandaloneHandler } from 'easy-cms'

const cms = await createEasyCMS(await loadConfig())
const handler = createStandaloneHandler(cms)
Bun.serve({ port: 4000, fetch: (request) => handler(request) })
```

## Next steps

- [REST API](./rest-api): call the API from your frontend.
- [TypeScript](./typescript): types for a frontend in another repository.

---

<!-- https://easy-cms-website.vercel.app/docs/other-frameworks.md -->

# Other frameworks

::: info What you'll learn
Run Easy CMS inside a framework without its own package: Astro, SvelteKit, React Router, Hono or any
server that handles Web `Request`s.

**Before this page:** [Getting started](./getting-started).
:::

Easy CMS has packages for [Nuxt](./nuxt) and [Next.js](./next), and its own
[standalone server](./standalone). Underneath, the API and the admin are plain
`(Request) => Response` handlers, so any framework that hands you a Web `Request` can mount them.
The packages for Nuxt and Next.js are built from the same three pieces:

| | From | What it does |
|---|---|---|
| `sharedEasyCMS(config)` | `@easy-cms/core` | One instance per server, kept across hot reloads; a changed config makes a new one |
| `createApiHandler(config, options?)` | `@easy-cms/core` | The REST API on that instance |
| `adminHandlerFor(resolvedConfig, overrides?)` | `@easy-cms/admin` | The admin app for the config's path, language and brand |

And `cms.auth.userFromHeaders(headers)` gives the signed-in user of a request (session cookie or
Bearer token), e.g. to show drafts to editors.

## Mount the API and the admin

Install the core, your database adapter and the admin:

```sh
npm install @easy-cms/core @easy-cms/db-sqlite @easy-cms/admin
```

Then, in one module your routes import:

```ts
// cms.ts
import { adminHandlerFor } from '@easy-cms/admin'
import { createApiHandler, resolveConfig, sharedEasyCMS } from '@easy-cms/core'
import config from './easy-cms.config'

/** The Local API, typed from the config, for your pages. */
export const getCMS = () => sharedEasyCMS(config)

/** `/api/cms/*`, every method. */
export const api = createApiHandler(config, {
  // Behind a proxy you trust (Vercel, Netlify, a load balancer): the client's IP for login limits.
  trustProxy: true,
})

/** `/admin/*`, GET and HEAD. */
export const admin = async (request: Request) =>
  adminHandlerFor(await resolveConfig(config), { siteURL: '/' })(request)
```

Route every request under `routes.api` (default `/api/cms`) to `api`, and under `admin.path`
(default `/admin`) to `admin`. For example, in a server that takes a `fetch` handler:

```ts
import { admin, api } from './cms'

export default {
  fetch(request: Request) {
    const { pathname } = new URL(request.url)
    if (pathname.startsWith('/api/cms')) return api(request)
    if (pathname === '/admin' || pathname.startsWith('/admin/')) return admin(request)
    return new Response('Not found', { status: 404 })
  },
}
```

In a framework with file-based routes, export the handler from a catch-all route for each path,
for every HTTP method the framework asks you to name.

## Read content in your pages

```ts
import { getCMS } from './cms'

const cms = await getCMS()
const { docs } = await cms.find('posts', { where: { status: { equals: 'published' } } })
// Editors see drafts:
const user = await cms.auth.userFromHeaders(request.headers)
```

## Good to know

- **Server only.** Easy CMS runs in Node.js 22.12 or newer, not in edge runtimes.
- **The admin's files** are read from `@easy-cms/admin` on disk. If your framework bundles server
  code, keep `@easy-cms/admin` (and your database driver) external, so the files stay where it
  looks for them; or pass `appDir` to `adminHandlerFor`.
- **Trailing slashes:** the admin redirects `/admin` to `/admin/`. If your framework strips trailing
  slashes, pass `trailingSlashRedirect: false` to avoid a loop.
- **Hot reloads** keep the same instance while the config is alike; a change to a hook's code alone
  needs a restart.
- **Migrations** are the same as everywhere: [Deployment](./deployment#production-migrations).

These pieces are what the packages use, but only Nuxt, Next.js and the standalone server are tested
end to end. If you mount Easy CMS in another framework, tell us how it went.

## Next steps

- [Local API](./local-api): reading and writing content in your pages.
- [REST API](./rest-api): for code in the browser.

---

<!-- https://easy-cms-website.vercel.app/docs/configuration.md -->

# Configuration

::: info What you'll learn
Every option of `easy-cms.config.ts`: the top-level settings, collections, globals, branding and plugins.

**Before this page:** [Getting started](./getting-started).
:::

Everything about your content lives in `easy-cms.config.ts` at the project root:

```ts
import { defineConfig, isAdmin } from '@easy-cms/core'
import { sqlite } from '@easy-cms/db-sqlite'

export default defineConfig({
  secret: process.env.EASY_CMS_SECRET ?? '',
  db: sqlite({ url: 'file:./cms.db' }),
  admin: { locale: 'th' },
  collections: [
    {
      slug: 'posts',
      drafts: true,
      useAsTitle: 'title',
      access: { read: () => true, update: isAdmin },
      fields: [
        { name: 'title', type: 'text', required: true },
        { name: 'body', type: 'richText' },
      ],
    },
  ],
  globals: [{ slug: 'site', fields: [{ name: 'siteName', type: 'text' }] }],
})
```

`defineConfig` returns the config unchanged, keeping its literal types so documents can be typed
from it. The config is validated at startup; every problem is reported at once with where it is
and how to fix it.

## Top-level options

| Option | Default | |
|---|---|---|
| `secret` | — | **Required**, at least 32 characters. Signs sessions. Read it from an env var. |
| `db` | — | **Required**. A database adapter: `sqlite()` or `postgres()`. See [Databases](./databases). |
| `serverURL` | — | Public origin such as `https://example.com`. Makes media URLs absolute, and is where password links point (needed for them in production). |
| `webhooks` | `[]` | Endpoints notified when content changes. See [Webhooks](./webhooks). |
| `cronSecret` | `CRON_SECRET` | Lets a cron run [scheduled jobs](./drafts#scheduled-publishing) at `<api>/jobs/run`. At least 32 characters. |
| `jobs` | `[]` | Your own background work, every few seconds or at a time of day. See [Jobs](./jobs). |
| `timeZone` | the server's | IANA zone of times of day in the config (`jobs[].at`, `backups.at`), e.g. `Asia/Bangkok`. |
| `localization` | — | `{ locales, defaultLocale?, fallback? }`: content in several languages (leave it out, or `false`, for one). See [Localization](./localization). |
| `cors` | `[]` | Origins whose browser code may call the REST API, or `'*'` for any (anonymous requests). Origins in `auth.trustedOrigins` are always allowed, with cookies. |
| `routes.api` | `/api/cms` | Where the REST API is served. |
| `admin.path` | `/admin` | Where the admin UI is served. |
| `admin.locale` | `en` | Default admin language: `en` or `th`. |
| `admin.siteURL` | `/` (Nuxt, Next.js) | The public site, for the admin's "View site" button: a path or an `https://` URL. |
| `admin.brand` | — | `{ name, logo, color }`: your or your client's brand in the admin. See [Branding the admin](#branding-the-admin). |
| `admin.modules` | `[]` | JavaScript modules with Web Components for the admin, by package export or path; usually added by [plugins](./plugins#admin-components). |
| `auth` | | See [Users & auth](./auth). |
| `upload` | | See [Uploads & media](./uploads). |
| `collections` | `[]` | See below. |
| `globals` | `[]` | See below. |
| `endpoints` | `[]` | Custom REST endpoints: `{ path, method, handler }`. See [Plugins](./plugins#endpoints). |
| `plugins` | `[]` | Functions `(config) => config`, run in order before validation. See [Plugins](./plugins). |

## Collections

A collection is a type of content with many documents: posts, products, pages.

| Option | |
|---|---|
| `slug` | URL and table name: lowercase letters, digits, `-`, `_`. |
| `fields` | The [fields](./fields). |
| `labels` | `{ singular, plural }`, each a string or `{ en, th }`. |
| `useAsTitle` | Top-level field shown as the document title in the admin. |
| `admin.icon` | Icon in the admin menu (default `file-text`); one of the names under [Branding the admin](#branding-the-admin). |
| `admin.order` | Its place in its menu group, e.g. `1`: lower first; collections without one come after, in config order. The media library comes last, user accounts are under Settings. |
| `admin.group` | `'settings'` lists the collection under Settings in the menu (with Users and API keys) instead of with the content; `false` leaves it out of the menu (links still reach it). See [The menu](./admin#the-menu). |
| `admin.editIn` | `'drawer'`: create and edit in a panel over the list, for small collections such as categories; relationship fields to it get a "Create" button that opens the same panel. Collections with drafts, versions or preview always use the full page. |
| `admin.list` | `{ tree: 'parent', sort: 'title' }` shows the list as a tree along a relationship to the same collection, and sets its default order. |
| `drafts` | Adds `status` (`draft` \| `published`). See [Drafts](./drafts). |
| `versions` | `true` or `{ keep }`: keep a version of every save, with history and restore; with `drafts`, drafts of published documents are kept separately. See [Versions](./drafts#versions). |
| `preview` | `({ doc }) => url`: the page that shows a document, for [live preview](./live-preview). |
| `admin.sidebar` | Panels from [admin components](./plugins#admin-components) in the edit page's side column. |
| `schedule` | Publish and unpublish at a set time (needs `drafts`). See [Scheduled publishing](./drafts#scheduled-publishing). |
| `access` | `{ read, create, update, delete }`. See [Access control](./access-control). |
| `hooks` | See [Hooks](./hooks). |

Every document also has `id` (integer), `createdAt` and `updatedAt`.

Two collections are built in: [`users`](./auth) and [`media`](./uploads). Declare a collection
with the same slug to add fields, access rules or hooks to them.

Reserved slugs: `admin`, `globals`, `jobs`, `sessions`, `login-attempts`, `document-versions`,
`scheduled-jobs`, `migrations`, `access`, `auth`.

## Globals

A global has exactly one document: site settings, navigation, a footer.

```ts
globals: [
  {
    slug: 'site',
    label: { en: 'Site settings', th: 'ตั้งค่าเว็บไซต์' },
    access: { read: () => true },
    fields: [
      { name: 'siteName', type: 'text', defaultValue: 'My site' },
      { name: 'menu', type: 'array', fields: [{ name: 'label', type: 'text' }, { name: 'url', type: 'text' }] },
    ],
  },
],
```

Globals accept `fields`, `label`, `drafts`, `versions`, `preview`, `admin` (`icon`, `order`,
`group`, `sidebar`, `layout`), `access` (`read`, `update`) and `hooks` (`beforeValidate`,
`beforeChange`, `afterChange`, `afterRead`).

## Branding the admin

Agencies can show their client's brand instead of Easy CMS's:

```ts
admin: {
  brand: {
    name: 'Acme Coffee',     // menu, login page and browser tab
    logo: '/acme-logo.svg',  // a path on your site or an https:// URL
    color: '#b45309',        // main color; lighter and darker shades are derived
  },
},
collections: [
  { slug: 'posts', admin: { icon: 'newspaper' }, fields: [/* … */] },
  { slug: 'menu', admin: { icon: 'utensils' }, fields: [/* … */] },
],
globals: [{ slug: 'site', admin: { icon: 'house' }, fields: [/* … */] }],
```

Text on the brand color turns dark when white would be hard to read. Each user picks light,
dark or their system's theme in the menu.

Icons ([Lucide](https://lucide.dev)): `file-text`, `newspaper`, `book-open`, `notebook`, `folder`, `tag`, `tags`, `image`, `images`, `video`, `music`, `file`, `users`, `user`, `building`, `store`, `shopping-bag`, `shopping-cart`, `package`, `box`, `calendar`, `calendar-days`, `map-pin`, `globe`, `house`, `layout-grid`, `layers`, `star`, `heart`, `message-square`, `mail`, `phone`, `briefcase`, `graduation-cap`, `utensils`, `car`, `settings`, `sliders-horizontal`, `palette`, `megaphone`, `bell`, `link`, `quote`, `circle-help`, `award`, `ticket`, `camera`, `key`, `chart-column`, `chart-line`.

## Plugins

A plugin receives the config and returns a new one, so it can add fields, collections,
[endpoints](./plugins#endpoints) and [admin components](./plugins#admin-components):

```ts
import { seoPlugin } from '@easy-cms/plugin-seo'

export default defineConfig({ /* … */ plugins: [seoPlugin({ collections: ['posts'] })] })
```

See [Plugins](./plugins) for the official plugins and how to write your own.

## Next steps

- [Fields](./fields): the fields of your collections.
- [Access control](./access-control): who may read and change what.

---

<!-- https://easy-cms-website.vercel.app/docs/fields.md -->

# Fields

::: info What you'll learn
Every field type, its options, what it looks like in the admin, and how it is stored.

**Before this page:** [Configuration](./configuration).
:::

Every field has a `name` and a `type`. Common options:

| Option | |
|---|---|
| `label` | String or `{ en, th }`. Defaults to the humanized name. |
| `required` | Must have a value (skipped while saving a [draft](./drafts)). |
| `defaultValue` | Used when creating a document without this field. |
| `unique` | No two documents may share the value. Top-level fields only: inside a group, array or block it is ignored, and the config check warns. |
| `index` | Create a database index. |
| `validate` | `(value, { data, operation }) => true \| 'error message'`, may be async. |
| `access` | `{ read, create, update }` field-level [access](./access-control#field-access). |
| `hidden` | Stored, but never returned by the API nor accepted as input. |
| `localized` | One value per locale; see [Localization](./localization). |
| `admin` | How the admin shows it: `{ position: 'sidebar' }` puts it in the edit page's side panel (for top-level fields such as a category, tags or a date); `{ component, after }` are [admin components](./plugins#admin-components) instead of the input, and below the field. |

## Types

| Type | Value | Options |
|---|---|---|
| `text` | `string` | `minLength`, `maxLength` |
| `textarea` | `string` | `minLength`, `maxLength` |
| `email` | `string`, stored lowercase | |
| `number` | `number` | `min`, `max` |
| `boolean` | `boolean` | |
| `date` | ISO 8601 `string` (accepts `Date`) | |
| `select` | one of the options, or an array with `hasMany` | `options`, `hasMany` |
| `slug` | URL-safe `string`, unique in the collection | `from` |
| `json` | any JSON value | |
| `richText` | Tiptap JSON document | see [Rich text](./rich-text) |
| `upload` | id of a `media` document, or an array with `hasMany` | `hasMany`, `mimeTypes`: see [Uploads](./uploads#several-files-galleries) |
| `relationship` | id(s) of documents in another collection | `to`, `hasMany`, `minRows`, `maxRows` |
| `array` | list of rows, each with an `id` and sub-fields | `fields`, `minRows`, `maxRows` |
| `group` | nested object | `fields` |
| `blocks` | list of rows of different kinds, each with `id` and `blockType` | `blocks`, `minRows`, `maxRows` |

### select

```ts
{ name: 'kind', type: 'select', options: ['news', { label: { en: 'Blog', th: 'บล็อก' }, value: 'blog' }] }
{ name: 'tags', type: 'select', options: ['vue', 'react'], hasMany: true }
```

### slug

```ts
{ name: 'slug', type: 'slug', from: 'title' }
```

Filled from `title` when empty. Letters of any script are kept (`สวัสดี ชาวโลก` →
`สวัสดี-ชาวโลก`); duplicates get `-2`, `-3`. Changing the title later does not change the slug.

`uniqueWithin: 'parent'` makes slugs unique only among documents with the same value of another
field, e.g. pages under the same parent (as the [nested pages](./nested-docs) plugin does).

### relationship

```ts
{ name: 'author', type: 'relationship', to: 'users' }
{ name: 'related', type: 'relationship', to: 'posts', hasMany: true }
```

Ids are checked to exist when saving. Reads populate related documents to `depth` levels
(default 1, max 3); at depth 0 you get ids. Deleted or unreadable documents become `null`, or
are dropped from `hasMany` lists.

`filterOptions` limits which documents a relationship may point to. It runs on the server with
the document's id (`undefined` while creating) and the user, and returns a `where` on the target
collection, or `true` for any. The admin's picker offers only those, and saving checks them:

```ts
{
  name: 'manager',
  type: 'relationship',
  to: 'users',
  filterOptions: ({ id }) => ({ role: { equals: 'admin' } }),
}
```

### array and group

```ts
{
  name: 'links',
  type: 'array',
  maxRows: 5,
  fields: [
    { name: 'label', type: 'text', required: true },
    { name: 'url', type: 'text' },
  ],
}
{ name: 'seo', type: 'group', fields: [{ name: 'title', type: 'text' }] }
```

Updating an array replaces all its rows; keep a row's `id` to keep its identity.

### blocks

Rows of different kinds, for pages editors lay out themselves:

```ts
{
  name: 'layout',
  type: 'blocks',
  blocks: [
    {
      slug: 'hero',
      labels: { singular: 'Hero' },
      fields: [
        { name: 'heading', type: 'text', required: true },
        { name: 'image', type: 'upload' },
      ],
    },
    { slug: 'text', fields: [{ name: 'body', type: 'richText' }] },
  ],
}
```

Each row is `{ id, blockType, ...fields }`; `blockType` picks the block. Rows are validated,
relationships and uploads inside them populated, and the generated types are a union of the
block kinds.

Blocks are stored as JSON. `where` can look inside them: a field name means that field in
whichever block has it, and a document matches when some block does.

```ts
await cms.find('pages', { where: { 'layout.blockType': { equals: 'hero' } } })
await cms.find('pages', { where: { 'layout.heading': { like: 'sale' } } })
await cms.find('pages', { where: { 'layout.heading.en': { equals: 'Hello' } } }) // a locale
await cms.find('pages', { where: { layout: { exists: false } } }) // no blocks at all
```

Lists inside blocks work the same way: some row must match.

```ts
await cms.find('pages', { where: { 'layout.meta.tone': { equals: 'warm' } } }) // a group
await cms.find('pages', { where: { 'layout.items.title': { equals: 'One' } } }) // an array
await cms.find('pages', { where: { 'layout.tags': { in: ['sale'] } } }) // hasMany values
await cms.find('pages', { where: { 'layout.content.blockType': { equals: 'quote' } } }) // blocks
```

`sort` uses the first value found, going through the blocks (and the rows of lists in them) in
order: `sort: '-layout.columns'`, `sort: 'layout.items.title'`. These queries read the JSON of each document,
so they suit filters more than large, hot lists.

## How fields are stored

Each collection is a table; fields are columns (group fields are flattened: `seo.title` →
`seo_title`). Arrays and `hasMany` values live in child tables; blocks are a JSON column. Two fields that would map to the
same column are reported as a config error.

## Next steps

- [Custom field types](./field-types): more types from packages, such as `color`, or your own.
- [Access control](./access-control): field-level access.
- [Rich text](./rich-text): show rich text on your pages.

---

<!-- https://easy-cms-website.vercel.app/docs/field-types.md -->

# Custom field types

::: info What you'll learn
Use field types from packages, such as `color` from `@easy-cms/fields`, and write your own: a
`type` with its own checks, its own input in the admin and its own look in lists.

**Before this page:** [Fields](./fields). For the admin part: [Admin components](./plugins#admin-components).
:::

<Screenshot name="drawer" alt="A category in a panel over its list: a color picker with suggested colors, and swatches in the list" />

Easy CMS has [built-in field types](/reference/fields#types) for most content. A package can add
more: a color, a rating, a phone number. Each one is stored like a built-in type (its **base**), so
queries, the REST API, localization and migrations work as they do for that type. What it adds is
its own **validation**, its own **input** in the admin and its own **cell** in lists.

## Colors: `@easy-cms/fields`

```bash [pm]
npm install @easy-cms/fields
```

List the type in `fieldTypes`, then use it like any other:

```ts
import { color } from '@easy-cms/fields'

export default defineConfig({
  // …
  fieldTypes: [color],
  collections: [
    {
      slug: 'categories',
      fields: [
        { name: 'name', type: 'text', required: true },
        { name: 'color', type: 'color', presets: ['#2f6f5e', '#e8a33d', '#2563eb'] },
      ],
    },
  ],
})
```

| Option | Type | |
|---|---|---|
| `presets` | `string[]` | Colors offered as swatches, e.g. your brand's. |
| `alpha` | `boolean` | Allow transparency: `#rrggbbaa` as well as `#rrggbb`. Default `false`. |

The value is a string like `#2f6f5e` (stored as text). Other values are rejected with
"must be a color like #2f6f5e". In the admin, editors pick a color, type its code or click a
suggested one; lists show a swatch next to the code, as a column that is on by default.

Add the column to an existing database like any new field:

```bash [pm]
npx easy-cms migrate:create category_color
```

## TypeScript

Importing the package teaches TypeScript the new type: `type: 'color'` is accepted in `fields`,
its options are checked, and documents have `color?: string | null`. `easy-cms generate:types`
writes `string` too.

## Writing a field type

A field type is a plain object. `defineFieldType` checks it against `FieldTypeDefinition`:

```ts
// rating.ts
import { defineFieldType } from '@easy-cms/core'

export const rating = defineFieldType({
  name: 'rating',
  // Stored, queried and sorted as a number.
  base: 'number',
  // Not called for empty values: `required` covers those.
  validate: (value, { field }) =>
    (Number.isInteger(value) && Number(value) >= 1 && Number(value) <= Number(field.max ?? 5)) ||
    'must be 1 to 5 stars',
  // Mistakes in a field's options, found when the config loads.
  checkOptions: (field) =>
    field.max === undefined || Number.isInteger(field.max) ? undefined : 'max must be a whole number',
  admin: {
    component: 'ecms-stars',            // the input
    cell: 'ecms-stars-cell',            // in lists
    module: '@acme/easy-cms-rating/admin',
    props: ['max'],                     // field options the components get as `options`
  },
  typescript: '1 | 2 | 3 | 4 | 5',      // for generate:types
})
```

| Option | | |
|---|---|---|
| `name` | **Required** | The `type` fields use: lowercase letters and digits, starting with a letter (`rating`, `phoneNumber`). Not a built-in type's name. |
| `base` | **Required** | How it is stored: `text`, `textarea`, `email`, `number`, `boolean`, `date` or `json`. |
| `validate` | | `(value, { field, data, operation, … }) => true \| string`, may be async. `field` has the field's own options. A field's own `validate` runs after it. |
| `checkOptions` | | `(field) => string \| undefined`: a message makes the config invalid. |
| `admin.component` | | The input: a Web Component, as a tag or `{ tag, props }` ([admin component](./plugins#admin-components)). Without it, the base type's input. |
| `admin.cell` | | Shows the value in lists, as a tag or `{ tag, props }`. Without it, the value as text. |
| `admin.module` | | The [admin module](./plugins#the-module) that defines the components; added to `admin.modules` for you. |
| `admin.props` | | Field options passed to the components as `options`. |
| `typescript` | | The value's type for `generate:types`. Default: the base type's. |

The components follow the [admin component](./plugins#what-the-element-receives) contract: the
input gets `value`, `options`, `readOnly`, `label` and `uiLocale`, and sends
`new CustomEvent('change', { detail: value })`. A cell gets the same properties, read-only.

A field that sets `admin.component` itself keeps it, so a project can swap the input of one field.

### Types for your package

Tell TypeScript about the type by adding it to `CustomFieldTypes`, with its value and options:

```ts
declare module '@easy-cms/core' {
  interface CustomFieldTypes {
    rating: { value: 1 | 2 | 3 | 4 | 5; options: { readonly max?: number } }
  }
}
```

Put this next to `defineFieldType` in your package's entry, so it applies wherever the package is
imported.

### Rules

- Names are checked when the config loads: a built-in name (`text`, `select`…) or a type listed
  twice is an error, and so is a field whose `type` no listed type provides.
- The type adds behavior, not storage: changing a field from `text` to `color` needs no
  migration, and removing the package leaves the values as text.
- Keep the admin module free of imports, as for any [admin module](./plugins#the-module).

## Next steps

- [Admin components](./plugins#admin-components): the elements in more detail.
- [Fields](./fields): the built-in types.

---

<!-- https://easy-cms-website.vercel.app/docs/sso.md -->

# Single sign-on

::: info What you'll learn
How people sign in to the admin with Google, Microsoft, GitHub or any OpenID Connect provider,
who gets in, and how to set up each provider.

**Before this page:** [Users & auth](./auth).
:::

<Screenshot name="login-sso" alt="The login page with a Sign in with Company SSO button above the password form" />

Install the providers package and add them to the config:

```sh [pm]
npm install @easy-cms/auth-oauth
```

```ts
import { github, google, microsoft } from '@easy-cms/auth-oauth'

export default defineConfig({
  serverURL: 'https://cms.example.com', // needed in production, for the callback URL
  auth: {
    providers: [google()], // reads GOOGLE_CLIENT_ID and GOOGLE_CLIENT_SECRET
  },
  // …
})
```

The login page gets a **Sign in with Google** button. The provider sends people back to
`<serverURL>/api/cms/auth/<id>/callback`, e.g. `https://cms.example.com/api/cms/auth/google/callback`:
give that URL to the provider (Settings → **Single sign-on** shows it, with a copy button). Single
sign-on is for the admin; your site's visitors are not affected.

<Screenshot name="sso" alt="Settings → Single sign-on: each provider with its callback URL to copy, and who can sign in" />

Turning providers on adds the `user-identities` table: create a migration
(`npx easy-cms migrate:create sso`).

## Who gets in

- **People who already have an account** link the provider first: they sign in with their
  password and choose **Link Google** (or another provider) on their **Account** page; the
  provider's button on the login page doesn't link. After that, the provider's own id for the
  account is used, so a changed email at the provider doesn't matter. Someone who tries the
  provider before linking is told to do so.
- **Matched by email** instead, for a provider whose emails your organization controls (its own
  Google Workspace or Entra ID): `google({ linkByEmail: true })`. Only emails the provider has
  verified count. Site members (`auth.members`) are always matched by email.
- **People from your domains**, when you allow it: they get an account the first time they sign in.

  ```ts
  auth: {
    providers: [google()],
    providerSignUp: { domains: ['example.com'], role: 'editor' }, // never 'admin'
  },
  ```

- Nobody else: an unknown email lands back on the login page with a message to ask an admin.
- Deactivated users can't sign in, whatever the provider says.

To add someone, create their user in **Settings → Users** (with their work email) and invite them;
they link the provider from their account page, or sign in with it at once with `linkByEmail`.

## Passwords

By default people can sign in either way. To make everyone use the provider:

```ts
auth: { providers: [google()], password: false },
```

Then only **admins** can still use a password, as a way in when the provider is down; the login
page folds the password form away for them. Invitations tell people to sign in with the provider
instead of setting a password, and "forgot password" works only for admins.

## Linking accounts

On their **Account** page, people see the outside accounts they sign in with, can link another
provider (**Link GitHub**) and unlink one. The last way into an account can't be unlinked. Admins see
and unlink a user's accounts on the user's page. Deleting a user forgets their linked accounts.

## Setting up providers

### Google

1. In the [Google Cloud console](https://console.cloud.google.com/apis/credentials), create an
   **OAuth client ID** of type **Web application**. Set up the consent screen first if asked: for a
   Google Workspace, choose **Internal**; otherwise **External**, and while it is in **Testing**, add
   the Google accounts that will sign in under **Test users** (others are refused, and Google warns
   that the app isn't verified until you publish it).
2. Add the callback URL under **Authorized redirect URIs**, e.g.
   `https://cms.example.com/api/cms/auth/google/callback` (and `http://localhost:3000/api/cms/auth/google/callback`
   for development).
3. Set `GOOGLE_CLIENT_ID` and `GOOGLE_CLIENT_SECRET`, and use `google()`. For a Workspace,
   `google({ hd: 'example.com' })` opens the account chooser on your domain.

### Microsoft (Entra ID)

1. In the [Entra admin center](https://entra.microsoft.com), **App registrations** → **New
   registration**: accounts in this organizational directory only; redirect URI of type **Web**:
   `https://cms.example.com/api/cms/auth/microsoft/callback`.
2. Under **Certificates & secrets**, create a client secret.
3. Set `MICROSOFT_TENANT_ID` (the **Directory (tenant) ID**), `MICROSOFT_CLIENT_ID` (the
   **Application (client) ID**) and `MICROSOFT_CLIENT_SECRET`, and use `microsoft()`.

Entra ID doesn't mark emails as verified; your organization manages them, so they count as
verified. Only the `email` claim is used, never the user principal name.

### GitHub

1. In GitHub, **Settings** → **Developer settings** → **OAuth Apps** → **New OAuth App**, with the
   callback URL `https://cms.example.com/api/cms/auth/github/callback`.
2. Generate a client secret, set `GITHUB_CLIENT_ID` and `GITHUB_CLIENT_SECRET`, and use `github()`.

People are matched by the primary, verified email of their GitHub account. For GitHub Enterprise
Server: `github({ server: { web: 'https://github.example.com', api: 'https://github.example.com/api/v3' } })`.

### Any OpenID Connect provider

Okta, Keycloak, Auth0, Authentik, Zitadel and others:

```ts
import { oidc } from '@easy-cms/auth-oauth'

oidc({
  id: 'okta', // in the callback URL: <api>/auth/okta/callback
  name: 'Okta', // on the button
  issuer: 'https://example.okta.com',
  clientId: process.env.OKTA_CLIENT_ID ?? '',
  clientSecret: process.env.OKTA_CLIENT_SECRET,
})
```

The provider's `/.well-known/openid-configuration` is read for its endpoints. Its ID token needs
`email` and `email_verified` (the `email` scope).

The `id` can't be one of the sign-in actions under `/auth/` (`login`, `logout`, `me`, `init`,
`signup`, `verify-email`, `forgot-password`, `reset-password`, `first-register`) or `identities`.

## How it is kept safe

- The Authorization Code flow with **PKCE**, a `state` and (OpenID Connect) a `nonce`, kept in a
  short-lived signed cookie; ID tokens are verified with the provider's keys
  ([oauth4webapi](https://github.com/panva/oauth4webapi)).
- After signing in, people only go back to pages of the admin.
- Linking an account needs the session and its CSRF token, and an account already linked to
  someone else can't be linked again.
- Client secrets stay in environment variables, never in the database or backups.

## Next steps

- [Roles from the admin](./roles): what each person may do once they're in.
- [Security](./security): what else is protected.

---

<!-- https://easy-cms-website.vercel.app/docs/audit-log.md -->

# Audit log

::: info What you'll learn
How to keep a record of who changed what and when, who signed in, and what admins did, and how to
check that the record wasn't tampered with.

**Before this page:** [Users & auth](./auth).
:::

<Screenshot name="audit" alt="Settings → Audit log: filters, and entries with the time, the action, the document and who did it" />

Turn it on in the config:

```ts
audit: true, // or { keepDays: 365, values: true, failedLogins: 20 }
```

It adds the `audit-logs` table: create a migration (`npx easy-cms migrate:create audit`). Admins
then find **Settings → Audit log**; with [roles from the admin](./roles), other roles can be given
the page too.

Without the audit log, failed sign-ins (`login.failed`, `login.locked`, `sso.failed`) are still
written to the server's log as warnings, with the email tried and the IP address.

## What is recorded

- **Content:** creating, changing, publishing, unpublishing, restoring and deleting documents and
  globals, uploads, and scheduling, with **which fields changed** and their values before and
  after. A deleted document's title is kept.
- **Signing in:** creating the first admin, logins, failed logins (with the email tried),
  lock-outs, logouts, forgotten passwords, password links, and [single sign-on](./sso): signing in, linking and unlinking.
- **Admin actions:** users (their role, deactivating them), roles, API keys, backups started,
  downloaded and deleted, test emails, and retried or deleted deliveries.

Each entry says **who** (their email at the time, still readable after they're deleted), **how**
(signed in, an API key by name, code, or a scheduled job), the **IP address** and the browser.
Reading is not recorded.

Changes made by your own code through the Local API (hooks, plugins, seed scripts) are recorded
too, as **code (system)**, or as the `user` you pass.

## Values

- Hidden fields, such as the password hash, are never kept; a new password shows only as
  "password changed".
- Rich text, blocks, arrays and JSON show only that they changed; values over 500 characters are cut.
- With sensitive data, `audit: { values: false }` keeps only which fields changed.

## In the admin

- **Settings → Audit log** lists entries newest first. Filter by action, collection, who and dates;
  open an entry to see each field before and after. **Export CSV** downloads the filtered entries
  (up to 10,000).
- A document's page shows its **Activity**: its latest entries, with a link to all of them.
- The dashboard warns about **many failed sign-ins** within an hour (`failedLogins`, default 20),
  entries that couldn't be written, and a failed integrity check.

## Tamper evidence

Entries can't be changed or deleted through Easy CMS: only those older than `keepDays` days (default
365; `0` keeps them all) are deleted, oldest first. Each entry is **signed** with your
`EASY_CMS_SECRET`. **Check integrity** (and a daily check from [scheduled jobs](./drafts#scheduled-publishing))
finds entries edited in the database, and missing ones between others. With the
[multi-tenant plugin](./multi-tenant), only admins of all tenants run it.

Entry ids are never reused, so deleting the newest entries leaves missing ids once anything is
written after them. A check writes an entry itself, so the next check reports them at the latest.
Each check also writes a line to the server's log with the number of entries and the last id
(`Audit log checked: …`), to compare with later.

What it can't see: someone who also has your secret. For a copy nobody here can change, send
events elsewhere too, e.g. with [webhooks](./webhooks).

## Writing entries from code

```ts
await cms.audit.record({ action: 'invoice.sent', target: 'invoices', doc: invoice.id })
```

## If an entry can't be written

The change still goes through: the error is logged on the server, and the dashboard says how many
entries are missing.

## Next steps

- [Roles from the admin](./roles): who may see the audit log.
- [Security](./security): what else is protected.

---

<!-- https://easy-cms-website.vercel.app/docs/access-control.md -->

# Access control

::: info What you'll learn
How to decide who may read, create, update and delete, per collection, per document and per field.

**Before this page:** [Configuration](./configuration), [Users & auth](./auth).
:::

Access rules are functions in the config. They decide what the REST API (and so the admin UI)
lets each user do. The Local API trusts its caller and skips them unless you ask for them.

```ts
import { anyone, isAdmin, isStaff } from '@easy-cms/core'

{
  slug: 'posts',
  access: {
    read: anyone,
    create: isStaff,
    update: ({ user }) => user?.role === 'admin' || { author: { equals: user?.id } },
    delete: isAdmin,
  },
  fields: [/* … */],
}
```

## Rules

A collection has `read`, `create`, `update`, `delete`; a global has `read` and `update`.
Each receives `{ user, id?, data? }` (`user` is `null` when not logged in) and returns:

- `true` / `false`, or
- a **where query**: the operation is allowed for matching documents only. For `read` it filters
  results; for `update`/`delete` the document must match. `create` must return a boolean.

Updating or deleting a document the user may not even read answers `404`, as for an id that
doesn't exist, so ids tell nothing about what exists. One they may read but not change answers
`403`.

**If you define no rule, only logged-in users are allowed.** Make public content public on
purpose, e.g. `read: () => true`.

Helpers: `anyone`, `isStaff`, `isAdmin`, and `isSignedIn`. `isStaff` means a user of the admin:
[site members](./members) such as customers don't count, `isSignedIn` counts them too. `isAdmin`
means an admin of the whole site: with the [multi-tenant plugin](./multi-tenant), a tenant's admins
don't count.

### Common patterns

```ts
// Visitors see published posts; editors see everything.
read: ({ user }) => (user ? true : { status: { equals: 'published' } }),

// Authors edit their own posts.
update: ({ user }) => (user?.role === 'admin' ? true : user ? { author: { equals: user.id } } : false),
```

## Field access

```ts
{ name: 'internalNotes', type: 'text', access: { read: ({ user }) => user !== null } }
{ name: 'featured', type: 'boolean', access: { update: ({ user }) => user?.role === 'admin' } }
```

Fields that can't be read are left out of responses (and of the admin), and can't be used to
filter or sort (`403`); fields that can't be updated are ignored in input (and shown read-only in
the admin).

`create` decides who may set the field when creating a document; without it, `update` decides
both. To let anyone fill a field in once but only admins change it later:

```ts
{
  name: 'referrer',
  type: 'text',
  access: { create: () => true, update: ({ user }) => user?.role === 'admin' },
}
```

## Populated documents

When a response includes related documents, the related collection's `read` rule applies too:
documents the user can't read are not populated.

## In the Local API

```ts
const user = await useEasyCMSUser(event) // or getEasyCMSUser(config) in Next.js
await cms.find('posts', { overrideAccess: false, user })
```

Without `overrideAccess: false` the Local API does everything; it's for trusted server code.

## Built-in users collection

Logged-in users can read users; admins create and delete them; users update themselves but not
their own `role` or `active`. The last active admin can't be demoted, deactivated or deleted.

## Next steps

- [Roles from the admin](./roles): let admins tick what each role may do, without code.
- [Hooks](./hooks): run code when content changes.
- [Security](./security): what else is protected.

---

<!-- https://easy-cms-website.vercel.app/docs/roles.md -->

# Roles from the admin

::: info What you'll learn
How admins add roles and tick what each one may do, per collection, global and admin page, without
writing code.

**Before this page:** [Access control](./access-control), [Users & auth](./auth).
:::

<Screenshot name="roles" alt="Settings → Roles: the roles on the left, and a table of collections against Read, Create, Update, Delete and Publish" />

Turn it on in the config:

```ts
auth: { rbac: true },
```

Admins then find **Settings → Roles** in the admin. They can add roles (an editor team, a reviewer,
a translator…), copy one to start from, rename them, and tick what each may do:

- **Content:** each collection and global against **Read**, **Create**, **Update**, **Delete** and
  **Publish** (Publish only with [drafts](./drafts)). Ticking anything else ticks Read too; a whole
  row or column can be ticked at once. **Create** on Media means uploading.
- **Admin pages:** the system status on the dashboard, [Deliveries](./health-checks), [Jobs](./jobs), and the pages
  and dashboard panels of [plugins](./plugins#pages-and-dashboard-panels). Backups, Email and Roles
  stay with admins: a backup holds the whole database, and Email sends mail from the site.

Users are given a role on their page under **Settings → Users**. Changes apply on the next request.
Each role keeps a history of who changed it and when.

## How it works with access rules

A user may do something when **both** their [access rules](./access-control) and their role allow
it. The rules in your code stay the outer limit, so a rule like "authors edit their own posts" keeps
working, and a role can only narrow what the code allows.

- **Admins** (`role: 'admin'`) may always do everything. Their role can't be changed or deleted.
- **Visitors who aren't logged in** are left to access rules alone: `read: () => true` stays public.
- **Everyone** sees and edits their own account (Account page), whatever their role. A role with
  Read or Update on Users gives more: the other users. A user's `role` can still be changed only by
  admins.
- **[API keys](./api-keys)** can do no more than their owner's role, now and after the role
  changes. The admin only offers what the owner may do.
- Checks run on the server, for the REST API, the [MCP server](./mcp) and the Local API with
  `overrideAccess: false`. The admin hides what a role can't do, and its main button saves a draft
  for roles without Publish.

## Roles in the config

The roles in `auth.roles` always exist. They can be renamed and their permissions changed, but not
deleted, since your code may check them (`user.role === 'editor'`). Roles added in the admin can be
deleted once no user has them.

When roles are first turned on, every role in `auth.roles` gets everything it could do before:
every collection and global, and the plugin pages and panels. The system status and Deliveries stay
with admins, as before. So turning roles on changes nothing until an admin unticks something.

Collections and globals added to the config later are **not given to any role**: only admins can
use them until someone ticks them. Settings → Roles marks them **New**.

## Own documents only

Next to a ticked Read, Update, Delete or Publish, the person button limits it to the role's **own
documents**: a writer can read every post but change, publish and delete only their own. Creating
always makes a document one's own.

Who owns a document:

- **Who created it**, by default. With roles on, every collection but Users gets a `createdBy`
  field, set by Easy CMS when a document is created and shown as **Created by** in the admin.
  Requests can't set or change it; trusted Local API calls can (for imports).
- **Or a field of yours** that names the owner, such as a post's author:

  ```ts
  {
    slug: 'posts',
    admin: { ownerField: 'author' }, // a relationship to users, not hasMany
    fields: [{ name: 'author', type: 'relationship', to: 'users' }, /* … */],
  }
  ```

  An empty owner field is filled with whoever creates the document. A role limited to its own
  documents can't change it (so it can't give a document away, or take one); roles with all
  documents can.

Lists in the admin have a **Mine** filter for documents you own. Documents from before roles get
`createdBy` from who saved their first [version](./drafts#versions), for collections with versions;
the others have no owner, so only roles with all documents (and admins) can change them.

### Deleting users who own documents

Deleting a user in the admin says what they own and asks who gets it, or nobody. Over REST:
`DELETE <api>/users/:id?transferTo=<id>` (or `none`); the Local API takes
`cms.delete('users', id, { transferTo })`. Without a choice, their documents have no owner.

## Field permissions

**Fields ›** on a row opens its fields: each top-level field can be **Can edit**, **Read only** or
**Hidden** for the role. Fields not set follow the row, so a field added later works like the rest.
Fields inside groups, arrays and blocks follow their parent.

- Hidden fields are not sent, and can't be used to filter or sort (`403`): the results would give
  their values away. The same now holds for fields hidden by `access.read` in the code.
- Read-only fields are shown but not saved from requests.
- A field that must be filled in (required, without a default) stays editable for roles that create
  documents.

## Fields that point elsewhere

When a role may edit posts but not read categories, a post's category field shows the value as it
is, says the user can't open categories, and keeps the value on save. Settings → Roles warns about
this when the role is saved. Upload fields need Read on Media to choose files, and Create to upload.

With [media folders](./uploads#folders), admins can narrow Media further for each folder: who may
view, edit or manage it.

## Setting it up

Roles are stored in the `ecms_user_roles` table, and their history with
[versions](./drafts#versions). Turning them on adds that table and the `createdBy` column of every
collection, so create a migration:

```sh
npx easy-cms migrate:create roles
```

Plugin dashboard panels are given to roles by their `id`, or their component tag without one.
Give each panel its own `id` (so it keeps its roles when the tag changes) and, optionally, a
`label` for the Roles page:

```ts
admin: {
  dashboard: [
    { id: 'sales', component: 'ecms-sales-chart', label: { en: 'Sales', th: 'ยอดขาย' } },
  ],
}
```

## Next steps

- [Access control](./access-control): rules in code, per document and per field, when a role
  isn't enough.
- [API keys](./api-keys): access for scripts, limited to what their owner may do.

---

<!-- https://easy-cms-website.vercel.app/docs/hooks.md -->

# Hooks

::: info What you'll learn
How to run your own code when documents are created, changed, read or deleted, with examples
for the common cases.

**Before this page:** [Configuration](./configuration).
:::

Hooks run your code at points of a document's life. They run for every way in: the Local API,
the REST API and the admin. Each hook is a list of functions, so plugins can add their own next
to yours.

```ts
{
  slug: 'posts',
  hooks: {
    beforeChange: [({ data }) => ({ ...data, readingTime: minutes(data.body) })],
    afterChange: [({ doc }) => notifyTeam(`Saved: ${doc.title}`)],
  },
  fields: [/* … */],
}
```

## Collection hooks

| Hook | When | Arguments | Return |
|---|---|---|---|
| `beforeValidate` | before fields are checked | `data`, `operation`, `previousDoc?` | new data, or nothing |
| `beforeChange` | after validation, before saving | `data`, `operation`, `previousDoc?` | new data, or nothing |
| `afterChange` | after saving | `doc`, `operation`, `previousDoc?` | — |
| `beforeDelete` | before deleting | `id` | — |
| `afterDelete` | after deleting | `id`, `doc` | — |
| `afterRead` | for every document returned | `doc` | new doc, or nothing |

Every hook also gets:

- `user`: who is doing it, or `null` (anonymous, or a trusted Local API call).
- `cms`: the [Local API](./local-api), to read or write other collections.
- `slug`: the collection's (or global's) slug, handy when one function serves several.

`operation` is `'create'` or `'update'`. On an update, `previousDoc` is the document before the
change, and `data` holds only what is being changed.

Globals support `beforeValidate`, `beforeChange`, `afterChange` and `afterRead`.

## Order and errors

`beforeValidate` → validation → `beforeChange` → save → `afterChange`

- A **before** hook that throws cancels the operation, and the error reaches the caller: throw
  a `ValidationError` or `ForbiddenError` from `@easy-cms/core` for a clear message in the
  admin.
- An **after** hook that throws is logged; the change stays saved.
- `afterRead` runs for every document returned, including populated relationships, before
  hidden and unreadable fields are removed.
- Hooks in a list run one after another; each sees the previous one's result.
- Hooks run outside the database transaction: an `afterChange` sees the saved document, and
  a slow one delays the response. Send long work to a queue or a [webhook](./webhooks).

## Recipes

### Set the author on create

```ts
beforeChange: [
  ({ data, operation, user }) =>
    operation === 'create' && user ? { ...data, author: user.id } : data,
],
```

### Keep a derived field up to date

```ts
import { richTextToPlainText } from '@easy-cms/richtext'

beforeChange: [
  ({ data }) =>
    data.body === undefined
      ? data // not changing on this update
      : { ...data, readingTime: Math.ceil(richTextToPlainText(data.body).split(/\s+/).length / 200) },
],
```

### Refresh pages after publishing (Next.js)

```ts
import { revalidatePath } from 'next/cache'

afterChange: [
  ({ doc }) => {
    revalidatePath('/')
    if (doc.slug) revalidatePath(`/posts/${doc.slug}`)
  },
],
```

For a site built elsewhere (a static site on a CDN), use [webhooks](./webhooks) instead: they
retry when the other side is down.

### Refuse to delete what is still in use

```ts
import { ForbiddenError } from '@easy-cms/core'

// On the categories collection
beforeDelete: [
  async ({ id, cms }) => {
    const totalDocs = await cms.count('posts', { where: { category: { equals: id } } })
    if (totalDocs > 0) throw new ForbiddenError(`${totalDocs} posts still use this category`)
  },
],
```

### Add a computed value to what is read

```ts
afterRead: [({ doc }) => ({ ...doc, url: `/posts/${doc.slug}` })],
```

`afterRead` values are sent to clients but not stored or searchable. Store the value with
`beforeChange` when you need to filter or sort by it.

## Pitfalls

- **Loops:** calling `cms.update()` on the same collection inside its own `afterChange` runs the
  hooks again. Change `data` in `beforeChange` instead.
- **Access:** `cms` calls inside hooks skip access rules (trusted server code). Pass
  `{ user, overrideAccess: false }` to act as the user.
- **Partial updates:** on an update, `data` has only the fields being changed; read the rest
  from `previousDoc`.

## Next steps

- [Access control](./access-control): decide who may do what, instead of checking in hooks.
- [Webhooks](./webhooks): tell other services about changes, with retries.

---

<!-- https://easy-cms-website.vercel.app/docs/drafts.md -->

# Drafts, versions & scheduling

::: info What you'll learn
Drafts, the version history and restoring, and publishing or unpublishing at a set time.

**Before this page:** [Configuration](./configuration).
:::

<Screenshot name="edit" alt="A post with its publishing panel and history" />

```ts
{ slug: 'posts', drafts: true, fields: [/* … */] }
```

A collection (or global) with `drafts: true` gets a `status` of `draft` or `published`. New
documents start as drafts.

- **Drafts may be incomplete:** `required` is not enforced while `status` is `draft`; types
  still are.
- **Publishing validates everything.**
- **Reads return published documents** unless you pass `draft: true`: `find`, `findById`,
  `count`, populated relationships and the REST API. Over REST, `?draft=true` only works for
  logged-in users.

```ts
await cms.find('posts') // published only
await cms.find('posts', { draft: true }) // everything
await cms.update('posts', id, { status: 'published' }) // publish
await cms.update('posts', id, { status: 'draft' }) // unpublish
```

In the admin, a draft has **Save draft** and **Publish**; a published document has **Save** (stays
published) and **Unpublish**.

Without versions a document has one copy: saving a published document as a draft unpublishes
it. Turn on versions to edit drafts while the published version stays live.

## Versions

```ts
{ slug: 'posts', drafts: true, versions: true, fields: [/* … */] }
// or versions: { keep: 100 } — versions kept per document (default 50)
```

With `versions`, every save of a document (or global) is kept as a version:

- **History:** list versions, see a document as it was, and **restore** one. Restoring saves
  the old content as a new version; with drafts it is restored as a draft.
- **Separate drafts** (with `drafts: true`): saving a draft of a **published** document keeps the
  draft as a version. The site keeps showing the published content until you publish again.
  Reads with `draft: true` (the admin, previews) return the pending draft.
- **Unpublish** is its own action, and a pending draft can be **discarded** to go back to what is
  live. Discarded drafts stay in the history.

```ts
await cms.update('posts', id, { title: 'New title', status: 'draft' }) // live post unchanged
await cms.findById('posts', id) // the published post
await cms.findById('posts', id, { draft: true }) // the pending draft
await cms.update('posts', id, { status: 'published' }) // publish the draft
await cms.discardDraft('posts', id) // or throw it away
await cms.unpublish('posts', id) // take the post off the site

const { docs } = await cms.findVersions('posts', id) // newest first: id, status, latest, author, createdAt
const version = await cms.findVersion('posts', id, docs[1].id) // { ..., data }
await cms.restoreVersion('posts', id, docs[1].id)
```

Globals have the same methods: `findGlobalVersions`, `findGlobalVersion`, `restoreGlobalVersion`,
`unpublishGlobal` and `discardGlobalDraft`.

In the admin, documents with versions get a **History** panel. A published document with a
pending draft shows **Unpublished changes**, with **Publish changes**, **Save draft**,
**Discard changes** and **Unpublish**.

Things to know:

- Only users who may **update** a document can read its versions; they hold unpublished content.
- Versions are stored in one table (`ecms_document_versions`), added when the first collection or
  global turns versions on. In production that change needs a migration like any other.
- `where` filters and sorting apply to the published content; a pending draft is shown in the
  results but not matched on its own values.
- Hidden fields (such as password hashes) are never stored in versions.

## Scheduled publishing

```ts
{ slug: 'posts', drafts: true, schedule: true, fields: [/* … */] }
```

`schedule` lets editors publish or unpublish at a set time (the admin gets a **Schedule**
button). With versions, a scheduled publish puts the pending draft live.

```ts
await cms.schedule('posts', id, { action: 'publish', at: '2026-10-01T09:00:00+07:00' })
await cms.findSchedule('posts', id) // pending jobs, soonest first
await cms.cancelSchedule('posts', id, jobId)
```

Scheduling the same action again replaces the pending job. Globals have `scheduleGlobal`,
`findGlobalSchedule` and `cancelGlobalSchedule`.

**Who runs the jobs.** Long-running servers (Nuxt, `easy-cms serve`, a self-hosted Next.js)
check every minute. On serverless platforms nothing keeps running, so call the jobs endpoint
from a cron:

```json
// vercel.json — Vercel Cron sends Authorization: Bearer $CRON_SECRET
{ "crons": [{ "path": "/api/cms/jobs/run", "schedule": "* * * * *" }] }
```

Set `CRON_SECRET` (or `cronSecret` in the config) to a random string of at least 32 characters.
Logged-in admins can call it too, with `POST` (a link can't run jobs for them), and
`npx easy-cms jobs:run` runs due jobs once from any cron. Failed jobs (e.g. a publish that
doesn't pass validation) are logged and not retried. The same run retries
[webhook deliveries](./webhooks#delivery) that failed earlier. **Settings → Jobs** lists what is
waiting, running or failed, and runs your own [jobs](./jobs) too.

## Next steps

- [Live preview](./live-preview): preview drafts on the real page.
- [Webhooks](./webhooks): tell other services about publishing.

---

<!-- https://easy-cms-website.vercel.app/docs/localization.md -->

# Localization

::: info What you'll learn
Content in several languages: which fields to translate, how editors switch languages, and how pages read a locale.

**Before this page:** [Fields](./fields).
:::

<Screenshot name="translate" alt="Editing the other language of a post" />

Content in several languages: localized fields hold one value per locale, other fields are
shared.

```ts
export default defineConfig({
  localization: { locales: ['th', 'en'], defaultLocale: 'th' },
  collections: [
    {
      slug: 'posts',
      fields: [
        { name: 'title', type: 'text', required: true, localized: true },
        { name: 'body', type: 'richText', localized: true },
        { name: 'cover', type: 'upload' }, // the same in every language
      ],
    },
  ],
})
```

| Option | Default | |
|---|---|---|
| `locales` | required | Locale codes, e.g. `['th', 'en']` or `['en', 'en-GB']` |
| `defaultLocale` | the first | Used when no locale is given; its values fill empty ones |
| `fallback` | `true` | Reads return the default locale's value when a locale's value is empty |

`localized: true` works on text, textarea, email, slug, rich text, number, boolean, date, JSON,
select, upload, relationship (also `hasMany`), array and blocks fields. A localized array or blocks
field holds a whole list per locale; otherwise localize the fields inside it. Groups can't be
localized: localize their fields. Fields inside a localized list can't be localized again.

## Reading and writing

```ts
await cms.find('posts') // default locale
await cms.find('posts', { locale: 'en' }) // English, falling back to Thai when empty
await cms.findById('posts', id, { locale: 'en', fallbackLocale: false }) // empty stays null
await cms.findById('posts', id, { locale: 'all' }) // title: { th: '…', en: '…' }

await cms.update('posts', id, { title: 'Hello' }, { locale: 'en' }) // Thai title kept
```

- Writes set the given locale's values (the default locale when none is given) and keep the other
  locales. `locale: 'all'` writes `{ [locale]: value }` maps directly.
- Reads with `locale: 'all'` are typed that way too: `title` is `{ th?: string | null; en?: …}`
  ([TypeScript](./typescript#every-locale-at-once)).
- `required` and custom `validate` apply to the locale being written.
- `where` and `sort` use the locale being read: `{ locale: 'en', where: { title: { like: 'x' } } }`
  matches English titles. Name a locale explicitly with `title.en`.
- Localized slugs are generated and kept unique per locale; so are `unique` values.
- Versions keep every locale, and restoring brings them all back.

Over REST add `?locale=en` (or `all`) and `?fallbackLocale=false`, on reads and writes.

## Admin

The edit page of a collection or global with localized fields has a **content language**
switcher; localized fields show the language being edited, and languages a document still needs
are marked with a dot. Editing shows empty values for untranslated fields instead of the
fallback. Live preview follows the chosen language; `preview` receives it as `locale`:

```ts
preview: ({ doc, locale }) => `/${locale}/posts/${doc.slug}`
```

Lists show the default language, with a **Translations** column: a language counts as translated
when every localized field filled in the default language is filled in it too.

The admin's own interface language (English or Thai) is separate: each user switches it at the
bottom of the menu or on their Account page.

## Adding a language

Languages are part of the schema (each localized field gets a column per language), so they are
added in the config and deployed with a migration, not from the admin:

1. Add the locale code: `localization: { locales: ['th', 'en', 'ja'], defaultLocale: 'th' }`.
2. Run `npx easy-cms migrate:create add-japanese` and review the new `*__ja` columns.
3. Deploy and run `easy-cms migrate`.

The admin then shows the new language everywhere, named in the user's interface language
(日本語 / Japanese / ญี่ปุ่น); existing documents list it as not translated yet. Removing a
language drops its columns and their text: back up first.

## Storage

Each localized field gets one column per locale. The default locale keeps the plain column
(`title`), the others add columns (`title__en`). So:

- Turning `localized` on for an existing field keeps its values as the default locale's.
- Localized arrays and `hasMany` fields keep their rows in the child table with a `_locale`
  column; existing rows become the default locale's.
- Adding a locale adds columns: create a migration as for any config change.
- Changing `defaultLocale` later moves the values between columns, so every locale keeps its
  own: the generated migration (and development push) adds the new columns, copies the values
  with `UPDATE` statements, then drops the old ones. Review it before deploying. Migrations
  created before 0.7 don't record the locales, so for the first change after upgrading, create
  a migration (`easy-cms migrate:create`) before changing `defaultLocale`.

## Next steps

- [REST API](./rest-api): the locale parameters of the API.
- [Migrations & deployment](./deployment): migrations when locales change.

---

<!-- https://easy-cms-website.vercel.app/docs/uploads.md -->

# Uploads & media

::: info What you'll learn
The media library, its folders and file types, image sizes, file limits, and storing files on disk or S3, Cloudflare R2 and MinIO.

**Before this page:** [Fields](./fields).
:::

<Screenshot name="media" alt="The media library" />

Files live in the built-in `media` collection. Link them with `upload` fields:

```ts
{ name: 'cover', type: 'upload' }
```

Editors upload in the admin's Media library or from an upload field's picker: choose or drop
several files at once. They upload three at a time, each with its progress; a file can be
cancelled, and one that failed tried again. Files larger than `upload.maxFileSize` are refused
before they are sent. The files just uploaded stay selected, ready to move to a folder.

The Media page shows a grid of cards (pictures, or an icon in each file type's color) or, with
the switch beside the search, a table. A file's page previews it by type: images, a player for
audio and video, the browser's PDF viewer, the start of text and CSV files (CSV as a table, Thai
text from Excel read as Windows-874). Word, Excel, PowerPoint and zip files show their icon and a
**Download** button; they are never sent to an outside viewer.

<Screenshot name="media-preview" alt="A CSV file's page: its first rows as a table" />

## Several files: galleries

<Screenshot name="gallery" alt="A gallery field: images in order, with buttons to move and remove them" />

`hasMany` makes an upload field hold several files, in the order editors arrange them:

```ts
{
  name: 'gallery',
  type: 'upload',
  hasMany: true,
  maxRows: 12, // and minRows
  mimeTypes: ['image/*'], // only images: the picker shows only these, and saving checks them
}
```

In the admin editors drop several files at once, pick several from the library, drag them into
order (or use the arrow buttons) and remove them. Reads return the media documents in that order
(ids with `depth: 0`):

```vue
<ul class="gallery">
  <li v-for="image in post.gallery" :key="image.id">
    <img :src="image.sizes?.thumbnail?.url ?? image.url" :alt="image.alt" />
  </li>
</ul>
```

Each image's caption is its alt text in the media library. When a caption belongs to this page
only, use an `array` with an `upload` and a `text` field instead: see the
[Image galleries](./recipes/image-galleries) recipe.

`mimeTypes` works on single uploads too, e.g. a cover that must be an image, or
`['application/pdf']` for a brochure.

## Folders

<Screenshot name="media-folders" alt="The media library with folders: the tree on the left, a folder open with its files" />

Turn on folders to sort the media library:

```ts
upload: { folders: true }
```

The Media page gets a folder tree, a path and the open folder's subfolders above its files.
**New folder** adds one in the open folder; uploads go into it; drag files onto a folder, or select
some and use **Move to…**. A folder's **⋯** menu renames, moves or deletes it. Deleting a folder
deletes no file: what it holds moves up to its parent. Searching looks in the open folder and its
subfolders. Upload fields' pickers browse folders too, and open where you last were.

A file is in one folder at most. Folders are records in the `media-folders` collection (`name`,
`parent`); files have a `folder`. Their URLs don't change when they move. Over REST:

```http
GET /api/cms/media?where[folder][equals]=4        files in folder 4
GET /api/cms/media?where[folder][exists]=false    files at the top
POST /api/cms/media-folders  { "name": "Banners", "parent": 4 }
```

Uploads take a `folder` too (a form field, or in the JSON with `url`). Folder names are unique
within their folder, ignoring case. Folders follow the role's [Media permissions](./roles), and are
visible to signed-in users only.

### Who can use a folder

With [roles](./roles) (`auth.rbac`), admins choose who can use each folder from its menu: **Who can
use it**. A folder follows the folder it is in until it is given its own list; then each role gets
one of:

| | |
|---|---|
| **View** | sees the folder and its files, and uses them in content |
| **Edit** | also uploads, renames, moves and deletes files there |
| **Manage** | also adds, renames, moves and deletes its folders |

Roles not listed don't see the folder. The top level is open to every role that may use Media.

<Screenshot name="media-folder-permissions" alt="Who can use a folder: each role's level" />

- A folder only narrows what the role may do with Media in Settings → Roles: a role without Delete
  on Media can't delete files anywhere.
- Admins can do everything, and only they set who can use a folder; changes are in the
  [audit log](./audit-log) as **Folder access changed**.
- API keys use their own Media permissions; folders don't limit them, unless the key is
  [limited to some folders](./api-keys#what-a-key-may-do).
- A post can keep a file from a folder its editor can't see: the field shows "A file you may not
  see", and the file stays unless they change or remove it.

::: warning This sorts the team's work
Files in other folders are still public: anyone with a file's link can open it, and requests that
are not signed in list them as before. For files that must stay private, use a private folder.
:::

### Private folders

An admin ticks **Private** in a folder's **Who can use it**: its files and subfolders become
private. They are kept apart from public files and served only through the API, at
`/api/cms/media/private/<name>`:

- to signed-in users who may see the folder (their role, and the folder's permissions);
- to anyone with a **signed link** your code makes, until it expires.

Requests that are not signed in don't list private files, and a post's private cover isn't sent to
visitors. Moving a file into or out of a private folder (or making a folder private or public)
moves it to the other storage and gives it a new URL: the admin asks first, saying how many
documents use it.

```ts
// A download link for a member, valid for an hour (the default; at most 7 days).
const link = cms.signedMediaURL(report, { expiresIn: '1h' })
// A resized copy:
cms.signedMediaURL(photo, { expiresIn: '30m', size: 'thumbnail' })
```

Links stop working when they expire, when `secret` changes, or when the file stops being private.
Public files get their usual URL.

**Where private files go.** On the local disk and Netlify Blobs, which serve nothing publicly, with
the other files. A storage with public URLs (S3 with `publicURL`, a public Vercel Blob store) needs
`upload.privateStorage` before folders can be private:

```ts
upload: {
  folders: true,
  storage: vercelBlobStorage(),
  privateStorage: vercelBlobStorage({ access: 'private', prefix: 'private/' }),
}
```

<Screenshot name="media-folder-permissions" alt="A folder's access: private, and each role's level" />

### Upload fields with a folder

Point an upload field at a folder by its key, and its picker opens there and its uploads land
there. The folder is made at the top level the first time it's needed; rename or move it in the
admin and the key stays.

```ts
{ name: 'banner', type: 'upload', folder: 'banners' }
// Only files from that folder (and the folders inside it), checked when saving:
{ name: 'logo', type: 'upload', folder: 'brand', folderOnly: true }
```

Turning folders on adds the `media-folders` table and the media `folder` and `private` columns:

```sh
npx easy-cms migrate:create media-folders
```

## Uploading from code

```ts
const media = await cms.upload({ data: bytes, name: 'photo.jpg' }, { alt: 'A photo' })
media.url // "/api/cms/media/file/photo-3f9a2c1b.jpg"
```

Over REST: `POST /api/cms/media` with `multipart/form-data`, the file in `file` and other fields
(like `alt`) as text parts.

## From a link

Moving content from another site? Let the server download files from their links:

```ts
upload: {
  fromURL: {
    // Hosts files may come from: exact names, `*.example.com` for subdomains, or `*` for any.
    allowedHosts: ['images.oldsite.com', '*.cdn.example.com'],
  },
},
```

The media library, the media picker and galleries then have a **From a link** field. Editors can
also paste a link (or several, one per line) into the upload area, or drop an image dragged from
another page. In code:

```ts
const media = await cms.uploadFromURL('https://images.oldsite.com/2024/beach.jpg', { alt: 'The beach' })
```

Over REST: `POST /api/cms/media` with JSON `{ "url": "https://…", "alt": "…" }`.

The file is then checked like any upload: its type from the contents, `maxFileSize` and the
right to create media. The name comes from the server's `Content-Disposition`, else the link.

::: warning Links reach your network
The server makes the request, from inside your network. So that a link can't reach what isn't
public, the server refuses:

- other schemes than `http` and `https`, and links with a password;
- private network addresses: `localhost`, `10.x`, `172.16–31.x`, `192.168.x`, link-local
  (`169.254.x`, where clouds serve their metadata), IPv6 ones too. Names are checked after DNS,
  and every redirect is checked again (three at most);
- hosts not in `allowedHosts`, also after a redirect;
- downloads over 15 seconds or larger than `maxFileSize`.

`allowPrivate: true` lifts the private address rule, e.g. for an intranet image server. Only
set it if every user who can upload may reach your internal network.
:::

`cms.uploadFromURL()` without a user is your own code (a migration script, say): it doesn't need
`upload.fromURL`, and ignores `allowedHosts`, but still refuses private addresses unless
`allowPrivate`. Called with `user` and `overrideAccess: false`, as the REST API does, it needs
both.

## File types

Easy CMS recognizes these from their contents:

| | Types |
|---|---|
| Images | JPEG, PNG, GIF, WebP, AVIF, SVG |
| Documents | PDF; Word, Excel, PowerPoint (`docx`, `xlsx`, `pptx`); OpenDocument (`odt`, `ods`, `odp`); CSV; text |
| Audio | MP3, WAV, Ogg, M4A |
| Video | MP4, WebM, MOV |
| Archives | zip |

Older Office files (`doc`, `xls`, `ppt`) are not recognized. Allow what you need with MIME types,
`type/*`, or these groups:

```ts
upload: {
  // documents: PDF, Word, Excel, PowerPoint, OpenDocument, CSV and text
  // office: docx, xlsx, pptx · archives: zip
  mimeTypes: ['image/*', 'documents', 'audio/*', 'video/*'],
}
```

An upload field's `mimeTypes` takes the same names, e.g. `{ type: 'upload', mimeTypes: ['office'] }`.

**SVG is left out of `image/*`.** An SVG can carry scripts, so it is allowed only when listed by
name: `mimeTypes: ['image/*', 'image/svg+xml']`. Do that only for people you trust to upload, and
see [S3, Cloudflare R2 and MinIO](#s3-cloudflare-r2-and-minio) when files are served from a bucket or CDN.

### Large files

Hosts limit how much one request carries: about 4.5 MB on Vercel, 6 MB on Netlify. With S3
(R2, MinIO) or Vercel Blob, the admin sends files larger than 4 MB from the browser straight to the
storage, so only `upload.maxFileSize` limits them:

1. `POST /api/cms/media/uploads` with `{ "name", "size", "type", ...fields }` checks who uploads
   what where, as any upload, and returns a signed `ticket` and where to send the file:
   `upload: { url, method, headers }` (valid for 15 minutes), or `upload: null` when the storage
   can't take files directly (send it to `POST /api/cms/media` instead).
2. The browser sends the file there.
3. `POST /api/cms/media/uploads/complete` with `{ "ticket" }` checks the file in the storage, its
   size and its type from its contents, and makes the media document. A file that fails is
   deleted.

From code: `cms.createUpload({ name, size }, fields, options)` and `cms.completeUpload(ticket)`.

- **S3, R2, MinIO:** the bucket needs CORS for the admin's origin, allowing `PUT` with a
  `content-type` header.
- **Vercel Blob:** nothing to set up.
- The admin's Content-Security-Policy lets the browser send files to the storage's upload
  address (S3's bucket, `vercel.com` for Blob) and nowhere else.
- **Netlify Blobs and the local disk** take files through the server: on Netlify, up to about
  6 MB.

A file sent but never completed (the page closed in between) stays in the storage, linked from
nowhere; on S3, a lifecycle rule can delete what nothing points to.

## What happens to a file

- **The type is detected from the contents**, not the name or the client's Content-Type. It must
  match `upload.mimeTypes` (default `image/*`, `application/pdf`). Only CSV is told apart from
  plain text by its name.
- **Size** is limited by `upload.maxFileSize` (default 10 MB); larger files get `413`.
- **Names** become `<name>-<random>.<detected extension>`, so they are safe and unique, and
  Thai names stay readable.
- **Images** get `width` and `height`. With [`sharp`](https://sharp.pixelplumbing.com) installed,
  `upload.imageSizes` creates resized copies in `sizes`.
- Deleting a media document deletes its files.

```ts
upload: {
  maxFileSize: 5 * 1024 * 1024,
  mimeTypes: ['image/*'],
  imageSizes: [
    { name: 'thumbnail', width: 400, height: 300 },
    { name: 'wide', width: 1600 },
  ],
}
```

A media document looks like:

```json
{
  "id": 7,
  "filename": "photo-3f9a2c1b.jpg",
  "originalName": "photo.jpg",
  "mimeType": "image/jpeg",
  "filesize": 184213,
  "width": 2400,
  "height": 1600,
  "alt": "A photo",
  "url": "/api/cms/media/file/photo-3f9a2c1b.jpg",
  "sizes": { "thumbnail": { "width": 400, "height": 300, "url": "…" } }
}
```

Only `alt` (and fields you add) can be edited afterwards. Media metadata is readable by anyone and
writable by logged-in users; declare a `media` collection to change that or add fields.

## Serving and storage

Files are served at `/api/cms/media/file/<name>` with long-lived caching and a sandboxing Content
Security Policy, so an uploaded SVG can't run scripts. PDFs are served without it: browsers won't
show a PDF in a sandbox, and their PDF viewers run apart from your site. Text files (`text/*`,
JSON, XML, JavaScript) are stored and served with `charset=utf-8`. Set `serverURL` for absolute
URLs.

The default storage is the local disk (`upload.dir`, default `uploads/`), which needs a persistent
filesystem. On serverless hosts (Vercel, Netlify) and in containers without a volume, use S3.

## S3, Cloudflare R2 and MinIO

```bash [pm]
npm install @easy-cms/storage-s3
```

```ts
import { s3Storage } from '@easy-cms/storage-s3'

export default defineConfig({
  // …
  upload: {
    storage: s3Storage({ bucket: 'my-site-media', region: 'eu-central-1' }),
  },
})
```

Credentials come from `AWS_ACCESS_KEY_ID` and `AWS_SECRET_ACCESS_KEY` (and `AWS_SESSION_TOKEN`,
`AWS_REGION`) unless you pass `accessKeyId` / `secretAccessKey`. They are read when the CMS
starts, so building without them works.

| Option | Default | |
|---|---|---|
| `bucket` | required | Bucket name |
| `region` | `AWS_REGION`, then `us-east-1` | `auto` for Cloudflare R2 |
| `endpoint` | AWS S3 | For S3-compatible services, e.g. `https://<account>.r2.cloudflarestorage.com` |
| `prefix` | none | Folder for the objects, e.g. `media/` |
| `publicURL` | none | Serve files from here (CDN or public bucket) instead of through the API |
| `forcePathStyle` | `true` with `endpoint` | `<endpoint>/<bucket>/<key>` addressing |

Cloudflare R2:

```ts
s3Storage({
  bucket: 'my-site-media',
  region: 'auto',
  endpoint: `https://${process.env.R2_ACCOUNT_ID}.r2.cloudflarestorage.com`,
  accessKeyId: process.env.R2_ACCESS_KEY_ID,
  secretAccessKey: process.env.R2_SECRET_ACCESS_KEY,
})
```

The access key needs `s3:PutObject`, `s3:GetObject` and `s3:DeleteObject` on the bucket.

**Private bucket (default).** Without `publicURL`, files are streamed through
`/api/cms/media/file/<name>` with the same caching and sandboxing headers as local storage, so
the bucket stays private. Put a CDN in front of your site to avoid fetching from S3 on every
request.

**Public bucket.** With `publicURL`, media URLs point to the bucket or CDN directly and your
server is not involved. Serve it from a **different domain** than your site: the sandboxing
Content Security Policy is not applied there, so an uploaded SVG could otherwise run scripts
with your site's origin.

## Vercel Blob

On Vercel, whose disk doesn't keep files, connect a Blob store to the project (Storage → Blob):
it sets `BLOB_READ_WRITE_TOKEN`.

```ts
import { vercelBlobStorage } from '@easy-cms/storage-vercel-blob'

upload: { storage: vercelBlobStorage() }, // public files on the Blob CDN
```

`vercelBlobStorage({ access: 'private', prefix: 'backups/' })` keeps files private (served through
the API), e.g. for `backups.storage`.

## Netlify Blobs

On Netlify, Blobs need no setup:

```ts
import { netlifyBlobsStorage } from '@easy-cms/storage-netlify-blobs'

upload: { storage: netlifyBlobsStorage() }, // served through <api>/media/file/…
```

Outside Netlify's runtime (a script, another host), pass `siteID` and a personal access `token`.

## Custom storage

Implement `StorageAdapter` (`put`, `get`, `delete`, optional `url` and `init`) and pass it as
`upload.storage`. For [large files](#large-files) straight from the browser, add `uploadURL(key,
{ contentType, size, expiresIn })` (where and how to send the file), `getStart(key, bytes)` (the
start of a file and its size, without reading it all) and `uploadOrigins` (the origins `uploadURL`
sends browsers to, which the admin allows in its Content-Security-Policy). Send only headers the
storage's CORS allows.

## Next steps

- [Rich text](./rich-text): images in rich text.
- [Backups & upgrades](./backups): back up uploads.

---

<!-- https://easy-cms-website.vercel.app/docs/auth.md -->

# Users & auth

::: info What you'll learn
Who can log in to the admin, how roles work, how to create users, and how sessions and tokens
authenticate requests from browsers and other apps.

**Before this page:** [Getting started](./getting-started).
:::

The built-in `users` collection holds the people who use the admin: editors, authors,
developers. They are separate from any customers or members your site has; keep those in your
own collection or auth system.

## The users collection

| Field | |
|---|---|
| `email` | Unique; used to log in. Stored in lowercase. |
| `name` | Shown in the admin (optional). |
| `role` | One of `auth.roles`. Only admins can change it. |
| `active` | Unticking it blocks logins and ends the user's sessions. Only admins can change it. |
| `password` | Write-only: accepted on create and update, never returned. At least 8 characters. |

Add your own fields, access rules or hooks by declaring a `users` collection; they are merged
into the built-in one:

```ts
collections: [
  {
    slug: 'users',
    fields: [
      { name: 'phone', type: 'text' },
      { name: 'avatar', type: 'upload' },
    ],
  },
]
```

By default logged-in users can read users, admins create and delete them, and users can update
themselves (but not their own role or `active`).

## Roles

```ts
auth: { roles: ['admin', 'editor', 'author'] } // default: ['admin', 'editor']
```

`admin` must be one of them; admins can do everything, including managing users. The other
roles mean what your [access rules](./access-control) say, for example:

```ts
{
  slug: 'posts',
  access: {
    read: () => true,
    // Authors create; editors and admins publish anything; authors change only their own posts.
    create: ({ user }) => !!user,
    update: ({ user }) =>
      user?.role === 'author' ? { author: { equals: user.id } } : !!user,
    delete: ({ user }) => user?.role === 'admin' || user?.role === 'editor',
  },
}
```

New users get `editor` when that role exists (otherwise the last role).

To let admins add roles and tick what each may do in the admin, without code, turn on
[roles from the admin](./roles) (`auth: { rbac: true }`). To sign in with Google, Microsoft, GitHub
or another OpenID Connect provider, see [Single sign-on](./sso).

Easy CMS refuses changes that would leave no active admin (deleting, demoting or deactivating
the last one), so nobody gets locked out.

## Creating users

- **The first admin:** when there are no users, the admin shows a form to create one. From a
  terminal or in CI, run `npx easy-cms admin:create` (see [CLI](./cli#admin-create)).
- **Everyone else:** admins add users under **Settings → Users** in the admin, or in code:

```ts
await cms.create('users', { email: 'ann@example.com', password: 'at least 8 chars', role: 'editor' })
```

Passwords are hashed with scrypt and never returned.

## Forgotten passwords and invitations

<Screenshot name="reset-password" alt="Setting a new password from an emailed link" />

With [email](./email) set up, the CMS sends links to set a password:

- **Forgot your password?** on the login page emails a link that works once, within an hour.
  The page answers the same whether or not the email has an account, so nobody can use it to
  find out who has one; a few requests per email and IP are allowed, more are ignored.
- **Invitations:** an admin creates a user and leaves the password empty. The user gets an email
  to choose a password (the link works for 7 days), so nobody else ever knows it.
- **Email a link to set the password** on a user's page sends an invitation, or a reset link if
  they have a password.

Setting the password signs the account out everywhere and in on this browser, and a reset is
followed by a "your password was changed" email.

The link points to `serverURL` (e.g. `https://cms.example.com`), which production needs: a
request's Host can be forged, and a link built from it could send the token to someone else.
In development the request's own address is used. Without `email`, or in production without
`serverURL`, the admin doesn't offer the links: admins set passwords themselves.

```ts
export default defineConfig({
  serverURL: 'https://cms.example.com',
  email: smtp(),
  auth: {
    resetPasswordExpiration: 60 * 60, // seconds; default 1 hour
    inviteExpiration: 7 * 24 * 60 * 60, // default 7 days
    // Your own text; the defaults are in English or Thai (the admin's language).
    emails: {
      invite: ({ user, url }) => ({
        subject: 'Welcome to the Acme content team',
        text: `Hi ${user.name ?? user.email}, set your password here: ${url}`,
      }),
    },
  },
})
```

Over REST: `POST /api/cms/auth/forgot-password` with `{ email }`, then
`POST /api/cms/auth/reset-password` with `{ token, password }` (which logs in);
`GET /api/cms/auth/reset-password?token=…` checks a link first. Admins send links with
`POST /api/cms/users/:id/password-link`. In code: `cms.auth.requestPasswordReset({ email })` and
`cms.auth.sendPasswordLink(userId)`.

## Sessions

Logging in creates a session:

1. `POST /api/cms/auth/login` with `{ email, password }` sets an HttpOnly session cookie (valid
   for 7 days, `auth.tokenExpiration` in seconds) and returns a CSRF token.
2. Browsers send the cookie on their own. Writes must also send the CSRF token in the
   `x-csrf-token` header (see [REST API](./rest-api#authentication)).
3. `POST /api/cms/auth/logout` ends it.

Logging out, changing the password or deactivating a user ends all of their sessions.

Users changing **their own** password or email send their current one too
(`PATCH /api/cms/users/<id>` with `{ password, currentPassword }`), so someone who got hold of a
session can't lock them out. Admins changing other users don't. An account without a password
(single sign-on only) can't change its email; an admin can.

### Tokens for other apps

Scripts and apps on other origins can send the session token instead of a cookie:

```bash
curl -s -X POST https://example.com/api/cms/auth/login \
  -H 'content-type: application/json' \
  -d '{"email":"bot@example.com","password":"…"}' -c cookies.txt
# Use the session cookie's value from cookies.txt: __Host-ecms-session over HTTPS,
# ecms-session over plain HTTP in development
curl https://example.com/api/cms/posts?draft=true -H "Authorization: Bearer $TOKEN"
```

Requests with `Authorization: Bearer` need no CSRF token. For scripts and apps, prefer
[API keys](./api-keys): they don't expire with a session and can be limited to what the app needs.

### Login limits

After `auth.maxLoginAttempts` failures (5) within `auth.lockWindow` seconds (15 minutes) for an
email (and IP, when the adapter knows it), login answers `429`. The lock lifts by itself.
Without the client's IP the count is per email only, so anyone could lock an account for a while:
see `trustProxy` in [Security](./security).

## The user in your pages

```ts
const user = await useEasyCMSUser(event) // Nuxt, in a server route
const user = await getEasyCMSUser(config) // Next.js, in a server component or route
```

Both return the logged-in admin user or `null`. Pass it to the Local API to apply that user's
access, for example to show editors drafts:

```ts
const { docs } = await cms.find('posts', { user, overrideAccess: false, draft: user !== null })
```

## Settings

| Option | Default | |
|---|---|---|
| `auth.roles` | `['admin', 'editor']` | Roles a user can have. Must include `admin`. |
| `auth.tokenExpiration` | 7 days | Session lifetime in seconds. |
| `auth.maxLoginAttempts` | `5` | Failed logins allowed within `lockWindow`. |
| `auth.lockWindow` | 15 minutes | In seconds. |
| `auth.trustedOrigins` | `[]` | Other origins allowed to send cookie-authenticated requests. |
| `auth.resetPasswordExpiration` | 1 hour | How long a "forgot password" link works, in seconds. |
| `auth.inviteExpiration` | 7 days | How long an invitation link works, in seconds. |
| `auth.emails` | — | `{ resetPassword, invite, passwordChanged }`: your own email text. |

## Next steps

- [Access control](./access-control): what each role may read and change.
- [Security](./security): what is protected, and a checklist for going live.

---

<!-- https://easy-cms-website.vercel.app/docs/api-keys.md -->

# API keys

::: info What you'll learn
How to give scripts, other apps and AI assistants their own key to the REST API, limited to
the collections and actions they need.

**Before this page:** [Users & auth](./auth) and [REST API](./rest-api).
:::

<Screenshot name="api-keys" alt="A new API key with its permissions ticked" />

A person logs in with a password and gets a session. A script shouldn't use someone's
password: give it an API key instead. A key belongs to a user and never does more than that
user may, and it can be limited further, e.g. "read and create posts, nothing else".

## Turn them on

```ts
export default defineConfig({
  // …
  apiKeys: true,
})
```

This adds an **API keys** page under **Settings** in the admin, and a table for them: in
production run `easy-cms migrate:create api-keys` and deploy the migration.

## Create a key

1. In the admin, open **Settings → API keys** and press **Create new**.
2. Give it a name that says who uses it ("Newsletter importer", "Claude").
3. Tick what it may do, per collection and global. Ticking create, update, delete or publish
   also ticks read.
4. Optionally set an expiry date, then **Save**.

The key is shown **once**, in a dialog: copy it into your app's secret settings or a password
manager. Easy CMS stores only a hash, so a lost key can't be shown again: delete it and create a
new one.

Keys look like `ecms_1a2b3c4d_…`. The part after `ecms_` is shown in the list as
**Key starts with**, so you can tell keys apart.

## Use it

Send the key as a Bearer token. No cookies, no CSRF token:

```bash
curl https://example.com/api/cms/posts?draft=true \
  -H "Authorization: Bearer $EASY_CMS_KEY"

curl -X POST https://example.com/api/cms/posts \
  -H "Authorization: Bearer $EASY_CMS_KEY" \
  -H 'content-type: application/json' \
  -d '{"title":"Imported"}'
```

| Answer | When |
|---|---|
| `401 Invalid or expired API key` | The key is wrong, deleted, expired, or its owner is deactivated. |
| `403 This API key may not create "posts"` | The key doesn't list that action. |
| `403 Forbidden` | The key lists it, but the owner's access rules don't allow it. |

## What a key may do

Each row of the table is a collection or global; each column an action:

| Action | Collections | Globals |
|---|---|---|
| **Read** | List and read documents, drafts included | Read |
| **Create** | Create documents; for **Media**, upload files | — |
| **Update** | Change documents, read their history, restore versions | Change |
| **Delete** | Delete documents | — |
| **Publish** | Publish, unpublish and schedule (collections with drafts) | The same |

- **Key and owner both decide.** The key narrows what its owner may do; the owner's
  [access rules](./access-control) still apply. An editor's key with "delete" can't delete
  what editors can't.
- **Unticked means not allowed**, including collections added later.
- **Keys never reach users or other keys.** Those collections aren't in the table, and a key
  can't create keys.
- **Saving as a draft needs no publish**: a key with create and update but not publish writes
  drafts that people review and publish.
- **Media folders** ([`upload.folders`](./uploads#folders)): a key with Media can use every folder,
  or **only some**, with the folders inside them. A limited key sees and changes only files in those
  folders, uploads only into them, and reads [private files](./uploads#private-folders) only there.

## Manage keys

- **Last used** shows when the key was last seen (updated at most once a minute).
- **Expires**: after this time the key answers `401`.
- **Revoke** a key by deleting it. It stops working at once.
- Deactivating a user (or deleting them) turns off their keys too.
- Admins see every key; other users see and manage only their own.
- **Account** lists your own keys, with **Revoke** and **Revoke all**. Keys keep working when a
  password changes, so after you reset a forgotten password the admin shows your keys first and
  offers to revoke them: whoever had the old password may have made one.

## In code

Create keys from a script or a seed with the Local API. The key is returned once:

```ts
const { key } = await cms.createApiKey({
  name: 'Newsletter importer',
  user: admin.id, // the owner; with { user, overrideAccess: false } it is that user
  permissions: { collections: { posts: ['read', 'create'] } },
  expiresAt: '2027-01-01T00:00:00Z',
})
```

A request made with a key has its user's `apiKey` set, so access rules and hooks can tell:

```ts
access: {
  // People may delete posts; keys may not, whatever they list.
  delete: ({ user }) => !!user && !user.apiKey,
}
```

## Security

- The secret part is 32 random bytes; Easy CMS stores its SHA-256 hash and compares in constant
  time.
- Treat keys like passwords: keep them in environment variables or a secrets manager, never in
  code or the browser.
- Give each app its own key with the fewest permissions, and an expiry when you can. Rotate by
  creating a new key, switching the app over, then deleting the old one.

## Next steps

- [REST API](./rest-api): every endpoint a key can call.
- [Plugins](./plugins): the MCP plugin uses API keys for AI assistants.

---

<!-- https://easy-cms-website.vercel.app/docs/local-api.md -->

# Local API

::: info What you'll learn
Every function of the typed Local API: finding, creating, updating, deleting, drafts, versions and access options.

**Before this page:** [Configuration](./configuration).
:::

The Local API is how your server code reads and writes content, without HTTP:

```ts
const cms = await useEasyCMS() // Nuxt server routes
const cms = await getEasyCMS(config) // Next.js
```

Outside a framework (scripts, tests):

```ts
import { createEasyCMS } from '@easy-cms/core'
import config from './easy-cms.config'

const cms = await createEasyCMS(config)
// …
await cms.destroy()
```

## Methods

```ts
await cms.find('posts', { where, sort: '-createdAt', limit: 10, page: 1, depth: 1, draft: false })
await cms.findById('posts', 12, { depth: 0 })
await cms.count('posts', { where })
await cms.create('posts', { title: 'Hello' })
await cms.update('posts', 12, { title: 'Hello again' })
await cms.delete('posts', 12)
await cms.findGlobal('site')
await cms.updateGlobal('site', { siteName: 'Easy' })
await cms.upload({ data, name: 'photo.jpg' }, { alt: '' })

// Collections and globals with versions (see Drafts & versions)
await cms.findVersions('posts', 12, { limit: 20, page: 1 })
await cms.findVersion('posts', 12, versionId)
await cms.restoreVersion('posts', 12, versionId)
await cms.unpublish('posts', 12)
await cms.discardDraft('posts', 12)

// Scheduled publishing and webhooks
await cms.schedule('posts', 12, { action: 'publish', at: new Date('2026-10-01T02:00:00Z') })
await cms.runJobs() // what servers do every minute: scheduled jobs and webhook retries
await cms.runScheduled() // only scheduled publishing
await cms.retryWebhooks() // only webhook deliveries that failed earlier
await cms.upcomingSchedules({ limit: 10 }) // next scheduled publishes across the site
await cms.flushWebhooks() // wait for webhook deliveries (serverless)
```

`find` returns `{ docs, totalDocs, limit, page, totalPages, hasNextPage, hasPrevPage }`.
`limit: 0` returns every match. `findById` returns `null` when missing; `update` and `delete`
throw `NotFoundError`.

## Options

| Option | Default | |
|---|---|---|
| `where` | — | Filter, below |
| `sort` | `-createdAt` | Field path or list; `-` for descending |
| `limit`, `page` | `10`, `1` | Pagination |
| `depth` | `1` | Levels of relationships to populate (max 3) |
| `draft` | `false` | Include drafts |
| `locale` | default locale | With [localization](./localization): the locale to read or write, or `'all'` |
| `fallbackLocale` | `true` | Empty localized values fall back to the default locale |
| `overrideAccess` | `true` | `false` applies [access rules](./access-control) for `user` |
| `user` | `null` | The user to check access for |
| `live` | `false` | `update` only: upkeep of the live version, for values a plugin keeps up to date. A pending draft stays pending, and no version is added to the history |

## where

```ts
{
  status: { equals: 'published' },
  views: { gte: 100 },
  title: { like: 'nuxt' }, // case-insensitive contains
  tags: { in: ['vue', 'nuxt'] }, // hasMany: any of
  'seo.title': { exists: true }, // group fields
  'links.url': { like: 'github' }, // fields inside array rows
  or: [{ featured: { equals: true } }, { views: { gt: 1000 } }],
}
```

Operators: `equals`, `not_equals`, `in`, `not_in`, `gt`, `gte`, `lt`, `lte`, `like`, `exists`,
plus `and` / `or`. `not_equals` and `not_in` include empty values. Querying inside a related
document (`author.name`) is not supported.

## Types

Results and inputs are typed from your config: `cms.find('posts')` gives `docs` with `title:
string`, `cms.create` requires required fields, and unknown collection names are errors. See
[TypeScript](./typescript).

## Errors

Errors carry an HTTP-like `status`: `ValidationError` (400, with `errors: [{ field, message }]`),
`UnauthorizedError` (401), `ForbiddenError` (403), `NotFoundError` (404),
`PayloadTooLargeError` (413), `TooManyRequestsError` (429), `QueryError` (400).

## Next steps

- [TypeScript](./typescript): the types it returns.
- [REST API](./rest-api): the same over HTTP.

---

<!-- https://easy-cms-website.vercel.app/docs/typescript.md -->

# TypeScript

::: info What you'll learn
How document types come from your config with no build step, how to name them in your code,
and how to generate a types file for apps that can't import the config.

**Before this page:** [Local API](./local-api).
:::

## Inferred from the config

Declare the config with `defineConfig`. It keeps the literal types (slugs, field names, select
options), so the Local API is typed without any generation step:

```ts
const cms = await getEasyCMS(config) // useEasyCMS() in Nuxt
const { docs } = await cms.find('posts')

docs[0].title // string (required: true)
docs[0].excerpt // string | null | undefined (optional)
docs[0].tags // ('vue' | 'nuxt')[] (select with hasMany)
docs[0].author // number | User (a relationship: an id, or the document when populated)
docs[0].status // 'draft' | 'published' (drafts: true)

await cms.find('pots') // error: unknown collection
await cms.create('posts', { titel: 'Hi' }) // error: unknown field, and `title` is missing
```

What each field becomes:

| Field | Type |
|---|---|
| `text`, `textarea`, `email`, `slug`, `date` | `string` (`date` as ISO 8601) |
| `number` | `number` |
| `boolean` | `boolean` |
| `select` | the options as a union; an array of them with `hasMany` |
| `relationship`, `upload` | the id, or the related document when populated; arrays with `hasMany` |
| `group` | an object of its fields |
| `array` | an array of rows with `id` and the row's fields |
| `blocks` | an array of rows, one type per block, told apart by `blockType` |
| `richText` | the Tiptap document |
| `json` | `unknown` |

Fields without `required: true` are optional: `null` or missing. `hidden` fields are not in the
type.

### Blocks

Rows of a `blocks` field are a union; check `blockType` to narrow:

```ts
for (const section of post.sections ?? []) {
  if (section.blockType === 'quote') section.author // only quote rows have author
}
```

### Fields that plugins add

Plugins tell the types what they add, so their fields are typed like yours:

```ts
plugins: [
  seoPlugin({ collections: ['posts'] }),
  nestedDocsPlugin({ collections: ['pages'] }),
  redirectsPlugin({ collections: ['pages'] }),
]

post.meta.title // string | null | undefined
page.breadcrumbs[0]?.url // string | null | undefined
await findByPath(cms, 'pages', '/about') // Page | null
await cms.find('redirects') // the collection the plugin adds
```

Your own plugins can do the same with [`definePlugin`](./plugins#typing-your-plugin).

### Every locale at once

With [localization](./localization), reads with `locale: 'all'` type localized fields as a map of
the config's locales; each locale may be missing or empty:

```ts
const post = await cms.findById('posts', id, { locale: 'all' })
post.title // { th?: string | null; en?: string | null }
post.slug // string (not localized)

const { docs } = await cms.find('posts', { locale: 'en' })
docs[0].title // string
```

Only the literal `'all'` does this: a locale typed `string` reads as one locale. Localized
relationships stay ids in the map (they are not populated). Name the type with
`CollectionDocument<typeof config, 'posts', true>` (`GlobalDocument` likewise).

## Naming the types

```ts
import type { CollectionDocument, CreateInput, GlobalDocument, UpdateInput } from '@easy-cms/core'
import type config from './easy-cms.config'

export type Post = CollectionDocument<typeof config, 'posts'>
export type NewPost = CreateInput<typeof config, 'posts'>
export type PostChanges = UpdateInput<typeof config, 'posts'>
export type Site = GlobalDocument<typeof config, 'site'>
```

`import type` keeps the config (and its database driver) out of browser bundles, so components
can use `Post` too.

### In Nuxt

Server routes return typed data, and `useFetch` passes the type on to the page:

```ts
// server/api/posts.get.ts
export default defineEventHandler(async () => (await useEasyCMS()).find('posts'))
```

```vue
<script setup lang="ts">
const { data } = await useFetch('/api/posts') // data.value.docs is Post[]
</script>
```

## Generated types for other apps

For a frontend that can't import your config, such as a Vite app in another repository calling
the REST API:

```bash [pm]
npx easy-cms generate:types              # writes easy-cms-types.ts
npx easy-cms generate:types --out ../web/src/cms.ts
```

The file has no imports: one interface per collection and global, plus `Collections` and
`Globals` maps.

```ts
import type { Post } from './easy-cms-types'

const res = await fetch('/api/cms/posts?limit=10')
const { docs }: { docs: Post[] } = await res.json()
```

Run it again after changing the config, or in CI to check the file is up to date:

```bash [pm]
npx easy-cms generate:types && git diff --exit-code easy-cms-types.ts
```

## Next steps

- [Local API](./local-api): every function and its options.
- [REST API](./rest-api): the same operations over HTTP.

---

<!-- https://easy-cms-website.vercel.app/docs/rich-text.md -->

# Rich text

::: info What you'll learn
What editors can do in a rich text field, how it is stored, and how to show it safely on your
pages with Nuxt, Next.js or any frontend.

**Before this page:** [Fields](./fields).
:::

## The field

```ts
{ name: 'body', type: 'richText', localized: true }
```

In the admin, `richText` fields are edited with [Tiptap](https://tiptap.dev): headings (H2–H4),
bold, italic, underline, inline code, links, bullet and numbered lists, quotes, images from the
media library (or a URL), undo and redo. Keyboard shortcuts work as in other editors
(<kbd>Ctrl</kbd>/<kbd>⌘</kbd> + <kbd>B</kbd>, <kbd>I</kbd>, <kbd>U</kbd>, <kbd>Z</kbd>).

The value is stored as Tiptap JSON, a tree of nodes, not HTML:

```json
{
  "type": "doc",
  "content": [
    { "type": "heading", "attrs": { "level": 2 }, "content": [{ "type": "text", "text": "Hello" }] },
    {
      "type": "paragraph",
      "content": [
        { "type": "text", "text": "Read the " },
        { "type": "text", "text": "guide", "marks": [{ "type": "link", "attrs": { "href": "/guide" } }] }
      ]
    }
  ]
}
```

JSON keeps content independent of how you show it: the same document can become HTML, plain
text for a search index, or your own components.

## Showing it on a page

Install the renderer:

```bash [pm]
npm install @easy-cms/richtext
```

::: code-group

```vue [Nuxt]
<script setup lang="ts">
import { renderRichText } from '@easy-cms/richtext'

const { data: post } = await useFetch(`/api/posts/${useRoute().params.slug}`)
const html = computed(() => renderRichText(post.value?.body))
</script>

<template>
  <!-- Safe: renderRichText escapes text and drops unsafe URLs -->
  <div class="prose" v-html="html" />
</template>
```

```tsx [Next.js]
import { renderRichText } from '@easy-cms/richtext'

export function PostBody({ body }: { body: unknown }) {
  // Safe: renderRichText escapes text and drops unsafe URLs
  return <div className="prose" dangerouslySetInnerHTML={{ __html: renderRichText(body) }} />
}
```

```ts [Any frontend]
import { renderRichText } from '@easy-cms/richtext'

const post = await fetch('/api/cms/posts/1').then((r) => r.json())
document.querySelector('#body')!.innerHTML = renderRichText(post.body)
```

:::

It renders these nodes: paragraphs, headings, bullet and numbered lists, quotes, code blocks,
line breaks, horizontal rules and images; and these marks: bold, italic, underline,
strikethrough, code and links. Unknown nodes keep their text without markup, so newer content never
breaks a page.

### Safety

`renderRichText` escapes all text and attributes and drops unsafe URLs (`javascript:`, `data:`
and anything other than http(s), mailto, tel and relative links), so its output is safe for
`v-html` or `dangerouslySetInnerHTML`. External links get `rel="noopener noreferrer"`.

## Plain text

```ts
import { richTextToPlainText } from '@easy-cms/richtext'

const excerpt = richTextToPlainText(post.body).slice(0, 160) // meta descriptions, search
```

## Markdown

`renderMarkdown(doc)` turns rich text into Markdown, e.g. for AI assistants
([AI search](./ai-search)), emails or exports. Text that looks like Markdown is escaped and
unsafe URLs are dropped, as with HTML. Pass `nodes` to render your own node types.

```ts
import { renderMarkdown } from '@easy-cms/richtext'

const markdown = renderMarkdown(post.body) // "## Heading\n\nA paragraph with **bold** text."
```

## Customizing the output

Replace or add the renderer of any node. It receives the node and its rendered children:

```ts
import { slugify } from '@easy-cms/core'
import { escapeHtml, renderRichText, safeUrl } from '@easy-cms/richtext'

renderRichText(post.body, {
  nodes: {
    // Wrap images in a figure with a caption from the alt text.
    image: (node) => {
      const src = safeUrl(node.attrs?.src)
      const alt = escapeHtml(String(node.attrs?.alt ?? ''))
      return src ? `<figure><img src="${escapeHtml(src)}" alt="${alt}"><figcaption>${alt}</figcaption></figure>` : ''
    },
    // Add ids to headings for a table of contents.
    heading: (node, children) => {
      const level = Number(node.attrs?.level ?? 2)
      return `<h${level} id="${escapeHtml(slugify(children))}">${children}</h${level}>`
    },
  },
  // Attributes for every link, e.g. open external ones in a new tab.
  linkAttributes: (href) => (href.startsWith('http') ? { target: '_blank', rel: 'noopener noreferrer' } : {}),
})
```

Custom renderers receive raw attributes: escape them yourself with the exported `escapeHtml`
and `safeUrl` (children are already escaped).

## Next steps

- [Uploads & media](./uploads): the images editors insert.
- [Localization](./localization): a rich text field per language.

---

<!-- https://easy-cms-website.vercel.app/docs/live-preview.md -->

# Live preview

::: info What you'll learn
Show editors the real page next to the form, updated as they type, before saving.

**Before this page:** [Drafts, versions & scheduling](./drafts).
:::

<Screenshot name="preview" alt="Live preview next to the form" />

Editors see the real page next to the form, updated as they type, before anything is saved.

```ts
{
  slug: 'posts',
  preview: ({ doc }) => (doc.slug ? `/posts/${doc.slug}` : null),
  fields: [/* … */],
}
```

`preview` returns the page that shows a document: a path on the site or an absolute URL, or
`null` when it has no page. Globals take the same option. The admin then shows a **Preview**
button; the page opens in a frame beside the form.

## How it works

1. The admin loads the page from `preview` once.
2. On every change it sends the form to the server (`POST /:collection/:id/preview`), which
   returns the document as a normal read would: relationships and uploads populated, `afterRead`
   hooks applied. Nothing is saved.
3. The admin posts that document to the page with `postMessage`; the page renders it.

So the page has to render from the document it receives. The helpers below do the wiring.

## Nuxt

`useLivePreview` is auto-imported. Give it the ref that holds the document:

```vue
<script setup lang="ts">
import { renderRichText } from '@easy-cms/richtext'

const { data: post } = await useFetch(`/api/posts/${useRoute().params.slug}`)
useLivePreview(post)
const html = computed(() => renderRichText(post.value?.body))
</script>
```

## Next.js

Load in a Server Component, render in a Client Component with `useLivePreview`:

```tsx
'use client'
import { useLivePreview } from '@easy-cms/next/live-preview'

export function PostView({ post: initial }: { post: Post }) {
  const post = useLivePreview(initial)
  return <h1>{post.title}</h1>
}
```

## Any frontend

```ts
import { subscribeLivePreview } from '@easy-cms/core/live-preview'

const stop = subscribeLivePreview((doc) => render(doc), {
  origin: 'https://cms.example.com', // the admin's origin; default: the page's own
})
```

Outside the admin's frame the helpers do nothing, so they can stay in production code.

## Preview tokens

When the admin opens the preview, it adds `easy-cms-preview=<token>` to the URL. The token opens
**one document's current draft** (or one global) for an hour, without a login, so a frontend on
another origin can show drafts that were never published:

```ts
import { getPreviewToken } from '@easy-cms/core/live-preview'

const token = getPreviewToken() // from ?easy-cms-preview=, or null
const post = await fetch(
  `${api}/posts/${id}?depth=2${token ? `&preview=${token}` : ''}`,
).then((r) => r.json())
```

Put the document id in the preview URL for this, e.g.
`preview: ({ doc }) => \`https://www.example.com/preview/posts/${doc.id}\``.

On the server, `cms.verifyPreviewToken(token)` returns what a token opens
(`{ collection, id }` or `{ global }`) or `null`, and `cms.createPreviewToken({ collection, id })`
creates one, e.g. for a "share preview link" button.

A token from the admin reads with the access of **whoever opened the preview**: fields they may
not read are left out, and their links stop working when the user is deactivated. Pass
`{ user }` to `createPreviewToken` for the same; without it, the token reads with full access.

## Things to know

- Render rich text on the client too (`renderRichText` from `@easy-cms/richtext` works in the
  browser), since the document carries the rich text JSON.
- With the Nuxt and Next adapters, the page and the admin share an origin and the editor's
  session, so a page that reads drafts for logged-in users (as the examples do) needs no token.
- A preview token only opens the document it was made for, expires after an hour, and is refused
  when tampered with. Treat preview URLs like a temporary share link.
- Only users who may update a document (or create one, for new documents) can preview it.

## Next steps

- [Localization](./localization): preview each language.
- [SEO](./seo): preview search results too.

---

<!-- https://easy-cms-website.vercel.app/docs/plugins.md -->

# Plugins

::: info What you'll learn
How to use plugins, and how to write your own with fields, REST endpoints and admin components.

**Before this page:** [Configuration](./configuration), [Hooks](./hooks).
:::

A plugin is a function that receives your config and returns a new one. It can add fields,
collections, hooks, [REST endpoints](#endpoints) and [admin components](#admin-components).

```ts
import { seoPlugin } from '@easy-cms/plugin-seo'

export default defineConfig({
  // …
  plugins: [seoPlugin({ collections: ['posts'] })],
})
```

Plugins run in order, before the config is validated, so a plugin's mistakes are reported like
your own.

## Official plugins

| Package | |
|---|---|
| [`@easy-cms/plugin-seo`](./seo) | Meta title, description and share image, with length meters, a search preview and Generate buttons in the admin, and page metadata for Nuxt and Next.js. |
| [`@easy-cms/plugin-mcp`](./mcp) | An MCP server, so AI assistants (Claude, Cursor, VS Code) read and write content with an API key. |

Plugins from others are named `easy-cms-plugin-*` and have the npm keyword `easy-cms-plugin`.

## Writing a plugin

Take options, return `(config) => config`, and add to what is there instead of replacing it:

```ts
import type { Field, Plugin } from '@easy-cms/core'

const minutes = (text: unknown) => Math.ceil(String(text ?? '').split(/\s+/).length / 200)

export function readingTime(options: { collections: string[] }): Plugin {
  const field: Field = { name: 'readingTime', type: 'number', admin: { position: 'sidebar' } }
  return (config) => ({
    ...config,
    collections: config.collections?.map((c) =>
      options.collections.includes(c.slug)
        ? {
            ...c,
            fields: [...c.fields, field],
            hooks: {
              ...c.hooks,
              beforeChange: [
                ...(c.hooks?.beforeChange ?? []),
                ({ data }) => ({ ...data, readingTime: minutes(data.excerpt) }),
              ],
            },
          }
        : c,
    ),
  })
}
```

Throw an `Error` for wrong options (an unknown slug, a field name that is taken): it stops
startup with your message.

### Naming your plugin

Give `definePlugin` your package's name and version, and admins see them on the dashboard
(System), which helps when someone reports a problem:

```ts
import { definePlugin } from '@easy-cms/core'
import pkg from '../package.json' with { type: 'json' }

export const readingTime = (options: { collections: string[] }) =>
  definePlugin((config) => ({ /* … */ ...config }), {
    name: pkg.name,
    version: pkg.version,
    apiVersion: 1,
  })
```

Plugins without a name are counted there as "without a name". The official plugins name
themselves.

`apiVersion` (optional) is the plugin API your plugin is written for: `PLUGIN_API_VERSION`,
exported from `@easy-cms/core` and `@easy-cms/core/plugin`. A plugin for another version stops
startup with a `ConfigError` that says what to upgrade: Easy CMS or the plugin. Leave it out to
skip the check.

Adapters (database, storage, email, auth providers) can do the same: set `apiVersion: 1` on the
adapter object to check it against `ADAPTER_API_VERSION` from `@easy-cms/core`.

### Typing your plugin

Document types are inferred from the config, and a plain `Plugin` is invisible to them:
`post.readingTime` wouldn't exist for TypeScript. `definePlugin` tells the types what the plugin
adds, written like config fields. A `const` type parameter keeps the collection names the user
passes:

```ts
import { definePlugin } from '@easy-cms/core'

type ReadingTimeField = { readonly name: 'readingTime'; readonly type: 'number' }

export function readingTime<const S extends string>(options: { collections: readonly S[] }) {
  return definePlugin<{ fields: { [K in S]: readonly [ReadingTimeField] } }>((config) => ({
    // … as above
  }))
}

// readingTime({ collections: ['posts'] }) → `post.readingTime: number | null | undefined`
```

`definePlugin<T>` returns the plugin unchanged; `T` has up to three parts:

| | |
|---|---|
| `fields` | Fields added to collections, by slug. |
| `globalFields` | Fields added to globals, by slug. |
| `collections` | Whole collections the plugin adds, e.g. `[{ readonly slug: 'redirects'; readonly fields: readonly [...] }]`. |

The official plugins do the same, so `post.meta` (SEO), `page.path` and `page.breadcrumbs`
(nested pages) and the `redirects` and `forms` collections are typed. When the options aren't
literals (a `string[]` variable), the plugin's fields are left out rather than added everywhere.

If your package also has code for browsers (like `seoMeta`), keep core out of it: instead of
importing `definePlugin`, write the type only, `return plugin as TypedPlugin<…>`.

## Endpoints

`endpoints` adds routes to the REST API, under `routes.api` (`/api/cms`):

```ts
import { UnauthorizedError } from '@easy-cms/core'

// in the config, or added by a plugin
endpoints: [
  {
    path: '/stats/:collection',
    method: 'get',
    handler: async ({ params, user, cms }) => {
      if (!user) throw new UnauthorizedError()
      const totalDocs = await cms.count(params.collection, { user, overrideAccess: false })
      return { totalDocs }
    },
  },
],
```

The handler receives:

| | |
|---|---|
| `request`, `url` | The Web `Request` and its URL. |
| `params` | Values of `:name` segments. |
| `user` | The logged-in user (session cookie or Bearer token), or `null`. |
| `cms` | The [Local API](./local-api). Pass `{ user, overrideAccess: false }` to apply the user's access rules. |
| `json()` | The JSON body; it must be an object of at most 1 MB. |

Return a value to send it as JSON, or a `Response` for anything else. Throw `UnauthorizedError`,
`ForbiddenError`, `NotFoundError` or `ValidationError` from `@easy-cms/core` for error responses in the
API's format.

- Writes from the browser pass the same [CSRF check](./security) as the built-in API.
- The first segment can't be a collection slug or one the built-in API uses: `users`, `globals`,
  `admin`, `jobs`, `media`, `api-keys`, `auth`, and `system`, `health`, `internal` (kept free for
  later versions). Such a path is an error at start. Start with your plugin's name: `/seo/generate`.
- A fixed segment wins over a parameter: `/stats/summary` before `/stats/:collection`.
- A request whose path matches but whose method doesn't falls through to the built-in API; when
  that has no such route either, it gets `405` with an `Allow` header.
- `root: true` serves the path from the site's root instead, e.g. `/robots.txt`. Only the
  [standalone server](./standalone) serves these: a Nuxt or Next.js app owns its root, so a
  plugin with a root endpoint should also offer a helper for the app's own route (as the
  [SEO plugin](./seo#robots-txt) does). Root paths can't be under `routes.api`, the admin or
  `/healthz`.

## Admin components

The admin is a prebuilt app, so plugins extend it with **Web Components**: custom elements the
admin creates and passes the edit page's state to. They work with any framework (or none) and
keep working when the admin's own code changes.

Use them in fields and edit pages:

```ts
fields: [
  // Instead of the input; the admin keeps the label and error messages.
  { name: 'color', type: 'text', admin: { component: 'ecms-color-picker' } },
  // Below the field.
  { name: 'summary', type: 'textarea', admin: { after: [{ tag: 'ecms-word-count', props: { max: 80 } }] } },
],
// Panels in the edit page's side column (collections and globals).
admin: { sidebar: ['ecms-checklist'] },
```

A component is a custom element name (lowercase, with a `-`, e.g. `acme-color-picker`), or
`{ tag, props }`. `props` must be plain JSON;
the element receives them as `options`.

### Pages and dashboard panels

<Screenshot name="forms-overview" alt="The form builder's Form overview page: submissions per day and per form" />

A plugin can also have **pages of its own** and **panels on the dashboard**, e.g. a report:

```ts
admin: {
  pages: [
    {
      path: 'site-stats',                         // /admin/p/site-stats
      label: { en: 'Site stats', th: 'สถิติเว็บไซต์' },
      icon: 'chart-column',
      component: 'ecms-site-stats',
      group: 'content',                           // or 'settings', or false: not in the menu
      access: ({ user }) => user.role === 'admin', // default: every logged-in user
    },
  ],
  dashboard: [
    { component: 'ecms-visits-widget', width: 'half' }, // the side column; 'full': below
  ],
},
```

A dashboard panel can have an `id`. [Roles](./roles#setting-it-up) are given panels by it
(`widget:<id>`), so a panel keeps its permissions when its tag changes. Default: the tag.

- The admin draws a page's header (its `label` as the title, also in the browser tab); the
  element draws the rest. Dashboard panels follow the built-in ones in config order: `half`
  (the default) in the side column, beside drafts to review and recent edits; `full` below
  both columns. On phones everything is one column.
- `access` is checked on the server: pages and panels a user may not see are left out of their
  admin. It only hides them: check access again in the endpoints that give them data.
- Page paths are lowercase letters, digits and `-`, and unique.
- A page gets `route`: what follows its path (`/admin/p/site-stats/2026` → `subpath: '2026'`)
  and the query. To change them, or to open another admin page, send `navigate`:
  `this.dispatchEvent(new CustomEvent('navigate', { detail: '/p/site-stats?range=30' }))`.
  Keep state such as tabs or a date range in the address, so it survives a reload and can be
  shared.

### The module

Put the elements in one self-contained ES module (no imports) and list it in `admin.modules`, by
package export or by path from the project root:

```ts
admin: { modules: ['./admin/color-picker.js'] }            // your own
admin: { modules: ['@acme/easy-cms-plugin-color/admin'] }  // from a package
```

A plugin adds its module itself:
`admin: { ...config.admin, modules: [...(config.admin?.modules ?? []), '@acme/easy-cms-plugin-color/admin'] }`.

The server finds the file (a package export needs the `default` condition) and serves it to
logged-in users at `<api>/admin/ui/modules/<n>.js`; the admin imports every module after login.
URLs of other sites are not allowed. If a module fails to load, the admin works without it and
shows which component is missing.

```js
// admin/color-picker.js
class ColorPicker extends HTMLElement {
  #input = document.createElement('input')

  constructor() {
    super()
    this.#input.type = 'color'
    // `change` with the new value as `detail` sets the field.
    this.#input.addEventListener('input', () =>
      this.dispatchEvent(new CustomEvent('change', { detail: this.#input.value })),
    )
    this.attachShadow({ mode: 'open' }).append(this.#input)
  }

  set value(value) {
    this.#input.value = value ?? '#000000'
  }

  set readOnly(readOnly) {
    this.#input.disabled = readOnly
  }
}
customElements.define('ecms-color-picker', ColorPicker)
```

### What the element receives

The admin sets these properties, and sets them again whenever the form changes:

| Property | |
|---|---|
| `apiVersion` | `1`. Raised only for changes that break components. |
| `value` | The field's value (field components). |
| `path` | The field's path, e.g. `meta.title` (field components). |
| `field` | The field as the admin sees it: `name`, `type`, `label`, `maxLength`… |
| `label` | The field's label in the admin's language. |
| `doc` | The whole form as edited, not saved yet (a copy). |
| `collection` / `global` | Slug of what is being edited. |
| `id` | The document's id; `null` while creating one. |
| `locale` | The content locale being edited, or `null` without localization. |
| `uiLocale` | The admin's language: `en` or `th`. |
| `readOnly` | The user may not change it. |
| `options` | The component's `props`. |
| `api(method, path, body?)` | Calls the REST API as the logged-in user (cookies and CSRF included), e.g. your plugin's endpoint. |
| `user` | The logged-in user: `id`, `email`, `role`. |
| `route` | On a page: `{ subpath, query }` after its path. Otherwise `undefined`. |

And listens for these events:

| Event | `detail` | |
|---|---|---|
| `change` | the new value | Sets the field (field components). |
| `set-field` | `{ path, value }` | Sets any field of the form, e.g. `meta.title` from a Generate button. |
| `navigate` | a path in the admin | Opens it, e.g. `/collections/posts` or `/p/site-stats?range=30`. |

Changes are not saved until the editor saves.

### Styling

Styles inside a shadow root don't leak in or out, and CSS variables pass through, so use the
admin's to match its light and dark themes: `--text`, `--text-muted`, `--surface`, `--surface-2`,
`--border`, `--border-strong`, `--brand`, `--accent-soft`, `--danger`, `--ok`, `--warning-text`,
`--info`, `--focus`, `--radius`, `--radius-sm`.

### Security and deployment

- Admin modules run with the rights of whoever is logged in. Install plugins you trust, as you
  would any dependency.
- The Nuxt module and `withEasyCMS()` for Next.js include the module files in the server build
  (since 0.13.1 for Next.js), so they load on Vercel and in standalone output too.

## Next steps

- [Custom field types](./field-types): add a `type` with its own input, e.g. for your plugin.
- [SEO](./seo): the SEO plugin.
- [REST API](./rest-api): how endpoints fit the API.

---

<!-- https://easy-cms-website.vercel.app/docs/seo.md -->

# SEO

::: info What you'll learn
Add SEO fields with a search preview to the admin, fill your pages' metadata from them, and
publish a sitemap, robots.txt and structured data.

**Before this page:** [Plugins](./plugins).
:::

<Screenshot name="seo" alt="The SEO fields with length meters and a search preview" />

`@easy-cms/plugin-seo` adds a `meta` group (title, description, share image) to the collections
and globals you choose. Editors see how long each text is, a preview of the search result, and
Generate buttons; your pages get their metadata from one function.

```bash [pm]
npm install @easy-cms/plugin-seo
```

```ts
import { seoPlugin } from '@easy-cms/plugin-seo'

export default defineConfig({
  // …
  plugins: [
    seoPlugin({
      collections: ['posts'],
      globals: ['site'],
      generateTitle: ({ doc }) => `${doc.title} | My Blog`,
      generateDescription: ({ doc }) => doc.excerpt,
      generateImage: ({ doc }) => doc.cover,
      generateURL: ({ doc }) => `https://example.com/posts/${doc.slug}`,
    }),
  ],
})
```

The group adds columns to the collections' tables: run `easy-cms migrate:create seo` (see
[Migrations](./deployment)). Upgrading from 0.16 or earlier? The "Hide from search engines"
checkbox is a new column too, so create a migration after updating.

## In the admin

- **Meta title** and **Meta description**, with a meter: short, good length or too long
  (50–60 and 100–150 characters by default, counting Thai letters with their marks as one).
- **Share image**, from the media library.
- **Hide from search engines**: adds `noindex` to the page and leaves it out of the sitemap,
  for thank-you pages, landing pages for ads and the like.
- **Generate** buttons for each generator you configure. They send the form as it is, unsaved,
  to your generator on the server, and fill the field.

A generator's `doc` is a plain object (the plugin is set up before the config, so it can't be
typed from the collection), and a generator may return a field as it reads it, as above: text
for the title, description and address, a media id or document for the image. Other values
suggest nothing.
- **Search result preview** below the group: the meta title (or the document's title), the
  address from `generateURL` and the description.

## Options

| Option | Default | |
|---|---|---|
| `collections` | `[]` | Collections that get SEO fields. |
| `globals` | `[]` | Globals that get SEO fields. |
| `position` | `'main'` | `'sidebar'` puts the group in the edit page's side column. |
| `tab` | `true` | With `position: 'main'`: the fields in an **SEO** tab of the edit page ([layouts](./admin#edit-pages)), its label, or `false` to keep them below the other fields. |
| `generateTitle` | — | Suggests a meta title. |
| `generateDescription` | — | Suggests a meta description. |
| `generateImage` | — | Suggests a share image: a media document or its id. |
| `generateURL` | — | The page's address, for the preview and the [sitemap](#sitemap). |
| `autoGenerate` | `false` | Fill empty meta fields with the generators when a document is saved. |
| `fields` | — | `(defaults) => fields`: change the group's fields, e.g. add a keywords field. |
| `titleLength` | `{ min: 50, max: 60 }` | Length marked as good. |
| `descriptionLength` | `{ min: 100, max: 150 }` | Length marked as good. |
| `localized` | with `localization` | One value per [content locale](./localization). |
| `label` | `'SEO'` | Label of the group. |
| `robots` | `{}` | Options of [`robots.txt`](#robots-txt) for the standalone server, or `false` for none. |
| `llms` | `{}` | [`llms.txt`](./ai-search#llms-txt) for AI assistants: title, summary, limit, Markdown links. `false` turns off the standalone server's routes. |
| `markdown` | — | `{ [slug]: (doc) => markdown }`: your own [Markdown version](./ai-search#markdown-versions-of-pages) of a collection's pages. |
| `indexNow` | — | `{ key }`: tell search engines about changed pages with [IndexNow](./ai-search#indexnow). |

Generators receive `{ doc, id, locale, collection | global, cms, user }` and may be async. Return
`null` when there is nothing to suggest.

::: tip generateURL is the page's address
The sitemap, the search preview and the hreflang links all come from `generateURL`, so give it
the real address of each page, per locale when your site has several. Return `null` for
documents that have no page (yet), e.g. without a slug.

```ts
generateURL: ({ doc, collection, locale }) =>
  collection === 'posts'
    ? doc.slug ? `/${locale}/posts/${doc.slug}` : null
    : `/${locale}`,
```
:::

## On your pages

`seoMeta(doc, options)` reads the meta fields, falls back to the document (`title`, then
`excerpt` or `description`) and returns the metadata for Nuxt and Next.js: title, description,
Open Graph and Twitter tags, `noindex`, the canonical and hreflang links, and
[JSON-LD](#structured-data-json-ld). Fetch the document with `depth` 1 or more so the share
image has its URL.

::: code-group

```vue [Nuxt page]
<script setup lang="ts">
import { seoMeta } from '@easy-cms/plugin-seo'

const route = useRoute()
const locale = route.params.locale as string
const { data: post } = await useFetch(`/api/posts/${route.params.slug}`, { query: { locale } })
const seo = seoMeta(post.value ?? {}, {
  siteURL: useRequestURL().origin,
  locale,
  locales: ['th', 'en'],
  url: (p, l) => `/${l}/posts/${p.slug}`,
  type: 'article',
})
useSeoMeta(seo.nuxt) // title, description, Open Graph, robots
useHead(seo.head) // canonical and hreflang links, JSON-LD
</script>
```

```tsx [Next.js page]
import { jsonLdScript, seoMeta } from '@easy-cms/plugin-seo'
import { connection } from 'next/server'
import { Suspense } from 'react'

async function load(slug: string, locale: string) {
  const cms = await getEasyCMS(config)
  const { docs } = await cms.find('posts', { where: { slug: { equals: slug } }, locale, limit: 1 })
  if (!docs[0]) return null
  const seo = seoMeta(docs[0], {
    config, // admin.siteURL and the locales
    locale,
    url: (p, l) => `/${l}/posts/${p.slug}`,
    type: 'article',
  })
  return { post: docs[0], seo }
}

export async function generateMetadata({ params }) {
  const { slug, locale } = await params
  return (await load(slug, locale))?.seo.next ?? {}
}

export default function Page({ params }) {
  return (
    <Suspense>
      <Post params={params} />
    </Suspense>
  )
}

async function Post({ params }) {
  await connection() // read on each request
  const { slug, locale } = await params
  const page = await load(slug, locale)
  if (!page) notFound()
  return (
    <>
      <script
        type="application/ld+json"
        dangerouslySetInnerHTML={{ __html: jsonLdScript(page.seo.jsonLd) }}
      />
      <PostView post={page.post} />
    </>
  )
}
```

:::

| Option | |
|---|---|
| `siteURL` | The site's address, to make the canonical and image URLs absolute. Default: `config.admin.siteURL`, then `config.serverURL`. |
| `config` | Your Easy CMS config, to read those and `localization` from. |
| `url` | The page's address, or `(doc, locale) => url`. As a function it also makes the hreflang links, one per locale plus `x-default`. |
| `locale` | The page's content locale: `og:locale` and the canonical address. |
| `locales`, `defaultLocale` | For hreflang. Default: `config.localization`. |
| `type` | `'article'` for posts: `og:type`, the published and modified times, and BlogPosting JSON-LD. Default `'website'`. |
| `publishedTime` | `(doc) => date`. Default: `publishedAt`, then `createdAt`. |
| `author` | The article's author, or `(doc) => name`. |
| `articleType` | `'BlogPosting'` (default), `'Article'` or `'NewsArticle'`. |
| `breadcrumbs` | `[{ name, url }]`, top level first: adds BreadcrumbList JSON-LD (`breadcrumbList`). See [Nested pages](./nested-docs#when-pages-move). |
| `title`, `description` | `(doc) => text`: fallbacks when the meta fields are empty. |
| `siteName` | `og:site_name`. |

It returns `title`, `description`, `canonical`, `image`, `noindex`, `alternates` and `jsonLd`,
plus `nuxt` (for `useSeoMeta`), `head` (for `useHead`) and `next` (for `generateMetadata`).

## Sitemap

`sitemap(cms)` lists every page a visitor can find: documents in the plugin's collections and
globals, read **without a login** (so drafts and anything your read access hides stay out),
with an address from `generateURL`, and not marked "Hide from search engines". Each entry has
`lastModified` (from `updatedAt`) and, on a site with several locales, the page in every locale.
`sitemapXml(cms)` renders it as XML; over 50,000 addresses it becomes an index of
`/sitemap.xml?page=1`, `?page=2`…

::: code-group

```ts [Nuxt: server/routes/sitemap.xml.ts]
import { sitemapXml } from '@easy-cms/plugin-seo'

export default defineEventHandler(async (event) => {
  setHeader(event, 'content-type', 'application/xml; charset=utf-8')
  return sitemapXml(await useEasyCMS(), { page: getQuery(event).page as string | undefined })
})
```

```ts [Next.js: app/sitemap.ts]
import { getEasyCMS } from '@easy-cms/next'
import { sitemap } from '@easy-cms/plugin-seo'
import { connection } from 'next/server'
import config from '@/easy-cms.config'

// The entries have the shape of MetadataRoute.Sitemap.
export default async function Sitemap() {
  await connection() // read on each request, not at build time
  return sitemap(await getEasyCMS(config))
}
```

```ts [Next.js, over 50,000 pages: app/sitemap.xml/route.ts]
import { getEasyCMS } from '@easy-cms/next'
import { sitemapXml } from '@easy-cms/plugin-seo'
import config from '@/easy-cms.config'

export async function GET(request: Request) {
  const page = new URL(request.url).searchParams.get('page')
  const xml = await sitemapXml(await getEasyCMS(config), { page })
  return new Response(xml, { headers: { 'content-type': 'application/xml; charset=utf-8' } })
}
```

:::

The addresses must be absolute: set `admin.siteURL` in the config (or pass `{ siteURL }`), or
return full URLs from `generateURL`. The plugin also serves the sitemap at
`<routes.api>/seo/sitemap.xml`, and the [standalone server](./standalone) at `/sitemap.xml`.
These build their own addresses (such as the pages of a sitemap index) from `serverURL` when it
is set, not from the request's host: a client can send any `Host`, and the answers are cached.
Set `serverURL` in production.

## robots.txt

`robotsTxt({ config })` keeps crawlers out of the admin and the API (but not uploaded files, so
share images still load) and points them to the sitemap:

```txt
User-agent: *
Allow: /api/cms/media/file/
Disallow: /admin/
Disallow: /api/cms/

Sitemap: https://example.com/sitemap.xml
```

::: code-group

```ts [Nuxt: server/routes/robots.txt.ts]
import { robotsTxt } from '@easy-cms/plugin-seo'

export default defineEventHandler(async (event) => {
  setHeader(event, 'content-type', 'text/plain; charset=utf-8')
  return robotsTxt({ config: (await useEasyCMS()).config })
})
```

```ts [Next.js: app/robots.txt/route.ts]
import { robotsTxt } from '@easy-cms/plugin-seo'
import config from '@/easy-cms.config'

export function GET() {
  return new Response(robotsTxt({ config }), { headers: { 'content-type': 'text/plain' } })
}
```

:::

| Option | |
|---|---|
| `config` | Your Easy CMS config: the admin and API paths, and `admin.siteURL`. |
| `siteURL` | The site's address for the `Sitemap:` line, if not `admin.siteURL`. |
| `sitemap` | The sitemap's address, or `false` for no `Sitemap:` line. |
| `disallow` | More paths to keep out, e.g. `['/search']`. |
| `disallowAll` | Keep crawlers out of the whole site, e.g. on staging. |
| `ai` | `{ training?, search?, user? }`: allow or block [AI crawlers](./ai-search#choose-which-ai-crawlers-may-read) by group. All allowed by default. |
| `rules` | `[{ userAgent, allow?, disallow? }]` for other crawlers. |

::: warning Staging
`disallowAll` is not turned on by `NODE_ENV`: staging servers usually run in production mode
too. Set it from your own variable, e.g. `disallowAll: process.env.SITE_ENV !== 'production'`.
:::

The standalone server serves `/robots.txt` itself; change it with the plugin's `robots` option,
or turn it off with `robots: false`. Its `Sitemap:` line uses `serverURL` when it is set, like
the sitemap.

## Structured data (JSON-LD)

Search engines read [schema.org](https://schema.org) data to show richer results.

- `seoMeta(...).jsonLd` is the page's: `BlogPosting` (or `articleType`) with `type: 'article'`,
  otherwise `WebPage`. Nuxt gets it through `useHead(seo.head)`.
- `seoMeta(...).breadcrumbList` is a `BreadcrumbList` from the `breadcrumbs` option, so search
  results can show where the page sits. `seo.head` has it too; in Next.js render it like `jsonLd`.
- `siteJsonLd({ name, url, logo?, sameAs? })` is the site's `Organization` and `WebSite`: put it
  in the layout, once per page.
- `jsonLdScript(data)` turns either into text for a `<script type="application/ld+json">`,
  escaped so content can't close the tag.

```tsx
// Next.js app/layout.tsx
<script
  type="application/ld+json"
  dangerouslySetInnerHTML={{
    __html: jsonLdScript(siteJsonLd({ name: 'My Blog', url: 'https://example.com' })),
  }}
/>
```

Check the result with Google's [Rich Results Test](https://search.google.com/test/rich-results).

## Next steps

- [Localization](./localization): SEO fields per language.
- [AI search](./ai-search): AI crawlers, `llms.txt`, Markdown pages and IndexNow.
- [Get your site ready for search engines](./recipes/search-engines): the steps in order, and how
  to check them.
- [Migrations & deployment](./deployment): migrate the new columns.

---

<!-- https://easy-cms-website.vercel.app/docs/ai-search.md -->

# AI search

::: info What you'll learn
How AI search and assistants (ChatGPT, Claude, Perplexity, Google's AI answers…) find and read
your content, and what the SEO plugin gives them: crawler rules, `llms.txt`, Markdown versions of
pages and IndexNow.

**Before this page:** [SEO](./seo).
:::

AI answers start from the same things as search: pages a crawler can read, clear titles and
descriptions, dates and authors, structured data. The [SEO plugin](./seo) already covers those.
On top, AI tools read Markdown more easily than HTML, and some sites want to choose which AI
crawlers may use their content.

::: warning Render on the server
AI crawlers generally don't run JavaScript. Pages that load their content in the browser, like
the [standalone](./standalone) example's frontend, look empty to them. Render on the server
(Nuxt, Next.js, Astro…) or prerender the pages. The admin's own pages don't matter: they are
kept out of search.
:::

## Choose which AI crawlers may read

AI crawlers come in three kinds, and `robotsTxt()` sets each as a group. All are allowed by
default.

| Group | What it does | Crawlers |
|---|---|---|
| `training` | Collects text to train AI models. | GPTBot, ClaudeBot, Google-Extended, Applebot-Extended, CCBot, Meta-ExternalAgent, Bytespider, cohere-training-data-crawler |
| `search` | Indexes pages for AI search and for answers that cite and link them. | OAI-SearchBot, Claude-SearchBot, PerplexityBot |
| `user` | Opens a page when someone asks an assistant to read it. | ChatGPT-User, Claude-User, Perplexity-User |

The common choice: stay out of training, but let AI answers find and link to you.

```ts
robotsTxt({ config, ai: { training: false } })
```

```txt
User-agent: GPTBot
User-agent: ClaudeBot
…
Disallow: /
```

- Blocking `search` keeps your pages out of AI search results and citations.
- The `user` crawlers act for a person who asked about a page, and some companies say they may
  not always follow `robots.txt`.
- `Google-Extended` only covers Gemini's use of your content. Google's AI answers in search come
  from the normal Googlebot, like search itself.
- The list is kept up to date in each release (`AI_CRAWLERS`). For others, add `rules`:
  `rules: [{ userAgent: 'SomeBot', disallow: ['/'] }]`. Each rule also gets the admin and API
  rules, because a crawler follows only the group that names it.

The standalone server's `/robots.txt` takes the same options through the plugin:
`seoPlugin({ robots: { ai: { training: false } } })`.

## llms.txt

[`llms.txt`](https://llmstxt.org) is a proposed standard: a short Markdown file at the site's
root that tells AI tools what the site is and where its main pages are. `llmsTxt(cms)` makes it
from your content, with the same pages as the sitemap (published, visible to visitors, not
hidden from search engines), the newest first:

```md
# My Blog

> A blog about building websites in Thailand.

## Posts

- [Hello](https://example.com/posts/hello.md): What this blog is about.

## Pages

- [My Blog](https://example.com/)
```

```ts
seoPlugin({
  // …
  llms: {
    title: 'My Blog', // default: the site name from a global with SEO fields
    description: 'A blog about building websites in Thailand.',
    limit: 100, // newest pages per collection
    markdownURL: ({ doc, collection }) =>
      collection === 'posts' ? `/posts/${doc.slug}.md` : null,
  },
})
```

`markdownURL` links to the [Markdown versions](#markdown-versions-of-pages) of your pages;
without it, links go to the pages themselves. With several locales, the file is in the default
locale (or `llms.locale`).

`llmsFullTxt(cms)` puts the Markdown of every page in one file, for tools that read a whole site
at once. It stops at about 5 MB (`maxBytes`) and says where the rest is.

::: code-group

```ts [Nuxt: server/routes/llms.txt.ts]
import { llmsTxt } from '@easy-cms/plugin-seo'

export default defineEventHandler(async (event) => {
  setHeader(event, 'content-type', 'text/markdown; charset=utf-8')
  return llmsTxt(await useEasyCMS())
})
// server/routes/llms-full.txt.ts: the same with llmsFullTxt
```

```ts [Next.js: app/llms.txt/route.ts]
import { getEasyCMS } from '@easy-cms/next'
import { llmsTxt } from '@easy-cms/plugin-seo'
import { connection } from 'next/server'
import config from '@/easy-cms.config'

export async function GET() {
  await connection() // read on each request, not at build time
  const text = await llmsTxt(await getEasyCMS(config))
  return new Response(text, { headers: { 'content-type': 'text/markdown; charset=utf-8' } })
}
// app/llms-full.txt/route.ts: the same with llmsFullTxt
```

:::

The standalone server serves `/llms.txt` and `/llms-full.txt` itself; `llms: false` turns them
off.

## Markdown versions of pages

`docMarkdown(cms, { collection, doc, url })` renders one document as Markdown: its title,
description, address and dates, then its rich text, long text and the text in its blocks and
arrays, in the order of the fields. Serve it next to the page, e.g. `/posts/hello.md`.

::: code-group

```ts [Nuxt: server/middleware/markdown.ts]
import { docMarkdown } from '@easy-cms/plugin-seo'

export default defineEventHandler(async (event) => {
  const slug = /^\/posts\/([^/]+)\.md$/.exec(getRequestURL(event).pathname)?.[1]
  if (!slug) return
  const cms = await useEasyCMS()
  const { docs } = await cms.find('posts', {
    where: { slug: { equals: decodeURIComponent(slug) } },
    limit: 1,
    overrideAccess: false, // as a visitor: published posts only
    user: null,
  })
  if (!docs[0]) throw createError({ statusCode: 404 })
  setHeader(event, 'content-type', 'text/markdown; charset=utf-8')
  return docMarkdown(cms, { collection: 'posts', doc: docs[0] })
})
```

```ts [Next.js: app/md/posts/[slug]/route.ts]
import { getEasyCMS } from '@easy-cms/next'
import { docMarkdown } from '@easy-cms/plugin-seo'
import config from '@/easy-cms.config'

// next.config.ts: rewrites: async () => [{ source: '/posts/:slug.md', destination: '/md/posts/:slug' }]
export async function GET(_: Request, { params }) {
  const { slug } = await params
  const cms = await getEasyCMS(config)
  const { docs } = await cms.find('posts', {
    where: { slug: { equals: decodeURIComponent(slug) } },
    limit: 1,
    overrideAccess: false, // as a visitor: published posts only
    user: null,
  })
  if (!docs[0]) return new Response('Not found', { status: 404 })
  return new Response(docMarkdown(cms, { collection: 'posts', doc: docs[0] }), {
    headers: { 'content-type': 'text/markdown; charset=utf-8' },
  })
}
```

:::

To write the Markdown yourself for a collection, pass `markdown` to the plugin. Rich text alone
converts with [`renderMarkdown()`](./rich-text#markdown).

```ts
seoPlugin({
  // …
  markdown: {
    products: (doc) => `# ${doc.name}\n\nPrice: ${doc.price} THB\n\n${renderMarkdown(doc.details)}`,
  },
})
```

## IndexNow

[IndexNow](https://www.indexnow.org) tells Bing, Yandex and other search engines about a changed
page right away, instead of waiting for their next crawl. Bing's index also feeds ChatGPT search
and Copilot, so new posts reach AI answers sooner.

```ts
seoPlugin({
  // …
  indexNow: { key: process.env.INDEXNOW_KEY }, // 8–128 letters, digits or dashes, e.g. a UUID
})
```

- When a page is published, changed while published, unpublished or deleted, its addresses (in
  every locale) are sent. Drafts don't send anything.
- Changes are gathered for 5 seconds (`delay`) and sent together. A failed send is logged and
  not retried: search engines still crawl as usual.
- Only public `https` addresses are sent, so development and staging on `localhost` or a
  `.test` domain stay quiet.

IndexNow checks that you own the site with a key file at `/<key>.txt`. The standalone server
serves it; in Nuxt or Next.js, answer it with `indexNowKeyFile(cms, pathname)`, or put the file
in `public/`:

```ts
// Nuxt: server/middleware/indexnow.ts
import { indexNowKeyFile } from '@easy-cms/plugin-seo'

export default defineEventHandler(async (event) => {
  const key = indexNowKeyFile(await useEasyCMS(), getRequestURL(event).pathname)
  if (key) return key
})
```

## Next steps

- [SEO](./seo): metadata, sitemap and structured data that AI answers rely on too.
- [MCP](./mcp): let AI assistants *edit* your content, with an API key.

---

<!-- https://easy-cms-website.vercel.app/docs/redirects.md -->

# Redirects

::: info What you'll learn
Send visitors (and search engines) from old addresses to new ones: redirects editors manage in
the admin, and automatic ones when a page's address changes.

**Before this page:** [Plugins](./plugins).
:::

<Screenshot name="redirects" alt="Redirects under Settings, one open in a panel" />

When a page moves, its old address should answer with a **301** to the new one: visitors and
old links keep working, and search engines move the page's ranking to the new address instead of
dropping it. `@easy-cms/plugin-redirects` adds a **Redirects** list under **Settings** in the
admin, and adds a redirect by itself when a published post's slug changes.

```bash [pm]
npm install @easy-cms/plugin-redirects
```

```ts
import { redirectsPlugin } from '@easy-cms/plugin-redirects'

// One function for a post's address, shared with the SEO plugin's generateURL.
const postURL = (doc) => (doc.slug ? `/posts/${doc.slug}` : null)

export default defineConfig({
  // …
  plugins: [
    redirectsPlugin({
      collections: ['posts'], // redirects may point to posts, and follow them
      url: ({ doc }) => postURL(doc),
    }),
  ],
})
```

The plugin adds a `redirects` collection: create a migration for it (`easy-cms migrate:create
redirects`, see [Migrations](./deployment)).

## In the admin

Each redirect has:

- **From**: the old path, e.g. `/old-page`. The query string and a trailing slash are ignored.
  A Thai path like `/ข่าว` is stored percent-encoded (`/%E0%B8%82…`) and matches requests
  however they spell it, lowercase escapes included.
- **To (address)**: a path on the site or a full URL, **or** a document from `collections`. A
  redirect to a document always goes to its current address, even after it moves again.
- **Type**: `301` (moved for good, the default), `302` or `307` (for now), `308` (for good,
  keeping the method).

Redirects open in a drawer over the list, like other small collections. With the [MCP
plugin](./mcp), an assistant can manage them too, when its API key allows.

## When an address changes

With `collections` and `url`, a published document whose address changes gets a redirect from
the old address to the document, in every locale:

- Only live pages: saving a draft, or renaming a page that was never published, adds nothing.
- No chains: the redirect points to the document, so after a second rename both old addresses
  go straight to the newest one.
- No loops: renaming a page back to an old address removes the redirect from that address.

Turn it off with `autoRedirect: false`, or give a list of collections.

## Serve them

`resolveRedirect(cms, url)` returns `{ location, status }` for a path, or `null`. Redirects are
read once and kept in memory, so it is cheap to call on every request. Changes apply at once on
the server that made them; other servers (serverless instances) catch up within `cacheMaxAge`
(60 seconds by default). The visitor's query string is kept.

::: code-group

```ts [Nuxt: server/middleware/redirects.ts]
import { resolveRedirect } from '@easy-cms/plugin-redirects'

export default defineEventHandler(async (event) => {
  const url = getRequestURL(event)
  if (/^\/(_nuxt|api|admin)(\/|$)/.test(url.pathname)) return
  const redirect = await resolveRedirect(await useEasyCMS(), url)
  if (redirect) return sendRedirect(event, redirect.location, redirect.status)
})
```

```ts [Next.js 16: proxy.ts]
import { getEasyCMS } from '@easy-cms/next'
import { resolveRedirect } from '@easy-cms/plugin-redirects'
import { type NextRequest, NextResponse } from 'next/server'
import cmsConfig from './easy-cms.config'

// The proxy (formerly middleware) runs on Node.js, so it can use the Local API.
export async function proxy(request: NextRequest) {
  const redirect = await resolveRedirect(await getEasyCMS(cmsConfig), request.nextUrl)
  if (redirect)
    return NextResponse.redirect(new URL(redirect.location, request.url), redirect.status)
}

export const config = { matcher: ['/((?!api/|admin|_next/|favicon.ico).*)'] }
```

```ts [Other frontends]
// GET <routes.api>/resolve-redirect?path=/old-page
// 200 { "location": "/new-page", "status": 301 }, or 404
const response = await fetch(`${CMS}/api/cms/resolve-redirect?path=${encodeURIComponent(path)}`)
if (response.ok) {
  const { location, status } = await response.json()
  // send the redirect with your framework or host
}
```

:::

On Next.js 15, middleware runs on the Edge runtime by default, where the Local API doesn't run:
set `export const config = { runtime: 'nodejs', matcher: [...] }` (Next.js 15.5 and later), or
look the path up in your `not-found` page and call `permanentRedirect()`.

## Options

| Option | Default | |
|---|---|---|
| `collections` | `[]` | Collections a redirect can point to; their pages get automatic redirects. |
| `url` | — | `({ collection, doc, locale }) => address`: a document's address. Needed with `collections`. |
| `autoRedirect` | `true` | Add redirects when a published page's address changes; `false`, or a list of collections. |
| `slugs.redirects` | `'redirects'` | Slug of the redirects collection. |
| `cacheMaxAge` | `60` | How long other servers keep redirects in memory, in seconds. |

## In several tenants

With the [multi-tenant plugin](./multi-tenant#with-other-plugins), each tenant has its own
redirects. Pass the request's `context`: `resolveRedirect(cms, url, { context })`.

## Next steps

- [SEO](./seo): the sitemap lists pages at their new addresses.
- [Configuration](./configuration): put your own collections under Settings with
  `admin: { group: 'settings' }`.

---

<!-- https://easy-cms-website.vercel.app/docs/nested-docs.md -->

# Nested pages

::: info What you'll learn
Put pages inside pages (About → Team → Alice), give each its full address (`/about/team`) and
its breadcrumbs, and keep them right when a page above moves or changes its slug.

**Before this page:** [Plugins](./plugins).
:::

<Screenshot name="nested-docs" alt="Pages as a tree in the admin, with the pages under About open" />

Sites have sections: an About page with Team and History under it, docs with chapters and
sections. `@easy-cms/plugin-nested-docs` lets each document have a **parent** in the same
collection, and keeps two values up to date for your pages:

- **Path**: the page's full address from the slugs of the pages above it, e.g. `/about/team`.
- **Breadcrumbs**: the trail from the top-level page down to the page itself, each step with its
  label and path, e.g. About (`/about`) › Team (`/about/team`).

```bash [pm]
npm install @easy-cms/plugin-nested-docs
```

```ts
import { nestedDocsPlugin } from '@easy-cms/plugin-nested-docs'

export default defineConfig({
  // …
  collections: [
    {
      slug: 'pages',
      useAsTitle: 'title',
      drafts: true,
      fields: [
        { name: 'title', type: 'text', required: true },
        { name: 'slug', type: 'slug', from: 'title' },
        { name: 'body', type: 'richText' },
      ],
    },
  ],
  plugins: [nestedDocsPlugin({ collections: ['pages'] })],
})
```

The plugin adds three fields to each collection: `parent`, `path` and `breadcrumbs`. Create a
migration for them (`easy-cms migrate:create nested-pages`, see [Migrations](./deployment)).

## In the admin

<Screenshot name="nested-docs-page" alt="A page's side panel: its parent, path and where it sits" />

- **The list is a tree.** Top-level pages come first; the arrow next to a page shows the pages
  under it. Searching or filtering lists the matching pages flat.
- **Parent page** is in the side panel. It only offers pages that make sense: never the page
  itself or a page under it, which would make a loop.
- The side panel shows the page's **path** and **where it sits**, and how many pages are under
  it, with a link to them.

Slugs only need to differ among pages with the same parent: `/about/team` and `/careers/team`
can both be `team`.

## Showing a page by its address

A page's address on your site is up to you: `/about/team`, or `/p/about/team` to keep it apart
from your other routes. Take your prefix off and look the rest up with `findByPath`:

::: code-group

```ts [Nuxt: server/api/page.get.ts]
import { findByPath } from '@easy-cms/plugin-nested-docs'

export default defineEventHandler(async (event) => {
  const cms = await useEasyCMS()
  const page = await findByPath(cms, 'pages', String(getQuery(event).path))
  if (!page) throw createError({ statusCode: 404 })
  return page
})
```

```tsx [Next.js: app/p/[...path]/page.tsx]
import { getEasyCMS } from '@easy-cms/next'
import { findByPath } from '@easy-cms/plugin-nested-docs'
import { notFound } from 'next/navigation'
import config from '@/easy-cms.config'

export default async function Page({ params }: { params: Promise<{ path: string[] }> }) {
  const cms = await getEasyCMS(config)
  const page = await findByPath(cms, 'pages', `/${(await params).path.join('/')}`)
  if (!page) notFound()
  return <h1>{String(page.title)}</h1>
}
```

```ts [REST]
// Any frontend: the path is an ordinary field.
const { docs } = await fetch(
  `/api/cms/pages?where[path][equals]=${encodeURIComponent('/about/team')}&limit=1`,
).then((r) => r.json())
```

:::

`findByPath` takes the same options as `find` (`locale`, `draft`, `depth`, `user` and
`overrideAccess`), and decodes the address, so `/%E0%B8%97%E0%B8%B5%E0%B8%A1` finds `/ทีม`.

### Breadcrumbs

Each page has its trail in `breadcrumbs`, top level first and the page last:

```vue
<nav aria-label="Breadcrumb">
  <ol>
    <li v-for="(crumb, i) in page.breadcrumbs" :key="crumb.id">
      <NuxtLink v-if="i < page.breadcrumbs.length - 1" :to="`/p${crumb.url}`">{{ crumb.label }}</NuxtLink>
      <span v-else aria-current="page">{{ crumb.label }}</span>
    </li>
  </ol>
</nav>
```

A step is `{ doc, label, url }`: the page's id, its title (`useAsTitle`) and its path.

## Menus

`getTree` gives the published pages as a tree, for a menu or a docs sidebar:

```ts
import { getTree } from '@easy-cms/plugin-nested-docs'

const menu = await getTree(cms, 'pages', { depth: 2, locale: 'en' })
// [{ id, title, slug, path, children: [{ id, title, slug, path, children: [] }] }, …]
```

Pages come in the list's order: `admin.list.sort` if you set it (e.g. `order`, a number field
you add), else by title. A page under an unpublished page is left out with it.

Frontends on another server get the same from `GET /api/cms/tree/pages?depth=2&locale=en`,
with the collection's read access.

## When pages move

When a page gets a new slug or parent, the plugin saves the pages under it with their new paths
and breadcrumbs, level by level.

- **Drafts** don't move anything: paths change when the page is **published**. A page under it
  that has a pending draft keeps the draft, and its live version gets the new path.
- **Deleting** a page that has pages under it is refused: move or delete them first. With
  `onDeleteParent: 'orphan'` they move to the top level instead (and their addresses change).
- **Old addresses**: with the [redirects plugin](./redirects) and the same address function,
  every page that moved gets a redirect from its old address, the pages under a moved page too.

```ts
const pageURL = (doc) => (doc.path ? `/p${doc.path}` : null)

plugins: [
  nestedDocsPlugin({ collections: ['pages'] }),
  redirectsPlugin({ collections: ['pages'], url: ({ doc }) => pageURL(doc) }),
  seoPlugin({ collections: ['pages'], generateURL: ({ doc }) => pageURL(doc) }),
]
```

For search results that show where a page sits, pass the breadcrumbs to the SEO plugin's
`seoMeta`, which adds BreadcrumbList JSON-LD:

```ts
const seo = seoMeta(page, {
  url: (p) => pageURL(p),
  breadcrumbs: page.breadcrumbs.map((b) => ({ name: b.label, url: `/p${b.url}` })),
})
```

## Localization

When the slug is `localized`, every language has its own path and breadcrumbs: `/about/team`
in English, `/เกี่ยวกับ/ทีม` in Thai. The parent is shared: a site has the same sections in every
language. Pass the `locale` to `findByPath` and `getTree`.

## Pages that existed before the plugin

Adding the plugin to a collection that has pages leaves their paths empty until they are saved.
Work them all out at once:

```bash [pm]
npx easy-cms nested:rebuild
```

It also repairs pages after an error, and moves pages whose parent was deleted to the top level.
`rebuildNestedDocs(cms, 'pages')` does the same from code. A collection that already has a
`parent` relationship to itself keeps it.

## Options

| Option | Default | |
|---|---|---|
| `collections` | — | Collections whose documents can have a parent in the same collection. |
| `slugField` | `slug` | The field each page adds to its parent's path (a `slug` or `text` field). |
| `titleField` | `useAsTitle` | The field shown in breadcrumbs. |
| `fieldNames` | `parent`, `breadcrumbs`, `path` | `{ parent, breadcrumbs, path }`: the added fields' names. |
| `maxDepth` | `10` | Levels of pages, the top level included. |
| `onDeleteParent` | `restrict` | `restrict` or `orphan`: deleting a page with pages under it. |

## Built on core features

The plugin uses parts of Easy CMS that your own collections can use too:

- `admin: { list: { tree: 'parent' } }`: a tree list along a relationship to the same collection;
  `list.sort` sets the list's default order. See [Configuration](./configuration).
- `filterOptions` on a relationship: which documents it may point to. See [Fields](./fields).
- `uniqueWithin` on a slug: unique only among documents with the same value of another field.
- `update(…, { live: true })` in the [Local API](./local-api): upkeep of the live version that
  leaves a pending draft pending and adds no version.
- `cliCommands` in the config: `easy-cms <name>` commands from plugins. See [CLI](./cli).

## In several tenants

With the [multi-tenant plugin](./multi-tenant#with-other-plugins), paths only differ within a
tenant. Pass the request's `context` to `findByPath()` and `getTree()`.

## Next steps

- [Redirects](./redirects): keep old addresses working.
- [SEO](./seo): metadata and sitemaps for your pages.

---

<!-- https://easy-cms-website.vercel.app/docs/forms.md -->

# Forms

::: info What you'll learn
Let editors build forms in the admin (contact, sign-up, surveys), collect what visitors send,
get an email for each submission, and keep bots out.

**Before this page:** [Plugins](./plugins) and [Email](./email).
:::

<Screenshot name="forms" alt="A contact form in the admin, with its submissions panel" />

`@easy-cms/plugin-form-builder` adds **Forms** to the admin. Editors put fields together from
blocks, choose what happens after sending, and who gets an email. Your pages show a form with
one element, `<easy-form>`, or render it themselves from the API.

```bash [pm]
npm install @easy-cms/plugin-form-builder
```

```ts
import { consoleEmail, defineConfig } from '@easy-cms/core'
import { formBuilderPlugin } from '@easy-cms/plugin-form-builder'

export default defineConfig({
  // …
  email: consoleEmail(), // or smtp() from @easy-cms/email-smtp: see Email
  plugins: [formBuilderPlugin({ defaultTo: 'hello@example.com' })],
})
```

The plugin adds the `forms` and `form-submissions` collections: create a migration for them
(`easy-cms migrate:create forms`).

## Building a form

A form has:

- **Title** and **slug**: the slug (e.g. `contact`) is how pages find the form.
- **Fields**, as blocks: text, long text, email, number, phone, choice (a dropdown or radio
  buttons, one or several), checkbox (e.g. "I agree to the terms"), date, and message (text
  between fields). Each has a name in the data, a label, whether it is required, a placeholder,
  a default and a width (full or half a row).
- **Submit button** label.
- **After sending**: a message (rich text) or a redirect to another page.
- **Emails**: who is told about each submission (see below).

Forms have drafts: only a **published** form accepts submissions, so you can prepare one
without it going live. With [localization](./localization), labels and texts are translated
like other content, and visitors get the form in their language.

The form's side panel shows how many submissions it has, links to them, exports them as CSV
(with a byte-order mark, so Excel reads Thai text), and gives the snippet for a page.

## On your pages

<Screenshot name="form-page" alt="The form on the site, rendered by <easy-form>" />

`<easy-form>` is a Web Component: it loads the form, renders it, checks it with the server and
shows the confirmation. It renders in the light DOM, so your site's CSS styles it (classes
`easy-form__field`, `easy-form__input`, `easy-form__error`…); `element.css` has optional
defaults.

::: code-group

```ts [Nuxt: app/plugins/easy-form.client.ts]
import '@easy-cms/plugin-form-builder/element'
import '@easy-cms/plugin-form-builder/element.css'

export default defineNuxtPlugin(() => {})

// nuxt.config.ts: vue: { compilerOptions: { isCustomElement: (tag) => tag === 'easy-form' } }
// In a page: <ClientOnly><easy-form form="contact" /></ClientOnly>
```

```tsx [Next.js: a client component]
'use client'
import { useEffect } from 'react'
import '@easy-cms/plugin-form-builder/element.css'

export function EasyForm({ form }: { form: string }) {
  useEffect(() => {
    void import('@easy-cms/plugin-form-builder/element')
  }, [])
  return <easy-form form={form} />
}
```

```html [Any page]
<!-- The CMS serves the element too, for static sites and other frameworks. -->
<script type="module" src="https://cms.example.com/api/cms/form/element.js"></script>
<easy-form form="contact" api="https://cms.example.com/api/cms"></easy-form>
```

:::

| Attribute | Default | |
|---|---|---|
| `form` | — | The form's slug. |
| `api` | `/api/cms` | The REST API, when the CMS is on another server. |
| `locale` | the page's `lang` | The content locale, when it is one of yours. |

It fires `easy-form:submitted` (`event.detail.confirmation`) and `easy-form:error`
(`event.detail.errors`), e.g. for analytics.

A page on **another origin** (the [standalone](./standalone) setup) needs its origin in `cors`:
submissions carry no cookies, so no `auth.trustedOrigins` entry is needed.

### Rendering it yourself

`@easy-cms/plugin-form-builder/client` has the same API without the element, for your own
components:

```ts
import { getForm, submitForm } from '@easy-cms/plugin-form-builder/client'

const form = await getForm('contact', { api: '/api/cms', locale: 'th' })
// form.fields: [{ kind, name, label, required, placeholder, options… }]

const result = await submitForm(
  'contact',
  { data: { name, email, message }, token: form.token, [form.honeypot]: '' },
  { api: '/api/cms' },
)
if (result.ok) show(result.confirmation) // { type: 'message', html } or { type: 'redirect', url }
else showErrors(result.errors) // [{ field?, message }]
```

Send `token` back as you got it, and the `honeypot` field empty (see [spam](#spam)). Behind the
client are `GET <api>/form/:slug` and `POST <api>/form/:slug/submit`.

## Submissions at a glance

<Screenshot name="forms-overview" alt="Form overview: submissions per day, and per form with a link to each form's submissions" />

**Form overview**, in the menu's Forms group with the forms and their submissions, shows the submissions of the last 7 or 30 days:
per day in a chart, and per form with a link to that form's submissions. The dashboard has a
panel with the last 7 days' total and the forms with the most submissions. Days follow the
editor's time zone.

Both read `GET <api>/form/stats.json?days=7|30&tz=<time zone>` as the logged-in user, so they
count only the forms and submissions that user may read. It is a
[page and a dashboard panel](./plugins#pages-and-dashboard-panels) like any plugin can add.

## Emails

::: v-pre

Each form has a list of **emails** sent for every submission. In the subject and message,
`{{name}}` is a field's value and `{{*}}` a table of all fields.

- **To** is a list of addresses, comma separated. Leave it empty for the plugin's `defaultTo`.
- **Reply to** can be `{{email}}`, so replying reaches the person who sent the form.
- **To `{{email}}`** sends a confirmation to that person. Such an email can repeat only short
  fields (no long text, no `{{*}}`), each cut at 100 characters: otherwise anyone could use
  the form to send any text to any address.

Emails go through the config's [email adapter](./email), queued and retried if sending fails.
They are in the language the visitor used.
:::

## Spam

Public forms attract bots. These are always on:

- **A honeypot**: a hidden field people don't see and bots fill in.
- **A minimum time**: a form sent less than 2 seconds after it loaded (`minSubmitSeconds`) is a bot.
- **A rate limit**: 5 submissions per 10 minutes per visitor and form (`rateLimit`). Visitors
  are told by IP address, kept only as a hash that changes every window. When the adapter gives
  no client IP, all visitors share one limit of ten times `max`, and the server logs a warning once.

Caught bots get the same "thank you" as people, and nothing is stored, so they don't learn to
get around the checks.

For more, turn on [Cloudflare Turnstile](https://developers.cloudflare.com/turnstile/), a
privacy-friendly check that usually needs no clicks:

```ts
formBuilderPlugin({
  turnstile: {
    siteKey: process.env.TURNSTILE_SITE_KEY,
    secretKey: process.env.TURNSTILE_SECRET_KEY,
  },
})
```

`<easy-form>` then loads Cloudflare's script: add `https://challenges.cloudflare.com` to
`script-src` and `frame-src` if your site has a Content Security Policy.

## Privacy

Submissions keep the values, the page and the language, not the IP address. Delete old ones
automatically with `retentionDays`, e.g. `retentionDays: 365`. Tell visitors on the form what
you collect and why (a **message** field works well), as PDPA and GDPR ask.

Only logged-in users read submissions, and only admins delete them. Nobody creates them through
the REST API: they come only from the form's endpoint, after validation and the spam checks.

## Options

| Option | Default | |
|---|---|---|
| `defaultTo` | — | Recipients of emails whose "To" is empty. |
| `defaultFrom` | the adapter's `from` | Sender of emails that set none. |
| `fieldKinds` | all | Field types editors can use, e.g. `['text', 'email', 'textarea']`. |
| `rateLimit` | `{ max: 5, window: 600 }` | Submissions per visitor and form per `window` seconds, or `false`. Without the client IP, all visitors share ten times `max`. |
| `minSubmitSeconds` | `2` | Seconds before a submission counts as a person's. |
| `turnstile` | — | `{ siteKey, secretKey }` for Cloudflare Turnstile. |
| `retentionDays` | — | Delete submissions older than this. |
| `slugs` | `forms`, `form-submissions` | `{ forms, submissions }`: the collections' slugs. |

## More with what's built in

- **Webhooks**: a [webhook](./webhooks) with `collections: ['form-submissions']` and
  `events: ['create']` sends each submission to Slack, LINE, n8n or your CRM.
- **AI assistants**: with the [MCP plugin](./mcp) and an API key that may read
  `form-submissions`, an assistant can summarize this week's messages.

## In several tenants

With the [multi-tenant plugin](./multi-tenant#with-other-plugins), forms are found in the tenant
of the request (its domain or `x-easy-cms-tenant`), and submissions belong to it.

## Next steps

- [Email](./email): SMTP and retries.
- [Security](./security): CORS for forms on other origins.

---

<!-- https://easy-cms-website.vercel.app/docs/mcp.md -->

# MCP

::: info What you'll learn
How to let AI assistants such as Claude, Cursor or VS Code read and write your content through
the Model Context Protocol, with an API key that limits what they can do.

**Before this page:** [API keys](./api-keys) and [Plugins](./plugins).
:::

::: warning Experimental
This plugin is experimental. It has the same version number as the rest of Easy CMS, but its
options, endpoints and data may still change in a minor release, after 1.0 too: it is not covered
by the [stability promise](./versioning#experimental). The changelog and the
[upgrade guide](./upgrading) say what changed.
:::

`@easy-cms/plugin-mcp` turns your CMS into an MCP server. An assistant connected to it can
search posts, draft new ones, fix typos across pages or upload images, and only in the
collections and actions its API key allows. Everything it writes goes through the same
validation, hooks and access rules as the admin.

## Set it up

```bash [pm]
npm install @easy-cms/plugin-mcp
```

```ts
import { mcpPlugin } from '@easy-cms/plugin-mcp'

export default defineConfig({
  // …
  apiKeys: true,
  plugins: [mcpPlugin()],
})
```

The server is at `<routes.api>/mcp`, e.g. `https://example.com/api/cms/mcp`. It uses Streamable
HTTP without sessions, so it works on serverless hosts too.

Then create an [API key](./api-keys) for the assistant under **Settings → API keys**, ticking
only what it needs. For a writing assistant, for example: posts read, create and update, but not
publish or delete.

## Connect an assistant

Replace the URL and key with yours.

::: code-group

```bash [Claude Code]
claude mcp add --transport http easy-cms https://example.com/api/cms/mcp \
  --header "Authorization: Bearer ecms_…"
```

```json [Cursor: .cursor/mcp.json]
{
  "mcpServers": {
    "easy-cms": {
      "url": "https://example.com/api/cms/mcp",
      "headers": { "Authorization": "Bearer ecms_…" }
    }
  }
}
```

```json [VS Code: .vscode/mcp.json]
{
  "servers": {
    "easy-cms": {
      "type": "http",
      "url": "https://example.com/api/cms/mcp",
      "headers": { "Authorization": "Bearer ${input:easy-cms-key}" }
    }
  },
  "inputs": [{ "id": "easy-cms-key", "type": "promptString", "description": "Easy CMS API key", "password": true }]
}
```

```json [Claude Desktop: claude_desktop_config.json]
{
  "mcpServers": {
    "easy-cms": {
      "command": "npx",
      "args": ["mcp-remote", "https://example.com/api/cms/mcp", "--header", "Authorization: Bearer ${EASY_CMS_KEY}"],
      "env": { "EASY_CMS_KEY": "ecms_…" }
    }
  }
}
```

:::

Assistants and their settings change often; check your client's documentation for how it adds a
remote MCP server with a header.

**Claude Desktop** (tried in October 2026): open the file from **Settings → Developer → Edit
Config**, but edit it while Claude is closed (⌘Q). Changes made while it runs can be written
over, and the server then doesn't appear. After reopening Claude, the same page shows `easy-cms` as
**running**. "Server disconnected" with `ECONNREFUSED` in the log (`~/Library/Logs/Claude/mcp-server-easy-cms.log`
on macOS) means the CMS isn't reachable at that URL. In the Claude desktop app, a server added
this way is offered to its Claude Code sessions too.

## The tools

Tools are made for each collection and global, only for what the key allows:

| Tool | Needs | |
|---|---|---|
| `find_<collection>` | read | List with `where`, `sort`, `limit` (≤ 100), `page`, `locale`. Includes drafts. |
| `get_<collection>` | read | One document by id, relationships populated. |
| `create_<collection>` | create | Create; the input schema comes from your fields. |
| `update_<collection>` | update | Change the given fields. |
| `delete_<collection>` | delete | Delete. |
| `publish_<collection>`, `unpublish_<collection>` | publish | Collections with drafts. |
| `schedule_<collection>` | publish | Publish or unpublish later (collections with `schedule`). |
| `upload_media` | create on Media | A file as base64, with alt text. |
| `get_global_<slug>`, `update_global_<slug>`, `publish_global_<slug>` | read, update, publish | Globals. |

- **Drafts stay drafts.** In collections with drafts, `create_` and `update_` always save a
  draft, whatever the assistant sends; only `publish_` puts it live. With `versions` as well,
  the published page stays as it is until then. (Without versions, updating a published document
  as a draft takes it offline until it is published again, as in the admin; the tool's
  description tells the assistant so.)
- **Rich text** accepts plain text (blank lines start new paragraphs) or Tiptap JSON.
- **Relationships and uploads** take ids; the assistant finds them with `find_` or
  `upload_media`.
- **Localized fields** are read and written in `locale` (the default locale when left out).
- Users and API keys are never offered, whatever the key says.
- Errors come back as messages the assistant can act on, e.g.
  `Invalid data: title: is required`.

## Options

| Option | Default | |
|---|---|---|
| `path` | `/mcp` | Where under `routes.api` the server is. |
| `name` | `easy-cms` | The server's name in the assistant. |
| `instructions` | — | Extra guidance for the assistant, e.g. your house style. Added to the built-in notes. |
| `collections` | all | Collections to offer at all (the key still decides). |
| `globals` | all | Globals to offer at all. |

```ts
mcpPlugin({
  name: 'acme-blog',
  instructions: 'Write in British English. Posts need an excerpt of one sentence.',
  collections: ['posts', 'categories', 'media'],
})
```

## Safety

- Give each assistant its own key, with the fewest actions and an expiry. Leave out publish and
  delete unless you want the assistant to do that on its own.
- Read what the assistant drafted in the admin before publishing; history keeps every version.
- The server never fetches URLs the assistant sends: uploads come as file contents.
- Revoke the key under **Settings → API keys** to cut an assistant off at once.

## Next steps

- [API keys](./api-keys): permissions, expiry and revoking.
- [Access control](./access-control): rules that apply to assistants too.

---

<!-- https://easy-cms-website.vercel.app/docs/multi-tenant.md -->

# Multi-tenant

::: info What you'll learn
How to run several sites or clients in one CMS: each tenant with its own content, members and
settings, a tenant switcher in the admin, and frontends that read one tenant.

**Before this page:** [Access control](./access-control), [Roles](./roles) and [Plugins](./plugins).
:::

`@easy-cms/plugin-multi-tenant` adds tenants to one CMS, one database and one admin. A tenant is
a brand, a site, a branch or a client. Each has its own documents in the collections you name,
its own value of the globals you name, and its own members, each with a role there. Everything
else is shared.

It suits a company with several brands or sites, and an agency that runs its clients' sites:
the team works across tenants, each client only in theirs. If clients must not share a database
at all, run one CMS per client instead.

## Set it up

```bash [pm]
npm install @easy-cms/plugin-multi-tenant
```

```ts
import { multiTenantPlugin } from '@easy-cms/plugin-multi-tenant'

export default defineConfig({
  // …
  auth: { rbac: true }, // optional: roles from the admin, given per tenant
  plugins: [
    multiTenantPlugin({
      collections: ['posts', 'pages', 'media'], // each document belongs to one tenant
      globals: ['site-settings'], // one value per tenant
    }),
  ],
})
```

The plugin adds:

- **Settings › Users & access › Tenants**: name, slug and the domains of each tenant.
- **A `tenant` field** on each collection you name, set from the tenant you work in. With
  `media` and `upload.folders`, media folders belong to a tenant too.
- **A tenant switcher** at the top of the admin's menu, for people in more than one tenant.
- **Settings › Users & access › Members**: the people of the chosen tenant and their role there.
- **`tenants` on users**: the tenants each person is in, and their role in each.

Then create the database's changes as usual: `npx easy-cms migrate:create tenants`. For a site
that already has content, give it to a tenant once:

```bash
npx easy-cms tenants:assign brand-a
```

## Who sees what

| Who | Sees and changes |
|---|---|
| Users with access to all tenants (role `admin` by default) | Every tenant, or the one they choose; tenants, Settings, backups, roles. |
| Members | The tenant they work in, with their role there: one of their tenants, chosen in the switcher. |
| An admin of a tenant (role `admin` there) | That tenant's content and members; not Settings → Roles, Backups, Email or other tenants. |
| Visitors | The tenant their request names; nothing when it names none. |

- New documents get the tenant the request works in. Only users with access to all tenants
  choose another, or move a document.
- Relationships and uploads pointing to tenant collections offer and accept that tenant's
  documents only. A document a plugin writes without a tenant (a form's submission, a page's
  redirect) takes the tenant of what it points to.
- Media folders belong to a tenant; an upload field's `folder: 'banners'` opens each tenant's
  own `banners` folder.
- Unique values, slugs included, only need to differ within a tenant: two brands can both have
  `/posts/hello`.
- People are added under **Members**, by email: an account can be in several tenants. New people
  get an email to set a password when [email](./email) is set up. Admins of a tenant can't change
  people's accounts, only their membership. On someone's account, the staff of a tenant see
  that tenant's membership only; people see all of their own.
- An [API key](./api-keys) keeps the tenant it was created in.
- The [audit log](./audit-log) keeps each entry's tenant: admins of a tenant see its entries;
  users with access to all see everything, or the chosen tenant's. Checking the whole log's
  integrity is for them only.
- While all tenants are shown, lists have a Tenant column; the switcher narrows them to one.

## With other plugins

List their collections in `collections` to keep them per tenant, and add `multiTenantPlugin`
after the plugins that make them:

```ts
plugins: [
  nestedDocsPlugin({ collections: ['pages'] }),
  redirectsPlugin({ collections: ['pages'], url }),
  formBuilderPlugin(),
  multiTenantPlugin({
    collections: ['pages', 'redirects', 'forms', 'form-submissions', 'media'],
  }),
]
```

- **[Nested pages](./nested-docs):** paths only differ within a tenant, so each can have
  `/about`. Pass the tenant to the helpers:
  `findByPath(cms, 'pages', path, { context })` and `getTree(cms, 'pages', { context })`.
- **[Redirects](./redirects):** each tenant has its own; a page that moves gets a redirect in its
  tenant. `resolveRedirect(cms, url, { context })` applies the request's.
- **[Forms](./forms):** `<easy-form>` and `/api/cms/form/<slug>` find the form of the request's
  tenant (its domain or header), and submissions belong to it.
- **[SEO](./seo):** see below.

Here `context` is `await tenantContext(cms, { host })` or `(await cms.forRequest(request)).context`.

List all of a plugin's collections or none: with `forms` per tenant but `form-submissions`
shared (or some of the shop's collections), what the shared ones hold would cross tenants. The
server logs a warning on the first request when that is the case.

## Frontends

A request names its tenant, in this order:

1. The `x-easy-cms-tenant` header, with the tenant's slug or id.
2. `?tenant=` in the URL.
3. The admin's cookie (the switcher).
4. Its domain: a tenant whose **Domains** has the request's host.

```ts
// Another app or a static site
const posts = await fetch('https://cms.example.com/api/cms/posts', {
  headers: { 'x-easy-cms-tenant': 'brand-a' },
}).then((r) => r.json())
```

In Nuxt or Next.js pages, read with the Local API in the tenant of the page's domain:

```ts
import { tenantContext } from '@easy-cms/plugin-multi-tenant'

const context = await tenantContext(cms, { host: request.headers.get('host') ?? '' })
const posts = await cms.find('posts', { overrideAccess: false, user: null, context })
const site = await cms.findGlobal('site-settings', { overrideAccess: false, user: null, context })
```

`tenantContext(cms, { slug })` works with a slug from the URL, e.g. `/[tenant]/[slug]` routes.
`findTenantFor(cms, { slug | host })` gives the tenant itself, or `null`. When you changed
`slugs.tenants`, pass it as `collection`: `tenantContext(cms, { host, collection: 'sites' })`.
`cms.forRequest(request)` gives the user and context of a request as REST does.

With the [SEO plugin](./seo), `/sitemap.xml` and `/llms.txt` list the pages of the tenant of
the requesting domain. Its helpers take the same `context`:
`sitemap(cms, { context: await tenantContext(cms, { host }) })`.

A request that names no tenant finds nothing in tenant collections. With
`publicReads: 'all'`, it finds every tenant's instead.

## Options

| Option | Default | |
|---|---|---|
| `collections` | — | **Required**. Collections whose documents belong to one tenant. |
| `globals` | `[]` | Globals with a value per tenant. |
| `slugs.tenants` | `tenants` | The tenants collection; declare it yourself to add fields. |
| `userHasAccessToAllTenants` | role `admin` | `(user) => boolean`: who sees every tenant and the system. |
| `publicReads` | `none` | `all`: requests that name no tenant read every tenant's. Writing still needs a tenant. |
| `editShared` | `[]` | Shared collections and globals the people of a tenant (its admins too) may change; a change applies to every tenant. Otherwise only users with access to all tenants change shared data. |
| `header` | `x-easy-cms-tenant` | The header that names the tenant. |
| `cookie` | `ecms-tenant` | Where the admin keeps the chosen tenant. |

## Deleting a tenant

Deleting a tenant under Settings › Users & access › Tenants deletes its documents and files in the tenant
collections, and takes it off its members' lists. The admin says how much goes, and asks for the
tenant's name typed before deleting. It can't be undone: make a [backup](./backups) first.

## How it works

The plugin uses parts of the core you can use yourself:

- **`onRequest`** in the config works out each request's `context` (here, the tenant) and the
  user within it (their role there, `scoped`). Access rules, hooks and `filterOptions` receive
  the `context`; the Local API takes one as an option. `onRequest` can be a list: the plugin
  adds its own after yours, sees the user yours returned, and merges its `context` into yours.
- **`scoped` users** are admins of their part only: Settings, backups, roles and other people's
  API keys need an admin of the whole system.
- **Globals with `scope`** keep a value per scope.
- **`uniqueWithin`** works on any unique field, with a unique index per scope in the database.
- **`admin.switcher`** adds the choice at the top of the menu.
- **`audit.scope`** keeps each audit entry's part of the site.
- **`admin.confirmDelete`**, **`admin.initialValue`**, **`admin.column`** and
  **`admin.allowCreate`** shape deleting, new documents and lists in the admin.
- **`filterOptions` on upload fields** narrow which files fit.
- **`cms.uniqueScope()`** tells plugins what a unique field is unique within for a request.

Not yet: a backup or export of one tenant, webhooks per tenant, tenants that sign themselves
up, and single sign-on that puts people in a tenant by their email's domain.

## Next steps

- [Roles](./roles): what each role may do; members get one per tenant.
- [Access control](./access-control): your own rules see the request's `context`.
- [API keys](./api-keys): keys for one tenant's scripts.

---

<!-- https://easy-cms-website.vercel.app/docs/admin.md -->

# The admin

::: info What you'll learn
How to shape the admin for your editors: the menu's groups, badges and pins, the command palette
(⌘K) and shortcuts, and edit pages with tabs, sections that fold, rows and fields that show only
when they apply.

**Before this page:** [Configuration](./configuration) and [Fields](./fields).
:::

Everything here is set in the config: the admin needs no setup of its own. Each person can still
fold groups, pin items and collapse the menu; those choices stay in their browser.

## The menu

<Screenshot name="dashboard" alt="The admin with its menu in groups: Content, the media library, Forms and Settings" />

The menu has groups that fold, two levels deep:

```
Dashboard
Content          posts, pages… (collections without a group)
Media library
Shop             a group of a plugin, e.g. the shop's
  Catalog          products, variants…
  Sales            orders, payments…
Settings
  Site             globals, redirects
  Users & access   users, roles, API keys, single sign-on
  System           backups, email, deliveries, jobs, audit log
```

Collections, globals and pages name their group in `admin.group`; groups of your own are declared
in `admin.nav`:

```ts
export default defineConfig({
  admin: {
    nav: [
      {
        id: 'library',
        label: { en: 'Library', th: 'ห้องสมุด' },
        icon: 'book-open',
        children: [{ id: 'authors', label: { en: 'Authors', th: 'ผู้เขียน' } }],
      },
    ],
  },
  collections: [
    { slug: 'books', admin: { group: 'library' }, fields: [/* … */] },
    { slug: 'people', admin: { group: 'library.authors' }, fields: [/* … */] },
  ],
  globals: [{ slug: 'footer', admin: { group: 'content' }, fields: [/* … */] }],
})
```

- Without `group`, a collection is under **Content** and a global under **Settings › Site**.
  `group: 'settings'` also means Settings › Site.
- A label instead of an id (`group: 'Library'`) makes a group of that name, as before.
- `group: false` leaves a collection, global or page out of the menu; links still reach it.
- `order` places a group: Content is 0, the media library 10, plugins' groups about 100–200,
  Settings 1000. Within a group, `admin.order` on a collection, global or page places it (lower
  first); those without one come after, in config order.
- Declaring a group a plugin or Easy CMS has (`{ id: 'shop', label: 'Store' }`, or `content`)
  changes its label, icon or order.
- People only see what they may open, and groups with nothing in them are left out.

### Numbers and badges

Each collection shows how many documents it has; `admin.count: false` hides it. A **badge** shows
what needs attention instead, counted for each user from what they may read:

```ts
{
  slug: 'orders',
  admin: {
    badge: { where: { status: { equals: 'paid' } }, label: { en: 'to send', th: 'รอจัดส่ง' } },
  },
}
```

A folded group shows the sum of its badges. `tone` is `accent` (default), `warning` or `danger`.

### What each person changes

- **Folding**: groups stay as they were left; the group of the open page always unfolds.
- **Pins**: the pin button beside a collection's title puts it at the top of the menu.
- **Collapse**: on wide screens the menu becomes icons (the button at its foot, or `[`); a group's
  icon opens its items beside it.
- **Create**: hover an item (or tab to it) for a **+** that creates a document there.

Arrow keys move through the menu; left and right fold and unfold groups.

## Search and commands (⌘K)

<Screenshot name="command-palette" alt="The command palette: places, documents and commands found as you type" />

`⌘K` (`Ctrl+K`), `/` or the search box at the top of the menu opens the command palette:

- **Go to** any collection, global, page or setting, by name or group.
- **Create** a document of any collection you may create in.
- **Documents**: titles in every collection you may read and ids.
- **Recently opened** documents, before you type.
- **Commands**: theme, language, collapsing the menu, shortcuts, logging out, and your own:

```ts
admin: {
  commands: [{ label: 'Drafts to review', href: '/collections/posts?status=draft', icon: 'file-text' }],
}
```

| Shortcut | |
|---|---|
| `⌘K`, `/` | Search and commands |
| `⌘S` | Save (without publishing a draft, or taking a page off the site) |
| `⌘⇧P` | Publish |
| `[` | Collapse or expand the menu |
| `g` then `d` | The dashboard |
| `?` | All shortcuts |

## Edit pages

`admin.layout` arranges the edit page by field name: tabs, sections that fold, and rows. It only
changes how the page looks; the data stays as the fields say.

```ts
{
  slug: 'products',
  admin: {
    layout: [
      { tab: 'Product', fields: ['title', 'slug', { row: ['brand', 'category'] }, 'description'] },
      {
        tab: 'Shipping',
        fields: [
          { row: ['weight', 'width', 'height'] },
          { collapsible: 'Customs', collapsed: true, fields: ['hsCode', 'origin'] },
        ],
      },
    ],
  },
  fields: [
    { name: 'title', type: 'text' },
    { name: 'brand', type: 'text', admin: { width: '1/3' } },
    { name: 'category', type: 'relationship', to: 'categories', admin: { width: '2/3' } },
    { name: 'hsCode', type: 'text', admin: { description: 'The customs tariff code' } },
    // …
  ],
}
```

- Top-level fields, each placed once. Fields not placed follow at the end, in the first tab;
  fields with `admin: { position: 'sidebar' }` stay in the side column.
- With tabs, every entry at the top is a tab. A tab shows how many of its fields have errors.
- `row` puts fields side by side, an equal share each or their `admin.width` (`'1/4'`…`'full'`);
  on narrow screens they stack.
- `collapsible` sections fold; `collapsed: true` starts folded.
- `admin.description` on a field adds help below it.
- The [SEO plugin](./seo) puts its fields in an **SEO** tab of their own (`tab: false` to keep them
  below the other fields).

### Conditions

`admin.condition` shows a field only when the fields beside it match:

```ts
fields: [
  { name: 'linkType', type: 'select', options: ['internal', 'external'] },
  { name: 'page', type: 'relationship', to: 'pages', admin: { condition: { field: 'linkType', equals: 'internal' } } },
  {
    name: 'url',
    type: 'text',
    required: true,
    admin: { condition: { field: 'linkType', equals: 'external' } },
  },
]
```

`equals`, `not_equals`, `in: [...]`, `not_in: [...]` and `exists: true | false` (named as in
`where`), combined with `and`, `or` and `not`.
`field` names a sibling (in a group or an array row, the fields beside it), or a path inside one
(`link.type`). The server reads the same condition: a hidden field isn't required, and keeps its
value.

### Saving

- A save that fails lists every field to fix at the top of the form, opens the tab or section of
  the first, and moves focus there.
- Unsaved changes are kept in the browser while you type; opening the document again offers them
  back.
- `⌘S` saves, `⌘⇧P` publishes.

### The side column

Plugins' panels (`admin.sidebar`) come below the side fields; `position: 'top'` puts one right
below publishing, e.g. an order's actions:

```ts
admin: { sidebar: [{ tag: 'ecms-order-actions', position: 'top' }] }
```

## Lists

- Going back from a document returns to the list as it was: search, filters, page and scroll.
- `admin.empty` says what a collection is for before it has documents:

```ts
admin: {
  empty: {
    description: 'Add the first product: a name, a price, then publish it.',
    link: { label: 'Shop guide', href: 'https://example.com/docs/shop' },
  },
}
```

## Next steps

- [Plugins](./plugins): give a plugin's collections and pages their own group.
- [Fields](./fields): the fields these layouts arrange.
- [Roles](./roles): what each person sees in the menu.

---

<!-- https://easy-cms-website.vercel.app/docs/ecommerce.md -->

# Shop (ecommerce)

::: info What you'll learn
How to sell on your site: products with variants and prices in several currencies, a cart that
guests keep too, checkout with Stripe or a bank transfer, orders made exactly once, stock,
customer accounts, emails and events.

**Before this page:** [Plugins](./plugins), [Site members](./members) and [Access control](./access-control).
:::

::: warning Experimental
This plugin is experimental. It has the same version number as the rest of Easy CMS, but its
options, endpoints and data may still change in a minor release, after 1.0 too: it is not covered
by the [stability promise](./versioning#experimental). The changelog and the
[upgrade guide](./upgrading) say what changed.
:::

`@easy-cms/plugin-ecommerce` turns the CMS into a shop's back office, and gives your pages a cart
and checkout. Products and orders are managed in the admin; pages use the REST API through a
small client, with hooks for React and composables for Vue.

## Set it up

```bash [pm]
npm install @easy-cms/plugin-ecommerce
```

```ts
import { ecommercePlugin, manualAdapter } from '@easy-cms/plugin-ecommerce'
import { stripeAdapter } from '@easy-cms/plugin-ecommerce/stripe'

export default defineConfig({
  // …
  email: smtp({ /* … */ }), // order emails and sign-up links
  plugins: [
    ecommercePlugin({
      currencies: { supported: ['THB', 'USD'] }, // default ['THB']
      payments: {
        methods: [
          stripeAdapter({
            secretKey: process.env.STRIPE_SECRET_KEY!,
            publishableKey: process.env.STRIPE_PUBLISHABLE_KEY!,
            webhookSecret: process.env.STRIPE_WEBHOOK_SECRET,
          }),
          manualAdapter({
            instructions: {
              en: 'Transfer to Kasikorn Bank 123-4-56789-0, then reply with the slip.',
              th: 'โอนเข้าบัญชีกสิกรไทย 123-4-56789-0 แล้วตอบกลับพร้อมสลิป',
            },
          }),
        ],
      },
      emails: { notify: 'shop@example.com' },
    }),
  ],
})
```

Then create the database's changes: `npx easy-cms migrate:create shop`.

The plugin adds, under **Shop** in the admin's menu:

| Collection | |
|---|---|
| `products` | Name, slug, description, images, a price per currency, SKU, stock, option types. Drafts: only published products are for sale. |
| `variant-types`, `variant-options` | Option types (Size) and their options (M, L), shared by products. |
| `variants` | A product in one combination of options, with its own price (or the product's), SKU and stock. Named after its options. |
| `carts` | Customers' and guests' carts. |
| `addresses` | Customers' saved addresses. |
| `orders` | What was bought, at what price, by whom, where to, and how it was paid. |
| `transactions` | Payments, with what the payment provider returned. Staff only. |

It also adds a role `customer` for [site members](./members) who sign up on the site, a `price`
field type, a dashboard panel with sales and orders waiting, and the events below.

## Prices and currencies

Prices are whole numbers of the currency's smallest unit: `25000` is ฿250.00, `1999` is $19.99.
Each currency adds a price field, `priceInTHB`, `priceInUSD`; the admin shows them as money.
There are no exchange rates: set each currency's price yourself. A product without a price in a
cart's currency can't go in that cart.

Built in: `THB`, `USD`, `EUR`, `GBP`, `JPY` (no decimals). Others: `{ code, symbol, label, decimals }`.

```ts
import { formatPrice, CURRENCIES } from '@easy-cms/plugin-ecommerce'

formatPrice(123450, CURRENCIES.THB, 'th-TH') // ฿1,234.50
```

## Pages

Pages talk to the shop's endpoints with the client. In React (Next.js):

```tsx
'use client'
import { ShopProvider } from '@easy-cms/plugin-ecommerce/react'

export function Providers({ children }) {
  return <ShopProvider locale="th">{children}</ShopProvider>
}
```

```tsx
'use client'
import { useCart, useCurrency } from '@easy-cms/plugin-ecommerce/react'

export function AddToCart({ product, variant }) {
  const { addItem, busy } = useCart()
  return <button disabled={busy} onClick={() => addItem(product, { variant })}>Add to cart</button>
}

export function CartTotal() {
  const { cart } = useCart()
  const { formatPrice } = useCurrency()
  return <span>{formatPrice(cart?.total ?? 0)}</span>
}
```

In Vue (Nuxt), `app.use(shopPlugin())` in a client plugin, then the same names as composables:

```ts
import { shopPlugin } from '@easy-cms/plugin-ecommerce/vue'

export default defineNuxtPlugin((nuxtApp) => {
  nuxtApp.vueApp.use(shopPlugin({ locale: 'th' }))
})
```

| Hook | |
|---|---|
| `useCart()` | `cart` (lines, `subtotal`, `adjustments`, `total`, `count`), `addItem`, `updateItem`, `incrementItem`, `decrementItem`, `removeItem`, `clearCart`, `busy` |
| `useCurrency()` | `currency`, `currencies`, `setCurrency`, `formatPrice` |
| `usePayments()` | `paymentMethods`, `checkout`, `confirm` |
| `useCustomer()` | `user`, `login`, `logout`, `signUp`, `verifyEmail`, `forgotPassword`, `resetPassword` |
| `useAddresses()` | The customer's `addresses`, `createAddress`, `updateAddress`, `deleteAddress` |
| `useOrders()` | The customer's `orders`, newest first |

Without a framework, `createShopClient()` from `@easy-cms/plugin-ecommerce/client` has the same
methods and `subscribe(listener)`. Product pages read products as any content, with the Local API
or `GET /api/cms/products`.

A guest's cart is remembered in `localStorage` (its id and a secret). When the guest signs in,
the cart joins their account's. Turn guests off with `carts: { allowGuests: false }`.

## Checkout

```ts
const { checkout, confirm } = usePayments()

const result = await checkout({
  method: 'stripe', // or 'bank-transfer'
  email: 'guest@example.com', // guests; customers use their account's
  shippingAddress: { name: 'Somchai', line1: '99 Sukhumvit', province: 'Bangkok', country: 'TH' },
  // or address: savedAddress.id
})
```

1. The shop checks the cart (prices, stock, still for sale), works out the total with your
   `totals`, and keeps it all on a new transaction.
2. The payment method starts the payment and returns what the page needs in `result.payment`.
3. **Bank transfer** (`manualAdapter`): the order is made now, `pending`, and
   `result.payment.instructions` tells the customer where to pay. Staff press **Payment
   received** on the order when the money arrives. An order left unpaid for 3 days is cancelled
   and its 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).
4. **Stripe**: `result.payment.clientSecret` goes to Stripe.js's Payment Element (card,
   PromptPay…). After paying, the page calls `confirm(result.transaction)`; the shop asks Stripe,
   and makes the order if the money is in. Only whoever paid may confirm: the signed-in customer,
   or the guest whose cart it was (the client sends the cart's secret); others get `404`.

An order is made **exactly once** however often it is confirmed: by the page, a retry, and
Stripe's webhook at the same moment. The first claims the transaction (an update that only
succeeds while it is still `pending`); the others get the same order. Set `webhookSecret` and
point a Stripe webhook at `/api/cms/shop/payments/stripe/webhook` (events
`payment_intent.succeeded`, `payment_intent.payment_failed`), so orders are made even when a
customer closes the page.

Without a webhook, or when one is lost, the scheduled jobs catch up: a payment still pending
after a few minutes is checked with its provider (the adapter's `confirm`), and gets its order if
the money is in. A method with `expiresIn` gives up on payments that didn't go through in that
time (`failed`, "expired"), e.g. an expired PromptPay QR code; one that is paid later still gets
its order. Without `expiresIn`, payments are checked for a day.

Stripe needs the `stripe` package, version 17 to 23. Whichever is installed, the adapter asks
Stripe for the API version it is tested with, `STRIPE_API_VERSION` (`2026-09-30.endive`), so
upgrading the package doesn't change Stripe's answers; `stripeAdapter({ apiVersion })` picks
another. Create the webhook endpoint in Stripe with the same API version.

`clientConfig` passes more settings to the Stripe client, e.g. `{ maxNetworkRetries: 2 }`. In
tests, point it at [stripe-mock](https://github.com/stripe/stripe-mock) or your own stand-in
(`{ host: 'localhost', port: 12111, protocol: 'http' }`), then send the webhook a signed event
(`Stripe.webhooks.generateTestHeaderStringAsync`) to make the order, as the
[shop example's end-to-end test](https://github.com/maritonx/easy-cms/blob/main/e2e/tests/shop.spec.ts) does.

### Shipping, tax and discounts

`totals` adds lines to (or, negative, takes them off) the subtotal. It runs for the cart and
again at checkout, with the address:

```ts
ecommercePlugin({
  totals: ({ cart, currency, shippingAddress }) => {
    const lines = []
    if (cart.subtotal < 100_000) lines.push({ label: 'Shipping', amount: 5_000 })
    if (shippingAddress?.province === 'Bangkok') lines.push({ label: 'Same-day', amount: 3_000 })
    return lines
  },
})
```

The order keeps these lines as they were.

## Orders

| Status | |
|---|---|
| `pending` | Waiting for payment (bank transfer). |
| `paid` | Paid, to be sent. |
| `fulfilled` | Sent or handed over. |
| `cancelled` | Called off before payment; items go back in stock. |
| `refunded` | Money given back (Stripe refunds it); items go back in stock. |

Staff change the status with the buttons on the order (**Payment received**, **Mark as sent**,
**Cancel order**, **Refund**), or `POST /api/cms/shop/orders/:id/:status`. Orders keep each line
as bought (title, SKU, price), so changing or deleting a product later doesn't change them.
Order numbers count up from `1001`; `orders: { number: (n) => \`A-${n}\` }` makes your own.

Customers see their own orders: `GET /api/cms/orders` (or `useOrders()`).

## Stock

With `inventory` (on by default), products and variants have **In stock**: empty means not
counted. Stock is checked when an item goes in the cart and at checkout, and taken when the
order is made, in one database statement so two buyers can't take the last one. If it ran out
while a customer paid, the order is still made (the money is in) and marked **Not enough
stock**. Cancelled and refunded orders put their items back.

## Emails and events

With [email](./email) set up, customers get an email when an order is made (with the bank
transfer instructions) and when a transfer is marked received; `emails.notify` gets new orders.
Each takes your own function, or `false`.

[Webhooks](./webhooks) can listen to the shop's events: `order.created`, `order.paid`,
`order.fulfilled`, `order.cancelled`, `order.refunded`, e.g. for a warehouse or an accounting
system:

```ts
webhooks: [{ url: 'https://erp.example.com/hook', events: ['order.paid'], secret }],
```

## Several shops

With the [multi-tenant plugin](./multi-tenant), each tenant is a shop of its own: list the
shop's collections, and add `multiTenantPlugin` after it.

```ts
import { ecommercePlugin, SHOP_COLLECTIONS } from '@easy-cms/plugin-ecommerce'

plugins: [
  ecommercePlugin({ /* … */ }),
  multiTenantPlugin({ collections: [...SHOP_COLLECTIONS, 'media'] }),
]
```

Products, carts and orders belong to the tenant of the site's domain; order numbers count per
tenant. A customer's account works on every tenant's site, and each site shows its own orders.
One Stripe account serves all tenants.

## Your own payment method

A payment adapter starts a payment, says whether it went through, and may refund it:

```ts
import { definePaymentAdapter, paymentSucceeded } from '@easy-cms/plugin-ecommerce'

export const omise = definePaymentAdapter({
  name: 'omise',
  label: { en: 'Card', th: 'บัตร' },
  async initiate({ transaction }) {
    const charge = await createCharge(transaction.amount, transaction.currency)
    return { reference: charge.id, data: { charge: charge.id }, client: { authorizeUri: charge.authorize_uri } }
  },
  async confirm({ transaction }) {
    const charge = await getCharge(transaction.data.charge)
    return charge.paid ? 'succeeded' : charge.status === 'failed' ? 'failed' : 'processing'
  },
  async refund({ transaction }) { /* … */ },
  // Served at /api/cms/shop/payments/omise/webhook
  endpoints: [{ path: '/webhook', method: 'post', handler: async (req) => {
    const event = await req.json() // check its signature first
    await paymentSucceeded(event.data.id)
    return { ok: true }
  } }],
})
```

Never trust the page: `confirm` must ask the provider.

## Options

| Option | Default | |
|---|---|---|
| `payments.methods` | — | **Required**. `stripeAdapter()`, `manualAdapter()`, your own. |
| `currencies.supported` | `['THB']` | Currency codes or `{ code, symbol, label, decimals }`. |
| `currencies.default` | the first | For new carts. |
| `products.variants` | `true` | Option types and variants. |
| `products.fields` | `[]` | More product fields. |
| `inventory` | `true` | Count stock. |
| `carts.allowGuests` | `true` | Guests can shop and pay. |
| `carts.guestCartDays` | `30` | Guests' unused carts are deleted after this. |
| `addresses.supportedCountries` | `['TH']` | Countries the shop sends to. |
| `addresses.fields` | Thai address | Name, phone, address lines, sub-district, district, province, postal code, country. |
| `customers.role` | `customer` | The role of customers' accounts. |
| `customers.signUp` | `true` | Visitors create accounts (`false` for none, or Turnstile keys). |
| `customers.pages` | — | The site's pages for email links ([Site members](./members)). |
| `totals` | — | Shipping, tax and discounts. |
| `orders.number` | `1000 + n` | Order numbers. |
| `emails` | — | `orderConfirmation`, `paymentReceived`, `newOrder`: functions or `false`; `notify`. |
| `overrides` | — | `{ products: (c) => ({ ...c, … }) }` to change a collection. |
| `group` | Shop / ร้านค้า | The label of the shop's menu group (`shop`, with Catalog, Sales and Customers). |

Not yet: Omise and other providers out of the box, Stripe Checkout (Stripe's page), reserving
stock while paying, ready-made shipping rates, VAT and coupons, subscriptions, and a Stripe
account per tenant.

## Next steps

- [Site members](./members): customers' accounts, sign-up and email links.
- [Webhooks](./webhooks): tell other systems about orders.
- The shop example: `examples/shop` (Next.js).

---

<!-- https://easy-cms-website.vercel.app/docs/members.md -->

# Site members

::: info What you'll learn
How people sign in on your site, not the admin: customers, subscribers, members of a club.
Signing up with email confirmation, the site's own pages for email links, and what members may
see.

**Before this page:** [Authentication](./auth) and [Access control](./access-control).
:::

Users of the admin and members of the site share the `users` collection and its sign-in, but
not what they may do. A **member** has one of the roles in `auth.members.roles`:

- They never get into the admin: it refuses them, and so do its endpoints.
- Access that defaults to logged-in users (`isStaff`, the default of every collection)
  doesn't count them. Give them access where they need it, e.g. with `isSignedIn`.
- They see only their own account in `users`, and can't change their role.

```ts
import { isSignedIn } from '@easy-cms/core'

export default defineConfig({
  auth: {
    roles: ['admin', 'editor', 'customer'],
    members: {
      roles: ['customer'],
      signUp: { role: 'customer' },
      pages: { verifyEmail: '/account/verify', resetPassword: '/account/reset' },
    },
  },
  collections: [
    {
      slug: 'wishlists',
      access: {
        read: ({ user }) => (user ? { owner: { equals: user.id } } : false),
        create: isSignedIn,
      },
      fields: [/* … */],
    },
  ],
})
```

The [shop](./ecommerce) sets this up for customers.

## Signing up

With `signUp`, visitors create their own account with its `role`:

```ts
// The form's token, when the form is shown: proves it wasn't filled in by a bot at once.
const { token } = await fetch('/api/cms/auth/signup').then((r) => r.json())

await fetch('/api/cms/auth/signup', {
  method: 'POST',
  headers: { 'content-type': 'application/json' },
  body: JSON.stringify({ email, password, name, token, website: '' }),
})
// 202 { verify: true }: a link to confirm the email is on its way.
```

1. The new account can't sign in until the email is confirmed (`signUp.verifyEmail`, default
   `true`). The answer is the same whether or not the email has an account, so nobody learns who
   is signed up.
2. The email's link opens `pages.verifyEmail` on the site with `?token=`. The page sends it to
   `POST /api/cms/auth/verify-email` (`{ token }`), which confirms the email and signs in.
3. Without `verifyEmail`, signing up signs in at once (`201` with the session).

Spam protection is the same as the [form builder's](./forms): the token (sent too quickly or
a day later is refused), a hidden field bots fill in (`website`), sign-ups per IP, and Cloudflare
Turnstile with `signUp.turnstile: { siteKey, secretKey }`. The token's request also says
`turnstile` (the site key, or `null`).

Signing up needs [email](./email), and a site URL in production for the links: `admin.siteURL`
as a full URL, or `serverURL`.

## Email links

Members' "forgot password" links open `pages.resetPassword` on the site, which sends the token
and the new password to `POST /api/cms/auth/reset-password`. Pages can be paths (on
`admin.siteURL`, else `serverURL`) or full URLs. Without them, links open the admin's pages.

`emails.verifyEmail` changes the confirmation email's text, like `auth.emails`.

## Signing in on the site

Members sign in like admins: `POST /api/cms/auth/login` sets the session cookie (send
`x-csrf-token` from `GET /api/cms/auth/me` with writes), or returns a token for
`Authorization: Bearer`. The session's user has `member: true`. In server code,
`cms.forRequest(request)` gives the user.

With the [multi-tenant plugin](./multi-tenant), members aren't in a tenant: they use the site
of the request (its domain or header), so one account works on every tenant's site.

## Next steps

- [Shop](./ecommerce): customers, carts and orders.
- [Access control](./access-control): rules for members' own documents.

---

<!-- https://easy-cms-website.vercel.app/docs/databases.md -->

# Databases

::: info What you'll learn
SQLite, Turso, Postgres and PGlite: how to connect, choose and tune each.

**Before this page:** [Getting started](./getting-started).
:::

## SQLite

```bash [pm]
npm install @easy-cms/db-sqlite
```

```ts
import { sqlite } from '@easy-cms/db-sqlite'

db: sqlite({ url: 'file:./cms.db' }) // relative to the project root
db: sqlite({ url: 'libsql://my-db.turso.io', authToken: process.env.TURSO_TOKEN })
```

Uses libSQL. In-memory databases (`:memory:`) are not supported.

SQLite has one writer at a time. Writes from one process wait their turn in a queue; when
another process writes to the same file (a second server, `easy-cms` commands), a write waits
for it up to `busyTimeoutMs` (default 10000 ms), during which that process pauses. Many busy
processes writing to one file are better served by Postgres.

```ts
db: sqlite({ url: 'file:./cms.db', busyTimeoutMs: 5_000 })
```

## Postgres

```bash [pm]
npm install @easy-cms/db-postgres
```

```ts
import { postgres } from '@easy-cms/db-postgres'

db: postgres({ url: process.env.DATABASE_URL }) // a server, via postgres.js
db: postgres({ pglite: '.pglite' }) // PGlite: Postgres in WebAssembly, needs @electric-sql/pglite
```

PGlite is handy locally and in tests: real Postgres with nothing to install. A common setup:

```ts
db: process.env.DATABASE_URL ? postgres({ url: process.env.DATABASE_URL }) : postgres({ pglite: '.pglite' }),
```

## Common options

| Option | Default | |
|---|---|---|
| `tablePrefix` | `ecms_` | Prefix of every table Easy CMS creates |
| `migrationDir` | `easy-cms/migrations` | Where migration files live |

## Sharing a database with your app

Easy CMS creates and changes only tables with its prefix, so it can use your app's database.
Your own tables are never touched, in development or by migrations.

## Differences to know

- Text sorting follows the database's collation: SQLite and PGlite sort case-sensitively, most
  Postgres servers don't.
- Migration files are made for one database; files created for SQLite are refused on Postgres.

## Performance

Measured with `packages/integration/load.ts` on Postgres 17 (Docker, a 10-core laptop):
100,000 posts with two locales, drafts and versions, relationships, `hasMany` tags and blocks;
20 concurrent REST requests, a pool of 10 connections.

| Request | Requests/s | p50 | p95 |
|---|---|---|---|
| By id, relationships populated (`depth=2`) | 2,750 | 8 ms | 10 ms |
| By slug (`where[slug][equals]`) | 2,770 | 8 ms | 10 ms |
| First page of a list | 1,060 | 18 ms | 23 ms |
| Filter by relationship, sort by date | 560 | 34 ms | 55 ms |
| Admin list with drafts | 550 | 35 ms | 49 ms |
| Save a draft (with a version) | 500 | 38 ms | 52 ms |
| Random page among 8,000 | 150 | 129 ms | 189 ms |
| Filter by `hasMany` value, sort by number | 115 | 159 ms | 258 ms |
| `like` search | 100 | 198 ms | 262 ms |
| Inside blocks (`layout.blockType`) | 83 | 237 ms | 304 ms |

What that means for your site:

- **Pages and lookups are fast.** Frontends read by id or slug, and list the first pages.
- **Deep pages cost more** the further in they are: the database skips the rows before them.
  Paginate archives by date (`where[publishedAt][lt]=…`) rather than to page 5,000.
- **`like` reads every row.** For site search, use a search service (Meilisearch, Algolia,
  Typesense) fed by [webhooks](./webhooks), or add a `pg_trgm` index on Postgres yourself.
- **Queries inside blocks** read the JSON of every document. On Postgres, `equals` and `in` use
  JSON containment and are about six times faster than other operators; keep them for filters,
  not for every page view.
- **Cache public pages** (a CDN, or your framework's cache) and revalidate them from webhooks:
  most traffic then never reaches the CMS.
- More concurrent requests than connections wait for a connection; raise `max` on
  `postgres()` if the database has room.

## Next steps

- [Migrations & deployment](./deployment): migrations in production.
- [Backups & upgrades](./backups): back up the database.

---

<!-- https://easy-cms-website.vercel.app/docs/deployment.md -->

# Migrations & deployment

::: info What you'll learn
How schema changes are applied in development and shipped as migrations to production.

**Before this page:** [Databases](./databases).
:::

To try it on Vercel or Netlify in a few clicks, with a database and storage made for you, use
the [one-click deploy](./one-click-deploy).

## Development: automatic

While developing (`NODE_ENV` is not `production`), Easy CMS brings the database in line with
your config at startup: new fields become columns, removed fields are dropped.

::: warning Renames lose data in development
Renaming a field in development drops the old column and adds a new one. Use a migration (below)
when the data matters.
:::

## Production: migrations

In production nothing changes automatically. The server refuses to start when migrations are
pending or the config changed without one, and tells you what to run.

```bash [pm]
npx easy-cms migrate:create init     # after your first model, and after every change
git add easy-cms/migrations          # review the SQL, then commit
npx easy-cms migrate                 # where you deploy, before starting the new version
```

`migrate:create` compares your config with the last migration. Run in a terminal, it asks whether
a changed field was renamed, so data is kept. Each migration runs in one transaction; a failing
one is rolled back and not recorded.

A database set up by development push can't take migrations; run them against a fresh database.

## Environment variables

`create-easy-cms` puts `EASY_CMS_SECRET` (and `DATABASE_URL`, if you use one) in `.env`. That file
is for development; whether production reads it depends on how the server starts:

| Server | Reads `.env` in production? | What to do |
|---|---|---|
| Next.js (`next start`) | Yes | Nothing, or set the variables on the host |
| Nuxt (`node .output/server/index.mjs`) | **No** | Set the variables on the host, or start with `node --env-file=.env .output/server/index.mjs` |
| Platforms (Vercel, Netlify, Fly, Docker…) | Use the platform's settings | Add the variables in its dashboard, CLI or compose file; see [Deploy](./deploy/) |

When `EASY_CMS_SECRET` is missing, the API answers `500` and the log says
`secret: is required`.

## Checklist

- `EASY_CMS_SECRET` set in the production environment (at least 32 random characters:
  `openssl rand -hex 32`); see [Environment variables](#environment-variables)
- `NODE_ENV=production`
- The `easy-cms/migrations` folder deployed, and `easy-cms migrate` run
- Start the server **from the project root**: relative database paths, migrations and uploads
  resolve from the working directory
- Uploads on local disk need a persistent volume; on serverless platforms use
  [S3 storage](./uploads#s3-cloudflare-r2-and-minio), [Vercel Blob](./uploads#vercel-blob) or
  [Netlify Blobs](./uploads#netlify-blobs)
- `EASY_CMS_SETUP_CODE` set until the first admin exists, so nobody else can claim the site
- Build on the same OS and architecture as the server when using SQLite (native driver)
- Behind a trusted proxy, enable `trustProxy` for per-IP login rate limiting
- Set `auth.trustedOrigins` if the admin or frontend call the API from another origin
- [Backups](./backups) of the database and uploads, and a restore you have tried

After going live, the dashboard tells admins what needs attention: see
[Health checks](./health-checks).

## Next steps

- [Deploy](./deploy/): step by step on Vercel, Netlify, Docker, a VPS and other hosts.
- [Backups & upgrades](./backups): back up before upgrading.
- [Security](./security): a checklist for going live.

---

<!-- https://easy-cms-website.vercel.app/docs/health-checks.md -->

# Health checks

::: info What you'll learn
What the dashboard's **Needs attention** and **System** panels tell admins, and how to fix each
problem they report.

**Before this page:** [Deployment](./deployment).
:::

<Screenshot name="dashboard" alt="The dashboard, with the System panel at the bottom of the side column" />

Admins see two panels on the dashboard that editors don't:

- **Needs attention**, at the top, only when something is wrong. Each line links to its section
  below.
- **System**, at the bottom of the side column: the Easy CMS version, the database, where files
  are stored, the email adapter, the plugins with their versions and the
  [field types](./field-types) from packages. Handy when you report a problem or ask for help.

Both come from `GET <api>/admin/status`, for admins only (not for API keys). Easy CMS never
checks for new versions on its own.

## Failed webhooks

**Shown when** a [webhook](./webhooks) delivery failed for good in the last 7 days: every retry,
over about a day, got an error. The panel shows how many, and the address that failed most.

**Check:**

- The receiving service is up and answers `2xx`. Its last error is kept with the delivery.
- The URL in `webhooks` is still right (a redeployed build hook often gets a new address).
- The receiver checks the signature with the same secret.

Once the receiver works, open **Settings → Deliveries** (the banner links there): each failed
delivery shows what changed, the address, its last error and the body it sent, with **Retry**
(one attempt now, with the same delivery id and a fresh signature) and **Delete**, one at a time
or all at once. Failed deliveries are deleted after 30 days.

## Emails not sent

**Shown when** an email has waited in the queue for over an hour, or failed for good in the last
7 days. See [queued and retried](./email#queued-and-retried).

**Check** the settings with **Check the connection** and **Send test email** on
[Settings → Email](./email#checking-the-settings), and the failed emails in **Settings → Deliveries → Emails** (who to, the subject and the
error; not the content), then:

- The SMTP settings (`SMTP_HOST`, `SMTP_USER`, `SMTP_PASSWORD`) and that the server accepts this
  sender. Failures are logged with the server's answer.
- On serverless hosts, that something runs the queue: a cron calling `<api>/jobs/run` (as for
  [late scheduled publishing](#late-scheduled-publishing)).

Then **Retry** them there. Failed emails are deleted after 30 days.

## Late scheduled publishing

**Shown when** a [scheduled](./drafts#scheduled-publishing) publish or unpublish is more than 10
minutes late. Nothing is running the jobs.

**Fix:** long-running servers (Nuxt, `easy-cms serve`, a self-hosted Next.js) run them every
minute. On serverless platforms, call `<api>/jobs/run` every minute from a cron with
`Authorization: Bearer $CRON_SECRET`, or run `npx easy-cms jobs:run` from one. The late jobs
run on the next call, and so do webhook and email retries.

## Failed jobs

**Shown when** the last run of one of the config's [jobs](./jobs) (or a plugin's) failed.

**Fix:** open **Settings → Jobs** to see the error. Fix the cause (a service that was down, a
setting), then **Run due jobs now**, or wait for its next run. The warning goes once a run
succeeds.

## Paused jobs

**Shown when** an admin paused a job on **Settings → Jobs**. It doesn't run until resumed.

**Fix:** **Resume** it on Settings → Jobs once it should run again. The audit log says who paused it.

## Backups

**Shown when** `backups.every` is set and the last scheduled backup failed, or no backup finished
in two periods (two days, or two weeks). See its error on **Settings → Backups**; the usual causes
are a full disk, storage that refuses the file, Postgres without `backups.sqlite`, or nothing
running the jobs (as for [late scheduled publishing](#late-scheduled-publishing)). Then
**Back up now** to check. See [Backups](./backups#from-the-admin).

## Failed logins

**Shown when** the [audit log](./audit-log) is on and more sign-ins failed within the last hour
than `audit.failedLogins` (default 20). Someone may be guessing passwords. Open the audit log
filtered by sign-ins to see which emails and IP addresses; deactivate accounts under attack, or
block the addresses at your host. Each email and IP is also locked after `auth.maxLoginAttempts`.

## Audit log

**Shown when** audit log entries could not be written (the change went through; see the server log
for why, often the database), or when the last integrity check found entries changed in the
database. See [tamper evidence](./audit-log#tamper-evidence).

## No email

**Shown when** the config has no `email`. Without it there are no forgotten-password links, no
invitations and no form notifications. Set one up: [Email](./email).

## No serverURL

**Shown when** the site runs in production (`NODE_ENV=production`) without `serverURL`. Links in
emails (forgotten password, invitations) need the site's public address, and in production Easy
CMS doesn't trust the address in the request. Set `serverURL: 'https://example.com'` in the
config.

## Next steps

- [Deployment](./deployment): the checklist before going live.
- [Plugins](./plugins#naming-your-plugin): how a plugin shows its name and version here.

---

<!-- https://easy-cms-website.vercel.app/docs/jobs.md -->

# Jobs

::: info What you'll learn
Run your own background work on a schedule (`jobs`), at a time of day in your time zone, and see,
pause or reschedule it from **Settings → Jobs**.

**Before this page:** [Scheduled publishing](./drafts#scheduled-publishing).
:::

A job is a function Easy CMS runs with its scheduled jobs: every minute on a long-running server
(Nuxt, `easy-cms serve`, a self-hosted Next.js), or each time a cron calls `<api>/jobs/run` on
serverless hosts (see [who runs the jobs](./drafts#scheduled-publishing)). Plugins add their own;
the shop, for example, checks payments every 5 minutes.

```ts
// easy-cms.config.ts
export default defineConfig({
  // Times of day below are in this zone. Default: the server's (often UTC on serverless).
  timeZone: 'Asia/Bangkok',
  jobs: [
    // Every 10 minutes.
    { name: 'feed:import', every: 600, run: ({ cms }) => importFeed(cms) },
    // Every day at 02:00, Bangkok time. Admins may change when it runs.
    { name: 'sitemap:refresh', at: '02:00', adminSchedule: true, run: refreshSitemap },
    // Weekly: every 7 days at 03:00, counted from the last run.
    { name: 'report:weekly', every: 604_800, at: '03:00', run: sendReport },
  ],
})
```

| Option | | |
|---|---|---|
| `name` | **required** | Unique, lowercase, e.g. `shop:clean-up`. |
| `run` | **required** | `({ cms, now }) => …`. A thrown error is logged and shown on Settings → Jobs. |
| `every` | every run | At most this often, in seconds. With `at`, a number of whole days (default one). |
| `at` | — | A time of day, `HH:MM` in `timeZone`. |
| `adminSchedule` | `false` | Admins may change `every` and `at` from Settings → Jobs. |

**Missed runs.** A job whose time passed while nothing ran (the server was down, a cron was late)
runs once as soon as jobs run again, then goes back to its schedule.

**Several instances.** Each run is claimed in the database before it starts, so a job runs once at
its time however many servers (or the scheduler and the cron at once) run jobs. Write jobs so a
second run would do no harm anyway: a run that stops half-way (a crash, a deploy) is not retried
until its next time.

**The time zone** also applies to [`backups.at`](./backups).

## Settings → Jobs

Admins, and [roles](./roles) given **Jobs**, see every job with its schedule, when it last ran and
how long it took, its result (with the error when it failed) and when it runs next. A second tab
lists [scheduled publishing](./drafts#scheduled-publishing) that is waiting, running or failed.
**Run due jobs now** runs whatever is due, as the cron would.

The page also says when jobs last ran, and warns when nothing has run them for 10 minutes (on
serverless hosts, usually a missing cron). **Built in** lists what Easy CMS does with every run:
scheduled publishing, webhook and email retries, scheduled backups and the audit log check, each
with a link to its page.

Admins of the whole site can also:

- **Pause** a job, for example while the service it calls is down. It doesn't run until someone
  **resumes** it, and the dashboard shows it under **Needs attention** so it isn't forgotten.
- **Change the schedule** of a job with `adminSchedule`: how often, and at what time of day. The
  card says it was set by an admin and shows the config's schedule beside it; **Use the config's
  schedule** goes back. Jobs without `adminSchedule` keep the schedule their code gives, since it
  may matter (the shop's payment check must run often).

Pausing, resuming and schedule changes are kept in the [audit log](./audit-log). A job whose last
run failed also shows on the dashboard; see [health checks](./health-checks#failed-jobs).

## Next steps

- [Scheduled publishing](./drafts#scheduled-publishing): publish and unpublish at a set time.
- [Health checks](./health-checks): what the dashboard warns admins about.
- [Plugins](./plugins): add jobs from a plugin.

---

<!-- https://easy-cms-website.vercel.app/docs/backups.md -->

# Backups & upgrades

::: info What you'll learn
What to back up, how to restore, and how to upgrade Easy CMS safely.

**Before this page:** [Migrations & deployment](./deployment).
:::

Easy CMS keeps everything in two places: the **database** (documents, users, versions, scheduled
jobs, queued webhooks and the migrations table) and the **uploads** (local `upload.dir` or your
S3 bucket). Back up both. The database can be backed up [from the admin](#from-the-admin), on a
schedule; uploads are yours to back up.

## What to back up

| What | Where | How often |
|---|---|---|
| Database | SQLite file or Postgres database | Daily, and before every deploy that runs migrations |
| Uploads | `uploads/` (or `upload.dir`), or the S3 bucket | Daily; turn on bucket versioning for S3 |
| `EASY_CMS_SECRET` | Your host's secret settings | Once, kept in a password manager |
| `backups.encryptionKey` | Your host's secret settings | Once, kept somewhere other than the backups |
| `easy-cms/migrations` | Git | Already there when you commit them |

Losing `EASY_CMS_SECRET` loses no content: it signs sessions and preview links, not passwords.
Setting a new one signs everyone out and invalidates preview links.

## From the admin

<Screenshot name="backups" alt="Settings → Backups: the schedule, back up now, and the backups with Download" />

Admins find **Settings → Backups** in the admin: **Back up now**, the list of backups with who
made them, **Download** and **Delete**. Scheduled backups and how many to keep are set in the
config:

```ts
import { sqlite } from '@easy-cms/db-sqlite'

backups: {
  frequency: 'daily',   // or 'weekly'; leave out for backups by hand only
  at: '03:00',           // in `timeZone` (the server's by default; see Jobs)
  keep: 7,               // older ones are deleted
  // dir: 'backups',     // default; never served publicly
  // storage: s3Storage({ bucket: 'my-private-backups' }), // instead of the server's disk
  // encryptionKey: process.env.EASY_CMS_BACKUP_KEY,       // encrypt the files: see below
  // sqlite,             // Postgres only: see below
},
```

- **Each backup is one compressed SQLite file** (`<site>-YYYY-MM-DD-HHmm.db.gz`) of the whole
  database: documents, users, versions, settings. SQLite copies itself; **Postgres** is copied
  into a SQLite file, so pass `sqlite` from `@easy-cms/db-sqlite` as `backups.sqlite` (install the
  package). For a large Postgres database, keep your provider's backups or `pg_dump` too.
- **Uploaded files are not included**: back up the uploads folder, or turn on versioning for the
  S3 bucket.
- **Where they're kept:** `backups/` on the server's disk by default, which is lost with the server
  (and on serverless hosts, with every deploy). Set `backups.storage` to a **private** bucket, or
  download them. Never use the uploads folder or a public bucket: a backup holds password hashes
  and API key hashes. A `backups.storage` that gives public URLs (e.g. S3 with `publicURL`) logs a
  warning at startup. `create-easy-cms` adds `backups/` to `.gitignore`.
- **Encryption:** with `backups.encryptionKey` (at least 32 characters, e.g.
  `process.env.EASY_CMS_BACKUP_KEY`), each file is encrypted with AES-256-GCM and named
  `….db.gz.enc`, so a leaked bucket or disk holds nothing readable. Downloads from the admin are
  decrypted. Keep a copy of the key somewhere other than the backups: without it they can't be
  restored.
- **Scheduled backups** run with [scheduled jobs](./drafts#scheduled-publishing): every minute on a
  long-running server, or from your cron calling `<api>/jobs/run`. One runs at a time; one a
  process stopped in the middle is finished by the next run. The dashboard tells admins when the
  last scheduled backup failed, or when none finished in two periods.
- **Downloads** go through the server, for admins only, and the list shows who downloaded each
  backup last. Keep downloaded files as safe as the database.
- Upgrading to Easy CMS 0.32 adds the `database-backups` table: create a migration
  (`npx easy-cms migrate:create backups`).

### Restoring a backup from the admin

Restoring is done on the server, not in the admin. Unpack the file first:

```bash
gunzip my-site-2026-10-04-0300.db.gz
```

An encrypted file taken straight from the storage (`.db.gz.enc`) is decrypted first with
[`easy-cms backup:decrypt`](./cli#backup-decrypt), which uses the key from the config:

```bash [pm]
npx easy-cms backup:decrypt my-site-2026-10-04-0300.db.gz.enc
```

- **SQLite:** stop the server, replace `cms.db` with the unpacked file (delete `cms.db-wal` and
  `cms.db-shm` if they exist), start the server.
- **Postgres:** copy the file into an empty database with `easy-cms copy`, as in
  [moving from SQLite to Postgres](./recipes/sqlite-to-postgres#with-content-to-keep): a config
  whose `db` is `sqlite({ url: 'file:./my-site-2026-10-04-0300.db' })` is the source.

## SQLite

The database runs in WAL mode, so copying `cms.db` while the server runs can give a broken copy.
Use `easy-cms backup` instead: it reads your config and writes a consistent copy while the CMS
keeps running.

```bash [pm]
npx easy-cms backup backups/cms-$(date +%F).db
```

The `sqlite3` command does the same: `sqlite3 cms.db ".backup 'backups/cms.db'"`.

**Restore:** stop the server, replace `cms.db` with the backup and delete `cms.db-wal` and
`cms.db-shm` if they exist, then start the server.

## Postgres

```bash
pg_dump --format=custom --file=cms-$(date +%F).dump "$DATABASE_URL"
```

If the database is shared with other apps, limit the dump to Easy CMS's tables with their prefix
(`tablePrefix`, default `ecms_`): `pg_dump -t 'ecms_*' …`. Managed Postgres (Neon, Supabase, RDS…)
usually has point-in-time recovery; turn it on as well.

**Restore:** stop the server, then

```bash
pg_restore --clean --if-exists --no-owner --dbname="$DATABASE_URL" cms-2026-10-01.dump
```

## After a restore

The backup includes the migrations table, so the database is at the schema of the day it was
taken. Start the version of your app that matches it, or deploy the current one and run
`easy-cms migrate` to apply the migrations made since. A production server refuses to start when
they don't match, so a mismatch shows up at once rather than as broken data.

**Test a restore** now and then: restore into a scratch database, start the app against it and
open the admin. A backup that has never been restored is a guess.

## Upgrading Easy CMS

All `@easy-cms/*` packages, `easy-cms` and `create-easy-cms` share one version number: upgrade
them together.

1. Read what changed in between: [Upgrading](./upgrading) (from 0.x to 1.0, and release by
   release), or the changelog (`packages/core/CHANGELOG.md` on GitHub).
2. Upgrade every package to the same version:
   ```bash [pm]
   npm install @easy-cms/core@latest @easy-cms/nuxt@latest @easy-cms/db-sqlite@latest easy-cms@latest
   ```
3. Run `npx easy-cms migrate:create upgrade`. A release sometimes adds internal tables
   (0.7 added `webhook-deliveries`); if it does, you get a migration to review and commit, and
   without it production refuses to start.
4. Try it against a copy of production data (a restored backup) before deploying.
5. Deploy as usual: back up, `easy-cms migrate`, start the new version.

**Rolling back:** migrations only go forward. To go back to the previous version, restore the
backup taken before the deploy and deploy the previous version of your app.

## Versions and support {#versioning}

What each version number promises, what it covers, how old names are retired and how long
releases get security fixes: see [Versions and deprecations](./versioning).

## Next steps

- [CLI](./cli): the backup command.
- [Security](./security): keep backups safe too.

---

<!-- https://easy-cms-website.vercel.app/docs/email.md -->

# Email

::: info What you'll learn
How Easy CMS sends email (for [password links](./auth#forgotten-passwords-and-invitations), [forms](./forms) and your own hooks), how to connect SMTP, and
what happens when sending fails.

**Before this page:** [Configuration](./configuration).
:::

Easy CMS doesn't send email until you give it an **email adapter** in the config. The CMS uses it
for forgotten passwords and invitations, plugins such as the form builder for notifications,
and your own code with `cms.sendEmail()`.

## SMTP

`@easy-cms/email-smtp` sends through any SMTP server: Gmail or Google Workspace, Amazon SES,
Resend, Mailgun, Postmark, or your own.

```bash [pm]
npm install @easy-cms/email-smtp
```

```ts
import { smtp } from '@easy-cms/email-smtp'

export default defineConfig({
  // …
  email: smtp({ from: 'My Site <no-reply@example.com>' }),
})
```

```bash [.env]
SMTP_HOST=smtp.resend.com
SMTP_PORT=465
SMTP_USER=resend
SMTP_PASSWORD=re_…
```

| Option | Default | |
|---|---|---|
| `host` | `SMTP_HOST` | The SMTP server. |
| `port` | `SMTP_PORT`, else `587` | |
| `secure` | `true` for port 465 | TLS from the start; other ports upgrade with STARTTLS. |
| `requireTLS` | `true` for port 587 | Refuse to send unless the connection upgrades with STARTTLS. `false` e.g. for a local mail catcher without TLS. |
| `user`, `password` | `SMTP_USER`, `SMTP_PASSWORD` | |
| `from` | `SMTP_FROM` | The sender when a message sets none. |
| `transport` | — | Other [nodemailer](https://nodemailer.com/smtp/) options (pooling, DKIM…). |

The settings are read when the first email is sent, so the config loads without them, e.g. in a
build.

::: tip Deliverability
Send from an address on your own domain, and set up SPF, DKIM and DMARC for it with your
provider. Mail from `@gmail.com` addresses sent through another server usually lands in spam.
:::

## Checking the settings

<Screenshot name="email-settings" alt="Settings → Email: the SMTP settings in use and a test email" />

Admins find **Settings → Email** in the admin. It shows the adapter and the settings in use, and
where each comes from (`SMTP_HOST`, `smtp({ from })`…), `requireTLS` included. Passwords only show as set or not. To
change a setting, change the environment variable or the config and restart.

- **Check the connection** connects and logs in to the SMTP server without sending anything.
- **Send test email** sends one email right away, not through the queue, to you or another
  address (5 in 10 minutes at most).
  With `consoleEmail()` nothing is sent: the page says the email went to the server log, and so
  does the answer (`{ "ok": true, "logged": true }`), as it does for any adapter whose
  `describe()` says `delivery: log`.

Both show the server's answer, with a hint for the usual mistakes: a wrong user or password
(Gmail needs an app password), the port and `secure` not matching (465 is TLS from the start, 587
is STARTTLS), a server that can't be reached, or a sender the server refuses.

## In development

`consoleEmail()` prints each email in the server's log instead of sending it:

```ts
import { consoleEmail, defineConfig } from '@easy-cms/core'
import { smtp } from '@easy-cms/email-smtp'

export default defineConfig({
  email: process.env.SMTP_HOST ? smtp() : consoleEmail(),
})
```

## Sending from your code

```ts
await cms.sendEmail({
  to: 'editor@example.com',
  subject: `New comment on ${post.title}`,
  text: comment.body,
  html: `<p>${escapeHtml(comment.body)}</p>`, // escapeHtml from @easy-cms/richtext
})
```

`to`, `cc` and `bcc` take one address or a list; `from` and `replyTo` are optional. Escape
anything that comes from visitors before you put it in `html`.

## Queued and retried

`sendEmail` resolves once the email is saved in the database (the internal `email-deliveries`
collection); it is sent in the background. When sending fails (the SMTP server is down, a
limit is reached), the email is tried again after 1 minute, then 5, 30 minutes, 2, 6 and 12
hours, and then marked failed: admins see failed emails under **Settings → Deliveries** and can
retry or delete them; they are deleted after 30 days. Retries run with [scheduled jobs](./drafts#scheduled-publishing):
every minute on a long-running server, or from your cron (`GET <api>/jobs/run`).

On serverless hosts, call `await cms.flushEmails()` before the function returns, or let the cron
pick up what didn't finish.

The table is created only when `email` is set: add it with a migration
(`easy-cms migrate:create email`).

## Your own adapter

An adapter is an object with `send()`. For example, with an HTTP API instead of SMTP:

```ts
import type { EmailAdapter } from '@easy-cms/core'

const resend: EmailAdapter = {
  // Shown to admins on the dashboard (System).
  name: 'resend',
  from: 'My Site <no-reply@example.com>',
  async send(message) {
    const response = await fetch('https://api.resend.com/emails', {
      method: 'POST',
      headers: {
        authorization: `Bearer ${process.env.RESEND_API_KEY}`,
        'content-type': 'application/json',
      },
      body: JSON.stringify({
        from: message.from,
        to: message.to,
        cc: message.cc,
        bcc: message.bcc,
        reply_to: message.replyTo,
        subject: message.subject,
        html: message.html,
        text: message.text,
      }),
    })
    // Throw to have the email retried later.
    if (!response.ok) throw new Error(`Resend answered ${response.status}`)
  },
  // Optional, for Settings → Email: what to show (never secrets) and a connection check.
  describe: () => [{ key: 'from', value: 'My Site <no-reply@example.com>' }],
  async verify() {
    const response = await fetch('https://api.resend.com/domains', {
      headers: { authorization: `Bearer ${process.env.RESEND_API_KEY}` },
    })
    if (!response.ok) throw new Error(`Resend answered ${response.status}`)
  },
}
```

## Next steps

- [Forms](./forms): notifications when someone sends a form.
- [Deployment](./deployment): set the SMTP variables where you host.

---

<!-- https://easy-cms-website.vercel.app/docs/webhooks.md -->

# Webhooks

::: info What you'll learn
Notify other services when content changes, with signatures and retries that survive restarts.

**Before this page:** [Hooks](./hooks).
:::

Tell other services when content changes: rebuild a static site, clear a CDN cache, notify a
chat channel.

```ts
export default defineConfig({
  webhooks: [
    // Every event of every collection and global, signed.
    { url: 'https://example.com/cms-hook', secret: process.env.WEBHOOK_SECRET },
    // Only publishing of posts, e.g. a Vercel or Netlify build hook.
    { url: process.env.BUILD_HOOK_URL!, events: ['publish', 'unpublish'], collections: ['posts'], globals: [] },
  ],
})
```

| Option | Default | |
|---|---|---|
| `url` | required | Where events are POSTed |
| `events` | all | `create`, `update`, `delete`, `publish`, `unpublish`, `draft` |
| `collections` | all but `users` | Collection slugs; `[]` for none. User accounts are sent only when `users` is listed. |
| `globals` | all | Global slugs; `[]` for none |
| `secret` | — | Signs each request (below) |
| `headers` | — | Extra headers, e.g. a token the receiver checks |

## Events

| Event | When |
|---|---|
| `create` | A document was created |
| `update` | A stored document or global changed |
| `delete` | A document was deleted |
| `publish` | Its status became `published` (sent with `create` or `update`) |
| `unpublish` | Its status left `published` |
| `draft` | Only a draft was saved; what is live did not change ([versions](./drafts#versions)) |

Publishing on a [schedule](./drafts#scheduled-publishing) sends the same events.

## Your own events

Plugins and apps send events of their own, e.g. the [shop](./ecommerce)'s `order.paid`. Declare
them in `events` (plugins do), send them with `cms.emit()`, and list them in a webhook's
`events`: they go only to webhooks that list them.

```ts
export default defineConfig({
  events: ['invoice.sent'],
  webhooks: [{ url: 'https://erp.example.com/hook', events: ['invoice.sent'], secret }],
})

cms.emit('invoice.sent', { number: 'INV-1' }, { collection: 'invoices', id: 7 })
```

The body is `{ event, collection?, id?, doc, timestamp }`, with what was given as `doc`;
delivery, signatures and retries are the same.

## Requests

```http
POST /cms-hook
content-type: application/json
x-easy-cms-event: publish
x-easy-cms-delivery: 2f1c…            (the same on retries)
x-easy-cms-signature: sha256=9a0b…    (with `secret`)

{ "event": "publish", "collection": "posts", "id": 12, "doc": { … }, "timestamp": "…" }
```

`doc` is the stored document: every locale, without hidden fields. Check the signature with an
HMAC-SHA256 of the raw body:

```ts
import { createHmac, timingSafeEqual } from 'node:crypto'

const expected = `sha256=${createHmac('sha256', secret).update(rawBody).digest('hex')}`
const valid =
  expected.length === signature.length && timingSafeEqual(Buffer.from(expected), Buffer.from(signature))
```

## Delivery

Webhooks are sent after the change is saved and never slow it down or make it fail. Timeouts
(10 s), network errors, `429` and `5xx` answers are retried; other `4xx` answers are logged and
not retried.

- Two quick retries happen in the process that made the change (after 1 s and 5 s).
- Each delivery is saved in the database (the internal `webhook-deliveries` table) before the
  first attempt and removed once it succeeds, so a restart or a stopped serverless function
  doesn't lose it, even in the middle of sending. A process that stopped mid-send is picked up
  by the next run after 5 minutes.
- After the quick retries, it is retried with [scheduled jobs](./drafts#scheduled-publishing):
  after 1 minute, 5 minutes, 30 minutes, 2, 6 and 12 hours. Servers run them every minute; on
  serverless, call `<api>/jobs/run` from a cron.
- After the last attempt (about a day later) the delivery is kept with `state: 'failed'` and its
  last error, and not retried by itself. Admins see it under **Settings → Deliveries**, where
  they can retry or delete it ([Health checks](./health-checks#failed-webhooks)). Failed
  deliveries are deleted after 30 days.

Every attempt sends the same body, signature and `x-easy-cms-delivery`, so a receiver can skip
deliveries it has already handled. Adding `webhooks` to a config adds the
`webhook-deliveries` table: create a migration as for any config change.

On serverless platforms a function may stop before a webhook is sent: call
`await cms.flushWebhooks()` before returning when you write through the Local API there.
`await cms.retryWebhooks()` tries the saved deliveries that are due (`runJobs()` does that and
runs scheduled publishing).

## Next steps

- [Drafts, versions & scheduling](./drafts): what publishing triggers.
- [CLI](./cli): run retries from cron.

---

<!-- https://easy-cms-website.vercel.app/docs/security.md -->

# Security

::: info What you'll learn
What Easy CMS protects for you, what stays your job, and a checklist before going live.

**Before this page:** [Access control](./access-control) and [Users & auth](./auth).
:::

## What Easy CMS does for you

### Accounts and sessions

- **Passwords** are hashed with scrypt (N=2¹⁷) and never returned by any API. They must be at
  least 8 characters, and not one of the ~2,000 most common passwords (such as `password1` or
  `iloveyou`).
- **Session tokens** are random, signed with `EASY_CMS_SECRET`, and stored only as hashes, so a
  copy of the database does not let anyone log in.
- **Cookies** are `HttpOnly`, `SameSite=Lax`, and `Secure` in production (or over HTTPS).
  Over HTTPS (or in production) the session, CSRF and SSO cookies are named with the `__Host-`
  prefix (`__Host-ecms-session`, `__Host-ecms-csrf`, `__Host-ecms-sso`), so only this exact host
  can set them, not a subdomain. Over plain HTTP in development they keep the plain names.
  Cookies under the old plain names are still read, and logging out clears both.
- **Logins are rate-limited**: after 5 failures for an email (and IP, when known) within 15
  minutes, login answers `429` (`auth.maxLoginAttempts`, `auth.lockWindow`).
- Logging out, changing the password or deactivating a user ends all of their sessions.
- **Logging out** answers with `Clear-Site-Data: "cache"`, so the browser drops cached pages.
  It is not `"storage"`, because your site may share the origin and keep its own data (such as
  a guest's cart). The admin also forgets what it kept in the browser about content: unsaved
  drafts, recent documents, the last media folder and list positions. Preferences such as the
  theme stay.

### Requests

- **CSRF**: a write authenticated by the session cookie must send the session's CSRF token in
  the `x-csrf-token` header, and its `Origin` must be the API's own or in
  `auth.trustedOrigins`. Cross-site requests without an `Origin` are refused. Requests with a
  Bearer token skip the token check (a browser never sends one on its own).
- **CORS** is closed by default. `cors` lists origins whose browser code may call the API;
  only `auth.trustedOrigins` may send cookies. Origins in `cors` may also write **without** a
  session cookie (e.g. a public [form](./forms)): there is no session to forge, and the
  collection's access rules still decide.
- **Request bodies** are limited to 1 MB (JSON) and `upload.maxFileSize` (files, 10 MB).
- **HSTS**: in production (`NODE_ENV=production`) the REST API and the admin send
  `Strict-Transport-Security: max-age=31536000` (without `includeSubDomains`), so browsers use
  HTTPS only for a year. Browsers ignore it over plain HTTP.
- **Errors** show their details in development only; in production unexpected errors answer
  `Internal Server Error` and are logged on the server.

### Content

- **Access is closed by default**: without rules, only logged-in users can read or change a
  collection. Field-level rules remove fields from responses and ignore them in input.
- **Uploads** are checked by their content (not the file name), limited in size, renamed, and
  served with `Content-Security-Policy: sandbox` and `nosniff`, so an uploaded SVG or HTML file
  can't run scripts on your site.
- **Rich text rendering** (`renderRichText`) escapes text and attributes and drops unsafe URLs
  such as `javascript:`.
- **Preview links** carry a signed token for one document or global that expires after an hour.

### The admin

- Admin pages send a strict Content Security Policy (scripts only from your own origin),
  `X-Frame-Options: DENY` and `Referrer-Policy: same-origin`.
- [Admin modules](./plugins#admin-components) are served by your server to logged-in users only;
  URLs of other sites are refused in the config.

## What stays your job

- **Keep `EASY_CMS_SECRET` secret and long** (32+ random characters, e.g. `openssl rand -hex 32`).
  Changing it logs everyone out.
- **Write access rules on purpose**, especially `read`: a collection is public only when you
  say so, e.g. `read: () => true` or "published only".
- **Give people the smallest role they need**: editors get `editor`, not `admin`. Only admins
  can manage users.
- **Install plugins you trust.** A plugin runs on your server, and its admin components run with
  the rights of whoever is logged in.
- **Review migrations** before deploying and keep dependencies updated.
- **Back up** the database and uploads. See [Backups](./backups).

## Checklist before going live

- [ ] `EASY_CMS_SECRET` is set in the server's environment, not only in `.env` on your laptop.
- [ ] `NODE_ENV=production`, and the site is served over HTTPS.
- [ ] The site is served over HTTPS only: the proxy or host redirects HTTP to HTTPS.
- [ ] Every collection's `read` rule is what you intend; test it logged out.
- [ ] `cors` and `auth.trustedOrigins` list only your own origins.
- [ ] Behind a proxy you control, enable trust-proxy (`trustProxy` for Nuxt and Next.js,
  `--trust-proxy` for standalone) so rate limiting sees real client IPs: the last address in
  `X-Forwarded-For` counts. The CSRF origin check then also uses `X-Forwarded-Host`; without
  trust-proxy, a proxy that changes the `Host` makes admin writes fail with `403`. Vercel and
  Netlify are recognized automatically and need nothing. Without an IP, failed logins count
  per email, and the server log says so.
- [ ] `EASY_CMS_SECRET` is random (`openssl rand -hex 32`): in production, one that looks like an
  example or a pattern is refused.
- [ ] `EASY_CMS_SETUP_CODE` is set until the first admin exists (in production the server warns
      while there is no admin and no code), and creating that admin is in the audit log.
- [ ] `CRON_SECRET` is a random string of at least 32 characters, like `EASY_CMS_SECRET`.
- [ ] The first admin has a strong password; other people have the `editor` role.
- [ ] Migrations are applied (`easy-cms migrate`) and backups run on a schedule.

## Reporting a vulnerability

Report vulnerabilities privately as described in
[SECURITY.md](https://github.com/maritonx/easy-cms/blob/main/SECURITY.md), not in a public issue.

## Next steps

- [Access control](./access-control): rules per collection, document and field.
- [Migrations & deployment](./deployment): ship schema changes safely.

---

<!-- https://easy-cms-website.vercel.app/docs/upgrading.md -->

# Upgrading

::: info What you'll learn
What changed between versions, and what to change in your app when you upgrade.

**Before this page:** [Configuration](./configuration).
:::

Upgrade every `@easy-cms/*` package and `easy-cms` together: they are released with the same
version number.

```sh [pm]
npm install @easy-cms/core@latest easy-cms@latest
```

Add the other `@easy-cms/*` packages your app uses (`@easy-cms/nuxt`, `@easy-cms/db-sqlite`, …)
to the same command.

Before 1.0, a minor version (0.48 → 0.60) may change names. Each change below says what to do.
When the config still uses an old name, the app doesn't start and the error names the new one:

```
Invalid Easy CMS config (1 problem):
  • admin.siteUrl: is now `siteURL`
```

## 1.3 {#v1-3}

- **Apps with `jobs`** (in the config or from a plugin, such as the shop's) get a `job-runs`
  table: each run is claimed there, so it happens once however many instances run jobs. Run
  `npx easy-cms migrate:create` and commit the migration, then `npx easy-cms migrate` where you
  deploy; a production server refuses to start until the migration is applied. When each job
  last ran is carried over, so nothing runs early.
- Scheduled publishes, webhook and email retries and backups are claimed the same way (1.2.1),
  with no migration.

## From 0.x to 1.0 {#to-1-0}

0.60 settled the names 1.0 keeps; the releases after it tightened security and readied the
experimental plugins. An app on 0.48 or older meets all of it at once. Changes that stop the app
say so, with the new name; the ones in [the checklist](#checklist) don't, so read it before you
deploy.

### What changed

- **Names** of config options, Local API methods, CLI commands and plugin options
  ([0.60](#v0-60)). A renamed config option stops startup and names the new one.
- **REST routes**: signing in moved to `<api>/auth/*`; the old `/users/*` paths, `POST` to a
  global and `?fallback-locale=` keep working through 1.x with a
  [deprecation warning](./versioning#deprecations).
- **Errors** carry a `code` (`NOT_FOUND`, `VALIDATION_ERROR`…): check it instead of the message.
- **Security defaults**, release by release from [0.62](#v0-62) to [0.66](#v0-66).
- **GraphQL** needs `graphql` installed in your project ([0.68](#v0-68)); without it the app
  doesn't start (`Cannot find module 'graphql'`). npm, pnpm and Bun add it for you, Yarn doesn't.

### Checklist: changes that don't stop the app {#checklist}

| If your app… | Then |
| --- | --- |
| Relies on webhooks for `users` | List `users` in the webhook's `collections` ([0.60](#v0-60)). |
| Uses `isAdmin` for admins of one tenant | Use your own rule; `isAdmin` now means the whole site ([0.60](#v0-60)). |
| Expects `403` when changing a document the user can't read | It answers `404` now ([0.63](#v0-63)). |
| Runs behind its own reverse proxy that changes `Host` | Turn on trust-proxy, or admin writes fail with `403` ([0.64](#v0-64)). |
| Calls `GET <api>/jobs/run` with an admin's cookie | Use `POST`, or the cron secret ([0.62](#v0-62)). |
| Has a `cronSecret` shorter than 32 characters | Make it longer: the app won't start ([0.62](#v0-62)). |
| Sends mail through an SMTP server on port 587 without TLS | Set `requireTLS: false` ([0.65](#v0-65)). |
| Reads the GraphQL schema from production | Set `introspection: true` ([0.65](#v0-65)). |
| Takes cash on delivery with `manualAdapter` | Set `expiresIn: false`, or unpaid orders are cancelled after 3 days ([0.65](#v0-65)). |
| Calls `POST <api>/shop/confirm` without the shop client | Send the guest's cart too ([0.63](#v0-63)). |
| Has `unique` on a field inside a group, array or block | It was never enforced there; move it up or check it in a hook ([0.66](#v0-66)). |
| Takes payments with Stripe | Give the webhook endpoint API version `2026-09-30.endive`, or set `apiVersion` ([0.67](#v0-67)). |

### Steps

1. **Back up** the database and uploads ([Backups](./backups)). Migrations only go forward.
2. **Upgrade every package together**, in development first:
   ```sh [pm]
   npm install @easy-cms/core@latest easy-cms@latest
   ```
   with the other `@easy-cms/*` packages your app uses on the same line.
3. **Start the app** and fix what the config check names, until it starts without errors. Its
   warnings (unknown options, `unique` in a group…) are worth reading too.
4. **Find old names in your code**: run your tests with
   `NODE_OPTIONS=--throw-deprecation`, so anything still using a deprecated route or command
   fails loudly. Then go through [the checklist](#checklist).
5. **Create the migration**: `npx easy-cms migrate:create upgrade`. A release may add internal
   tables or columns; review the SQL and commit it.
6. **Try it on a copy of production data**: restore a backup into a scratch database, run
   `npx easy-cms migrate`, start the app and open the admin.
7. **Deploy**: back up, run `easy-cms migrate`, start the new version. Watch the server log for
   warnings on the first requests.

Upgrades from databases made by 0.10 and later releases are tested for every change, on SQLite
and Postgres.

**Rolling back:** restore the backup taken before the deploy, and deploy the previous version
of your app.

The sections below list each release's changes.

## 0.68 {#v0-68}

- **GraphQL needs `graphql` in your project:** `@easy-cms/plugin-graphql` no longer brings its
  own copy, so the plugin and your `extend` code use the same one. npm, pnpm and Bun install it
  for you; with Yarn, run `yarn add graphql`. Versions 16 and 17 both work.

## 0.67 {#v0-67}

- **Shop with Stripe:** the adapter asks for Stripe API version `2026-09-30.endive`, whichever
  `stripe` package (17 to 23) is installed. Create the webhook endpoint with that version, or
  set `stripeAdapter({ apiVersion })`.

## 0.66 {#v0-66}

- **`unique` inside a group, array or block logs a warning:** it was never enforced there. Move
  the field to the top level, or check it in a hook.
- **Multi-tenant:** staff of a tenant see others' memberships in that tenant only; checking the
  audit log (`POST <api>/admin/audit/verify`) is for admins of all tenants; a plugin's
  collections listed only partly per tenant log a warning.

## 0.65 {#v0-65}

- **Shop: unpaid bank-transfer orders are cancelled after 3 days** and their stock put back
  (`manualAdapter({ expiresIn })`, in seconds; `false` to keep them, e.g. for cash on delivery).
  Orders made before the upgrade aren't affected. Orders get an `expiresAt` field: in production
  run `easy-cms migrate:create` and deploy the migration.
- **GraphQL introspection is off in production by default.** Set `introspection: true` on
  `graphqlPlugin()` to keep it; `easy-cms generate:graphql` still writes the schema for codegen.
  See [GraphQL](./graphql#options).
- **SMTP on port 587 requires STARTTLS.** For a server without TLS, e.g. a local mail catcher,
  set `smtp({ requireTLS: false })`. See [Email](./email).
- **Backups can be encrypted** with the optional `backups.encryptionKey`; decrypt a file by hand
  with `easy-cms backup:decrypt`. See [Backups](./backups#from-the-admin).
- **Without the audit log, failed sign-ins go to the server's log** as warnings.
- **Forms without a client IP share one larger rate limit** (ten times `max`) instead of none.

## 0.64 {#v0-64}

- **Cookies are named `__Host-ecms-session`, `__Host-ecms-csrf` and `__Host-ecms-sso` over
  HTTPS.** Cookies under the old names are still read, so nobody is signed out.
- **Behind your own reverse proxy that changes the `Host`, enable trust-proxy** (`trustProxy`,
  or `--trust-proxy` for standalone). The CSRF origin check uses `X-Forwarded-Host` only then, so
  without it admin writes may fail with `403`. Vercel and Netlify need nothing. See
  [Security](./security#checklist-before-going-live).
- **In production the API and the admin send `Strict-Transport-Security`** (HSTS, one year).
  Redirect HTTP to HTTPS at your proxy or host.
- **The SEO plugin's sitemap and `robots.txt` use `serverURL`** for their own addresses instead of
  the request's host. Set `serverURL` in production.

## 0.63 {#v0-63}

- **Updating or deleting a document the user may not read answers `404`** instead of `403`.
- **Preview links from the admin read with the access of whoever made them**; `createPreviewToken`
  takes `{ user }` for the same.
- **Shop: `POST <api>/shop/confirm` needs the payment's customer or the guest's cart** (`cart:
  { id, secret }`). The client sends it; custom pages that call the endpoint themselves must too.

## 0.62 {#v0-62}

- **Common passwords are refused:** setting a password that is one of the ~2,000 most common
  (`password1`, `iloveyou`…) fails with a validation error. Existing passwords keep working.
- **`cronSecret` must be at least 32 characters**, like `secret`: a shorter one in the config
  stops startup; a shorter `CRON_SECRET` is a warning. Generate one with `openssl rand -hex 32`.
- **`<api>/jobs/run` with the session cookie takes `POST` only** (`405` for `GET`). Crons with
  `Authorization: Bearer <cronSecret>` still use `GET`.

## 0.60 {#v0-60}

0.60 settles the names and shapes kept through 1.x. Most apps only need the config changes
(the error tells you each one) and, if they call them, the renamed Local API methods.

### Config

| Before | Now |
| --- | --- |
| `admin.siteUrl` | `admin.siteURL` |
| `admin.menu` | `admin.order` on each collection, global and page |
| `icon`, `editIn` of a collection or global | `admin.icon`, `admin.editIn` |
| `position` of a field | `admin.position` |
| `admin.defaultValue` of a field | `admin.initialValue` |
| `to` of an `admin.commands` entry | `href` |
| `commands` | `cliCommands` |
| `auth.allowSignUp` | `auth.providerSignUp` |
| `auth.members.signup` | `auth.members.signUp` |
| `audit.keep` | `audit.keepDays` |
| `audit.scope(context)` | `audit.scope({ context, user })` |
| `versions.max` | `versions.keep` |
| `backups.every: 'day' \| 'week'` | `backups.frequency: 'daily' \| 'weekly'` |
| `localization: null`, `admin.switcher: null` | `false` |
| `notEquals` in an `admin.condition` | `not_equals`, as in `where` (`not_in` too) |

Hooks get `previousDoc` (it was `originalDoc` in `beforeValidate` and `beforeChange`). Options Easy CMS doesn't know are
now logged as warnings, with the closest name it knows.

Other changes in behaviour:

- **Webhooks** no longer send `users` events unless the webhook lists `users` in its
  collections.
- **Field access** has `create`. Without it, `update` applies when creating, as before.
- Globals have a `beforeValidate` hook, and `admin.group: false` hides a collection or global
  from the menu.

### Local API and access

| Before | Now |
| --- | --- |
| `isLoggedIn` | `isStaff` (`isSignedIn` is unchanged) |
| `cms.scheduled()` | `cms.findSchedule()` |
| `cms.scheduledGlobal()` | `cms.findGlobalSchedule()` |
| `cms.upcomingJobs()` | `cms.upcomingSchedules()` |
| type `ScheduledJob` | `ScheduledPublish` |
| `localStorage()` storage | `diskStorage()` (`DiskStorageOptions`) |

- `isAdmin` no longer allows admins of one part of the site (a user with `scoped: true`, e.g.
  the admin of one tenant). Use `isSystemAdmin` or your own rule if you relied on it.
- `cms.runJobs()` (and `GET <api>/jobs/run`) answers
  `{ scheduled: { ran, failed }, jobs, webhooks, emails }`.
- `cms.increment()` throws `NotFoundError` when the document doesn't exist. `null` now only
  means the value would go out of bounds.
- `cms.audit.list({ ...filter, page, limit })` answers like `find`: `{ docs, totalDocs, page, … }`.
- Helpers that only `@easy-cms/*` packages use moved to `@easy-cms/core/internal`, which is not
  covered by semver. Import from `@easy-cms/core` (or `@easy-cms/core/plugin`) only.

### Errors

Every `EasyCMSError` has a `code`, and REST errors carry it: `errors[].code`, e.g.
`VALIDATION_ERROR`, `UNAUTHORIZED`, `NOT_FOUND`, `TOO_MANY_REQUESTS`. GraphQL uses the same
codes. Check `code` rather than the message.

- `ConfigError` is an `EasyCMSError` (`CONFIG_ERROR`).
- `429` answers send `Retry-After`.
- Requests that start a session (login, first register, reset password) answer `200`.
- Cancelling a scheduled publish answers `{ deleted: 1 }`.

### REST routes

Only apps that call the REST API themselves need these.

- Signing in moved to `<api>/auth/<action>`: `login`, `logout`, `me`, `init`, `signup`,
  `verify-email`, `forgot-password`, `reset-password`, `first-register`. The old
  `<api>/users/<action>` paths keep working through 1.x.
- Globals are updated with `PATCH` (`POST` still works).
- `?fallbackLocale=` (`?fallback-locale=` still works).
- What only the admin uses moved to `<api>/admin/ui/*`, outside semver. Don't call it from
  your app.
- A plugin endpoint on a path the API also has, but with another method, now leaves that
  method to the API.

### CLI

| Before | Now |
| --- | --- |
| `easy-cms create-admin` | `easy-cms admin:create` |
| `easy-cms run-scheduled` | `easy-cms jobs:run` |

The old names still work. Built-in commands reject options they don't know, so a typo like
`--outt` is an error instead of being ignored.

### Plugins

Options whose names changed; the old names stop startup with the new name.

| Plugin | Before | Now |
| --- | --- | --- |
| SEO | `siteUrl` | `siteURL` |
| Forms | `fields` | `fieldKinds` |
| Forms | `minSubmitTime` (ms, 2000) | `minSubmitSeconds` (2) |
| Forms | `fetch` | removed |
| Multi-tenant | `tenantsSlug` | `slugs: { tenants }` |
| Multi-tenant | `findTenantFor(cms, by, slug)`, `tenantContext(cms, by, slug)` | `{ …by, collection: slug }` |
| Redirects | `slug` | `slugs: { redirects }` |
| Redirects | `cacheTTL` (ms, 60000) | `cacheMaxAge` (seconds, 60) |
| Nested docs | `fields` | `fieldNames` |
| GraphQL | `exclude` | `collections` and `globals`: the slugs in the schema (default all) |
| Shop | `customers.signup`, `signup()` | `customers.signUp`, `signUp()` |

Types: `FormBuilderOptions`, `MultiTenantOptions` and `EcommerceOptions` are now
`FormBuilderPluginOptions`, `MultiTenantPluginOptions` and `EcommercePluginOptions`.

Adapters:

| Adapter | Before | Now |
| --- | --- | --- |
| `sqlite()` | `busyTimeout` | `busyTimeoutMs` |
| `s3Storage()` | `publicUrl` | `publicURL` |

### Writing plugins

- Import `definePlugin`, `defineFieldType`, the plugin types and the error classes from
  `@easy-cms/core/plugin`. It has no server code, so admin elements can import it too.
- Declare the plugin API you wrote for: `definePlugin(fn, { name, version, apiVersion: 1 })`.
  A plugin for another version stops startup and says what to upgrade. Adapters can set
  `apiVersion: 1` too.
- `onRequest` may be a list. Plugins that add one should add to the list instead of calling the
  previous function: each sees the user the one before returned, and contexts are merged.
- `cliCommands` get `flags`: `--dry-run` is `flags.dryRun`, `--limit=5` is `flags.limit`.
- Admin element tags can be any custom element name with a hyphen, not only `ecms-…`.
- Dashboard widgets take an `id`, which roles use instead of the tag.
- `warnDeprecated(code, message)` logs a deprecation warning once per process, for options of
  your own that you rename. See [Versions and deprecations](./versioning#deprecations).

Next.js: `createRouteHandlers(config, options)` takes `basePath` and `getClientIp` as well as
`trustProxy`.

---

<!-- https://easy-cms-website.vercel.app/docs/versioning.md -->

# Versions and deprecations

::: info What you'll learn
What a version number promises, which parts of Easy CMS the promise covers, how old names are
retired, and how long releases get security fixes.

**Before this page:** [Upgrading](./upgrading).
:::

Easy CMS follows [semantic versioning](https://semver.org). Every `@easy-cms/*` package,
`easy-cms` and `create-easy-cms` are released together with the same version number, so upgrade
them together.

## What a version number promises

- **Before 1.0 (0.x):** a minor release (0.60 → 0.61) could change APIs, config or REST answers;
  the changelog and the [upgrade guide](./upgrading) say what and how to update. Patch releases
  (0.60.0 → 0.60.1) only fix bugs.
- **From 1.0:** breaking changes only in a major release (1.x → 2.0), with an upgrade guide.
  Minor releases add features; patch releases fix bugs.

## What the promise covers

| Covered | Not covered |
| --- | --- |
| `@easy-cms/core` and `@easy-cms/core/plugin`: their exports and types | `@easy-cms/core/internal` and `@easy-cms/drizzle`, which the packages share |
| Config options you set, and the documented shape of `cms.config` | Defaults filled into `cms.config`, which may grow |
| The [Local API](/reference/local-api) | `cms.db`: the adapter beneath access rules and hooks |
| REST routes and their answers, error `code`s | `<api>/admin/ui/*`, which only the admin uses |
| CLI commands and their options | The admin's look and wording |
| Your data: upgrades migrate it forward | Members marked `@internal`, left out of the published types |
| Adapters and their options | [Experimental packages](#experimental) |

Each change is tested against databases made by older releases (from 0.10) and the latest one,
on SQLite and Postgres: they are migrated with `easy-cms migrate` and their documents, versions
and files read back.

A change that fixes a security problem may break something the promise covers when there is no
other way; the changelog says so.

## Experimental packages {#experimental}

[`@easy-cms/plugin-ecommerce`](./ecommerce), [`@easy-cms/plugin-graphql`](./graphql) and
[`@easy-cms/plugin-mcp`](./mcp) share the version number but not the promise: their options,
endpoints and data may change in a minor release, after 1.0 too. Their pages and READMEs say so
at the top.

A package leaves this list, in a minor release, once all of these hold:

1. **Tested against the real thing:** a real service or client (Stripe, a GraphQL client and
   codegen, an MCP client such as Claude Desktop), with the result written down in an issue.
2. **Settled:** no breaking change in 3 minor releases in a row.
3. **Covered:** CI tests every option the docs list.

The changelog and the [upgrade guide](./upgrading) say when a package leaves; from then on the
promise above covers it. The [roadmap](https://github.com/maritonx/easy-cms/blob/main/docs/ROADMAP.md)
lists the work still open for each.

## Deprecations {#deprecations}

When a name changes in a minor release, the old one keeps working until the next major and
logs a warning **once per process**, with a code:

```
(node:4242) [EASY_CMS_DEP001] DeprecationWarning: POST <api>/users/login is now <api>/auth/login; the old path works through 1.x.
```

They are Node.js deprecation warnings, so Node's flags apply:

- `node --no-deprecation` hides them.
- `node --throw-deprecation` turns them into errors: useful in CI to find what still uses old
  names.
- `node --trace-deprecation` shows where each came from.

Set these with `NODE_OPTIONS`, e.g. `NODE_OPTIONS=--throw-deprecation npm test`.

Config options are the exception before 1.0: a renamed option stops startup with its new name
instead (``admin.siteUrl: is now `siteURL` ``), since a config is quick to fix and an ignored option
would quietly do nothing.

### Current deprecations

| Code | Old | New | Removed |
| --- | --- | --- | --- |
| `EASY_CMS_DEP001` | `<api>/users/<action>` (`login`, `me`, `init`, …) | `<api>/auth/<action>` | 2.0 |
| `EASY_CMS_DEP002` | `POST <api>/globals/<slug>` | `PATCH` | 2.0 |
| `EASY_CMS_DEP003` | `?fallback-locale=` | `?fallbackLocale=` | 2.0 |
| `EASY_CMS_DEP004` | `easy-cms create-admin` | `easy-cms admin:create` | 2.0 |
| `EASY_CMS_DEP005` | `easy-cms run-scheduled` | `easy-cms jobs:run` | 2.0 |

### In your plugins

`warnDeprecated(code, message)` from `@easy-cms/core` (or `@easy-cms/core/plugin`) does the same
for your own names. Use codes of your own:

```ts
import { warnDeprecated } from '@easy-cms/core/plugin'

export function acmePlugin(options: { apiKey?: string; key?: string }) {
  if (options.key !== undefined)
    warnDeprecated('ACME_DEP001', '`acmePlugin({ key })` is now `apiKey`; `key` goes in 2.0.')
  const apiKey = options.apiKey ?? options.key
  // …
}
```

## Security fixes

Security fixes go to the latest minor release. From 1.0, the previous major gets them too for
six months after the next major is out. Report a vulnerability as the
[security policy](https://github.com/maritonx/easy-cms/blob/main/SECURITY.md) says.

## Next steps

- [Upgrading](./upgrading): what changed in each release.
- [Backups & upgrades](./backups): back up before you deploy a new version.

---

<!-- https://easy-cms-website.vercel.app/docs/deploy.md -->

# Choosing a host

::: info What you'll learn
Which kind of host fits your site, what every host needs from you, and a checklist before going
live.

**Before this page:** [Migrations & deployment](../deployment).
:::

Easy CMS runs wherever Node.js 22.12 or newer runs a server. It needs two things a host may or
may not give you: a place for the **database** and a place for **uploaded files**. Edge runtimes
(Cloudflare Workers, Vercel Edge Functions) are not supported.

## Which host

| | Database | Uploads | Scheduled jobs | Good for |
|---|---|---|---|---|
| [Vercel](./vercel) | Hosted Postgres (Neon) or Turso | Vercel Blob or S3 | A cron calling `jobs/run` | Next.js sites, preview deployments |
| [Netlify](./netlify) | Netlify Database or hosted Postgres | Netlify Blobs or S3 | A scheduled function calling `jobs/run` | Next.js sites on Netlify |
| [Docker](./docker) | Postgres in a container, or SQLite on a volume | A volume, or S3 | Run by the server every minute | Any server or platform that runs containers |
| [VPS](./vps) | SQLite on the disk, or Postgres | The disk | Run by the server every minute | One server you control, the lowest cost |
| [Other hosts](./other-hosts) | Depends on the host | Depends on the host | Depends on the host | Railway, Render, Fly.io, Coolify… |

Two kinds of host behave differently:

- **Serverless** (Vercel, Netlify): no disk that survives and no process that keeps running. Use a
  hosted database and file storage, and call the jobs endpoint from a cron.
- **Long-running** (Docker, a VPS, most platforms): one server that keeps running. SQLite and
  uploads can stay on a persistent disk, and the server runs
  [scheduled jobs](../drafts#scheduled-publishing) every minute by itself.

## What every host needs

| | |
|---|---|
| `EASY_CMS_SECRET` | At least 32 random characters (`openssl rand -hex 32`). Without it the API answers 500. |
| `NODE_ENV=production` | Production refuses to start while migrations are pending, and never changes the schema by itself. |
| Migrations | Created with `easy-cms migrate:create`, committed, and applied with `easy-cms migrate` before the new version starts. See [Migrations & deployment](../deployment). |
| `EASY_CMS_SETUP_CODE` | Until the first admin exists, so nobody else can claim a fresh site. |
| `serverURL` | The public address, for absolute media URLs and email links. See [Health checks](../health-checks#no-serverurl). |
| Trust the proxy | Behind a proxy or platform you trust: `trustProxy` (Next.js, Nuxt) or `--trust-proxy` (standalone), so login limits see each visitor's IP and the CSRF origin check accepts `X-Forwarded-Host`. Vercel and Netlify are recognized automatically. |
| Scheduled jobs | On serverless, a cron calling `<api>/jobs/run` with `CRON_SECRET`. They publish scheduled posts, retry webhooks, send queued emails and run backups. |

## Before going live

- [ ] The secret, setup code and database URL are set in the host's environment, not committed
- [ ] `easy-cms migrate` runs before each new version starts
- [ ] Uploads go to a persistent disk or to file storage ([Uploads & media](../uploads#serving-and-storage))
- [ ] Scheduled jobs run: every minute on a long-running server, or from a cron
- [ ] `email` is configured, so password resets and invitations work ([Email](../email))
- [ ] The site is on HTTPS, with `serverURL` set to its address
- [ ] [Backups](../backups) run, and you have tried a restore
- [ ] The [security checklist](../security) is done

## Next steps

- [One-click deploy](../one-click-deploy): a starter site on Vercel or Netlify in a few clicks.
- [Vercel](./vercel), [Netlify](./netlify), [Docker](./docker) or [a VPS](./vps): step by step.

---

<!-- https://easy-cms-website.vercel.app/docs/deploy/vercel.md -->

# Deploy on Vercel

::: info What you'll build
A Next.js or Nuxt site with Easy CMS on Vercel: content in a hosted database, uploads in Vercel
Blob, migrations on every deploy and a cron for scheduled publishing.

**Before this page:** [Choosing a host](./), [Next.js](../next) or [Nuxt](../nuxt).
:::

Vercel runs your app as serverless functions: no disk survives between requests and no process
keeps running. So the database and uploads live elsewhere, and a cron runs scheduled jobs. To
start from a ready-made site instead, use the [one-click deploy](../one-click-deploy).

## 1. A database

Pick one and connect it in **Storage** (or copy its URL into the environment variables):

- **Postgres** (Neon from Vercel's Storage tab, Supabase, or any hosted Postgres): sets
  `DATABASE_URL`.
- **Turso** (hosted SQLite): a `libsql://` URL and a token.

::: code-group

```ts [Postgres]
import { postgres } from '@easy-cms/db-postgres'

// Vercel: the hosted database. Your laptop: PGlite, Postgres in a local folder, so development
// and migrations use the same SQL as production.
db: process.env.DATABASE_URL
  ? postgres({ url: process.env.DATABASE_URL, max: 2 }) // a small pool per function instance
  : postgres({ pglite: '.pglite' }),
```

```ts [Turso]
import { sqlite } from '@easy-cms/db-sqlite'

// Vercel: Turso. Your laptop: a SQLite file.
db: sqlite({
  url: process.env.DATABASE_URL ?? 'file:./cms.db',
  authToken: process.env.DATABASE_AUTH_TOKEN,
}),
```

:::

```bash [pm]
npm install @easy-cms/db-postgres @electric-sql/pglite
```

Use the database's direct connection string: poolers in transaction mode may not support the
prepared statements the Postgres driver uses. With Turso, `npm install @easy-cms/db-sqlite`.

## 2. Uploads in Vercel Blob

In **Storage**, create a **Blob** store (public) and connect it to the project: it sets
`BLOB_READ_WRITE_TOKEN`.

```bash [pm]
npm install @easy-cms/storage-vercel-blob
```

```ts
import { vercelBlobStorage } from '@easy-cms/storage-vercel-blob'

// On Vercel, Blob; on your laptop, the uploads folder.
upload: process.env.VERCEL ? { storage: vercelBlobStorage() } : {},
```

Vercel limits request bodies to 4.5 MB; with Blob, the admin sends larger files from the browser
straight to the store. Cloudflare R2 or any S3 storage works too:
[S3, Cloudflare R2 and MinIO](../uploads#s3-cloudflare-r2-and-minio).

## 3. Migrations on every deploy

Create the migrations locally and commit them. Your development database was set up by
development push, which can't take migrations, so create them against a fresh one (PGlite, or no
`DATABASE_URL`):

```bash [pm]
npx easy-cms migrate:create init
git add easy-cms/migrations
```

Then migrate before each build, so the database is ready before the new version serves requests:

```json [vercel.json]
{ "buildCommand": "npx easy-cms migrate && npm run build" }
```

Or put it in the `build` script: `"build": "easy-cms migrate && next build"`.

## 4. Environment variables

In **Settings → Environment Variables**, for Production (and Preview if you use it):

| Name | Value |
|---|---|
| `EASY_CMS_SECRET` | `openssl rand -hex 32` |
| `EASY_CMS_SETUP_CODE` | A phrase you choose, until the first admin exists |
| `DATABASE_URL` | Set by the Storage integration, or the database's URL |
| `DATABASE_AUTH_TOKEN` | Turso only |
| `BLOB_READ_WRITE_TOKEN` | Set by the Blob store |
| `CRON_SECRET` | Another random value, for the cron below |

New variables reach new deployments only: redeploy after changing them.

## 5. Scheduled jobs

Nothing keeps running on Vercel, so a cron calls the jobs endpoint. Vercel sends
`Authorization: Bearer $CRON_SECRET`, which Easy CMS checks.

```json [vercel.json]
{
  "crons": [{ "path": "/api/cms/jobs/run", "schedule": "*/5 * * * *" }]
}
```

::: warning Hobby plan: once a day
On the Hobby plan, cron jobs run at most once a day, and a more frequent schedule fails the
deploy. Pro runs them every minute. On Hobby, use `"0 3 * * *"` and call the endpoint more often
from elsewhere, for example a scheduled GitHub Actions workflow:

```bash
curl -fsS -H "Authorization: Bearer $CRON_SECRET" https://your-site.com/api/cms/jobs/run
```

:::

Without a cron, posts scheduled for later don't go live, failed webhooks aren't retried and
queued emails wait. The dashboard warns when scheduled publishing is late.

## 6. Visitors' IP addresses

Vercel sets `X-Forwarded-For`; trust it so login limits apply per visitor:

::: code-group

```ts [Next.js: app/api/cms/[[...path]]/route.ts]
export const { GET, HEAD, POST, PATCH, PUT, DELETE, OPTIONS } = createRouteHandlers(config, {
  trustProxy: true,
})
```

```ts [Nuxt: nuxt.config.ts]
easyCms: { trustProxy: true },
```

:::

## 7. Put the functions near the database

Every page reads the database. Run the functions in the database's region (Settings → Functions,
or in `vercel.json`), for example Tokyo for a database in `ap-northeast-1`:

```json [vercel.json]
{ "regions": ["hnd1"] }
```

## Check it

Deploy, open `/admin`, create the first admin with the setup code, upload an image (it lands in
the Blob store) and publish a post. Then set `serverURL` to the site's address.

## If something doesn't work

- **The build stops at `easy-cms migrate` with HTTP 401** (Turso): the token is wrong, read-only
  or missing in this environment. Create a full-access token and redeploy.
- **Uploads fail, saying to connect a Blob store**: the deployment has no `BLOB_READ_WRITE_TOKEN`.
  Connect the store to the project, then redeploy.
- **`/admin` answers 500 with "Cannot find module '@easy-cms/next/package.json'"**: update
  `@easy-cms/next` to 0.22.2 or later.
- **413 when uploading**: the file is over 4.5 MB and goes through the function. Use Blob or S3
  storage, which take large files straight from the browser.

## Next steps

- [One-click deploy](../one-click-deploy): the same setup, made for you.
- [Backups](../backups): copy the database somewhere else regularly.

---

<!-- https://easy-cms-website.vercel.app/docs/deploy/netlify.md -->

# Deploy on Netlify

::: info What you'll build
A Next.js or Nuxt site with Easy CMS on Netlify: content in Postgres, uploads in Netlify Blobs,
migrations on every deploy and a scheduled function for scheduled publishing.

**Before this page:** [Choosing a host](./), [Next.js](../next) or [Nuxt](../nuxt).
:::

Netlify builds Next.js (through its OpenNext adapter) and Nuxt without extra setup, and runs the
server code as functions: no disk survives and no process keeps running. To start from a
ready-made site instead, use the [one-click deploy](../one-click-deploy).

## 1. A database

- **Netlify Database** (Postgres): install `@netlify/database` and Netlify creates the database at
  the first deploy and sets `NETLIFY_DB_URL`. It needs a credit-based Netlify plan.
- **Any hosted Postgres** (Neon, Supabase…): set `DATABASE_URL`.

```bash [pm]
npm install @easy-cms/db-postgres @electric-sql/pglite
```

```ts
import { postgres } from '@easy-cms/db-postgres'

const url = process.env.DATABASE_URL ?? process.env.NETLIFY_DB_URL

// Netlify: the hosted database. Your laptop: PGlite in a local folder.
db: url ? postgres({ url, max: 2 }) : postgres({ pglite: '.pglite' }),
```

Turso works too, with `@easy-cms/db-sqlite`: see [Vercel](./vercel).

## 2. Uploads in Netlify Blobs

Netlify Blobs needs no setup: the store is created on first use.

```bash [pm]
npm install @easy-cms/storage-netlify-blobs
```

```ts
import { netlifyBlobsStorage } from '@easy-cms/storage-netlify-blobs'

// On Netlify, Blobs; on your laptop, the uploads folder.
upload: process.env.NETLIFY ? { storage: netlifyBlobsStorage() } : {},
```

Files are served through the CMS API. Netlify limits function requests to 6 MB (about 4.5 MB for
binary files), so larger uploads need [S3 storage](../uploads#s3-cloudflare-r2-and-minio), which
takes them straight from the browser.

## 3. Migrations on every deploy

Create the migrations locally against a fresh database (PGlite, or no `DATABASE_URL`) and commit
them:

```bash [pm]
npx easy-cms migrate:create init
git add easy-cms/migrations
```

Then migrate in the build command:

```toml [netlify.toml]
[build]
  command = "npx easy-cms migrate && npm run build"
```

## 4. Environment variables

In **Site configuration → Environment variables**:

| Name | Value |
|---|---|
| `EASY_CMS_SECRET` | `openssl rand -hex 32` |
| `EASY_CMS_SETUP_CODE` | A phrase you choose, until the first admin exists |
| `DATABASE_URL` | Your Postgres URL (not needed with Netlify Database) |
| `CRON_SECRET` | Another random value, for the scheduled function below |

Change a variable, then trigger a new deploy: running deploys keep the old values.

## 5. Scheduled jobs

A scheduled function calls the jobs endpoint every five minutes. Scheduled functions are on every
plan, run for at most 30 seconds, and only on published deploys (not previews).

```bash [pm]
npm install -D @netlify/functions
```

```ts [netlify/functions/easy-cms-jobs.mts]
import type { Config } from '@netlify/functions'

// Runs Easy CMS's due jobs: scheduled publishing, webhook retries, queued emails, backups.
export default async () => {
  const res = await fetch(`${process.env.URL}/api/cms/jobs/run`, {
    headers: { Authorization: `Bearer ${process.env.CRON_SECRET}` },
  })
  if (!res.ok) console.error(`Easy CMS jobs: ${res.status} ${await res.text()}`)
}

export const config: Config = { schedule: '*/5 * * * *' }
```

`URL` is the site's main address, which Netlify sets for functions.

## 6. Visitors' IP addresses

Netlify sets `X-Forwarded-For`; trust it so login limits apply per visitor:

::: code-group

```ts [Next.js: app/api/cms/[[...path]]/route.ts]
export const { GET, HEAD, POST, PATCH, PUT, DELETE, OPTIONS } = createRouteHandlers(config, {
  trustProxy: true,
})
```

```ts [Nuxt: nuxt.config.ts]
easyCms: { trustProxy: true },
```

:::

## Check it

Deploy, open `/admin`, create the first admin with the setup code, upload an image and publish a
post. Then set `serverURL` to the site's address.

## If something doesn't work

- **The site fails with "No database"**: neither `DATABASE_URL` nor `NETLIFY_DB_URL` is set. Netlify
  Database needs `@netlify/database` installed and a credit-based plan; otherwise set
  `DATABASE_URL`.
- **"CSRF check failed: untrusted origin" when creating the first admin**: update to Easy CMS
  0.37.2 or later, which accepts the public host Netlify's proxy forwards.
- **Scheduled posts don't go live**: check the function's log under **Logs → Functions**, and that
  `CRON_SECRET` is the same in the function's environment.

## Next steps

- [One-click deploy](../one-click-deploy): the same setup, made for you.
- [Backups](../backups): copy the database somewhere else regularly.

---

<!-- https://easy-cms-website.vercel.app/docs/deploy/docker.md -->

# Deploy with Docker

::: info What you'll build
A container image of your site with Easy CMS, run with Docker Compose: with Postgres in a second
container, or with SQLite on a volume. The same image runs on any server or platform that runs
containers.

**Before this page:** [Choosing a host](./), [Migrations & deployment](../deployment).
:::

A container keeps running, so the server runs [scheduled jobs](../drafts#scheduled-publishing)
every minute by itself, and SQLite and uploads can live on volumes. Building inside the image also
puts SQLite's native driver on the right system.

## 1. The Dockerfile

The image migrates the database when it starts, then serves the site. Pick your setup:

::: code-group

```dockerfile [Next.js]
# Build: install everything and build Next.js. No database is needed to build.
FROM node:24-slim AS build
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci
COPY . .
RUN npx next build

# Run: the built app with its node_modules (the easy-cms CLI runs the migrations).
FROM node:24-slim
WORKDIR /app
ENV NODE_ENV=production PORT=3000 HOSTNAME=0.0.0.0
COPY --from=build /app ./
EXPOSE 3000
HEALTHCHECK --interval=30s --start-period=30s \
  CMD node -e "fetch('http://localhost:3000/api/cms/auth/init').then(r => process.exit(r.ok ? 0 : 1), () => process.exit(1))"
CMD ["sh", "-c", "npx easy-cms migrate && npx next start"]
```

```dockerfile [Nuxt]
FROM node:24-slim AS build
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci
COPY . .
RUN npx nuxi build

FROM node:24-slim
WORKDIR /app
ENV NODE_ENV=production PORT=3000 HOST=0.0.0.0
COPY --from=build /app ./
EXPOSE 3000
HEALTHCHECK --interval=30s --start-period=30s \
  CMD node -e "fetch('http://localhost:3000/api/cms/auth/init').then(r => process.exit(r.ok ? 0 : 1), () => process.exit(1))"
CMD ["sh", "-c", "npx easy-cms migrate && node .output/server/index.mjs"]
```

```dockerfile [Standalone]
FROM node:24-slim
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci --omit=dev
COPY . .
ENV NODE_ENV=production PORT=4000
EXPOSE 4000
HEALTHCHECK --interval=30s --start-period=20s \
  CMD node -e "fetch('http://localhost:4000/healthz').then(r => process.exit(r.ok ? 0 : 1), () => process.exit(1))"
CMD ["sh", "-c", "npx easy-cms migrate && npx easy-cms serve --trust-proxy"]
```

:::

- The `build` script must not migrate: the image is built without a database. Run `next build`
  (or `nuxi build`) directly, as above.
- The health check calls `<api>/auth/init`, which needs the database; the standalone server also
  answers `/healthz`.
- `easy-cms` must be in `dependencies` (not only `devDependencies`) for the standalone image, which
  installs without dev dependencies.
- With pnpm, start the build stage with `RUN corepack enable`, copy `pnpm-lock.yaml` and run
  `pnpm install --frozen-lockfile`.

Keep local files out of the image with a `.dockerignore`:

```txt [.dockerignore]
node_modules
.next
.output
.env
.pglite
uploads
data
*.db
.git
```

## 2. Compose

::: code-group

```yaml [With Postgres: compose.yaml]
services:
  app:
    build: .
    ports:
      - "3000:3000"
    environment:
      EASY_CMS_SECRET: ${EASY_CMS_SECRET:?set EASY_CMS_SECRET in .env}
      EASY_CMS_SETUP_CODE: ${EASY_CMS_SETUP_CODE:-}
      DATABASE_URL: postgres://cms:cms@db:5432/cms
    volumes:
      - uploads:/app/uploads
    depends_on:
      db:
        condition: service_healthy
    restart: unless-stopped

  db:
    image: postgres:17
    environment:
      POSTGRES_USER: cms
      POSTGRES_PASSWORD: cms
      POSTGRES_DB: cms
    volumes:
      - pgdata:/var/lib/postgresql/data
    healthcheck:
      test: ["CMD-SHELL", "pg_isready -U cms -d cms"]
      interval: 5s
      retries: 10
    restart: unless-stopped

volumes:
  uploads:
  pgdata:
```

```yaml [With SQLite: compose.yaml]
services:
  app:
    build: .
    ports:
      - "3000:3000"
    environment:
      EASY_CMS_SECRET: ${EASY_CMS_SECRET:?set EASY_CMS_SECRET in .env}
      EASY_CMS_SETUP_CODE: ${EASY_CMS_SETUP_CODE:-}
      DATABASE_URL: file:/app/data/cms.db
    volumes:
      - data:/app/data
      - uploads:/app/uploads
    restart: unless-stopped

volumes:
  data:
  uploads:
```

:::

The config reads the database from `DATABASE_URL`, for example
`postgres({ url: process.env.DATABASE_URL })` or `sqlite({ url: process.env.DATABASE_URL })`. With
SQLite, run one container only: a SQLite file has one server.

Put the secrets in a `.env` file next to `compose.yaml` (not in the image), then start:

```bash
printf 'EASY_CMS_SECRET=%s\nEASY_CMS_SETUP_CODE=%s\n' "$(openssl rand -hex 32)" "choose-a-phrase" > .env
docker compose up -d --build
docker compose logs -f app
```

The log shows the applied migrations, then the server. Open `http://localhost:3000/admin` and
create the first admin with the setup code. Change the Postgres password before using this
anywhere but your machine.

## 3. Updating

Pull the new code and rebuild: the new container applies pending migrations before it starts, and
the volumes keep the database and uploads.

```bash
git pull
docker compose up -d --build
```

## 4. HTTPS and the proxy

Put a reverse proxy in front for HTTPS: Caddy, Traefik, nginx, or your platform's. It sets
`X-Forwarded-For`, so trust it: `--trust-proxy` (standalone, already in the image above) or
`trustProxy: true` (Next.js route handlers, Nuxt module). Then set `serverURL` to the public
address. The proxy should also redirect HTTP to HTTPS. A Caddy setup is in
[VPS](./vps#https-with-caddy).

## Backups

The volumes hold everything: back up the `pgdata` or `data` volume and `uploads`, or let Easy CMS
make database backups itself (`backups` in the config, stored on another volume or in S3). See
[Backups](../backups).

## Next steps

- [VPS](./vps): run the same site without containers.
- [Other hosts](./other-hosts): platforms that build and run this image for you.

---

<!-- https://easy-cms-website.vercel.app/docs/deploy/vps.md -->

# Deploy on a VPS

::: info What you'll build
Your site with Easy CMS on one Linux server you control (Ubuntu or Debian): Node.js, SQLite and
uploads on the server's disk, systemd to keep it running, Caddy for HTTPS, and daily backups.

**Before this page:** [Choosing a host](./), [Migrations & deployment](../deployment).
:::

One server is the simplest and cheapest way to run Easy CMS. The server keeps running, so it runs
[scheduled jobs](../drafts#scheduled-publishing) every minute by itself, and SQLite needs nothing
but a file.

## 1. Node.js and a user

Install Node.js 22.12 or newer (24 LTS), for example from
[nodejs.org](https://nodejs.org/en/download) or your distribution's NodeSource packages, then
create a user that runs the site and owns its files:

```bash
node -v   # v22.12 or newer
sudo useradd --system --create-home --shell /usr/sbin/nologin cms
sudo mkdir -p /srv/site && sudo chown cms:cms /srv/site
```

## 2. The site

As the `cms` user, put the code in `/srv/site` (`git clone`, or copy it), add the secrets, install
and build. Build on the server itself: SQLite's native driver must match its system.

```bash
sudo -u cms bash
cd /srv/site
git clone https://github.com/you/your-site.git .
printf 'EASY_CMS_SECRET=%s\nEASY_CMS_SETUP_CODE=%s\n' "$(openssl rand -hex 32)" "choose-a-phrase" > .env
chmod 600 .env
npm ci
npm run build   # Next.js or Nuxt; nothing to build for the standalone server
exit
```

Without `DATABASE_URL`, `sqlite({ url: process.env.DATABASE_URL ?? 'file:./cms.db' })` keeps the
database in `/srv/site/cms.db`, and uploads go to `/srv/site/uploads`. Postgres works too: install
it on the server or use a hosted one, and set `DATABASE_URL` in `.env`.

## 3. A systemd service

systemd starts the site at boot, applies migrations before each start and restarts it if it stops.

::: code-group

```ini [Next.js: /etc/systemd/system/easy-cms.service]
[Unit]
Description=Easy CMS site
After=network.target

[Service]
Type=simple
User=cms
WorkingDirectory=/srv/site
Environment=NODE_ENV=production
Environment=PORT=3000
EnvironmentFile=/srv/site/.env
ExecStartPre=/usr/bin/npx easy-cms migrate
ExecStart=/usr/bin/npx next start
Restart=always
RestartSec=5

[Install]
WantedBy=multi-user.target
```

```ini [Nuxt: /etc/systemd/system/easy-cms.service]
[Unit]
Description=Easy CMS site
After=network.target

[Service]
Type=simple
User=cms
WorkingDirectory=/srv/site
Environment=NODE_ENV=production
Environment=PORT=3000
EnvironmentFile=/srv/site/.env
ExecStartPre=/usr/bin/npx easy-cms migrate
ExecStart=/usr/bin/node .output/server/index.mjs
Restart=always
RestartSec=5

[Install]
WantedBy=multi-user.target
```

```ini [Standalone: /etc/systemd/system/easy-cms.service]
[Unit]
Description=Easy CMS site
After=network.target

[Service]
Type=simple
User=cms
WorkingDirectory=/srv/site
Environment=NODE_ENV=production
Environment=PORT=3000
EnvironmentFile=/srv/site/.env
ExecStartPre=/usr/bin/npx easy-cms migrate
ExecStart=/usr/bin/npx easy-cms serve --trust-proxy
Restart=always
RestartSec=5

[Install]
WantedBy=multi-user.target
```

:::

`EnvironmentFile` gives the server `.env` (Nuxt doesn't read it by itself), and
`WorkingDirectory` makes `file:./cms.db`, the migrations and `uploads` resolve in the project.
Check where `npx` and `node` are with `which npx` and adjust the paths. Then:

```bash
sudo systemctl daemon-reload
sudo systemctl enable --now easy-cms
journalctl -u easy-cms -f   # the applied migrations, then the server
```

## HTTPS with Caddy

[Caddy](https://caddyserver.com) gets and renews the HTTPS certificate by itself. Point the
domain's DNS at the server, open ports 80 and 443, install Caddy and use:

```txt [/etc/caddy/Caddyfile]
cms.example.com

reverse_proxy localhost:3000
```

```bash
sudo systemctl reload caddy
```

Caddy redirects HTTP to HTTPS by itself. It sends `X-Forwarded-For`, `X-Forwarded-Proto` and
`X-Forwarded-Host`, so trust it: `trustProxy: true` in the Next.js route handlers or the Nuxt
module (`--trust-proxy` for the standalone server, already in the unit above). Set
`serverURL: 'https://cms.example.com'` in the config. With nginx, proxy to the same port and set
those headers yourself, and redirect HTTP to HTTPS with a server block on port 80:

```nginx
server {
  listen 80;
  server_name cms.example.com;
  return 301 https://$host$request_uri;
}
```

## Backups

Let Easy CMS back up the database every day (see [Backups](../backups)):

```ts
backups: { frequency: 'daily', at: '03:00', keep: 7 },
```

Backups go to `backups/` on the same disk, so copy them and `uploads/` somewhere else too:
[Automate backups](../recipes/automate-backups) uses a cron job with S3 or rclone.

## Updating

```bash
cd /srv/site
sudo -u cms git pull
sudo -u cms npm ci
sudo -u cms npm run build
sudo systemctl restart easy-cms   # migrates, then starts
```

The site is down for the few seconds of the restart.

## Next steps

- [Security](../security): the checklist for a server on the internet.
- [Docker](./docker): the same site in a container.

---

<!-- https://easy-cms-website.vercel.app/docs/deploy/other-hosts.md -->

# Other hosts

::: info What you'll learn
What any Node.js host needs to run Easy CMS, and notes for Railway, Render, Fly.io and Coolify.

**Before this page:** [Choosing a host](./), [Docker](./docker).
:::

Most platforms either build your app from its `package.json` or run the image from
[Docker](./docker). Either way, check the list below. The notes per platform come from each
platform's documentation; we haven't run every one of them ourselves.

## What any host needs

- **Node.js 22.12 or newer.** Most platforms read `engines` in `package.json`:
  `"engines": { "node": ">=22.12" }`. Edge runtimes are not supported.
- **Build, then migrate, then start.** Build with `next build`, `nuxi build`, or nothing for the
  standalone server; before starting, run `easy-cms migrate`; then start with `next start`,
  `node .output/server/index.mjs` or `easy-cms serve`. Many platforms have a pre-deploy or release
  command for the migration; otherwise start with `easy-cms migrate && …`.
- **Somewhere to keep data.** Either a persistent disk (volume) for SQLite and `uploads`, or a
  hosted Postgres and [file storage](../uploads#serving-and-storage). With SQLite on a disk, run
  one instance.
- **A process that keeps running**, so the server runs scheduled jobs every minute. If the platform
  stops idle apps, call `<api>/jobs/run` with `CRON_SECRET` from a cron instead.
- **The environment variables** from [Choosing a host](./#what-every-host-needs), and trust the
  platform's proxy (`trustProxy` or `--trust-proxy`).

## Railway

- Railway builds from `package.json` and picks the Node.js version from `engines` (or
  `.node-version`). The start command is your `start` script.
- **SQLite:** add a volume to the service and mount it inside the app, for example `/app/data`, then
  set `DATABASE_URL=file:/app/data/cms.db`. Mount `uploads` the same way, or use S3 storage.
  Volumes are mounted when the app runs, not while it builds, so migrate in the start command.
- **Postgres:** add Railway's Postgres and reference its `DATABASE_URL` in the app's variables.
- Services keep running unless you turn on Serverless (app sleeping); leave it off, or scheduled jobs
  only run while someone visits.

## Render

- Create a **Web Service** from the repository. Render picks the Node.js version from
  `NODE_VERSION`, `.node-version`, `.nvmrc` or `engines`.
- **SQLite:** persistent disks are for paid services only. Mount one at an absolute path, for example
  `/opt/render/project/src/data`, and point `DATABASE_URL` at a file in it. A service with a disk
  runs one instance and has a few seconds of downtime per deploy.
- **Postgres:** create a Render Postgres and copy its Internal Database URL into `DATABASE_URL`.
- Free web services stop after 15 minutes without visitors, which also stops scheduled jobs. Use a
  paid instance, or a Render Cron Job that calls `<api>/jobs/run` with `curl`.

## Fly.io

- Fly runs the image from [Docker](./docker) (`fly launch` finds the `Dockerfile`).
- **SQLite:** create a volume (`fly volumes create data --size 1`) and mount it in `fly.toml`:

  ```toml [fly.toml]
  [mounts]
    source = "data"
    destination = "/app/data"
  ```

  A volume belongs to one machine and isn't replicated: run one machine, and back up yourself.
- **Keep it running:** `fly launch` sets `auto_stop_machines = "stop"`, which stops idle machines and
  the scheduled jobs with them. Set `auto_stop_machines = "off"` (or `min_machines_running = 1`)
  under `[http_service]`.
- **Postgres:** Fly Managed Postgres sets `DATABASE_URL` when attached (`fly mpg attach`).

## Coolify

Coolify runs on your own server and deploys from Git.

- Choose the **Dockerfile** build pack with the image from [Docker](./docker), or Nixpacks.
- **SQLite and uploads:** add a volume mount under **Persistent Storage**, for example `/app/data`
  and `/app/uploads`.
- **Postgres:** create one from Coolify's databases and copy its internal URL into `DATABASE_URL`.
  Coolify can back up its databases on a schedule.
- Coolify's proxy issues HTTPS certificates for your domain, and the app keeps running, so
  scheduled jobs run by themselves.

## Next steps

- [Docker](./docker): the image these platforms build.
- [Backups](../backups): whatever the host, keep a copy elsewhere.

---

<!-- https://easy-cms-website.vercel.app/docs/recipes.md -->

# Recipes

Short, complete answers for common tasks. Each one is a few steps you can copy into your project.

| Recipe | |
|---|---|
| [Authors edit only their own posts](./own-posts) | Roles, document-level access and setting the author automatically. |
| [Rebuild a static site on publish](./rebuild-on-publish) | A deploy hook for Netlify, Vercel or Cloudflare Pages. |
| [Reading time with a hook](./reading-time) | A read-only field the CMS keeps up to date. |
| [A landing page from blocks](./landing-page) | Editors lay out pages from hero, features and call-to-action blocks. |
| [A navigation menu in a global](./navigation-menu) | One editable menu for the whole site, in every language. |
| [Image galleries](./image-galleries) | Several photos on a post, in the order editors choose. |
| [Deploy on Vercel](../deploy/vercel) | Serverless: Postgres or Turso, Vercel Blob or R2 for files, and a cron for scheduled posts. |
| [Move from SQLite to Postgres](./sqlite-to-postgres) | Switch databases before launch, or with your data. |
| [Move from WordPress](./wordpress) | Posts, pages, categories and images from the WordPress REST API, with redirects from the old addresses. |
| [Automate backups](./automate-backups) | Daily copies of the database and uploads, and restore drills. |
| [Get your site ready for search engines](./search-engines) | Meta tags, a sitemap, robots.txt, hreflang and structured data, then Search Console. |

---

<!-- https://easy-cms-website.vercel.app/docs/recipes/own-posts.md -->

# Authors edit only their own posts

::: info What you'll build
An `author` role that can write posts but change only its own, while editors and admins manage
everything. **Uses:** [roles](../auth#roles), [access control](../access-control), [hooks](../hooks).
:::

## 1. Add the role

```ts
auth: { roles: ['admin', 'editor', 'author'] },
```

Give people the role under **Settings → Users**.

## 2. Record who wrote each post

```ts
{
  slug: 'posts',
  fields: [
    // …
    {
      name: 'author',
      type: 'relationship',
      to: 'users',
      admin: { position: 'sidebar' },
      // Only editors and admins may reassign a post.
      access: { update: ({ user }) => user?.role !== 'author' },
    },
  ],
  hooks: {
    beforeChange: [
      ({ data, operation, user }) =>
        operation === 'create' && user && !data.author ? { ...data, author: user.id } : data,
    ],
  },
}
```

## 3. Limit what authors may change

```ts
access: {
  read: () => true,
  create: ({ user }) => !!user,
  // A `where` result allows only the matching documents.
  update: ({ user }) =>
    user?.role === 'author' ? { author: { equals: user.id } } : !!user,
  delete: ({ user }) => user?.role === 'admin' || user?.role === 'editor',
},
```

In the admin, authors can open every post but only save their own; Delete is hidden for them.
To hide other people's posts from the list as well, return the same `where` from `read` for
logged-in authors:

```ts
read: ({ user }) =>
  user?.role === 'author'
    ? { author: { equals: user.id } }
    : user
      ? true
      : { status: { equals: 'published' } },
```

## Check it

Log in as an author: create a post, then open one by someone else. Saving it answers
**403 Forbidden**, and the admin says you can only view it.

---

<!-- https://easy-cms-website.vercel.app/docs/recipes/rebuild-on-publish.md -->

# Rebuild a static site on publish

::: info What you'll build
A site generated at build time (Astro, Nuxt `generate`, Next.js export, Hugo…) that rebuilds by
itself when content is published. **Uses:** [webhooks](../webhooks).
:::

## 1. Get a deploy hook

Your host gives you a URL that starts a build when it receives a `POST`:

- **Netlify:** Site configuration → Build & deploy → Build hooks
- **Vercel:** Project Settings → Git → Deploy Hooks
- **Cloudflare Pages:** Settings → Builds → Deploy hooks

Keep it secret: put it in an environment variable, e.g. `DEPLOY_HOOK_URL`.

## 2. Call it when something goes live

```ts
webhooks: [
  {
    url: process.env.DEPLOY_HOOK_URL as string,
    // Only changes visitors can see: not every draft save.
    events: ['publish', 'unpublish', 'delete'],
    collections: ['posts', 'pages'],
  },
],
```

For collections without drafts, add `'update'` and `'create'`: every save is live there.

Easy CMS sends the request after the change is saved, and retries for about a day if the host is
down (1 minute, 5 minutes, 30 minutes, 2, 6 and 12 hours), even across restarts.

## 3. Build with the content

At build time, read published content from the API, e.g. in Astro:

```ts
const res = await fetch(`${import.meta.env.CMS_URL}/api/cms/posts?limit=100&sort=-publishedAt`)
const { docs: posts } = await res.json()
```

## Tips

- Several publishes in a row start several builds; most hosts cancel older builds or queue them.
- Scheduled publishing sends `publish` too, so a post set for 9:00 rebuilds the site at 9:00.
- Failed attempts are logged on the server (`Webhook <url> failed for publish …; will retry
  later`), and the delivery is kept until it succeeds or runs out of retries.

---

<!-- https://easy-cms-website.vercel.app/docs/recipes/reading-time.md -->

# Reading time with a hook

::: info What you'll build
A "minutes to read" number that the CMS computes on every save and editors can see but not
change. **Uses:** [hooks](../hooks), [field access](../access-control#field-access).
:::

## 1. The field

```ts
{
  name: 'readingTime',
  type: 'number',
  label: { en: 'Reading time (min)', th: 'เวลาอ่าน (นาที)' },
  admin: { position: 'sidebar' },
  // Read-only in the admin and the API; the hook below sets it.
  access: { update: () => false },
}
```

Field access only filters what people send; hooks can still set the value.

## 2. The hook

```bash [pm]
npm install @easy-cms/richtext
```

```ts
import { richTextToPlainText } from '@easy-cms/richtext'

const WORDS_PER_MINUTE = 200

function minutes(body: unknown): number {
  const words = richTextToPlainText(body as never).split(/\s+/).filter(Boolean).length
  return Math.max(1, Math.round(words / WORDS_PER_MINUTE))
}

// In the posts collection:
hooks: {
  beforeChange: [
    ({ data }) => (data.body === undefined ? data : { ...data, readingTime: minutes(data.body) }),
  ],
},
```

On an update that doesn't touch `body`, `data.body` is missing and the old value stays.

::: tip Thai text
Thai has no spaces between words, so counting words by spaces undercounts. Count characters
instead, roughly 800–1,000 per minute for Thai:
`Math.round(richTextToPlainText(body).length / 900)`.
:::

## 3. Fill existing posts

The hook runs on save. To fill posts that already exist, save each once:

```ts
const cms = await getEasyCMS(config) // or useEasyCMS() in Nuxt
const { docs } = await cms.find('posts', { limit: 0, draft: true, depth: 0 })
for (const post of docs) await cms.update('posts', post.id, { body: post.body })
```

---

<!-- https://easy-cms-website.vercel.app/docs/recipes/landing-page.md -->

# A landing page from blocks

::: info What you'll build
A `pages` collection whose content is a list of blocks (hero, features, call to action) that
editors add, reorder and fill in, and a page that renders them. **Uses:** [blocks](../fields#blocks).
:::

## 1. The collection

```ts
{
  slug: 'pages',
  useAsTitle: 'title',
  drafts: true,
  preview: ({ doc }) => `/${doc.slug ?? ''}`,
  access: { read: ({ user }) => (user ? true : { status: { equals: 'published' } }) },
  fields: [
    { name: 'title', type: 'text', required: true },
    { name: 'slug', type: 'slug', from: 'title' },
    {
      name: 'layout',
      type: 'blocks',
      blocks: [
        {
          slug: 'hero',
          labels: { singular: { en: 'Hero', th: 'ส่วนหัว' } },
          fields: [
            { name: 'heading', type: 'text', required: true },
            { name: 'text', type: 'textarea' },
            { name: 'image', type: 'upload' },
          ],
        },
        {
          slug: 'features',
          labels: { singular: { en: 'Features', th: 'จุดเด่น' } },
          fields: [
            {
              name: 'items',
              type: 'array',
              maxRows: 6,
              fields: [
                { name: 'title', type: 'text', required: true },
                { name: 'text', type: 'textarea' },
              ],
            },
          ],
        },
        {
          slug: 'cta',
          labels: { singular: { en: 'Call to action', th: 'ปุ่มชวนคลิก' } },
          fields: [
            { name: 'text', type: 'text', required: true },
            { name: 'buttonLabel', type: 'text', required: true },
            { name: 'buttonUrl', type: 'text', required: true },
          ],
        },
      ],
    },
  ],
}
```

In the admin, **Add block** offers the three kinds; rows can be dragged into any order.

## 2. Render it

Switch on `blockType`; TypeScript narrows each row to its block's fields.

::: code-group

```vue [Nuxt]
<template>
  <template v-for="block in page.layout" :key="block.id">
    <section v-if="block.blockType === 'hero'" class="hero">
      <h1>{{ block.heading }}</h1>
      <p>{{ block.text }}</p>
    </section>
    <section v-else-if="block.blockType === 'features'" class="features">
      <div v-for="item in block.items" :key="item.id">
        <h3>{{ item.title }}</h3>
        <p>{{ item.text }}</p>
      </div>
    </section>
    <section v-else-if="block.blockType === 'cta'" class="cta">
      <p>{{ block.text }}</p>
      <a :href="block.buttonUrl">{{ block.buttonLabel }}</a>
    </section>
  </template>
</template>
```

```tsx [Next.js]
export function Blocks({ layout }: { layout: Page['layout'] }) {
  return layout?.map((block) => {
    switch (block.blockType) {
      case 'hero':
        return <section key={block.id}><h1>{block.heading}</h1><p>{block.text}</p></section>
      case 'features':
        return (
          <section key={block.id}>
            {block.items?.map((item) => <div key={item.id}><h3>{item.title}</h3><p>{item.text}</p></div>)}
          </section>
        )
      case 'cta':
        return <section key={block.id}><p>{block.text}</p><a href={block.buttonUrl}>{block.buttonLabel}</a></section>
    }
  })
}
```

:::

`Page` is `CollectionDocument<typeof config, 'pages'>`; see [TypeScript](../typescript#naming-the-types).

## 3. Query inside blocks

```ts
// Pages that have a call to action
await cms.find('pages', { where: { 'layout.blockType': { equals: 'cta' } } })
```

---

<!-- https://easy-cms-website.vercel.app/docs/recipes/navigation-menu.md -->

# A navigation menu in a global

::: info What you'll build
One menu for the whole site that editors change in the admin, with labels in every language.
**Uses:** [globals](../configuration#globals), [arrays](../fields#array-and-group),
[localization](../localization).
:::

## 1. The global

```ts
globals: [
  {
    slug: 'navigation',
    label: { en: 'Navigation', th: 'เมนู' },
    admin: { icon: 'link' },
    access: { read: () => true },
    fields: [
      {
        name: 'links',
        type: 'array',
        maxRows: 8,
        fields: [
          { name: 'label', type: 'text', required: true, localized: true },
          { name: 'url', type: 'text', required: true },
          { name: 'external', type: 'boolean' },
        ],
      },
    ],
  },
],
```

Editors find it under **Settings → Navigation**, add links and drag them into order.

## 2. Show it in the layout

::: code-group

```ts [Nuxt: server/api/navigation.get.ts]
export default defineEventHandler(async (event) => {
  const cms = await useEasyCMS()
  return cms.findGlobal('navigation', { locale: getQuery(event).locale as string | undefined })
})
```

```tsx [Next.js: app/layout.tsx]
import { getEasyCMS } from '@easy-cms/next'
import type { ReactNode } from 'react'
import config from '@/easy-cms.config'

export default async function Layout({ children }: { children: ReactNode }) {
  const nav = await (await getEasyCMS(config)).findGlobal('navigation', { locale: 'th' })
  return (
    <html lang="th">
      <body>
        <nav>
          {nav.links?.map((link) => (
            <a key={link.id} href={link.url} {...(link.external ? { target: '_blank', rel: 'noopener' } : {})}>
              {link.label}
            </a>
          ))}
        </nav>
        {children}
      </body>
    </html>
  )
}
```

:::

In Nuxt, fetch it once in `app.vue` with `useFetch('/api/navigation', { query: { locale } })`.

## Tips

- A link to a page that may be renamed? Use a `relationship` to `pages` instead of `url`, and
  build the path from the related page's slug.
- With `drafts: true` on the global, editors can prepare a new menu and publish it at once.

---

<!-- https://easy-cms-website.vercel.app/docs/recipes/image-galleries.md -->

# Image galleries

::: info What you'll build
A gallery of photos on each post, in the order editors choose, shown as a grid on the page.
**Uses:** [uploads](../uploads#several-files-galleries), [arrays](../fields#array-and-group).
:::

## 1. Choose the field

There are two ways to keep several images on a document:

| | `upload` with `hasMany` | `array` with an `upload` |
|---|---|---|
| Editors | Drop or pick several files at once, drag to reorder | Add a row, then pick its image |
| Caption | The image's alt text in the media library | Its own text, for this page only |
| Use it for | Photo galleries, product images, logos | Galleries where each image needs its own caption or link |

Most galleries need the first:

```ts
{
  name: 'gallery',
  type: 'upload',
  hasMany: true,
  maxRows: 12,
  mimeTypes: ['image/*'],
  label: { en: 'Gallery', th: 'แกลเลอรี' },
}
```

When captions belong to the page, use an array:

```ts
{
  name: 'gallery',
  type: 'array',
  maxRows: 12,
  fields: [
    { name: 'image', type: 'upload', required: true, mimeTypes: ['image/*'] },
    { name: 'caption', type: 'text', localized: true },
  ],
}
```

Create a migration for the new field (`easy-cms migrate:create gallery`).

## 2. Show it

Reads populate the images (`depth` 1, the default), with the resized copies from
[image sizes](../uploads#what-happens-to-a-file):

::: code-group

```vue [Nuxt: app/pages/posts/[slug].vue]
<ul v-if="post.gallery.length" class="gallery">
  <li v-for="image in post.gallery" :key="image.id">
    <a :href="image.url">
      <img :src="image.sizes?.thumbnail?.url ?? image.url" :alt="image.alt ?? ''" loading="lazy" />
    </a>
  </li>
</ul>
```

```tsx [Next.js]
{post.gallery.length > 0 && (
  <ul className="gallery">
    {post.gallery.map((image) =>
      typeof image === 'object' ? (
        <li key={image.id}>
          <a href={image.url}>
            <img src={image.sizes?.thumbnail?.url ?? image.url} alt={image.alt ?? ''} loading="lazy" />
          </a>
        </li>
      ) : null,
    )}
  </ul>
)}
```

```css [CSS]
.gallery {
  display: grid;
  grid-template-columns: repeat(auto-fill, minmax(10rem, 1fr));
  gap: 0.5rem;
  padding: 0;
  list-style: none;
}
.gallery img {
  width: 100%;
  aspect-ratio: 4 / 3;
  object-fit: cover;
}
```

:::

With the array, each row is `{ image, caption }`: use `row.image.url` and `row.caption`.

## 3. Check it

- Drop three images into the gallery, drag the last one first, and publish: the page shows them
  in that order.
- Try a PDF: the picker doesn't offer it, and the API refuses it with "must be an image".
- The example blogs in the repository have a gallery on every post.

---

<!-- https://easy-cms-website.vercel.app/docs/recipes/sqlite-to-postgres.md -->

# Move from SQLite to Postgres

::: info What you'll build
The same site on Postgres instead of SQLite: before launch (easy), or with content you want to
keep. **Uses:** [databases](../databases), [migrations](../deployment), [backups](../backups).
:::

SQLite is a fine choice for one server with a disk. Move to Postgres when you deploy to
serverless, run several servers, or your host offers managed Postgres with backups.

## Before launch: switch and start fresh

If the content so far is test content, switch the adapter and recreate it:

```bash [pm]
npm install @easy-cms/db-postgres @electric-sql/pglite
```

```ts
import { postgres } from '@easy-cms/db-postgres'

db: process.env.DATABASE_URL
  ? postgres({ url: process.env.DATABASE_URL })
  : postgres({ pglite: '.pglite' }), // local Postgres in a folder, no server needed
```

Migrations are written in one database's SQL, so replace the SQLite ones:

```bash [pm]
rm -r easy-cms/migrations
npx easy-cms migrate:create init
```

Commit the new folder, run `npx easy-cms migrate` where you deploy, and create the first admin
again (`npx easy-cms admin:create`).

## With content to keep

`easy-cms copy` moves everything: documents, versions, users (so logins keep working), globals,
scheduled jobs. Ids stay the same, so relationships still point to the right documents.

1. **Back up** first: `npx easy-cms backup backups/before-postgres.db`.
2. **Keep a config for the old database.** Create `easy-cms.old.config.ts` next to the main
   config; it reuses everything and changes only `db`:

   ```ts [easy-cms.old.config.ts]
   import { sqlite } from '@easy-cms/db-sqlite'
   import config from './easy-cms.config'

   export default { ...config, db: sqlite({ url: 'file:./cms.db' }) }
   ```

3. **Point the main config at Postgres**, as in the section above, and create the Postgres
   migrations (`rm -r easy-cms/migrations && npx easy-cms migrate:create init`).
4. **Prepare the new database.** Locally (PGlite, or `DATABASE_URL` set) the next step creates the
   tables itself. For a production database run `NODE_ENV=production npx easy-cms migrate` with
   its `DATABASE_URL` first.
5. **Copy**, while nobody is editing:

   ```bash [pm]
   npx easy-cms copy --from easy-cms.old.config.ts
   # Copying from sqlite (easy-cms.old.config.ts) to postgres…
   #   ecms_users: 3
   #   ecms_posts: 42
   #   …
   # Copied 318 row(s) from 17 table(s).
   ```

   The target must be empty, so running it twice by accident changes nothing.
6. **Check** the site: open the admin, a few documents with relationships and images, the history
   of a post, and log in.

Uploads don't move: they stay in `uploads/` or your bucket, and documents keep pointing to them.
When the site runs well on Postgres, delete `easy-cms.old.config.ts` (keep the backup a while).

The same command works the other way, from Postgres to SQLite.

---

<!-- https://easy-cms-website.vercel.app/docs/recipes/wordpress.md -->

# Move from WordPress

::: info What you'll build
Your WordPress posts, pages, categories and images in Easy CMS, with a redirect from every old
address, from a script you can run again until launch day. **Uses:** the
[Local API](../local-api), [uploads from a link](../uploads#from-a-link),
[rich text](../rich-text) and [redirects](../redirects).
:::

The script reads your old site through the WordPress REST API, which every WordPress site has at
`/wp-json/wp/v2/` (unless a plugin turned it off), so you need no plugin and no database access.
It reads what visitors can see: published posts and pages. For drafts, see
[Drafts and private posts](#drafts-and-private-posts).

## 1. Collections for the content

Add collections like these, or map the script's fields onto the ones you have. `wpId` remembers
which WordPress item each document came from, so running the script again updates documents
instead of adding them twice; `wpUrl` on media does the same for images.

```ts [easy-cms.config.ts]
import { redirectsPlugin } from '@easy-cms/plugin-redirects'

export default defineConfig({
  // …
  collections: [
    // The built-in media collection, plus where each imported image came from.
    { slug: 'media', fields: [{ name: 'wpUrl', type: 'text', unique: true }] },
    {
      slug: 'categories',
      useAsTitle: 'name',
      fields: [
        { name: 'name', type: 'text', required: true },
        { name: 'slug', type: 'slug', from: 'name' },
        { name: 'wpId', type: 'number', unique: true, admin: { position: 'sidebar' } },
      ],
    },
    {
      slug: 'posts',
      drafts: true,
      useAsTitle: 'title',
      fields: [
        { name: 'title', type: 'text', required: true },
        { name: 'slug', type: 'slug', from: 'title' },
        { name: 'excerpt', type: 'textarea' },
        { name: 'publishedAt', type: 'date' },
        { name: 'cover', type: 'upload' },
        { name: 'categories', type: 'relationship', to: 'categories', hasMany: true },
        { name: 'body', type: 'richText' },
        { name: 'wpId', type: 'number', unique: true, admin: { position: 'sidebar' } },
      ],
    },
    {
      slug: 'pages',
      drafts: true,
      useAsTitle: 'title',
      fields: [
        { name: 'title', type: 'text', required: true },
        { name: 'slug', type: 'slug', from: 'title' },
        { name: 'body', type: 'richText' },
        { name: 'wpId', type: 'number', unique: true, admin: { position: 'sidebar' } },
      ],
    },
  ],
  plugins: [redirectsPlugin()],
})
```

Then install what the script uses, and create a migration:

```bash [pm]
npm install @easy-cms/plugin-redirects @tiptap/html @tiptap/starter-kit @tiptap/extension-image
```

```bash [pm]
npx easy-cms migrate:create wordpress
```

The three `@tiptap` packages turn WordPress HTML into the JSON a `richText` field stores, with
the same editor the admin uses. Only the import script needs them.

## 2. The script

```ts [scripts/import-wordpress.ts]
// Import a WordPress site's published posts, pages, categories and images into Easy CMS.
// Run it again any time: documents are matched by their WordPress id and updated.
//
//   npx tsx --env-file=.env scripts/import-wordpress.ts https://old-site.com [--limit 10]
import { createEasyCMS } from '@easy-cms/core'
import { normalizePath } from '@easy-cms/plugin-redirects'
import Image from '@tiptap/extension-image'
import { generateJSON } from '@tiptap/html'
import StarterKit from '@tiptap/starter-kit'
import config from '../easy-cms.config'

const site = process.argv[2]?.replace(/\/+$/, '')
if (!site) throw new Error('Usage: import-wordpress.ts https://old-site.com [--limit 10]')
const limitArg = process.argv.indexOf('--limit')
const limit = limitArg > 0 ? Number(process.argv[limitArg + 1]) : Infinity

// Where each kind of document lives on the new site; old addresses redirect there.
const newPath = { posts: (slug: string) => `/blog/${slug}`, pages: (slug: string) => `/${slug}` }

const cms = await createEasyCMS(config)
const extensions = [StarterKit, Image]

type Node = { type: string; text?: string; attrs?: Record<string, unknown>; content?: Node[] }
type WPItem = {
  id: number
  slug: string
  link: string
  date_gmt: string
  title: { rendered: string }
  content: { rendered: string }
  excerpt?: { rendered: string }
  categories?: number[]
  _embedded?: { 'wp:featuredmedia'?: { source_url?: string; alt_text?: string }[] }
}

/** Every item of a WordPress REST collection, 100 a page, up to `max`. */
async function wp<T>(path: string, query = '', max = Infinity): Promise<T[]> {
  const items: T[] = []
  for (let page = 1; items.length < max; page++) {
    const res = await fetch(`${site}/wp-json/wp/v2/${path}?per_page=100&page=${page}${query}`)
    if (!res.ok) throw new Error(`${path}: ${res.status} ${await res.text()}`)
    items.push(...((await res.json()) as T[]))
    if (page >= Number(res.headers.get('x-wp-totalpages') ?? 1)) break
  }
  return items.slice(0, max)
}

/** Text without tags or entities: "Hello &#8211; world" → "Hello – world". */
function plain(html: string) {
  const walk = (node: Node): string => node.text ?? (node.content ?? []).map(walk).join(' ')
  return walk(generateJSON(html, extensions) as Node).replace(/\s+/g, ' ').trim()
}

/** Each image downloaded once into the media library, also across runs (by `wpUrl`). */
const media = new Map<string, { id: number | string; url: string } | null>()
async function upload(url: string, alt = '') {
  if (!media.has(url)) {
    const { docs } = await cms.find('media', { where: { wpUrl: { equals: url } }, limit: 1 })
    try {
      media.set(url, (docs[0] ?? (await cms.uploadFromURL(url, { alt, wpUrl: url }))) as never)
    } catch (error) {
      console.warn(`  image kept as a link: ${url} (${(error as Error).message})`)
      media.set(url, null)
    }
  }
  return media.get(url) ?? null
}

/** WordPress HTML as rich text, its images in the media library. */
async function richText(html: string) {
  const doc = generateJSON(html, extensions) as Node
  const visit = async (node: Node) => {
    if (node.type === 'image' && typeof node.attrs?.src === 'string') {
      const file = await upload(node.attrs.src, String(node.attrs.alt ?? ''))
      if (file) node.attrs.src = file.url
    }
    for (const child of node.content ?? []) await visit(child)
  }
  await visit(doc)
  return doc
}

/** Creates or updates the document with this WordPress id. */
async function save(collection: 'categories' | 'posts' | 'pages', wpId: number, data: Record<string, unknown>) {
  const { docs } = await cms.find(collection, { where: { wpId: { equals: wpId } }, limit: 1, draft: true })
  return docs[0]
    ? cms.update(collection, docs[0].id, { ...data, wpId } as never)
    : cms.create(collection, { ...data, wpId } as never)
}

/** A 301 from the old address, when it differs from the new one. */
async function redirect(oldLink: string, to: string) {
  // The path as the plugin stores it: no trailing slash, and Thai text, `%e0%b8%aa…` and
  // `%E0%B8%AA…` all as `%E0%B8%AA…`, so the next run finds it.
  const from = normalizePath(new URL(oldLink).pathname)
  if (!from || from === normalizePath(to)) return
  const { totalDocs } = await cms.find('redirects', { where: { from: { equals: from } }, limit: 0 })
  if (!totalDocs) await cms.create('redirects', { from, to, type: '301' })
}

// Categories first, so posts can point to them.
const categoryIds = new Map<number, number | string>()
for (const c of await wp<{ id: number; name: string; slug: string }>('categories')) {
  const doc = await save('categories', c.id, { name: plain(c.name), slug: decodeURIComponent(c.slug) })
  categoryIds.set(c.id, doc.id)
}
console.log(`categories: ${categoryIds.size}`)

for (const kind of ['posts', 'pages'] as const) {
  const items = await wp<WPItem>(kind, '&_embed=wp:featuredmedia', limit)
  for (const item of items) {
    const slug = decodeURIComponent(item.slug)
    const featured = item._embedded?.['wp:featuredmedia']?.[0]
    const data: Record<string, unknown> = {
      title: plain(item.title.rendered) || slug,
      slug,
      body: await richText(item.content.rendered),
      status: 'published',
    }
    if (kind === 'posts') {
      data.excerpt = item.excerpt ? plain(item.excerpt.rendered) : null
      data.publishedAt = `${item.date_gmt}Z`
      data.categories = (item.categories ?? []).map((id) => categoryIds.get(id)).filter(Boolean)
      data.cover = featured?.source_url ? ((await upload(featured.source_url, featured.alt_text))?.id ?? null) : null
    }
    await save(kind, item.id, data)
    await redirect(item.link, newPath[kind](slug))
    console.log(`${kind}: ${slug}`)
  }
}
console.log(`images: ${[...media.values()].filter(Boolean).length} in the media library`)
await cms.destroy()
```

`newPath` says where posts and pages live on your new site: change it to match your routes.

## 3. Run it

Try it on your local database first, with a few items:

```bash
npx tsx --env-file=.env scripts/import-wordpress.ts https://old-site.com --limit 10
```

Open `/admin` and look through the posts: text, headings, lists, links, images, the cover and
categories. Then run it without `--limit`. Each run prints what it imported:

```
categories: 22
posts: hello-world
pages: about
images: 48 in the media library
```

Running it again is safe: documents and images already imported are updated, not copied, and
existing redirects are kept. Run it once more on launch day to bring in the last posts.

For the production database, run it from your machine with the production settings, after
`easy-cms migrate`, and with `NODE_ENV=production` so the schema isn't pushed into it:

```bash
NODE_ENV=production DATABASE_URL=… npx tsx --env-file=.env scripts/import-wordpress.ts https://old-site.com
```

Images go to the storage your config sets: on serverless hosts make sure it is S3, R2 or Vercel
Blob there too, not the local `uploads/` folder.

## 4. Redirects

The script adds a `301` from each old address to the new one, such as
`/2024/05/hello-world/` → `/blog/hello-world`, so links and search rankings carry over. Thai
addresses work too: they are stored percent-encoded (`/%E0%B8%82…`) and match however a request
spells them, as WordPress's lowercase `%e0%b8%82…` or as Thai text. Make your site
follow them as [Serve them](../redirects#serve-them) shows: a server middleware in Nuxt, the proxy
in Next.js. Old category, tag and date archive pages aren't redirected; add the ones
that matter in **Settings → Redirects**.

## What comes across, and what doesn't

| Comes across | Doesn't |
|---|---|
| Titles, slugs, excerpts, dates | Tags and authors (add them like categories) |
| Paragraphs, headings, lists, quotes, code, links, bold and italic | Embeds (YouTube, tweets) and other `<iframe>`s: add them back, or allow them with a Tiptap extension |
| Images in posts, featured images | Page builder layouts: their text comes across, their columns don't |
| Categories | Custom fields (ACF), menus, comments |
| Shortcodes, as the HTML WordPress renders for them | Unpublished content, unless you sign in (below) |

Custom post types come across the same way when they are in the REST API (`show_in_rest`): add
a collection and their REST name to the loop.

## Drafts and private posts

To read unpublished content, sign in with an
[application password](https://wordpress.org/documentation/article/application-passwords/)
(**Users → Profile → Application Passwords** in WordPress). Keep it in `.env`, not in the script:

```ts
const auth = `Basic ${Buffer.from(`${process.env.WP_USER}:${process.env.WP_APP_PASSWORD}`).toString('base64')}`
// in wp(): fetch(url, { headers: { Authorization: auth } })
// and ask for every status: wp<WPItem>(kind, '&_embed=wp:featuredmedia&status=publish,draft,private', limit)
```

Map WordPress's `status` to `'published'` or `'draft'` in `data.status`. Delete the application
password when the move is done.

## Next steps

- [Redirects](../redirects): make your site follow the imported redirects.
- [SEO](../seo): titles, descriptions and share images for the imported posts.
- [Backups](../backups): back up before the final import on launch day.

---

<!-- https://easy-cms-website.vercel.app/docs/recipes/automate-backups.md -->

# Automate backups

::: info What you'll build
Daily backups of the database and the uploads, old ones cleaned up, and a restore drill so you
know they work. **Uses:** [backups](../backups), [CLI](../cli#backup).
:::

## SQLite on a server

A script that copies the database while the site runs, and the uploads next to it:

```bash [scripts/backup.sh]
#!/bin/sh
set -e
cd /srv/my-site                          # the project root
day=$(date +%F)
mkdir -p backups
npx easy-cms backup "backups/cms-$day.db"            # consistent copy, CMS keeps running
tar -czf "backups/uploads-$day.tar.gz" uploads       # the files
find backups -type f -mtime +14 -delete              # keep two weeks
```

Run it every night with cron (`crontab -e`):

```
30 2 * * * /srv/my-site/scripts/backup.sh >> /var/log/cms-backup.log 2>&1
```

Then copy `backups/` off the server: a backup on the same disk doesn't survive the disk. For
example to an S3 bucket with `aws s3 sync backups s3://my-backups/cms`, or with `rclone`.

## Postgres

Managed Postgres (Neon, Supabase, RDS…) usually keeps point-in-time backups: turn that on first.
For your own copies:

```bash
pg_dump --format=custom --file="backups/cms-$(date +%F).dump" "$DATABASE_URL"
```

## Uploads in a bucket

Turn on **versioning** for the bucket (S3, R2 and MinIO support it), so deleted or replaced files
can be brought back, and a lifecycle rule that removes old versions after a while.

## Restore drill

Once a month, prove the backups work:

1. Copy last night's backup to a scratch location (never over production).
2. Start the site against it, e.g. `DATABASE_URL=file:./restore-test.db npm run dev` (when your
   config reads `DATABASE_URL`, as the examples do).
3. Open the admin, a few recent documents and their images.

The steps to restore for real, and what to do about migrations afterwards, are in
[Backups & upgrades](../backups#after-a-restore).

---

<!-- https://easy-cms-website.vercel.app/docs/recipes/search-engines.md -->

# Get your site ready for search engines

::: info What you'll build
Pages with good titles, descriptions and share cards, a sitemap and robots.txt, links between
the Thai and English versions, and structured data, then the site submitted to Google.
**Uses:** [SEO plugin](../seo), [localization](../localization).
:::

## Add the plugin with real addresses

Set `admin.siteURL` to the public address, and make `generateURL` return each page's real path,
per locale. Everything below uses it.

```ts [easy-cms.config.ts]
import { seoPlugin } from '@easy-cms/plugin-seo'

export default defineConfig({
  admin: { siteURL: 'https://example.com' },
  localization: { locales: ['th', 'en'], defaultLocale: 'th' },
  plugins: [
    seoPlugin({
      collections: ['posts'],
      globals: ['site'],
      generateTitle: ({ doc }) => (doc.title ? `${doc.title} | My Blog` : null),
      generateDescription: ({ doc }) => doc.excerpt ?? null,
      generateImage: ({ doc }) => doc.cover ?? null,
      generateURL: ({ doc, collection, locale }) =>
        collection === 'posts'
          ? doc.slug ? `/${locale}/posts/${doc.slug}` : null
          : `/${locale}`,
    }),
  ],
})
```

Create a migration for the new fields: `npx easy-cms migrate:create seo`.

## Metadata on every page

In each page that shows a document, use `seoMeta()` with the same address. `type: 'article'` for
posts adds the publish date and BlogPosting data. See [On your pages](../seo#on-your-pages) for
Nuxt and Next.js.

```ts
const seo = seoMeta(post, {
  config,
  locale,
  url: (p, l) => `/${l}/posts/${p.slug}`,
  type: 'article',
})
```

Put `siteJsonLd({ name, url, logo })` in the layout once, so search engines know who publishes
the site.

## Sitemap and robots.txt

Add the two routes from [Sitemap](../seo#sitemap) and [robots.txt](../seo#robots-txt). Then check:

```bash
curl https://example.com/robots.txt
curl https://example.com/sitemap.xml
```

The sitemap should list your published posts in both languages, each with `xhtml:link` lines
to the other. Drafts and pages marked **Hide from search engines** are not in it.

## Check a page

1. Open a post and view its source: `<title>`, `<meta name="description">`, `og:*` tags,
   `<link rel="canonical">`, and `<link rel="alternate" hreflang="…">` for each language.
2. Paste its address into Google's [Rich Results Test](https://search.google.com/test/rich-results)
   to check the structured data.
3. Share it in a chat app to see the card with the share image.

## Tell Google

1. Add the site in [Google Search Console](https://search.google.com/search-console) and verify it.
2. Under **Sitemaps**, submit `https://example.com/sitemap.xml`.
3. Use **URL inspection** on a new post to ask for it to be crawled.

Search Console then shows which pages are indexed, and why others are not.

## Staging sites

A staging copy should stay out of search. Turn it off in `robots.txt` with your own variable
(not `NODE_ENV`, which is `production` on staging too):

```ts
robotsTxt({ config, disallowAll: process.env.SITE_ENV !== 'production' })
```

---

<!-- https://easy-cms-website.vercel.app/docs/reference/config.md -->

# Config reference

Every option of `easy-cms.config.ts`, grouped by where it goes. For explanations and examples,
follow the links to the guide. A test checks this page against the types in `@easy-cms/core`,
so every option is listed here.

Easy CMS checks the config when it starts. An option that was renamed is an error that names the
new one (e.g. ``admin.siteUrl is now `siteURL` ``), so an upgrade can't quietly drop a setting. An
option Easy CMS doesn't know is logged as a warning with the closest name (``did you mean
`maxLength`?``).

```ts
import { defineConfig } from '@easy-cms/core'

export default defineConfig({ secret, db, collections, /* … */ })
```

<!-- api: Config -->
## Top level

| Option | Type | Default | |
|---|---|---|---|
| `secret` | `string` | — | **Required**, 32+ characters. Signs sessions and preview tokens. |
| `db` | `DatabaseAdapter` | — | **Required**. `sqlite()` or `postgres()`. [Databases](/guide/databases) |
| `serverURL` | `string` | — | Public origin (`https://example.com`); makes media URLs absolute. |
| `cors` | `string[] \| '*'` | `[]` | Origins whose browser code may call the API. [Security](/guide/security) |
| `cronSecret` | `string` | `CRON_SECRET` | Bearer secret for `GET <api>/jobs/run`, at least 32 characters (a shorter `CRON_SECRET` is a warning). [Scheduling](/guide/drafts#scheduled-publishing) |
| `webhooks` | `WebhookConfig[]` | `[]` | See [webhooks](#webhooks). |
| `events` | `string[]` | `[]` | Events of the app or its plugins besides content changes (`<area>.<what>`, e.g. `order.paid`), sent with `cms.emit()` to webhooks that list them. [Webhooks](/guide/webhooks#your-own-events) |
| `jobs` | `JobConfig[]` | `[]` | Work run with the scheduled jobs, e.g. a plugin's clean-up. See [jobs](#jobs). |
| `timeZone` | `string` | the server's | IANA time zone of times of day in the config (`jobs[].at`, `backups.at`), e.g. `Asia/Bangkok`. [Jobs](/guide/jobs) |
| `localization` | `LocalizationConfig \| false` | — | See [localization](#localization). |
| `routes` | `RoutesConfig` | | See [routes](#routes). |
| `admin` | `AdminConfig` | | See [admin](#admin). |
| `upload` | `UploadConfig` | | See [upload](#upload). |
| `auth` | `AuthConfig` | | See [auth](#auth). |
| `collections` | `CollectionConfig[]` | `[]` | See [collections](#collections). |
| `globals` | `GlobalConfig[]` | `[]` | See [globals](#globals). |
| `endpoints` | `Endpoint[]` | `[]` | See [endpoints](#endpoints). |
| `cliCommands` | `CliCommand[]` | `[]` | `easy-cms <name>` commands, e.g. from plugins: `{ name, description, help?, run({ cms, args, flags, log }) }`. [CLI](/guide/cli#commands-from-plugins) |
| `onRequest` | `OnRequest \| OnRequest[]`: `({ headers, url, user, cms }) => { context?, user? }` | — | Runs on each API request once its user is known: returns the request's `context` (e.g. its tenant) and may change the user (their role there, `scoped`). Usually set by a plugin. A list runs in order: each gets the user the one before returned, and their `context` objects are merged. [Multi-tenant](/guide/multi-tenant#how-it-works) |
| `apiKeys` | `boolean` | `false` | API keys under Settings, for scripts and other apps. [API keys](/guide/api-keys) |
| `email` | `EmailAdapter` | — | Sends email for plugins, e.g. `smtp()` or `consoleEmail()`. [Email](/guide/email) |
| `backups` | `BackupsConfig` | by hand only | Database backups on a schedule. See [backups](#backups). |
| `audit` | `boolean \| AuditConfig` | off | The audit log: who changed what, sign-ins, admin actions. See [audit](#audit). |
| `plugins` | `Plugin[]` | `[]` | `(config) => config`, run in order before validation; `definePlugin(fn, { name, version, apiVersion? })` names one for the dashboard; `apiVersion` (`PLUGIN_API_VERSION`) stops startup when the plugin is for another plugin API. [Plugins](/guide/plugins) |
| `fieldTypes` | `FieldTypeDefinition[]` | `[]` | Field types from packages, e.g. `color` from `@easy-cms/fields`. [Custom field types](/guide/field-types) |

<!-- api: RoutesConfig -->
## routes

| Option | Type | Default | |
|---|---|---|---|
| `api` | `string` | `/api/cms` | Where the REST API is served. |

<!-- api: AdminConfig -->
## admin

| Option | Type | Default | |
|---|---|---|---|
| `path` | `string` | `/admin` | Where the admin is served. |
| `locale` | `'en' \| 'th'` | `en` | Default admin language before a user picks one. |
| `brand` | `AdminBrand` | `{}` | See [brand](#brand). |
| `siteURL` | `string` | `/` (Nuxt, Next.js) | The public site for "View site": a path or an `http(s)` URL. |
| `nav` | `NavGroup[]` | — | The menu's groups: `{ id, label, icon?, order?, children? }`, children one level deep. Built in: `content`, `settings` (`site`, `people`, `system`). [The admin](/guide/admin#the-menu) |
| `commands` | `{ label, href, icon?, keywords? }[]` | — | More entries of the command palette (⌘K) that open a page of the admin. |
| `modules` | `string[]` | `[]` | Admin modules with Web Components: package exports or paths. [Admin components](/guide/plugins#admin-components) |
| `pages` | `AdminPage[]` | `[]` | Pages of their own at `<admin>/p/<path>`, e.g. from plugins. See [pages](#pages). |
| `dashboard` | `DashboardWidget[]` | `[]` | Panels on the dashboard after the built-in ones. See [dashboard](#dashboard). |
| `switcher` | `{ cookie, label, options } \| false` | — | A choice at the top of the menu for the whole admin, e.g. the tenant, kept in a cookie. `options`: a path under the API returning `{ options: [{ value, label }], all? }`. Usually set by a plugin. |

<!-- api: AdminBrand -->
### brand

| Option | Type | |
|---|---|---|
| `name` | `string` | Shown in the menu, login page and browser tab. Default "Easy CMS". |
| `logo` | `string` | A path on your site or an `https://` URL. |
| `color` | `string` | Main color as `#rrggbb`; shades are derived. |

<!-- api: AdminPage -->
### pages

| Option | Type | |
|---|---|---|
| `path` | `string` | **Required**. Lowercase letters, digits and `-`, unique: the page is at `<admin>/p/<path>`. |
| `component` | `AdminComponent` | **Required**. The page's body; the admin draws the header. |
| `label` | `string \| { en, th }` | **Required**. Title in the header, the menu and the browser tab. |
| `icon` | `AdminIcon` | Menu icon. Default `file-text`. |
| `group` | `'content' \| 'settings' \| false \| string` | Its menu group: `content`, `settings`, a group's id (`admin.nav`) or a label; `false`: not listed. Default `content`. |
| `order` | `number` | Its place in its menu group: lower first. Those without one come after, in config order. |
| `access` | `({ user }) => boolean` | Who may open it, checked on the server. Default: every logged-in user. |

<!-- api: DashboardWidget -->
### dashboard

| Option | Type | |
|---|---|---|
| `id` | `string` | What roles are given in Settings → Roles (`widget:<id>`), so a panel keeps them when its tag changes. Default: the component's tag. |
| `component` | `AdminComponent` | **Required**. The panel's content. |
| `width` | `'half' \| 'full'` | `half` (default): the side column; `full`: below both columns. One column on phones. |
| `label` | `Label` | Its name in Settings → Roles (`auth.rbac`). Default: the component's tag. |
| `access` | `({ user }) => boolean` | Who sees it, checked on the server. Default: every logged-in user. |

[Pages and dashboard panels](/guide/plugins#pages-and-dashboard-panels) shows how to write them.

<!-- api: BackupsConfig -->
## backups

| Option | Type | Default | |
|---|---|---|---|
| `frequency` | `'daily' \| 'weekly'` | — | Back up automatically. Without it, admins back up by hand. |
| `at` | `string` | `03:00` | When, as `HH:MM` in the server's time zone. |
| `keep` | `number` | `7` | Finished backups to keep; older ones are deleted. |
| `dir` | `string` | `backups` | Folder for the default local storage. Never served publicly. |
| `storage` | `StorageAdapter` | local disk | E.g. `s3Storage()` with a private bucket. Public URLs log a warning. |
| `encryptionKey` | `string` | — | Encrypts the backup files (AES-256-GCM). At least 32 characters, e.g. `process.env.EASY_CMS_BACKUP_KEY`. [Backups](/guide/backups#from-the-admin) |
| `sqlite` | `(options) => DatabaseAdapter` | — | Postgres only: `sqlite` from `@easy-cms/db-sqlite`, which writes the backup file. [Backups](/guide/backups#from-the-admin) |

<!-- api: AuditConfig -->
## audit

| Option | Type | Default | |
|---|---|---|---|
| `keepDays` | `number` | `365` | Days to keep entries; older ones are deleted. `0`: keep them all. |
| `values` | `boolean` | `true` | Keep values before and after a change; `false`: only which fields changed. |
| `failedLogins` | `number` | `20` | Failed sign-ins within an hour that the dashboard warns about. [Audit log](/guide/audit-log) |
| `scope` | `({ context, user }) => string \| null` | — | The part of the site an entry belongs to, e.g. its tenant (usually set by a plugin). Admins of a part (`scoped`) see its entries; others the chosen part's, or all. |

<!-- api: AuthConfig -->
## auth

| Option | Type | Default | |
|---|---|---|---|
| `roles` | `string[]` | `['admin', 'editor']` | Must include `admin`. With `rbac`, the roles that always exist. [Users & auth](/guide/auth) |
| `rbac` | `boolean` | `false` | Roles and their permissions from the admin (Settings → Roles), on top of access rules. [Roles](/guide/roles) |
| `providers` | `AuthProvider[]` | `[]` | Signing in to the admin with outside accounts, e.g. `[google()]` from `@easy-cms/auth-oauth`. [Single sign-on](/guide/sso) |
| `providerSignUp` | `{ domains, role? }` | — | With `providers`: people from these email domains get an account on their first sign-in, with `role` (not `admin`). |
| `password` | `boolean` | `true` | `false`: only admins sign in with a password; everyone else uses `providers`. |
| `setupCode` | `string` | `EASY_CMS_SETUP_CODE` | The code the first admin must enter on `/admin`, so nobody else claims a fresh site. Without one, no code is asked, and in production the server warns while no admin exists. [One-click deploy](/guide/one-click-deploy) |
| `tokenExpiration` | `number` | `604800` (7 days) | Session lifetime in seconds. |
| `maxLoginAttempts` | `number` | `5` | Failed logins allowed per email (and IP) within `lockWindow`. |
| `lockWindow` | `number` | `900` (15 minutes) | In seconds. |
| `trustedOrigins` | `string[]` | `[]` | Other origins that may send cookie-authenticated requests. |
| `resetPasswordExpiration` | `number` | `3600` | Seconds a "forgot password" link works. Links need `email`, and `serverURL` in production. [Forgotten passwords](/guide/auth#forgotten-passwords-and-invitations) |
| `inviteExpiration` | `number` | `604800` | Seconds an invitation link works. |
| `emails` | `{ resetPassword?, invite?, passwordChanged? }` | — | Functions `({ user, url, locale, expiresAt }) => { subject, text, html? }` for your own email text. |
| `members` | `MembersConfig` | — | People who sign in on the site, not the admin, e.g. customers. See [members](#members). |

<!-- api: MembersConfig -->
### members

| Option | Type | |
|---|---|---|
| `roles` | `string[]` | Their roles, also in `roles`. Members never get into the admin, and `isStaff` (every collection's default) doesn't count them. [Site members](/guide/members) |
| `signUp` | `MembersSignup` | Visitors create their own account: `POST <api>/auth/signup`. |
| `pages` | `{ verifyEmail?, resetPassword? }` | The site's pages that open members' email links with `?token=`: paths on `admin.siteURL` (else `serverURL`) or URLs. Default: the admin's pages. |
| `emails` | `{ verifyEmail? }` | Your own text for the email that confirms an address. |

<!-- api: MembersSignup -->
#### signUp

| Option | Type | Default | |
|---|---|---|---|
| `role` | `string` | — | The role new accounts get: one of `members.roles`. |
| `verifyEmail` | `boolean` | `true` | New accounts confirm their email with a link before signing in. |
| `turnstile` | `{ siteKey, secretKey }` | — | Cloudflare Turnstile, checked on every sign-up. |

<!-- api: UploadConfig -->
## upload

| Option | Type | Default | |
|---|---|---|---|
| `dir` | `string` | `uploads` | Folder for the default local storage, from the project root. |
| `maxFileSize` | `number` | `10485760` (10 MB) | In bytes. |
| `mimeTypes` | `string[]` | `['image/*', 'application/pdf']` | Allowed types, detected from file contents: MIME types, `type/*`, or `documents`, `office`, `archives`. See [File types](/guide/uploads#file-types). |
| `storage` | `StorageAdapter` | local disk | E.g. `s3Storage()` from `@easy-cms/storage-s3`. [Uploads](/guide/uploads) |
| `imageSizes` | `ImageSize[]` | `[]` | Resized copies (needs `sharp`). See [image sizes](#image-sizes). |
| `fromURL` | `UploadFromURLConfig` | off | Uploads from links. See [fromURL](#fromurl). |
| `privateStorage` | `StorageAdapter` | `storage` without public URLs | Where files in private folders go. See [Private folders](/guide/uploads#private-folders). |
| `folders` | `boolean` | `false` | Folders in the media library, and with `auth.rbac` who can use each. See [Folders](/guide/uploads#folders). |

<!-- api: UploadFromURLConfig -->
### fromURL

| Option | Type | |
|---|---|---|
| `allowedHosts` | `string[]` | **Required**. `images.example.com`, `*.example.com` (subdomains) or `*` (any public host). |
| `allowPrivate` | `boolean` | Also private network addresses (`localhost`, `10.x`…). Default `false`. [Read first](/guide/uploads#from-a-link) |

<!-- api: ImageSize -->
### Image sizes

| Option | Type | Default | |
|---|---|---|---|
| `name` | `string` | — | Key in `media.sizes`, e.g. `thumbnail`. |
| `width` | `number` | — | Width in pixels. |
| `height` | `number` | — | Optional height. |
| `fit` | `'cover' \| 'contain' \| 'inside'` | `cover` | How to fit when both width and height are set. |

<!-- api: LocalizationConfig -->
## localization

| Option | Type | Default | |
|---|---|---|---|
| `locales` | `string[]` | — | Content locales, e.g. `['th', 'en']`. [Localization](/guide/localization) |
| `defaultLocale` | `string` | the first locale | Used when no locale is given; its values fill empty ones. |
| `fallback` | `boolean` | `true` | Reads return the default locale's value when a value is empty. |

<!-- api: WebhookConfig -->
## webhooks

| Option | Type | Default | |
|---|---|---|---|
| `url` | `string` | — | Where to POST events. [Webhooks](/guide/webhooks) |
| `events` | `string[]` | all | `create`, `update`, `delete`, `publish`, `unpublish`, `draft`. |
| `collections` | `string[]` | all but `users` | Collections to send events for; `[]` for none. List `users` to get user accounts. |
| `globals` | `string[]` | all | Globals to send events for; `[]` for none. |
| `secret` | `string` | — | Signs bodies: `x-easy-cms-signature: sha256=<hex>`. |
| `headers` | `Record<string, string>` | — | Extra request headers. |

`events` also takes the config's own `events` (e.g. `order.paid`): those are sent only to webhooks
that list them.

<!-- api: JobConfig -->
## jobs

| Option | Type | |
|---|---|---|
| `name` | `string` | Unique, lowercase with `-` and `:`, e.g. `shop:carts`. |
| `every` | `number` | At most this often, in seconds; when each last ran is kept in the database. With `at`, whole days (default one). Default: every run. |
| `at` | `string` | A time of day, `HH:MM` in `timeZone`. A missed time runs once when jobs next run. |
| `adminSchedule` | `boolean` | Admins may change `every` and `at` on Settings → Jobs. Default `false`. |
| `run` | `({ cms, now }) => unknown` | The work. A failure is logged; the other jobs still run. |

They run with the scheduled jobs: every minute in a server, or each time a cron calls
`GET <api>/jobs/run`. Each run is claimed in the database, so it happens once across instances.
Admins see, run and pause them on Settings → Jobs. [Jobs](/guide/jobs)

<!-- api: CollectionConfig -->
## collections

| Option | Type | Default | |
|---|---|---|---|
| `slug` | `string` | — | **Required**. URL and table name: lowercase letters, digits, `-`, `_`. |
| `fields` | `Field[]` | — | **Required**. See the [field reference](./fields). |
| `labels` | `{ singular?, plural? }` | from the slug | Each a string or `{ en, th }`. |
| `useAsTitle` | `string` | — | Top-level field shown as the document title. |
| `drafts` | `boolean` | `false` | Adds `status` (`draft` \| `published`). [Drafts](/guide/drafts) |
| `versions` | `boolean \| VersionsConfig` | `false` | Keep a version of every save. See [versions](#versions). |
| `schedule` | `boolean` | `false` | Publish and unpublish at a set time (needs `drafts`). |
| `preview` | `({ doc, locale }) => string \| null` | — | The page showing a document, for [live preview](/guide/live-preview). |
| `access` | `{ read?, create?, update?, delete? }` | logged in | [Access control](/guide/access-control) |
| `hooks` | `CollectionHooks` | — | See [hooks](#hooks). |
| `admin` | `CollectionAdmin` | — | See [admin components](#admin-components). |

<!-- api: VersionsConfig -->
### versions

| Option | Type | Default | |
|---|---|---|---|
| `keep` | `number` | `50` | Versions kept per document; older ones are deleted. |

<!-- api: CollectionHooks -->
### hooks

Each is a list of functions. [Hooks](/guide/hooks)

| Hook | Arguments | Return |
|---|---|---|
| `beforeValidate` | `data`, `operation`, `previousDoc?` | new data, or nothing |
| `beforeChange` | `data`, `operation`, `previousDoc?` | new data, or nothing |
| `afterChange` | `doc`, `operation`, `previousDoc?` | — |
| `beforeDelete` | `id` | — |
| `afterDelete` | `id`, `doc` | — |
| `afterRead` | `doc` | new doc, or nothing |

Every hook also gets `user`, `cms` and `slug`.

<!-- api: CollectionAdmin -->
### Admin components

| Option | Type | |
|---|---|---|
| `icon` | `AdminIcon` | Menu icon. Default `file-text`. [Branding](/guide/configuration#branding-the-admin) |
| `order` | `number` | Its place in its menu group: lower first. Those without one come after, in config order. |
| `editIn` | `'page' \| 'drawer'` | `drawer` edits in a panel over the list (without drafts, versions or preview). Default `page`. |
| `sidebar` | `SidebarPanel[]` | Panels in the edit page's side column; `{ tag, props, position: 'top' }` puts one above the rest. [Admin components](/guide/plugins#admin-components) |
| `group` | `string \| Label \| false` | Its menu group: a group's id (`admin.nav`, e.g. `shop.catalog`), `settings` (Settings › Site), or a label that makes a group of that name; `false`: not in the menu, reached by links. Default: Content. [The admin](/guide/admin#the-menu) |
| `layout` | `LayoutNode[]` | Tabs, sections that fold and rows of the edit page, by field name. [The admin](/guide/admin#edit-pages) |
| `badge` | `{ where, tone?, label? }` | A number beside its menu item: the documents matching `where` that the user may read, e.g. orders to send. |
| `count` | `boolean` | The number of documents beside its menu item. Default `true`. |
| `empty` | `{ description?, link? }` | What the list says before it has documents, and a link (`{ label, href }`). |
| `list` | `{ tree?, sort? }` | The list page: `tree` names a relationship to the same collection to show a tree (top-level documents first, children open below); `sort` is the default order, e.g. `'title'`. |
| `ownerField` | `string` | With `auth.rbac`: a relationship field to `users` naming the owner, e.g. `'author'`, for roles given "own documents only". Default: who created it (`createdBy`). [Roles](/guide/roles#own-documents-only) |
| `confirmDelete` | `{ typeTitle?, impact? }` | Deleting asks more: `typeTitle` has the user type the title; `impact` is a path under the API, called with `?id=`, returning `{ message }` about what goes too. No deleting from a selection. |

<!-- api: GlobalConfig -->
## globals

| Option | Type | Default | |
|---|---|---|---|
| `slug` | `string` | — | **Required**. |
| `fields` | `Field[]` | — | **Required**. |
| `label` | `string \| { en, th }` | from the slug | |
| `drafts` | `boolean` | `false` | |
| `versions` | `boolean \| VersionsConfig` | `false` | |
| `schedule` | `boolean` | `false` | Needs `drafts`. |
| `preview` | `({ doc, locale }) => string \| null` | — | |
| `access` | `{ read?, update? }` | logged in | |
| `scope` | `({ context, user }) => string \| null \| undefined` | — | One value per scope, e.g. per tenant: a string keeps a value of its own, `undefined` the shared one, `null` none (reads give it empty, changes are refused). |
| `hooks` | `GlobalHooks` | — | See below. |
| `admin` | `ContainerAdmin` | — | `{ icon, order, group, sidebar, layout }`, as for collections. Default icon `settings`, group Settings › Site. |

<!-- api: GlobalHooks -->
### Global hooks

| Hook | Arguments | Return |
|---|---|---|
| `beforeValidate` | `data`, `operation`, `previousDoc?` | new data, or nothing |
| `beforeChange` | `data`, `operation`, `previousDoc?` | new data, or nothing |
| `afterChange` | `doc`, `operation`, `previousDoc?` | — |
| `afterRead` | `doc` | new doc, or nothing |

<!-- api: Endpoint -->
## endpoints

| Option | Type | |
|---|---|---|
| `path` | `string` | Under `routes.api`, e.g. `/seo/generate` or `/stats/:collection`. The first segment can't be a collection slug or one the built-in API uses ([reserved paths](/guide/plugins#endpoints)). |
| `method` | `'get' \| 'post' \| 'put' \| 'patch' \| 'delete'` | |
| `handler` | `(request: EndpointRequest) => unknown` | Returns a `Response`, or a value sent as JSON. [Endpoints](/guide/plugins#endpoints) |
| `root` | `boolean` | Serve the path from the site's root (standalone server only), e.g. `/robots.txt`. Default `false`. |

<!-- api: EndpointRequest -->
### EndpointRequest

| Property | Type | |
|---|---|---|
| `request` | `Request` | The Web request. |
| `url` | `URL` | Its URL. |
| `params` | `Record<string, string>` | Values of `:name` segments. |
| `user` | `AuthUser \| null` | The logged-in user. |
| `context` | `RequestContext` | The request's context (`onRequest`); pass it to the Local API with `user`. |
| `ip` | `string \| undefined` | The client's IP address, when the adapter knows it. |
| `cms` | `EasyCMS` | The [Local API](./local-api). |
| `json` | `() => Promise<object>` | The JSON body (an object, at most 1 MB). |
| `text` | `() => Promise<string>` | The body as sent, as text (at most 1 MB), e.g. to check a webhook's signature. |

---

<!-- https://easy-cms-website.vercel.app/docs/reference/fields.md -->

# Field reference

Every field option, checked against the types in `@easy-cms/core` by a test. For how fields
look in the admin and how they are stored, see [Fields](/guide/fields).

<!-- api: BaseField -->
## Options of every field

| Option | Type | |
|---|---|---|
| `name` | `string` | **Required**. A JavaScript identifier; unique among its siblings. |
| `type` | see [types](#types) | **Required**. |
| `label` | `string \| { en, th }` | Default: the name, humanized. |
| `required` | `boolean` | Must have a value (not checked while saving a draft). |
| `unique` | `boolean` | No two documents share the value. Top-level fields only (inside a group, array or block it is ignored, with a warning). |
| `uniqueWithin` | `string \| string[]` | With `unique` or on a slug: sibling fields (e.g. `parent`, `tenant`); values only differ among documents with the same values there. |
| `index` | `boolean` | Adds a database index. |
| `defaultValue` | the field's value | Used when a document is created without it. |
| `validate` | `(value, { data, operation }) => true \| string` | Custom check; may be async. |
| `access` | `{ read?, create?, update? }` | Field-level [access](/guide/access-control#field-access). `create`: who may set it when creating a document; default: its `update`. |
| `hidden` | `boolean` | Stored, but never returned nor accepted as input. |
| `localized` | `boolean` | One value per locale (needs `localization`). |
| `admin` | `FieldAdmin` | Admin components; see [below](#admin). |
| `customType` | `string` | Set by Easy CMS for fields of an [added type](/guide/field-types) (e.g. `color`); don't set it yourself. |

<!-- api: FieldAdmin -->
### admin

| Option | Type | |
|---|---|---|
| `component` | `AdminComponent` | A Web Component instead of the input. [Admin components](/guide/plugins#admin-components) |
| `after` | `AdminComponent[]` | Components shown below the field. |
| `cell` | `AdminComponent` | Shows the value in the list's column, e.g. a color swatch. |
| `description` | `Label` | Help below the field. |
| `width` | `'1/4' \| '1/3' \| '1/2' \| '2/3' \| '3/4' \| 'full'` | Its share of a row (`admin.layout`). Default: an equal share. |
| `condition` | `FieldCondition` | Shown only when sibling fields match, e.g. `{ field: 'linkType', equals: 'external' }`; also `not_equals`, `in`, `not_in`, `exists`, `and`, `or`, `not`. A hidden field isn't required. [The admin](/guide/admin#conditions) |
| `column` | `boolean \| ({ user, context }) => boolean` | A column of the list at first (each user can change it). |
| `allowCreate` | `boolean` | Relationships: offer to create the related document in place. Default `true`. |
| `initialValue` | `({ user, context }) => unknown` | The value a new document's form starts with, per user, e.g. from the request's context. The field's `defaultValue` still applies on the server. |
| `position` | `'sidebar'` | Shown in the edit page's side column (top-level fields). |

An `AdminComponent` is a custom element name (lowercase, with a `-`), or `{ tag, props }`.

## Types

| Type | Value | Extra options |
|---|---|---|
| `text` | `string` | [`minLength`, `maxLength`](#text-and-textarea) |
| `textarea` | `string` | [`minLength`, `maxLength`](#text-and-textarea) |
| `email` | `string` (lowercase) | |
| `number` | `number` | [`min`, `max`](#number) |
| `boolean` | `boolean` | |
| `date` | ISO 8601 `string` | |
| `select` | an option, or an array with `hasMany` | [`options`, `hasMany`](#select) |
| `slug` | URL-safe `string`, unique | [`from`](#slug) |
| `json` | any JSON | |
| `richText` | Tiptap JSON | see [Rich text](/guide/rich-text) |
| `upload` | id of a `media` document, or an array with `hasMany` | [`hasMany`, `mimeTypes`](#upload) |
| `relationship` | id(s) of documents | [`to`, `hasMany`, `minRows`, `maxRows`](#relationship) |
| `array` | rows with `id` and sub-fields | [`fields`, `minRows`, `maxRows`](#array) |
| `group` | an object | [`fields`](#group) |
| `blocks` | rows of several kinds, with `blockType` | [`blocks`, `minRows`, `maxRows`](#blocks) |

Packages add more types, e.g. `color` from `@easy-cms/fields`: see [Custom field types](/guide/field-types).

<!-- api: TextField -->
<!-- api: TextareaField -->
### text and textarea

| Option | Type | |
|---|---|---|
| `minLength` | `number` | Fewest characters. |
| `maxLength` | `number` | Most characters; the admin stops typing there. |

<!-- api: NumberField -->
### number

| Option | Type | |
|---|---|---|
| `min` | `number` | Smallest value. |
| `max` | `number` | Largest value. |

<!-- api: SelectField -->
### select

| Option | Type | |
|---|---|---|
| `options` | `(string \| { label, value })[]` | **Required**. The choices; `label` may be `{ en, th }`. |
| `hasMany` | `boolean` | Pick several (checkboxes); the value is an array. |

<!-- api: SlugField -->
### slug

| Option | Type | |
|---|---|---|
| `from` | `string` | A sibling `text` field to make the slug from when it is empty. |
| `uniqueWithin` | `string` | A sibling field (e.g. `parent`): slugs only differ among documents with the same value there. |

<!-- api: UploadField -->
### upload

| Option | Type | |
|---|---|---|
| `hasMany` | `boolean` | Several files, in the order editors arrange (a gallery); the value is an array. |
| `minRows` | `number` | With `hasMany`: the fewest files. |
| `maxRows` | `number` | With `hasMany`: the most files. |
| `mimeTypes` | `string[]` | Allowed file types, e.g. `['image/*']`; the picker offers only these and saving checks them. |

<!-- api: RelationshipField -->
### relationship

| Option | Type | |
|---|---|---|
| `to` | `string` | **Required**. Slug of the target collection. |
| `hasMany` | `boolean` | Several documents; the value is an array. |
| `minRows` | `number` | With `hasMany`: the fewest documents. |
| `maxRows` | `number` | With `hasMany`: the most documents. |
| `filterOptions` | `({ id, user, cms }) => Where \| true` | Which documents it may point to; the admin's picker offers only these and saving checks them. |

<!-- api: ArrayField -->
### array

| Option | Type | |
|---|---|---|
| `fields` | `Field[]` | **Required**. The fields of each row. |
| `minRows` | `number` | Fewest rows. |
| `maxRows` | `number` | Most rows. |

<!-- api: GroupField -->
### group

| Option | Type | |
|---|---|---|
| `fields` | `Field[]` | **Required**. The fields inside. |

<!-- api: BlocksField -->
### blocks

| Option | Type | |
|---|---|---|
| `blocks` | `Block[]` | **Required**. The kinds of rows; see [Block](#block). |
| `minRows` | `number` | Fewest rows. |
| `maxRows` | `number` | Most rows. |

<!-- api: Block -->
#### Block

| Option | Type | |
|---|---|---|
| `slug` | `string` | **Required**. Stored in each row as `blockType`. |
| `labels` | `{ singular?, plural? }` | Names in the admin. |
| `fields` | `Field[]` | **Required**. The block's fields. |

---

<!-- https://easy-cms-website.vercel.app/docs/reference/local-api.md -->

# Local API reference

Every method of the `cms` object you get from `useEasyCMS()` (Nuxt), `getEasyCMS(config)`
(Next.js) or `createEasyCMS(config)`. A test checks this list against the `EasyCMS` class. For
examples, see [Local API](/guide/local-api).

The Local API trusts its caller and skips access rules. Pass `{ user, overrideAccess: false }`
to apply them, as the REST API does.

## Options

| Option | On | |
|---|---|---|
| `overrideAccess` | every method | `false` applies access rules for `user`. Default `true`. |
| `user` | every method | The user to check access for; `null` = not logged in. |
| `context` | every method | What the call is about besides its user, e.g. its tenant: given to access rules, hooks and `filterOptions`. From `forRequest`, or your own. |
| `depth` | reads and writes | Levels of relationships to populate. Default 1, at most 3. |
| `locale` | reads and writes | A content locale, or `'all'` for `{ [locale]: value }`. Default: the default locale. |
| `draft` | reads | Include drafts. Default: published documents only. |
| `fallbackLocale` | reads | Use the default locale's value when a value is empty. Default `localization.fallback`. |
| `where` | `find`, `count` | Filter: `{ field: { equals, not_equals, in, not_in, gt, gte, lt, lte, like, exists } }`, with `and` / `or`. |
| `sort` | `find` | Field path; `-` for descending. Default `-createdAt`. |
| `limit` | `find` | Default 10; `0` returns every match. |
| `page` | `find` | 1-based. Default 1. |

`find` returns `{ docs, totalDocs, limit, page, totalPages, hasNextPage, hasPrevPage }`.

<!-- api: EasyCMS -->
## Methods

### Documents

| Method | Returns | |
|---|---|---|
| `find(collection, options?)` | a page of documents | List with `where`, `sort`, `limit`, `page`. |
| `findById(collection, id, options?)` | document \| `null` | One document. |
| `count(collection, options?)` | `number` | How many match `where`. |
| `create(collection, data, options?)` | document | Validates, runs hooks, saves. |
| `update(collection, id, data, options?)` | document | Changes the given fields. With `where`, only while the stored document matches it (compare and set): `null` when it doesn't, so one of two concurrent calls wins. |
| `increment(collection, id, field, by, { min?, max? })` | `number \| null` | Adds `by` to a top-level number field in one statement, safe with concurrent calls, e.g. stock. `null` when the result would pass `min` / `max`; `NotFoundError` when there is no such document. Skips hooks and access. |
| `delete(collection, id, options?)` | the deleted document | Also deletes its versions and scheduled jobs. Deleting a user with `auth.rbac`: `{ transferTo: id }` gives the documents they own to another user; otherwise they have no owner. |

### Media

| Method | Returns | |
|---|---|---|
| `upload({ data, name }, fields?, options?)` | media document | Stores a file; images get dimensions and resized copies. |
| `uploadFromURL(url, fields?, options?)` | media document | Downloads a file from a link, then as `upload`. [From a link](/guide/uploads#from-a-link) |
| `mediaURL(key)` | `string` | URL of a stored file: public, or the API's for private files. |
| `signedMediaURL(doc, { expiresIn?, size? })` | `string` | A link to a private file that works without signing in until it expires (default `'1h'`, at most `'7d'`). [Private folders](/guide/uploads#private-folders) |
| `verifyMediaSignature(key, expires, signature)` | `boolean` | Whether a signed link is genuine and not expired. |
| `storageFor(isPrivate)` | `StorageAdapter` | The storage for public or private files (`upload.privateStorage`). |
| `createUpload({ name, size, type? }, fields?, options?)` | `{ ticket, upload }` | Starts an upload straight to the storage. [Large files](/guide/uploads#large-files) |
| `completeUpload(ticket, options?)` | media document | Checks a file sent straight to the storage and makes its document. |
| `mediaUsage(ids)` | `number` | How many documents use these files in a top-level upload field. |

### Drafts and versions

| Method | Returns | |
|---|---|---|
| `unpublish(collection, id, options?)` | document | Takes a document off the site. |
| `discardDraft(collection, id, options?)` | document | Throws away the pending draft of a published document. |
| `findVersions(collection, id, options?)` | a page of versions | Newest first. Needs update access. |
| `findVersion(collection, id, versionId, options?)` | version \| `null` | One version with its `data`. |
| `restoreVersion(collection, id, versionId, options?)` | document | Makes an old version current (as a draft with drafts). |

### Globals

| Method | Returns | |
|---|---|---|
| `findGlobal(slug, options?)` | global | Read a global. |
| `updateGlobal(slug, data, options?)` | global | Change a global. |
| `unpublishGlobal(slug, options?)` | global | Take it off the site. |
| `discardGlobalDraft(slug, options?)` | global | Throw away its pending draft. |
| `findGlobalVersions(slug, options?)` | a page of versions | Newest first. |
| `findGlobalVersion(slug, versionId, options?)` | version \| `null` | One version. |
| `restoreGlobalVersion(slug, versionId, options?)` | global | Make an old version current. |

### Live preview

| Method | Returns | |
|---|---|---|
| `preview(collection, id \| null, data, options?)` | `{ doc, url }` | The document as if `data` were saved; nothing is written. |
| `previewGlobal(slug, data, options?)` | `{ doc, url }` | The same for a global. |
| `createPreviewToken(target, { expiresIn? })` | `string` | A token that opens one draft without a login (default one hour). |
| `verifyPreviewToken(token)` | target \| `null` | What a token opens, or `null` when invalid or expired. |

### Scheduling

| Method | Returns | |
|---|---|---|
| `schedule(collection, id, { action, at }, options?)` | job | Publish or unpublish at `at`. |
| `findSchedule(collection, id, options?)` | jobs | Pending jobs of a document, soonest first. |
| `cancelSchedule(collection, id, jobId, options?)` | — | Cancel a pending job. |
| `scheduleGlobal(slug, { action, at }, options?)` | job | The same for a global. |
| `findGlobalSchedule(slug, options?)` | jobs | |
| `cancelGlobalSchedule(slug, jobId, options?)` | — | |
| `upcomingSchedules({ limit?, user?, overrideAccess? })` | jobs | The next pending jobs across everything. |

### Jobs and webhooks

| Method | Returns | |
|---|---|---|
| `runJobs(now?)` | `{ scheduled, webhooks, emails, jobs }` | Due scheduled jobs, webhook and email retries, and the config's `jobs` (what cron and the timer call). |
| `emit(event, data, about?)` | — | Sends an event of the config's `events` (e.g. `order.paid`) to the webhooks that list it; `about`: `{ collection, id }`. [Webhooks](/guide/webhooks#your-own-events) |
| `runScheduled(now?)` | `{ ran, failed }` | Only the due scheduled jobs. |
| `retryWebhooks(now?)` | `{ sent, failed }` | Only the due webhook retries. |
| `startScheduler(interval?)` | — | Runs `runJobs` every `interval` ms (default one minute). `createEasyCMS` starts it when something is scheduled or webhooks are set. |
| `flushWebhooks()` | — | Waits for deliveries in progress; call before a serverless function returns. |
| `sendEmail(message)` | — | Queues an email for the config's `email` adapter and sends it in the background. [Email](/guide/email) |
| `flushEmails()` | — | Waits for emails being sent; call before a serverless function returns. |

### Other

| Method | Returns | |
|---|---|---|
| `createApiKey({ name, permissions?, expiresAt?, user? }, options?)` | `{ key, doc }` | A new [API key](/guide/api-keys) (with `apiKeys: true`); the key is returned only here. |
| `documentPermissions(collection, id, user)` | `{ update, delete }` | What a user may do with one document. |
| `destroy()` | — | Stops the scheduler, waits for webhook deliveries and closes the database. |
| `uniqueScope(collection, field, { user?, context? })` | `{ [field]: value }` | What a unique field's values differ within for a call (`uniqueWithin`, e.g. the tenant), as its hooks would set it; `{}` when unique everywhere. For finding documents by such a field. |
| `forRequest(request \| headers)` | `{ user, context }` | A request's user (session cookie or Bearer token) and context (`onRequest`), as REST works them out; pass both with `overrideAccess: false`. |

The `cms` object also has `config` (the resolved config), `auth` (login and session checks),
`auth.sso` (signing in with `auth.providers`), `audit` (with `audit`: `audit.record({ action, target, doc })` writes an entry), `roles` (with `auth.rbac`: `roles.allows(user, { collection }, operation)` says what a user's role
allows), `db`, `storage`, `logger` and `cwd`.

`db` is the database adapter, beneath access rules, hooks and versions: what it offers can change
between minor versions. `config` is the resolved config: the options you set are stable, the rest
(defaults filled in) may grow. `@easy-cms/core/internal` and `@easy-cms/drizzle` are shared by the
`@easy-cms/*` packages and are not part of the public API.

---

<!-- https://easy-cms-website.vercel.app/docs/rest-api.md -->

# REST API

::: info What you'll learn
The HTTP API at `/api/cms`: endpoints, queries, authentication, CSRF and errors.

**Before this page:** [Local API](./local-api).
:::

Served at `routes.api` (default `/api/cms`). All responses are JSON; access rules always apply.

## Collections

| Method | Path | |
|---|---|---|
| GET | `/:collection` | List. Query: `where`, `sort`, `limit` (1–100), `page`, `depth`, `draft` |
| POST | `/:collection` | Create (JSON body) |
| GET | `/:collection/:id` | One document. Query: `depth`, `draft`, `preview` (a [preview token](./live-preview#preview-tokens): the current draft, no login) |
| PATCH | `/:collection/:id` | Update the given fields |
| DELETE | `/:collection/:id` | Delete |
| GET / PATCH | `/globals/:slug` | Read / update a global (`POST` works too) |
| POST | `/media` | Upload (`multipart/form-data`, field `file`), or JSON `{ url }` to download it ([from a link](/guide/uploads#from-a-link)) |
| GET | `/media/file/:name` | A stored file (public) |
| GET | `/auth/:provider/login` | Starts signing in with a provider (`?redirect=` an admin page); `/auth/:provider/callback` finishes. `POST /auth/:provider/link` (signed in) returns `{ url }` to link an account; `GET /auth/identities` and `DELETE /auth/identities/:id` list and unlink them ([Single sign-on](/guide/sso)) |
| GET | `/admin/audit` | For admins and roles given it: audit log entries, newest first (`action`, `target`, `doc`, `actor`, `from`, `to`, `page`). `/admin/audit.csv` exports them; `POST /admin/audit/verify` checks their signatures ([Audit log](/guide/audit-log)) |
| GET | `/admin/status` | For admins (and [roles](/guide/roles) given it): the system and what needs attention ([Health checks](/guide/health-checks)) |
| GET | `/admin/roles` | For admins, with `auth.rbac`: the roles and what can be given. `POST /admin/roles` with `{ key, name?, permissions? }` adds one; `PATCH /admin/roles/:id` with `{ name?, permissions? }` changes one; `DELETE /admin/roles/:id` deletes one; `GET /admin/roles/:id/history` lists its changes ([Roles](/guide/roles)) |
| GET | `/admin/backups` | For admins: the backup settings and backups. `POST /admin/backups` backs up now (202); `GET /admin/backups/:id/download` downloads one; `DELETE /admin/backups/:id` deletes one |
| GET | `/admin/email` | For admins: the email adapter and its settings (never secrets). `POST /admin/email/verify` checks the connection; `POST /admin/email/test` with `{ to?, locale? }` sends a test email now (5 in 10 minutes) |
| GET | `/admin/deliveries` | For admins (and roles given it): saved webhook deliveries or emails (`kind=webhook\|email`, `state=failed\|pending`, `page`). `POST /admin/deliveries/:kind/:id/retry` and `/:kind/retry` (all failed) send now; `DELETE /admin/deliveries/:kind/:id` and `/:kind` (all failed) delete |
| GET | `/:collection/:id/versions` | Versions, newest first. Query: `limit`, `page` |
| GET | `/:collection/:id/versions/:version` | One version with its `data` |
| POST | `/:collection/:id/versions/:version/restore` | Restore a version |
| POST | `/:collection/:id/unpublish` | Unpublish (collections with drafts) |
| POST | `/:collection/:id/discard-draft` | Discard the pending draft |
| GET / POST | `/:collection/:id/schedule` | Pending [scheduled](./drafts#scheduled-publishing) jobs / schedule `{ action, at }` |
| DELETE | `/:collection/:id/schedule/:job` | Cancel a scheduled job |
| GET / POST | `/jobs/run` | Run due scheduled jobs and webhook retries (cron secret; admins with `POST`) |
| POST | `/:collection/:id/preview` | [Live preview](./live-preview): `{ doc, url }` for unsaved changes (`/:collection/preview` for a new document) |

Globals have the same version routes under `/globals/:slug/…` (`versions`, `versions/:version`,
`versions/:version/restore`, `unpublish`, `discard-draft`, `preview`, `schedule`). Version and preview routes need update access.

What the admin UI alone uses lives under `/admin/ui/…` (`schema`, `counts`, `search`, `modules`,
`scheduled`, `access`, `media-usage`, `owned`, `sso`). Those routes are internal, not part of the
public API, and may change in any release; build on the routes above instead.

`where` uses brackets or JSON:

```
GET /api/cms/posts?where[status][equals]=published&where[views][gte]=10&sort=-createdAt&limit=20
GET /api/cms/posts?where={"or":[{"featured":{"equals":true}},{"views":{"gt":100}}]}
```

`in` / `not_in` accept comma-separated values, `exists` takes `true`/`false`, and `equals=null`
matches empty values. `draft=true` only works for logged-in users. With
[localization](./localization), `locale` (`th`, `en`… or `all`) and `fallbackLocale=false` work on
every read and write.

## Authentication

| Method | Path | |
|---|---|---|
| POST | `/auth/login` | `{ email, password }` → session cookie, `{ user, exp, csrfToken }` |
| POST | `/auth/logout` | Ends the session |
| GET | `/auth/me` | `{ user, csrfToken }` for the current session |
| GET | `/auth/init` | `{ hasUsers }` |
| POST | `/auth/first-register` | Creates the first admin while there are no users |
| GET | `/auth/signup` | With `auth.members.signUp`: `{ token, turnstile, verifyEmail }` for a sign-up form ([Site members](./members)) |
| POST | `/auth/signup` | `{ email, password, name?, token }` → `202 { verify: true }`, or a session without email confirmation |
| POST | `/auth/verify-email` | `{ token }` from the confirmation email → confirms it and signs in |
| POST | `/auth/forgot-password` | `{ email }` → emails a link to set a new password ([Authentication](./auth)) |
| GET | `/auth/reset-password` | `?token=` → checks a password link |
| POST | `/auth/reset-password` | `{ token, password }` → sets the new password and signs in |

These routes used to live under `/users/…` (`/users/login`, `/users/me`…). The old paths keep
working as aliases through 1.x.

**Browsers** send the session cookie. Every POST, PATCH, PUT and DELETE made with the cookie
must include the CSRF token as `x-csrf-token` (from the login response, `GET /auth/me` or the
`ecms-csrf` cookie), and come from the API's own origin or one in `auth.trustedOrigins`.

**Servers and apps** send `Authorization: Bearer <token>` with an [API key](./api-keys) or the
session token ([how to get one](./auth#tokens-for-other-apps)); no CSRF token is needed.

## CORS

Browser code on another origin can call the API when that origin is listed in `cors` (or in
`auth.trustedOrigins`, which also allows cookies). Preflight `OPTIONS` requests are answered for
those origins; other origins get no CORS headers, so the browser blocks the response.

## Errors

```json
{ "errors": [{ "message": "is required", "field": "title", "code": "VALIDATION_ERROR" }] }
```

| Code | Status | When |
|---|---|---|
| `VALIDATION_ERROR` | 400 | Invalid data: each error names its `field`. |
| `BAD_USER_INPUT` | 400 | A wrong query or argument, e.g. an unknown field in `where` or `limit=500`. |
| `UNAUTHORIZED` | 401 | Not signed in where that is needed (with `WWW-Authenticate: Bearer`). |
| `FORBIDDEN` | 403 | Not allowed. |
| `NOT_FOUND` | 404 | No such document, collection or route. |
| `METHOD_NOT_ALLOWED` | 405 | The route takes other methods (listed in `Allow`). |
| `PAYLOAD_TOO_LARGE` | 413 | The body or the file is over the limit. |
| `UNSUPPORTED_MEDIA_TYPE` | 415 | Not JSON (or multipart for uploads). |
| `TOO_MANY_REQUESTS` | 429 | Too many attempts; `Retry-After` says how many seconds to wait. |
| `CONFIG_ERROR`, `INTERNAL_SERVER_ERROR` | 500 | Something went wrong on the server; in production without details. |

New codes may be added; these keep their meaning. On the server, every Easy CMS error has the same
`code` (`error.code`, with `error.status`).

Successful calls answer 200, or 201 when they create a document (202 when signing up waits for the
email to be confirmed). Calls that start a session (login, sign-up, first admin, confirming an
email, setting a password) answer 200 with it. `DELETE` on a document answers with the document;
other deletions answer `{ "deleted": n }`.

## Next steps

- [Users & auth](./auth): log in and tokens.
- [TypeScript](./typescript): types for other apps.

---

<!-- https://easy-cms-website.vercel.app/docs/graphql.md -->

# GraphQL

::: info What you'll learn
How to add a GraphQL API next to the REST API: the schema it makes from your config, queries,
mutations, limits, and how to add your own fields.

**Before this page:** [REST API](./rest-api) and [Plugins](./plugins).
:::

::: warning Experimental
This plugin is experimental. It has the same version number as the rest of Easy CMS, but its
options, endpoints and data may still change in a minor release, after 1.0 too: it is not covered
by the [stability promise](./versioning#experimental). The changelog and the
[upgrade guide](./upgrading) say what changed.
:::

`@easy-cms/plugin-graphql` serves a GraphQL API made from your collections and globals. It reads
and writes through the [Local API](/reference/local-api), so access rules, field access, hooks,
validation, drafts and localization work as they do over REST. It suits frontends that already
use Apollo, urql or Relay, or that want one request with only the fields they need.

## Set it up

```bash [pm]
npm install @easy-cms/plugin-graphql graphql
```

`graphql` 16 or 17 is installed by your project, so the plugin and your own `extend` code use
the same copy.

```ts
import { graphqlPlugin } from '@easy-cms/plugin-graphql'

export default defineConfig({
  // …
  plugins: [graphqlPlugin()],
})
```

The endpoint is `<routes.api>/graphql`, e.g. `https://example.com/api/cms/graphql`. It takes
`POST` with `{ "query", "variables", "operationName" }`, and `GET` with `?query=` for queries
(never mutations). Open it in a browser outside production to get **GraphiQL**: logged in to the
admin, it reads what you may read.

## The schema

Each collection and global gets a type and operations, named after its slug:

| Collection `posts` | Global `site-settings` |
|---|---|
| type `Post` | type `SiteSettings` |
| `post(id)`: one document | `siteSettings`: the global |
| `posts(where, sort, limit, page)`: a page of documents | |
| `createPost`, `updatePost`, `deletePost` | `updateSiteSettings` |

Plus `me`, the logged-in user (or the owner of the API key). The built-in collections are
`User` (`user`, `users`), `Media` (`mediaItem`, `media`) and, with folders, `MediaFolder`. API
keys and the collections Easy CMS uses internally are never in the schema.

```graphql
# `locale` with localization, `sizes` with upload.imageSizes
query Home($category: ID!) {
  posts(where: { category: { equals: $category } }, sort: [publishedAt_DESC], limit: 5, locale: en) {
    docs {
      id
      title
      cover { url alt sizes { thumbnail { url width height } } }
      category { name }
    }
    totalDocs
    hasNextPage
  }
  siteSettings { name }
}
```

How fields map:

| Field | GraphQL |
|---|---|
| text, textarea, email, slug | `String` |
| number | `Float` |
| boolean | `Boolean` |
| date | `DateTime` (ISO 8601 string) |
| select | an enum of its options (`String` when an option isn't a valid GraphQL name); a list with `hasMany` |
| relationship, upload | the related type, so you can select its fields; a list with `hasMany` |
| group | an object type |
| array | a list of row types, each with `id` |
| blocks | a list of a union of block types: `... on HeroBlock { heading }` |
| richText, json | `JSON` (rich text is a Tiptap document) |
| your own field types | the type they are built on |

Every field can be `null`, except `id`, `createdAt` and `updatedAt`: a field may be hidden by
[field access](./access-control) or empty in a locale even when it is required. Hidden fields
(`hidden: true`) are never in the schema. Collections with drafts have `status`.

### Filtering and sorting

`where` is typed for each collection, with the operators of [REST](./rest-api#collections):
`equals`, `not_equals`, `in`, `not_in`, `like`, `exists`, and `gt`, `gte`, `lt`, `lte` for
numbers and dates. Combine with `AND` and `OR`; fields in a group are nested.

```graphql
posts(where: {
  OR: [{ tags: { in: [news] } }, { views: { gte: 100 } }]
  seo: { noindex: { equals: false } }
}) { totalDocs }
```

`sort` takes a list such as `[publishedAt_DESC, title_ASC]` (default: newest first). `limit` is
1 to 100 (default 10), `page` starts at 1.

### Languages and drafts

With [localization](./localization), queries take `locale` and `fallbackLocale`; relationships
are read in the same locale. To read several languages at once, use aliases:

```graphql
{
  th: post(id: "1", locale: th) { title }
  en: post(id: "1", locale: en) { title }
}
```

`draft: true` includes drafts, for logged-in users and API keys only, as in REST.

## Mutations

```graphql
mutation {
  createPost(data: { title: "Hello", body: "First paragraph\n\nSecond", category: "3" }, draft: true) {
    id
    status
  }
}
```

- `create<Type>(data, locale, draft)`, `update<Type>(id, data, locale, draft)`,
  `delete<Type>(id)` return the document; `update<Global>(data, locale, draft)` for globals.
- `draft: true` saves a draft, `draft: false` publishes; left out, the collection does what it
  always does.
- Relationships and uploads take ids. Rich text takes a Tiptap document or plain text (blank
  lines start new paragraphs). Blocks take rows as JSON with `blockType`.
- Required fields are required in `create` inputs, inside groups and array rows too (their
  create types are named `…CreateInput`, e.g. `PostLinksRowCreateInput`); `update` inputs
  change only what you send.
- Files are uploaded over [REST](./uploads), then used by id. Logging in, versions and
  scheduling are REST too.

## Who may do what

Requests are authenticated like REST: the admin's session cookie, `Authorization: Bearer` with a
session token, or an [API key](./api-keys), which may do only what it lists. Browser `POST`s pass
the same CSRF check as REST.

Errors come in `errors` with a code in `extensions.code`, the same codes as
[REST](./rest-api#errors):

| Code | When |
|---|---|
| `UNAUTHORIZED` | Not logged in where that is needed. |
| `FORBIDDEN` | Not allowed. |
| `NOT_FOUND` | No such document (updating or deleting). |
| `VALIDATION_ERROR` | Invalid data; `extensions.fields` lists each field's problem. |
| `BAD_USER_INPUT` | A wrong argument or a malformed request, e.g. `limit: 500`. |
| `PAYLOAD_TOO_LARGE`, `UNSUPPORTED_MEDIA_TYPE`, `TOO_MANY_REQUESTS` | As over REST. |
| `QUERY_TOO_DEEP`, `QUERY_TOO_LARGE` | Over a [limit](#limits). |
| `INTERNAL_SERVER_ERROR` | Something went wrong on the server. |

A document you may not see is `null`, as over REST; a collection you may not read at all gives
`UNAUTHORIZED` or `FORBIDDEN`. A malformed query is answered with status 400; everything else
with 200 and `errors`.

## Limits

GraphQL lets a client ask for a lot in one request, so the plugin caps it:

- **Depth:** at most 7 levels of fields (introspection is not counted).
- **Documents:** at most 2000 documents loaded per request, relationships included.
- **Lists:** `limit` at most 100.

Relationships are loaded in batches: a list of 100 posts and their categories takes one query
for the posts and one for the categories.

## Options

| Option | Default | |
|---|---|---|
| `path` | `/graphql` | Where under `routes.api` the endpoint is. |
| `names` | from the slug | Other names, by slug: `{ news: { type: 'NewsItem', one: 'newsItem', many: 'news' } }`. |
| `collections` | all | Collections in the schema, by slug, e.g. `['posts', 'categories']`. |
| `globals` | all | Globals in the schema, by slug. |
| `limits` | `{ depth: 7, documents: 2000 }` | |
| `introspection` | outside production | Let clients read the schema, as GraphiQL and codegen against the endpoint do. |
| `graphiql` | outside production | Show GraphiQL to browsers. |
| `extend` | — | Your own queries, mutations and fields. |

Two names can clash, e.g. a collection `data`, whose singular is also `data`. The app then stops
at startup and says which names to choose.

## Your own fields

`extend` adds to the schema with [graphql-js](https://graphql.org/graphql-js/) objects. Resolvers
get a context with `cms` (the Local API), `user` and `load(collection, id)`, which batches like
the built-in relationships:

```ts
import { GraphQLInt, GraphQLList, GraphQLNonNull, GraphQLString } from 'graphql'

graphqlPlugin({
  extend: ({ type }) => ({
    query: {
      search: {
        type: new GraphQLList(new GraphQLNonNull(type('Post'))),
        args: { text: { type: new GraphQLNonNull(GraphQLString) } },
        resolve: async (_root, { text }, { cms, user }) =>
          (await cms.find('posts', { user, overrideAccess: false, where: { title: { like: text } } })).docs,
      },
    },
    fields: {
      Post: {
        readingMinutes: {
          type: GraphQLInt,
          resolve: (post) => Math.ceil(JSON.stringify(post.body ?? '').length / 1500),
        },
      },
    },
  }),
})
```

Pass `{ user, overrideAccess: false }` so your resolvers apply the reader's access rules.

## Codegen and other servers

Write the schema to a file for [GraphQL Code Generator](https://the-guild.dev/graphql/codegen)
and similar tools:

```bash
npx easy-cms generate:graphql            # schema.graphql
npx easy-cms generate:graphql web/schema.graphql
```

It opens the database like other commands, and works whether or not `introspection` is on. Codegen
can also read the schema from the running endpoint, outside production or with `introspection: true`.

With the [client preset](https://the-guild.dev/graphql/codegen/plugins/presets/preset-client) and
[urql](https://commerce.nearform.com/open-source/urql/), for example:

```ts [codegen.ts]
import type { CodegenConfig } from '@graphql-codegen/cli'

export default {
  schema: './schema.graphql', // or 'http://localhost:3000/api/cms/graphql'
  documents: ['src/**/*.ts', '!src/gql/**'],
  generates: { './src/gql/': { preset: 'client' } },
} satisfies CodegenConfig
```

```ts
import { Client, fetchExchange } from '@urql/core'
import { graphql } from './gql'

const client = new Client({
  url: 'https://example.com/api/cms/graphql',
  exchanges: [fetchExchange],
  // From a server or script; pages in the browser use the visitor's session instead.
  fetchOptions: { headers: { authorization: `Bearer ${process.env.EASY_CMS_API_KEY}` } },
})

const CreatePost = graphql(`
  mutation CreatePost($data: PostCreateInput!) {
    createPost(data: $data, draft: true) { id status }
  }
`)
const { data, error } = await client
  .mutation(CreatePost, { data: { title: 'Hello', category: '1' } })
  .toPromise()
// data.createPost.status is typed; error.graphQLErrors[0].extensions.code says what went wrong.
```

Pass values as variables, as above: the client preset matches each document by its text, and
escapes such as `\n` inside the query make it miss. Input types are named `<Type>CreateInput`,
`<Type>UpdateInput` and `<Global>Input`. For Node's own ES modules, set
`config: { importExtension: '.ts' }` (or `'.js'`) on the output.

To serve the schema from your own GraphQL server (Apollo Server, Yoga, a gateway), build it and
give each request a context:

```ts
import { buildGraphQLSchema, createContext } from '@easy-cms/plugin-graphql'

const cms = await getEasyCMS()
const schema = buildGraphQLSchema(cms.config)
// For each request:
const contextValue = createContext(cms, await cms.auth.userFromHeaders(request.headers))
```

## Next steps

- [REST API](./rest-api): uploads, logging in, versions and scheduling.
- [Access control](./access-control): rules that apply to GraphQL too.
- [API keys](./api-keys): keys for scripts and other servers.

---

<!-- https://easy-cms-website.vercel.app/docs/cli.md -->

# CLI

::: info What you'll learn
The two command-line tools: `create-easy-cms` to set up a project, and `easy-cms` for
migrations, users, types, backups, scheduled jobs and the standalone server.
:::

## create-easy-cms

```bash [pm]
npx create-easy-cms [dir] [--db sqlite|postgres] [--pm npm|pnpm|yarn|bun] [--standalone] [--yes] [--skip-install]
```

Adds Easy CMS to a project:

- **In a Nuxt or Next.js project**, it installs the packages, writes `easy-cms.config.ts`, a
  `.env` with a random `EASY_CMS_SECRET`, wires up the module or route handlers, and adds the
  database and uploads to `.gitignore`.
- **In a new or empty directory** (or with `--standalone`), it sets up a
  [standalone server](./standalone) for any frontend.

| Option | |
|---|---|
| `dir` | Where to set up. Default: the current directory. |
| `--db` | `sqlite` (a file) or `postgres` (PGlite locally, a server in production). Asks when not given; `sqlite` with `--yes`. |
| `--pm` | `npm`, `pnpm`, `yarn` or `bun`. Default: the project's (its `packageManager` field, then its lockfile), else the one you ran it with. |
| `--standalone` | A standalone server even inside a Nuxt or Next.js project. |
| `--yes` | Accept the defaults without asking. |
| `--skip-install` | Only write files; run your package manager yourself. |

It installs and prints the next steps with that package manager (`pnpm exec easy-cms …`,
`yarn easy-cms …`, `bunx easy-cms …`). With Yarn 2 or later it writes `.yarnrc.yml` with
`nodeLinker: node-modules`: Plug'n'Play is not supported. Bun installs the packages; the CMS
still runs on Node.js.

See [Getting started](./getting-started) for what happens next.

## easy-cms

Installed as a dev dependency (a regular dependency for standalone servers). Every command
loads `.env` from the project root and reads `easy-cms.config.ts`.

```bash [pm]
npx easy-cms <command> [--config <file>] [--cwd <dir>]
```

| Command | |
|---|---|
| [`migrate`](#migrate) | Apply pending migrations. |
| [`migrate:create <name>`](#migrate-create) | Write a migration for config changes. |
| [`migrate:status`](#migrate-status) | List migrations and whether they are applied. |
| [`admin:create`](#admin-create) | Create a user. |
| [`generate:types`](#generate-types) | Write TypeScript types for other apps. |
| [`backup <file>`](#backup) | Copy the database to a SQLite file while the CMS runs. |
| [`backup:decrypt <file>`](#backup-decrypt) | Decrypt a backup made with `backups.encryptionKey`. |
| [`copy --from <config>`](#copy) | Copy all content into another database, e.g. SQLite to Postgres. |
| [`jobs:run`](#jobs-run) | Run due scheduled jobs and webhook retries once. |
| [`serve`](#serve) | Run the CMS as its own server. |

Options for every command: `--config <file>` (default `easy-cms.config.ts`), `--cwd <dir>`
(the project root; its `.env` is loaded) and `--help`. Commands exit non-zero on failure, so
they work in CI and deploy scripts. Set `DEBUG=1` to see stack traces. Built-in commands stop
on an option they don't know.

`admin:create` and `jobs:run` were called `create-admin` and `run-scheduled` before 0.60. The
old names still work.

### migrate

```bash [pm]
npx easy-cms migrate
```

Applies every migration in `easy-cms/migrations` that the database has not run yet, in order.
Each runs in its own transaction; a failure rolls it back and stops. Run it on every deploy,
before the new version starts. See [Migrations & deployment](./deployment).

### migrate:create

```bash [pm]
npx easy-cms migrate:create add-author-bio
```

Compares the config with the last migration and writes
`easy-cms/migrations/<timestamp>_<name>.sql` (and a `.json` snapshot) when something changed.
In a terminal it asks whether a field that disappeared was renamed, so data is kept instead of
dropped. Review the SQL, then commit both files.

### migrate:status

```bash [pm]
npx easy-cms migrate:status
# ✓ applied  20260925091723_init
# • pending  20260928040614_seo
```

### admin:create

```bash [pm]
npx easy-cms admin:create --email ann@example.com --name Ann
npx easy-cms admin:create --email bob@example.com --role editor
```

Creates a user with the `admin` role (or `--role`). The password is asked for in the terminal;
where there is no terminal (CI, containers), it is read from `EASY_CMS_ADMIN_PASSWORD`. Handy
when the first admin can't be created in the browser, or to recover access.

### generate:types

```bash [pm]
npx easy-cms generate:types --out ../web/src/cms-types.ts
```

Writes one interface per collection and global (default file `easy-cms-types.ts`). The file has
no imports, so a frontend in another repository can use it. Apps that import the config don't
need it: their types are [inferred](./typescript).

### backup

```bash [pm]
npx easy-cms backup backups/cms-2026-09-28.db
```

Copies the database to a new SQLite file while the CMS keeps running, as a consistent
snapshot; a name ending in `.gz` is compressed, like the backups made from the admin. The file
must not exist yet. Postgres needs `backups: { sqlite }` in the config (or use `pg_dump`).
Uploads are not included: back up the uploads folder or bucket separately. See
[Backups](./backups).

### backup:decrypt

```bash [pm]
npx easy-cms backup:decrypt my-site-2026-10-04-0300.db.gz.enc [--out <file>]
```

Decrypts a backup made with `backups.encryptionKey` (a `.db.gz.enc` file, e.g. copied from the
backup bucket) into a `.db.gz` file, with the key from the config. The database isn't opened.
Without `--out`, the file is written next to it without `.enc`; it must not exist yet.

### copy

```bash [pm]
npx easy-cms copy --from easy-cms.old.config.ts
```

Copies every document, version, user and global from the database of the `--from` config into
the database of the project's config, for example from SQLite to Postgres (or back). Ids stay
the same, so relationships, history and logins keep working.

- Both configs need the same collections and fields: import the main config into the old one and
  change only `db`. Different fields are refused.
- The target must be empty. In development its tables are created; in production run
  `easy-cms migrate` against it first.
- Uploaded files are not copied; they stay in the uploads folder or bucket.
- Stop writing to the source while copying, or copy from a [backup](#backup).

See [Move from SQLite to Postgres](./recipes/sqlite-to-postgres).

### jobs:run

```bash [pm]
npx easy-cms jobs:run
```

Runs due [scheduled publishes and unpublishes](./drafts#scheduled-publishing) and retries
failed [webhook deliveries](./webhooks#delivery), once. Servers do this every minute on their
own; use this command from a cron job where no server process keeps running (serverless).

### serve

```bash [pm]
npx easy-cms serve --port 4000
npx easy-cms serve --watch          # development: reload on config changes
```

Runs Easy CMS without Nuxt or Next.js: the admin at `/admin`, the REST API at `/api/cms`, and
`/healthz` for load balancers.

| Option | |
|---|---|
| `--port <n>` | Port. Default: `PORT`, then 4000. |
| `--host <host>` | Interface to listen on. Default: `HOST`, then all interfaces. |
| `--watch` | Reload when the config or files it imports change. |
| `--trust-proxy` | Trust `X-Forwarded-For`, `X-Forwarded-Proto` and `X-Forwarded-Host` (for the CSRF origin check) from your reverse proxy. |

In production (`NODE_ENV=production`) pending migrations stop the server from starting. See
[Standalone server](./standalone).

### Commands from plugins

Plugins can add commands, e.g. `easy-cms nested:rebuild` from the [nested pages](./nested-docs)
plugin. `npx easy-cms --help` for an unknown command lists the ones your config has. Your own
config can add them too:

```ts
export default defineConfig({
  // …
  cliCommands: [
    {
      name: 'posts:count',
      description: 'Print how many posts there are',
      run: async ({ cms, args, log }) => log(String(await cms.count('posts'))),
    },
  ],
})
```

`run` gets the CMS (its schema as it is: run `migrate` first), the words after the command name
(`args`), the options (`flags`) and `log`. Return a number to exit with it.

`flags` holds every option the CLI doesn't use itself (`--config`, `--cwd` and `--help` are
its own), in camelCase. Values are strings, or `true` for an option without a value:

| You type | `flags` |
|---|---|
| `--dry-run` | `{ dryRun: true }` |
| `--limit=5` | `{ limit: '5' }` |

## Next steps

- [Migrations & deployment](./deployment): when to run which command.
- [Standalone server](./standalone): run the CMS for any frontend.

---

<!-- https://easy-cms-website.vercel.app/why.md -->

# Why Easy CMS

> Why teams choose Easy CMS: it runs inside your Next.js or Nuxt app, the content model is TypeScript, roles and SSO are free under MIT, and the admin and docs are in Thai and English.

Page: https://easy-cms-website.vercel.app/why

Most headless CMSs are a second system: another server, another bill, another schema to keep in sync. Easy CMS is a package you install, so content ships with the app you already run.

## What makes it different

- **Inside your app:** Runs in Next.js or Nuxt, or as a standalone server for any other frontend. One deployment, one database. ([Read the docs](https://easy-cms-website.vercel.app/docs))
- **Content model in code:** Collections and fields in TypeScript, reviewed in git, with types for every query. ([Read the docs](https://easy-cms-website.vercel.app/docs/configuration))
- **Team features, free:** Roles, field permissions, SSO, an audit log and live preview, all under the MIT license. ([Read the docs](https://easy-cms-website.vercel.app/docs/roles))
- **Thai throughout:** A complete Thai admin, and Thai docs for every page, next to English. ([Read the docs](https://easy-cms-website.vercel.app/docs/admin))

## Who it’s for

- [Easy CMS for developers](https://easy-cms-website.vercel.app/why/developers.md): Easy CMS is an npm package. Your content model is TypeScript in your repo, your pages read it with a typed API, and the admin ships with the app you already deploy.
- [Easy CMS for content and marketing teams](https://easy-cms-website.vercel.app/why/teams.md): Your developers set up the content once. After that, your team writes, previews, translates and schedules on its own, in an admin that speaks Thai or English.
- [Easy CMS for agencies](https://easy-cms-website.vercel.app/why/agencies.md): Easy CMS is MIT, all of it. Hand each client an admin with the roles, sign-in and audit trail they ask for, in Thai if they want it, without a per-seat bill.

## How it compares

Honest side-by-sides with the CMSs people ask about most, with sources. [All side by side](https://easy-cms-website.vercel.app/why/compare.md)

- [Easy CMS vs Payload](https://easy-cms-website.vercel.app/why/compare/payload.md): Payload is the closest to Easy CMS: MIT, code-first and installed into your Next.js app. It is far more mature, with a large community. Easy CMS also runs in Nuxt or on its own, and keeps team features such as SSO in the free license.
- [Easy CMS vs Strapi](https://easy-cms-website.vercel.app/why/compare/strapi.md): Strapi is the most popular open-source headless CMS: a separate Node server with its own admin. Its Community Edition is MIT, while live preview, content history, SSO and audit logs are in paid plans. Easy CMS runs inside your app and keeps those features in the free license.
- [Easy CMS vs Directus](https://easy-cms-website.vercel.app/why/compare/directus.md): Directus puts a data studio and APIs on top of any SQL database, with the model built in its interface. Its license is source-available, not OSI open source, and the free Core is capped. Easy CMS is MIT with no caps, and its model lives in your repo.
- [Easy CMS vs WordPress](https://easy-cms-website.vercel.app/why/compare/wordpress.md): WordPress runs a large share of the web, with themes and plugins for almost anything, on PHP and MySQL. Used headless, it is a separate PHP app next to your frontend. Easy CMS keeps everything in your TypeScript app, with a typed content model.
- [Easy CMS vs Sanity](https://easy-cms-website.vercel.app/why/compare/sanity.md): Sanity pairs an MIT-licensed Studio, configured in code, with a hosted content backend you can't self-host. It is strong at real-time collaboration and visual editing, billed by seats and usage. Easy CMS keeps your content in your own database.

Easy CMS is young: its first release was in September 2026 and its community is small. Every other product here has been in production longer, with more people answering questions.

---

<!-- https://easy-cms-website.vercel.app/why/developers.md -->

# Easy CMS for developers

> A headless CMS that installs into your Next.js or Nuxt app: content defined in TypeScript, a typed Local API, your own database, one deployment.

Page: https://easy-cms-website.vercel.app/why/developers

**A CMS you install, not one you run beside your app.** Easy CMS is an npm package. Your content model is TypeScript in your repo, your pages read it with a typed API, and the admin ships with the app you already deploy.

## What a separate CMS costs you

- A second service to deploy, secure, monitor and keep in step with your app.
- A schema built in a web UI that drifts from the code that reads it.
- Untyped JSON over HTTP, even when the page renders on the same server.
- Content locked in someone else’s database, behind someone else’s pricing.

## How Easy CMS helps

- **One deployment:** Route handlers in Next.js or a module in Nuxt mount the admin and the API inside your app. Other frontends use the standalone server. ([Read the docs](https://easy-cms-website.vercel.app/docs))
- **Content model in TypeScript:** Collections, fields, access rules and hooks live in easy-cms.config.ts, reviewed in pull requests like any other code. ([Read the docs](https://easy-cms-website.vercel.app/docs/configuration))
- **A typed Local API:** Server code calls cms.find() with types generated from your config. No HTTP round trip, no hand-written types. ([Read the docs](https://easy-cms-website.vercel.app/docs/local-api))
- **Your database, real tables:** SQLite (a file or Turso) or Postgres. Documents are rows and columns you can still query with SQL. ([Read the docs](https://easy-cms-website.vercel.app/docs/databases))
- **Migrations you commit:** Schema changes become migration files in git, applied on deploy. Production refuses a config that doesn’t match. ([Read the docs](https://easy-cms-website.vercel.app/docs/deployment))
- **REST, GraphQL and MCP:** REST at /api/cms for any client; GraphQL and an MCP server for AI assistants come as experimental plugins. ([Read the docs](https://easy-cms-website.vercel.app/docs/rest-api))

## When it isn’t a fit

- Editors must change the content model themselves, without a developer: here the model is code.
- You need edge runtimes: Easy CMS needs Node.js 22.12 or newer.
- You need a large marketplace of ready-made integrations today.

Easy CMS is young: its first release was in September 2026 and its community is small. Every other product here has been in production longer, with more people answering questions.

---

<!-- https://easy-cms-website.vercel.app/why/teams.md -->

# Easy CMS for content and marketing teams

> Drafts, scheduled publishing, live preview, SEO fields with a search preview, forms and translations, in an admin your team can use in Thai or English.

Page: https://easy-cms-website.vercel.app/why/teams

**Publish on your schedule, and see it before anyone else does.** Your developers set up the content once. After that, your team writes, previews, translates and schedules on its own, in an admin that speaks Thai or English.

## What slows a content team down

- Every change waits for a developer, a deploy or a time zone.
- Publishing blind: no way to see the page before it goes live.
- SEO titles and share images filled in somewhere else, or not at all.
- An admin in a language half the team doesn’t read comfortably.

## How Easy CMS helps

- **Drafts, history and scheduling:** Keep working on a draft while the live version stays up, restore any earlier version, and publish or unpublish at a set time. ([Read the docs](https://easy-cms-website.vercel.app/docs/drafts))
- **Live preview:** See the real page next to the form, updated as you type, before anything is saved. ([Read the docs](https://easy-cms-website.vercel.app/docs/live-preview))
- **SEO with a search preview:** Title and description with length meters, a preview of the search result and share images, plus a sitemap and structured data. ([Read the docs](https://easy-cms-website.vercel.app/docs/seo))
- **Forms without a developer:** Build contact and sign-up forms in the admin, collect submissions and get an email for each one. ([Read the docs](https://easy-cms-website.vercel.app/docs/forms))
- **Content in several languages:** Switch the language you’re editing; lists show which translations are still missing. ([Read the docs](https://easy-cms-website.vercel.app/docs/localization))
- **An admin in Thai or English:** Every screen of the admin is translated. Each person picks their own language. ([Read the docs](https://easy-cms-website.vercel.app/docs/admin))

## When it isn’t a fit

- Your team wants to add new content types or fields without a developer: the content model is code.
- You want a hosted service to sign up for: Easy CMS is installed into a site by a developer.
- You need a large marketplace of ready-made integrations today.

Easy CMS is young: its first release was in September 2026 and its community is small. Every other product here has been in production longer, with more people answering questions.

---

<!-- https://easy-cms-website.vercel.app/why/agencies.md -->

# Easy CMS for agencies

> Build client sites on an MIT-licensed CMS: roles, SSO and an audit log for free, several clients in one CMS, backups, and an admin your clients can use in Thai.

Page: https://easy-cms-website.vercel.app/why/agencies

**One stack for every client site, with nothing to license.** Easy CMS is MIT, all of it. Hand each client an admin with the roles, sign-in and audit trail they ask for, in Thai if they want it, without a per-seat bill.

## What adds up across client sites

- A CMS subscription or seat count for every client, growing with their team.
- Enterprise plans just to get single sign-on or an audit log.
- One more server per client to patch and back up.
- Clients who need their admin, and its help pages, in Thai.

## How Easy CMS helps

- **Roles and field permissions:** Set up roles in the admin: who sees which collections, which fields, and only their own documents. ([Read the docs](https://easy-cms-website.vercel.app/docs/roles))
- **Single sign-on:** Google, Microsoft, GitHub or any OpenID Connect provider, with rules for who gets in. MIT, not an enterprise add-on. ([Read the docs](https://easy-cms-website.vercel.app/docs/sso))
- **An audit log:** Who changed what and when, with values, kept tamper-evident. ([Read the docs](https://easy-cms-website.vercel.app/docs/audit-log))
- **Several clients in one CMS:** The multi-tenant plugin gives each brand or client its own content, members and settings, with a switcher in the admin. ([Read the docs](https://easy-cms-website.vercel.app/docs/multi-tenant))
- **Backups and upgrades:** Back up the database from the admin, restore it, and upgrade with a guide for every release. ([Read the docs](https://easy-cms-website.vercel.app/docs/backups))
- **A starter in one click:** Deploy a site with its admin to Vercel or Netlify for a client demo, then move it to their server. ([Read the docs](https://easy-cms-website.vercel.app/docs/one-click-deploy))

## When it isn’t a fit

- Easy CMS is new: 1.0 came out in October 2026, and the shop, GraphQL and MCP plugins are still experimental.
- Clients must change the content model themselves, without your developers.
- You need a large marketplace of ready-made integrations, or edge runtimes.

Easy CMS is young: its first release was in September 2026 and its community is small. Every other product here has been in production longer, with more people answering questions.

---

<!-- https://easy-cms-website.vercel.app/why/compare.md -->

# Easy CMS compared with Payload, Strapi, Directus, WordPress and Sanity

> Easy CMS next to Payload, Strapi, Directus, WordPress and Sanity: license, where it runs, content model, databases, pricing, APIs, Thai admin, roles and SSO, with sources.

Page: https://easy-cms-website.vercel.app/why/compare

Eleven questions that decide most CMS choices, answered from each product’s own site. Where we couldn’t confirm something, the table says so.

| Feature | Easy CMS | Payload | Strapi | Directus | WordPress | Sanity |
|---|---|---|---|---|---|---|
| License | MIT. Every feature is free, roles and SSO included | MIT; an Enterprise plan (through sales) adds SSO and visual editing | MIT Community Edition; paid Growth ($45/month self-hosted) and Enterprise editions | Source-available (Monospace Sustainable Core License); free Core capped at 3 seats, 25 collections and 5 flows; each release becomes GPL after four years | GPL-2.0; free to self-host | Studio is MIT; the Content Lake backend is a proprietary hosted service |
| Where it runs | Inside your Next.js or Nuxt app, or as a standalone server | Inside your Next.js app | A separate Node server | A separate Node server and data studio on your SQL database | A separate PHP app (themes, or headless) | Studio as a React app (it can be embedded in Next.js); content in Sanity's cloud |
| Content model | TypeScript config in your repo | TypeScript config in your repo | Content-type Builder in the admin (in development), saved as schema files in the project | Built in the data studio as real tables; can introspect an existing database; YAML snapshots | Posts and pages; custom post types in PHP or plugins | Schemas in JavaScript or TypeScript |
| Databases | SQLite (file or Turso), Postgres (or PGlite) | MongoDB, Postgres, SQLite | PostgreSQL, MySQL, MariaDB, SQLite | Postgres, MySQL, MariaDB, MS SQL, SQLite, OracleDB, CockroachDB | MySQL 8.0+ or MariaDB 10.11+ | Sanity's hosted Content Lake only |
| Language and runtime | Node.js 22.12+, TypeScript | Node.js, TypeScript; Next.js only | Node.js | Node.js | PHP 8.3+ | Studio: React; backend: hosted |
| Hosted cloud | None. Self-host anywhere; one-click deploy to your own Vercel or Netlify | Self-host anywhere; one-click templates for Vercel and Cloudflare | Strapi Cloud from $35/month; no free tier listed | Team plan $499/month (annual); cloud hosting +$99/month | WordPress.com: free plan; paid plans from $4/month (yearly) | Free plan (20 seats, 10k documents); Growth $15 per seat per month |
| APIs | Typed Local API, REST; GraphQL and MCP as experimental plugins | See the official site | REST, GraphQL | REST, GraphQL | REST built in; GraphQL with the WPGraphQL plugin | GROQ, GraphQL |
| Admin in Thai | Complete: every admin string, and every docs page | Yes, near-complete | Partial (about a quarter of the admin) | Partial (community translation) | Partial (about two-thirds translated) | Community plugin |
| Roles, SSO, audit | Roles, field permissions, SSO (Google, Microsoft, GitHub, OIDC), audit log: all MIT | SSO is an Enterprise feature | Live preview, content history: Growth; SSO, review workflows, audit logs: Enterprise | No SSO in the free Core | See the official site | See the pricing page |
| Ecosystem | Small: official plugins (SEO, forms, redirects, nested pages, multi-tenant, shop, GraphQL, MCP), a new community | Large: about 45k GitHub stars; Payload has joined Figma | The largest here: about 73k GitHub stars | About 39k GitHub stars | The largest plugin and theme ecosystem | Mature hosted platform; real-time collaboration and visual editing |
| Maturity | Version 1.4; 1.0 in October 2026, first released September 2026 | Version 3 since November 2024 | Version 5 since September 2024 | Version 12 since June 2026 | Version 7.1 (August 2026), over two decades old | Studio version 6 since June 2026 |

- [Easy CMS vs Payload](https://easy-cms-website.vercel.app/why/compare/payload.md): Payload is the closest to Easy CMS: MIT, code-first and installed into your Next.js app. It is far more mature, with a large community. Easy CMS also runs in Nuxt or on its own, and keeps team features such as SSO in the free license.
- [Easy CMS vs Strapi](https://easy-cms-website.vercel.app/why/compare/strapi.md): Strapi is the most popular open-source headless CMS: a separate Node server with its own admin. Its Community Edition is MIT, while live preview, content history, SSO and audit logs are in paid plans. Easy CMS runs inside your app and keeps those features in the free license.
- [Easy CMS vs Directus](https://easy-cms-website.vercel.app/why/compare/directus.md): Directus puts a data studio and APIs on top of any SQL database, with the model built in its interface. Its license is source-available, not OSI open source, and the free Core is capped. Easy CMS is MIT with no caps, and its model lives in your repo.
- [Easy CMS vs WordPress](https://easy-cms-website.vercel.app/why/compare/wordpress.md): WordPress runs a large share of the web, with themes and plugins for almost anything, on PHP and MySQL. Used headless, it is a separate PHP app next to your frontend. Easy CMS keeps everything in your TypeScript app, with a typed content model.
- [Easy CMS vs Sanity](https://easy-cms-website.vercel.app/why/compare/sanity.md): Sanity pairs an MIT-licensed Studio, configured in code, with a hosted content backend you can't self-host. It is strong at real-time collaboration and visual editing, billed by seats and usage. Easy CMS keeps your content in your own database.

Easy CMS is young: its first release was in September 2026 and its community is small. Every other product here has been in production longer, with more people answering questions.

## Sources

Checked against each product’s own site on 2026-10-17. Prices and versions change; follow the links for today’s.

- [Payload: pricing and license](https://payloadcms.com/get-started)
- [Payload: databases](https://payloadcms.com/docs/database/overview)
- [Payload: admin languages](https://payloadcms.com/docs/configuration/i18n)
- [Payload on GitHub](https://github.com/payloadcms/payload)
- [Strapi: license](https://github.com/strapi/strapi/blob/develop/LICENSE)
- [Strapi: self-hosted pricing](https://strapi.io/pricing-self-hosted)
- [Strapi: cloud pricing](https://strapi.io/pricing-cloud)
- [Strapi: databases](https://docs.strapi.io/cms/configurations/database)
- [Strapi: Content-type Builder](https://docs.strapi.io/cms/features/content-type-builder)
- [Strapi: admin translations](https://github.com/strapi/strapi/tree/develop/packages/core/admin/admin/src/translations)
- [Directus: license](https://github.com/directus/directus/blob/main/license)
- [Directus: pricing](https://directus.com/pricing)
- [Directus: collections](https://directus.com/docs/guides/data-model/collections)
- [Directus: schema snapshots](https://docs.directus.io/self-hosted/cli)
- [Directus on GitHub](https://github.com/directus/directus)
- [WordPress: releases](https://wordpress.org/download/releases/)
- [WordPress: requirements](https://wordpress.org/about/requirements/)
- [WordPress.com: pricing](https://wordpress.com/pricing/)
- [WordPress: REST API](https://developer.wordpress.org/rest-api/)
- [WordPress: Thai translation](https://translate.wordpress.org/locale/th/)
- [Sanity: pricing](https://www.sanity.io/pricing)
- [Sanity: Studio](https://www.sanity.io/docs/studio)
- [Sanity: embedding the Studio](https://www.sanity.io/docs/studio/embedding-sanity-studio)
- [Sanity: self-hosting the Content Lake](https://www.sanity.io/answers/is-it-possible-to-self-host-the-content-lake)
- [Sanity: Studio languages](https://www.sanity.io/docs/studio/localizing-studio-ui)

---

<!-- https://easy-cms-website.vercel.app/why/compare/payload.md -->

# Easy CMS vs Payload

> Payload is the closest to Easy CMS: MIT, code-first and installed into your Next.js app. It is far more mature, with a large community. Easy CMS also runs in Nuxt or on its own, and keeps team features such as SSO in the free license.

Page: https://easy-cms-website.vercel.app/why/compare/payload

| Feature | Easy CMS | Payload |
|---|---|---|
| License | MIT. Every feature is free, roles and SSO included | MIT; an Enterprise plan (through sales) adds SSO and visual editing |
| Where it runs | Inside your Next.js or Nuxt app, or as a standalone server | Inside your Next.js app |
| Content model | TypeScript config in your repo | TypeScript config in your repo |
| Databases | SQLite (file or Turso), Postgres (or PGlite) | MongoDB, Postgres, SQLite |
| Language and runtime | Node.js 22.12+, TypeScript | Node.js, TypeScript; Next.js only |
| Hosted cloud | None. Self-host anywhere; one-click deploy to your own Vercel or Netlify | Self-host anywhere; one-click templates for Vercel and Cloudflare |
| APIs | Typed Local API, REST; GraphQL and MCP as experimental plugins | See the official site |
| Admin in Thai | Complete: every admin string, and every docs page | Yes, near-complete |
| Roles, SSO, audit | Roles, field permissions, SSO (Google, Microsoft, GitHub, OIDC), audit log: all MIT | SSO is an Enterprise feature |
| Ecosystem | Small: official plugins (SEO, forms, redirects, nested pages, multi-tenant, shop, GraphQL, MCP), a new community | Large: about 45k GitHub stars; Payload has joined Figma |
| Maturity | Version 1.4; 1.0 in October 2026, first released September 2026 | Version 3 since November 2024 |

## Choose Easy CMS when

- Your frontend is Nuxt, or not a Next.js app at all (the standalone server serves any frontend).
- You want roles, SSO and an audit log without an enterprise plan.
- Your editors work in Thai and need Thai docs too.

## Choose Payload when

- You want the most mature embedded CMS for Next.js, with a large community and many examples.
- You need MongoDB.
- You need visual editing or enterprise support from the vendor.

Easy CMS is young: its first release was in September 2026 and its community is small. Every other product here has been in production longer, with more people answering questions.

## Sources

Checked against each product’s own site on 2026-10-17. Prices and versions change; follow the links for today’s.

- [Payload: pricing and license](https://payloadcms.com/get-started)
- [Payload: databases](https://payloadcms.com/docs/database/overview)
- [Payload: admin languages](https://payloadcms.com/docs/configuration/i18n)
- [Payload on GitHub](https://github.com/payloadcms/payload)

---

<!-- https://easy-cms-website.vercel.app/why/compare/strapi.md -->

# Easy CMS vs Strapi

> Strapi is the most popular open-source headless CMS: a separate Node server with its own admin. Its Community Edition is MIT, while live preview, content history, SSO and audit logs are in paid plans. Easy CMS runs inside your app and keeps those features in the free license.

Page: https://easy-cms-website.vercel.app/why/compare/strapi

| Feature | Easy CMS | Strapi |
|---|---|---|
| License | MIT. Every feature is free, roles and SSO included | MIT Community Edition; paid Growth ($45/month self-hosted) and Enterprise editions |
| Where it runs | Inside your Next.js or Nuxt app, or as a standalone server | A separate Node server |
| Content model | TypeScript config in your repo | Content-type Builder in the admin (in development), saved as schema files in the project |
| Databases | SQLite (file or Turso), Postgres (or PGlite) | PostgreSQL, MySQL, MariaDB, SQLite |
| Language and runtime | Node.js 22.12+, TypeScript | Node.js |
| Hosted cloud | None. Self-host anywhere; one-click deploy to your own Vercel or Netlify | Strapi Cloud from $35/month; no free tier listed |
| APIs | Typed Local API, REST; GraphQL and MCP as experimental plugins | REST, GraphQL |
| Admin in Thai | Complete: every admin string, and every docs page | Partial (about a quarter of the admin) |
| Roles, SSO, audit | Roles, field permissions, SSO (Google, Microsoft, GitHub, OIDC), audit log: all MIT | Live preview, content history: Growth; SSO, review workflows, audit logs: Enterprise |
| Ecosystem | Small: official plugins (SEO, forms, redirects, nested pages, multi-tenant, shop, GraphQL, MCP), a new community | The largest here: about 73k GitHub stars |
| Maturity | Version 1.4; 1.0 in October 2026, first released September 2026 | Version 5 since September 2024 |

## Choose Easy CMS when

- You don't want a second server to deploy, secure and keep in step with your app.
- You want the content model as typed TypeScript, reviewed in pull requests.
- You want live preview, history, SSO and an audit log without a paid plan.

## Choose Strapi when

- One backend serves many frontends and teams that are not in one codebase.
- You want the largest community, plugins and hiring pool.
- You prefer building content types in an admin screen.

Easy CMS is young: its first release was in September 2026 and its community is small. Every other product here has been in production longer, with more people answering questions.

## Sources

Checked against each product’s own site on 2026-10-17. Prices and versions change; follow the links for today’s.

- [Strapi: license](https://github.com/strapi/strapi/blob/develop/LICENSE)
- [Strapi: self-hosted pricing](https://strapi.io/pricing-self-hosted)
- [Strapi: cloud pricing](https://strapi.io/pricing-cloud)
- [Strapi: databases](https://docs.strapi.io/cms/configurations/database)
- [Strapi: Content-type Builder](https://docs.strapi.io/cms/features/content-type-builder)
- [Strapi: admin translations](https://github.com/strapi/strapi/tree/develop/packages/core/admin/admin/src/translations)

---

<!-- https://easy-cms-website.vercel.app/why/compare/directus.md -->

# Easy CMS vs Directus

> Directus puts a data studio and APIs on top of any SQL database, with the model built in its interface. Its license is source-available, not OSI open source, and the free Core is capped. Easy CMS is MIT with no caps, and its model lives in your repo.

Page: https://easy-cms-website.vercel.app/why/compare/directus

| Feature | Easy CMS | Directus |
|---|---|---|
| License | MIT. Every feature is free, roles and SSO included | Source-available (Monospace Sustainable Core License); free Core capped at 3 seats, 25 collections and 5 flows; each release becomes GPL after four years |
| Where it runs | Inside your Next.js or Nuxt app, or as a standalone server | A separate Node server and data studio on your SQL database |
| Content model | TypeScript config in your repo | Built in the data studio as real tables; can introspect an existing database; YAML snapshots |
| Databases | SQLite (file or Turso), Postgres (or PGlite) | Postgres, MySQL, MariaDB, MS SQL, SQLite, OracleDB, CockroachDB |
| Language and runtime | Node.js 22.12+, TypeScript | Node.js |
| Hosted cloud | None. Self-host anywhere; one-click deploy to your own Vercel or Netlify | Team plan $499/month (annual); cloud hosting +$99/month |
| APIs | Typed Local API, REST; GraphQL and MCP as experimental plugins | REST, GraphQL |
| Admin in Thai | Complete: every admin string, and every docs page | Partial (community translation) |
| Roles, SSO, audit | Roles, field permissions, SSO (Google, Microsoft, GitHub, OIDC), audit log: all MIT | No SSO in the free Core |
| Ecosystem | Small: official plugins (SEO, forms, redirects, nested pages, multi-tenant, shop, GraphQL, MCP), a new community | About 39k GitHub stars |
| Maturity | Version 1.4; 1.0 in October 2026, first released September 2026 | Version 12 since June 2026 |

## Choose Easy CMS when

- You want an OSI open-source license with no seat or collection caps.
- Your schema should be code, reviewed and versioned with the app.
- Content belongs to a Next.js or Nuxt site rather than a shared data platform.

## Choose Directus when

- You already have a SQL database and want an admin and APIs on top of it.
- You need MySQL, MS SQL, Oracle or CockroachDB.
- Non-developers should change the data model themselves.

Easy CMS is young: its first release was in September 2026 and its community is small. Every other product here has been in production longer, with more people answering questions.

## Sources

Checked against each product’s own site on 2026-10-17. Prices and versions change; follow the links for today’s.

- [Directus: license](https://github.com/directus/directus/blob/main/license)
- [Directus: pricing](https://directus.com/pricing)
- [Directus: collections](https://directus.com/docs/guides/data-model/collections)
- [Directus: schema snapshots](https://docs.directus.io/self-hosted/cli)
- [Directus on GitHub](https://github.com/directus/directus)

---

<!-- https://easy-cms-website.vercel.app/why/compare/wordpress.md -->

# Easy CMS vs WordPress

> WordPress runs a large share of the web, with themes and plugins for almost anything, on PHP and MySQL. Used headless, it is a separate PHP app next to your frontend. Easy CMS keeps everything in your TypeScript app, with a typed content model.

Page: https://easy-cms-website.vercel.app/why/compare/wordpress

| Feature | Easy CMS | WordPress |
|---|---|---|
| License | MIT. Every feature is free, roles and SSO included | GPL-2.0; free to self-host |
| Where it runs | Inside your Next.js or Nuxt app, or as a standalone server | A separate PHP app (themes, or headless) |
| Content model | TypeScript config in your repo | Posts and pages; custom post types in PHP or plugins |
| Databases | SQLite (file or Turso), Postgres (or PGlite) | MySQL 8.0+ or MariaDB 10.11+ |
| Language and runtime | Node.js 22.12+, TypeScript | PHP 8.3+ |
| Hosted cloud | None. Self-host anywhere; one-click deploy to your own Vercel or Netlify | WordPress.com: free plan; paid plans from $4/month (yearly) |
| APIs | Typed Local API, REST; GraphQL and MCP as experimental plugins | REST built in; GraphQL with the WPGraphQL plugin |
| Admin in Thai | Complete: every admin string, and every docs page | Partial (about two-thirds translated) |
| Roles, SSO, audit | Roles, field permissions, SSO (Google, Microsoft, GitHub, OIDC), audit log: all MIT | See the official site |
| Ecosystem | Small: official plugins (SEO, forms, redirects, nested pages, multi-tenant, shop, GraphQL, MCP), a new community | The largest plugin and theme ecosystem |
| Maturity | Version 1.4; 1.0 in October 2026, first released September 2026 | Version 7.1 (August 2026), over two decades old |

## Choose Easy CMS when

- Your site is a Next.js or Nuxt app and you don't want a PHP stack beside it.
- You want the content model typed in TypeScript and reviewed in git.
- You want fewer plugins to update and audit.

## Choose WordPress when

- People who don't write code build and run the site with themes.
- You need a plugin for something specific, and it already exists.
- You want cheap managed hosting that anyone can set up.

Moving from WordPress? A script brings your posts, pages, categories and images across, with redirects from the old addresses. https://easy-cms-website.vercel.app/docs/recipes/wordpress

Easy CMS is young: its first release was in September 2026 and its community is small. Every other product here has been in production longer, with more people answering questions.

## Sources

Checked against each product’s own site on 2026-10-17. Prices and versions change; follow the links for today’s.

- [WordPress: releases](https://wordpress.org/download/releases/)
- [WordPress: requirements](https://wordpress.org/about/requirements/)
- [WordPress.com: pricing](https://wordpress.com/pricing/)
- [WordPress: REST API](https://developer.wordpress.org/rest-api/)
- [WordPress: Thai translation](https://translate.wordpress.org/locale/th/)

---

<!-- https://easy-cms-website.vercel.app/why/compare/sanity.md -->

# Easy CMS vs Sanity

> Sanity pairs an MIT-licensed Studio, configured in code, with a hosted content backend you can't self-host. It is strong at real-time collaboration and visual editing, billed by seats and usage. Easy CMS keeps your content in your own database.

Page: https://easy-cms-website.vercel.app/why/compare/sanity

| Feature | Easy CMS | Sanity |
|---|---|---|
| License | MIT. Every feature is free, roles and SSO included | Studio is MIT; the Content Lake backend is a proprietary hosted service |
| Where it runs | Inside your Next.js or Nuxt app, or as a standalone server | Studio as a React app (it can be embedded in Next.js); content in Sanity's cloud |
| Content model | TypeScript config in your repo | Schemas in JavaScript or TypeScript |
| Databases | SQLite (file or Turso), Postgres (or PGlite) | Sanity's hosted Content Lake only |
| Language and runtime | Node.js 22.12+, TypeScript | Studio: React; backend: hosted |
| Hosted cloud | None. Self-host anywhere; one-click deploy to your own Vercel or Netlify | Free plan (20 seats, 10k documents); Growth $15 per seat per month |
| APIs | Typed Local API, REST; GraphQL and MCP as experimental plugins | GROQ, GraphQL |
| Admin in Thai | Complete: every admin string, and every docs page | Community plugin |
| Roles, SSO, audit | Roles, field permissions, SSO (Google, Microsoft, GitHub, OIDC), audit log: all MIT | See the pricing page |
| Ecosystem | Small: official plugins (SEO, forms, redirects, nested pages, multi-tenant, shop, GraphQL, MCP), a new community | Mature hosted platform; real-time collaboration and visual editing |
| Maturity | Version 1.4; 1.0 in October 2026, first released September 2026 | Studio version 6 since June 2026 |

## Choose Easy CMS when

- Content should live in your own database, under your own backups.
- You don't want bills that grow with seats, documents or API requests.
- You want to run everything yourself, on any host.

## Choose Sanity when

- Editors work together in real time on the same documents.
- You want no backend to operate at all.
- The free plan's limits fit your project.

Easy CMS is young: its first release was in September 2026 and its community is small. Every other product here has been in production longer, with more people answering questions.

## Sources

Checked against each product’s own site on 2026-10-17. Prices and versions change; follow the links for today’s.

- [Sanity: pricing](https://www.sanity.io/pricing)
- [Sanity: Studio](https://www.sanity.io/docs/studio)
- [Sanity: embedding the Studio](https://www.sanity.io/docs/studio/embedding-sanity-studio)
- [Sanity: self-hosting the Content Lake](https://www.sanity.io/answers/is-it-possible-to-self-host-the-content-lake)
- [Sanity: Studio languages](https://www.sanity.io/docs/studio/localizing-studio-ui)
