Layers

SEO

@plutocms/seo adds SEO fields to a post, the head tags of the pages, a sitemap, robots.txt and an RSS feed.

@plutocms/seo is an add-on layer. It works with every backend (the Git CMS or Supabase), on a server and on a static site. It gives:

  • SEO fields: a post gets an SEO title, an SEO description, a social image and Hide from search engines. The post form shows a preview of the search result and of the social card while you type.
  • Head tags: each post page gets the canonical URL, the og:* tags and the twitter:* tags. A page that uses usePost needs no change.
  • Author tags: a post page gets <meta name="author">, article:author and JSON-LD for its authors. Each feed item gets <dc:creator>.
  • /sitemap.xml, /robots.txt and an RSS 2.0 feed at /feed.xml.

The blog template has it already.

Install

Install the package first. extends does not install it.

npm install @plutocms/seo

Add it next to the backend layer of your app. It does not extend core itself: the app or its backend layer brings core, so core loads one time. It needs @plutocms/core 0.57.0 or a newer version.

nuxt.config.ts
export default defineNuxtConfig({
  extends: ['@plutocms/core', '@plutocms/seo']
})

Set the address of the site

The sitemap, the feed and the canonical URL need the full address of the site. Set it in the environment:

NUXT_PUBLIC_WEBSITE_URL=https://my-blog.com
  • Vercel and Netlify give the address of the production site. Pluto uses it when you do not set the variable.
  • The GitHub Pages workflow of the admin sets it (Settings → Deploy).
  • On a server, without the variable, Pluto uses the address of the request.
A static site has no request. Without NUXT_PUBLIC_WEBSITE_URL, the sitemap and the feed are empty, the pages get no canonical URL, and the build shows a warning.

The fields

FieldIn the formUse
seoTitleSEO titleThe title in search results and on social cards. Empty: the title of the post.
seoDescriptionSEO descriptionThe text in search results. Empty: the excerpt.
seoImageSocial imageThe image of the social card. Empty: the cover image.
seoNoindexHide from search enginesAdds noindex to the page, and keeps the post out of the sitemap and the feed.

The preview counts the characters. It shows a warning above 60 characters for the title and above 160 for the description, because search engines cut longer text.

The public API gives the four fields with the post. seoImage is a media record, like cover.

Where the fields are stored

Git CMS: a seo: block in the front matter of the post file. Empty values are not written.

content/posts/hello-world.md
---
title: Hello, world
seo:
  title: A shorter title for Google
  description: One or two sentences for the search result.
  image: red-mug-k3j9a
  noindex: true
---

Supabase: the columns seo_title, seo_description, seo_image and seo_noindex of the posts table. @plutocms/supabase-blog 0.20.0 adds them in migration 006_post_seo.sql. Update supabase-blog before you add the SEO layer: a post save fails until the migration runs.

The head tags

On a post page, the layer sets:

  • the title and the description, from the SEO fields, else the title and the excerpt;
  • og:title, og:description, og:image, og:type and, for an article, the published and modified times;
  • twitter:card (a large image card when the post has an image), twitter:title, twitter:description, twitter:image;
  • robots: noindex, follow when Hide from search engines is on.

On every public page, the layer sets the canonical URL and og:url, og:site_name, the default social image and twitter:site from the settings, and a link to the RSS feed. The canonical URL of a list keeps ?page=N after the first page. The admin gets none of these tags.

A page that you write yourself, without usePost or definePlutoContent, calls usePlutoSeo:

usePlutoSeo(post, { type: 'post', ogType: 'article' })

The author tags

A post page with authors gets these tags. They need @plutocms/seo 0.2.0 or a newer version.

  • <meta name="author">, with the names of the authors joined with , .
  • One article:author for each author, on an article page. The value is the URL of the author page. If the author has no page, the value is the name.
  • A JSON-LD BlogPosting on an article page. The authors are Person items with name, url and image.
  • A <dc:creator> for each author, on each feed item.

An author page uses the bio as its description when it has no other description.

The routes

RouteContent
/sitemap.xmlThe home page and the paths of the settings, then each published item of a public type with a page. A hidden post is not in it.
/robots.txtAllows the site, disallows the admin paths and the paths of the settings, and gives the address of the sitemap.
/feed.xmlAn RSS 2.0 feed of the newest posts: the title, the link, the date and the description of each post.

A file with the same name in public/ replaces the route. In a static build (nuxt generate), the build writes the three files.

On a GitHub Pages project site, robots.txt is under /<repository>/. Search engines read robots.txt only at the root of the domain, so they do not read this file. A custom domain fixes it.

Settings

Change the settings in the app.config.ts of your app. Restart the dev server after a change.

app/app.config.ts
export default defineAppConfig({
  plutoSeo: {
    types: { post: true },
    image: '/og-default.png',
    twitterSite: '@myblog',
    indexable: true,
    sitemap: { paths: ['/about'], exclude: {} },
    robots: { disallow: ['/drafts'] },
    feed: { type: 'post', title: '', description: '', language: 'en', limit: 20 }
  }
})
SettingDefaultMeaning
types{ post: true }The content types that get the SEO fields.
image''The default social image: a site path or a full URL.
twitterSite''The twitter:site account, for example @myblog.
indexabletruefalse: every page gets noindex, and robots.txt disallows all. Use it for a staging site.
sitemap.paths['/']More site paths for the sitemap. Your list adds to the default.
sitemap.exclude{}Content types to keep out of the sitemap, for example { product: true }.
robots.disallow[]More paths to disallow. Your list adds to the default.
feed.type'post'The content type of the feed.
feed.title, feed.description''Empty: the site name and the site description.
feed.language''The language of the feed, for example en or pt-BR.
feed.limit20The number of posts in the feed, from 1 to 100.

SEO on another content type

Add the type to types. The layer adds the fields to the form. On the Git CMS, that is all. On Supabase, the table of the type needs the four columns too: add them in a migration of your app, with the same names and types as 006_post_seo.sql.

Limits

  • One feed, for one content type, with the description of each post. A full-text feed and Atom come later.
  • Redirects are not in the layer.