Skip to content

SEO ​

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.

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
npm install @easy-cms/plugin-seo
bash
pnpm add @easy-cms/plugin-seo
bash
yarn add @easy-cms/plugin-seo
bash
bun add @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). 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.
  • Search result preview below the group: the meta title (or the document's title), the address from generateURL and the description.

Options ​

OptionDefault
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.
generateTitle—Suggests a meta title.
generateDescription—Suggests a meta description.
generateImage—Suggests a share image: the id of a media document.
generateURL—The page's address, for the preview and the sitemap.
autoGeneratefalseFill 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.
localizedwith localizationOne value per content locale.
label'SEO'Label of the group.
robots{}Options of robots.txt for the standalone server, or false for none.
llms{}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 of a collection's pages.
indexNow—{ key }: tell search engines about changed pages with IndexNow.

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

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. Fetch the document with depth 1 or more so the share image has its URL.

vue
<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
import { jsonLdScript, seoMeta } from '@easy-cms/plugin-seo'

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 async function Page({ params }) {
  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
siteUrlThe site's address, to make the canonical and image URLs absolute. Default: config.admin.siteUrl, then config.serverURL.
configYour Easy CMS config, to read those and localization from.
urlThe page's address, or (doc, locale) => url. As a function it also makes the hreflang links, one per locale plus x-default.
localeThe page's content locale: og:locale and the canonical address.
locales, defaultLocaleFor 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.
authorThe 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.
title, description(doc) => text: fallbacks when the meta fields are empty.
siteNameog: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…

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
import { getEasyCMS } from '@easy-cms/next'
import { sitemap } from '@easy-cms/plugin-seo'
import config from '@/easy-cms.config'

export const dynamic = 'force-dynamic'

// The entries have the shape of MetadataRoute.Sitemap.
export default async function Sitemap() {
  return sitemap(await getEasyCMS(config))
}
ts
import { getEasyCMS } from '@easy-cms/next'
import { sitemapXml } from '@easy-cms/plugin-seo'
import config from '@/easy-cms.config'

export const dynamic = 'force-dynamic'

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 at /sitemap.xml.

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
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
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
configYour Easy CMS config: the admin and API paths, and admin.siteUrl.
siteUrlThe site's address for the Sitemap: line, if not admin.siteUrl.
sitemapThe sitemap's address, or false for no Sitemap: line.
disallowMore paths to keep out, e.g. ['/search'].
disallowAllKeep crawlers out of the whole site, e.g. on staging.
ai{ training?, search?, user? }: allow or block AI crawlers by group. All allowed by default.
rules[{ userAgent, allow?, disallow? }] for other crawlers.

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.

Structured data (JSON-LD) ​

Search engines read 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.

Next steps ​

Released under the MIT License.