Headless CMS SEO

Payload CMS SEO: The Complete Third-Party Guide

Payload CMS has excellent documentation but fragmented SEO guidance. This vendor-neutral guide covers access control, the SEO plugin, Next.js integration, structured data, and the mistakes that silently tank your rankings.

Published April 15, 2026
12 min read

Payload CMS SEO: the complete third-party guide

Payload CMS has some of the best documentation in the headless CMS space. The problem — from an SEO perspective — is that the SEO guidance is scattered across plugin docs, framework integration guides, community examples, and GitHub discussions. There's no single resource that covers how to make a Payload-backed site rank well in search engines, from initial configuration through production auditing.

This guide is that resource. It's written from outside the Payload ecosystem, which means it's not constrained to documenting Payload's own features — it covers the gaps, the footguns, and the patterns that Payload's docs assume you already know.

If you're a developer working with Payload CMS (v2 or v3) and responsible for the site's SEO, this is the reference. If you're an SEO inheriting a Payload site, this will tell you where to look and what to check. For the broader headless CMS SEO context, see the Headless CMS SEO pillar guide.

Why Payload's SEO story is different from other headless CMSs

Payload occupies a unique position in the headless CMS market. Unlike Contentful or Sanity, which are hosted SaaS platforms, Payload is self-hosted and code-first. You define your content model in TypeScript, not in a visual UI. You control the database, the API, the rendering — everything.

This is great for developers. For SEO, it creates a specific set of challenges:

No managed frontend. Contentful and Sanity don't render pages — they deliver content via API, and your frontend framework handles rendering. Payload does the same, but because Payload v3 bundles Next.js as its admin UI framework, the line between "Payload" and "your frontend" blurs. Many teams use Payload's built-in Next.js app for both the admin panel and the public-facing site. This architectural choice has SEO implications.

Access control affects crawlability. Payload's access control system is granular and powerful. It's also the most common source of SEO failures on Payload sites. If your access control configuration blocks unauthenticated reads on collections that serve public content, search engine crawlers — which don't authenticate — get 403s or empty responses. Your site looks fine when logged in and invisible to Google.

The SEO plugin is optional. Payload ships an official SEO plugin (@payloadcms/plugin-seo) that adds title, description, and preview fields to your collections. But it's opt-in, not default. Sites that don't install it have no structured SEO metadata in the CMS — developers handle metadata entirely in the frontend code, where it's easy to forget or implement inconsistently.

Payload access control and crawlability

Access control is the first thing to check on any Payload-backed site, and the most common source of invisible SEO failures.

How Payload access control works

Every collection in Payload has an access property that defines who can perform CRUD operations. The relevant operation for SEO is read — whether unauthenticated requests can retrieve published content.

// A collection with public read access (correct for SEO)
const Posts: CollectionConfig = {
  slug: "posts",
  access: {
    read: () => true, // Anyone can read
  },
  fields: [
    // ...
  ],
}
// A collection with authenticated-only read access (breaks SEO)
const Posts: CollectionConfig = {
  slug: "posts",
  access: {
    read: ({ req: { user } }) => Boolean(user), // Only logged-in users
  },
  fields: [
    // ...
  ],
}

The second pattern is correct for internal content (drafts, admin-only pages) but catastrophic for public-facing pages. Google's crawler doesn't have a Payload user account.

The draft/published access pattern

Most Payload sites need a split access pattern: published content is publicly readable, draft content is restricted to authenticated users.

const Posts: CollectionConfig = {
  slug: "posts",
  versions: {
    drafts: true,
  },
  access: {
    read: ({ req: { user } }) => {
      // Authenticated users can read everything (including drafts)
      if (user) return true
      // Unauthenticated users can only read published documents
      return {
        _status: {
          equals: "published",
        },
      }
    },
  },
  fields: [
    // ...
  ],
}

This is the correct pattern for any collection that serves public content. Verify it's applied to every collection that renders pages on your site.

How to verify crawlability

The easiest verification is to make an unauthenticated API request and confirm the response includes your content:

# Replace with your Payload API URL and collection slug
curl -s https://your-site.com/api/posts | jq '.docs | length'

If the response returns zero documents or a 403, your access control is blocking public reads. Fix the access configuration before doing anything else — nothing else in this guide matters if Google can't read your content.

The Payload SEO plugin: setup and limitations

The official @payloadcms/plugin-seo adds SEO metadata fields to your collections. It's the recommended starting point for SEO on Payload sites.

Installation and configuration

pnpm add @payloadcms/plugin-seo
// payload.config.ts
import { seoPlugin } from "@payloadcms/plugin-seo"

export default buildConfig({
  plugins: [
    seoPlugin({
      collections: ["posts", "pages"],
      // Generate title and description from content fields
      generateTitle: ({ doc }) => `${doc.title} | Your Site`,
      generateDescription: ({ doc }) => doc.excerpt || "",
      // Generate preview URL for the SERP preview
      generateURL: ({ doc }) => `https://your-site.com/${doc.slug}`,
    }),
  ],
})

The plugin adds three fields to each configured collection:

  • meta.title — The page's title tag content
  • meta.description — The page's meta description
  • meta.image — An open graph image (optional)

It also renders a SERP preview in the admin UI so editors can see how the page will appear in Google.

What the plugin does well

  • Gives editors a dedicated place to write SEO metadata (instead of repurposing content fields)
  • Provides title and description length validation in the admin panel
  • Renders a visual SERP preview that non-technical editors understand
  • Supports auto-generation from content fields as a fallback

What the plugin does not do

  • No structured data (JSON-LD). The plugin handles title and description but does not generate structured data markup. You need to implement schema markup in your frontend.
  • No canonical URL management. You need to handle canonicals in your rendering layer.
  • No sitemap generation. You need a separate solution for XML sitemaps.
  • No robots meta tag control. The plugin doesn't add noindex/nofollow controls per page. If you need page-level index control, add a custom field.
  • No internal linking analysis. The plugin focuses on per-page metadata, not site-wide SEO signals.

The plugin is a starting point, not a complete solution. The sections below cover what you need to build on top of it.

SEO plugin vs custom fields: which to use

Some Payload developers skip the plugin and implement SEO fields manually. This works but has tradeoffs.

Use the plugin when:

  • Your team includes non-technical editors who benefit from the SERP preview
  • You want consistent field naming across collections
  • You want the auto-generation fallback for titles and descriptions

Use custom fields when:

  • You need fields the plugin doesn't provide (canonical URL, robots directives, structured data hints)
  • You have specific validation requirements beyond character count
  • You're already deep into a custom field architecture and don't want plugin dependencies

The pragmatic approach is to use the plugin for the basics (title, description, OG image) and add custom fields for everything else (canonical, robots, schema type). This gives editors the SERP preview while giving developers the control they need.

Handling Next.js + Payload sites

The most common Payload deployment pattern is Next.js as the frontend, often sharing the same codebase. This pattern works well but creates specific SEO patterns to follow.

Metadata with Next.js App Router

Next.js App Router uses the generateMetadata function to produce <head> content. For a Payload-backed page:

// app/posts/[slug]/page.tsx
import { getPayloadClient } from "@/lib/payload"
import type { Metadata } from "next"

export async function generateMetadata({
  params,
}: {
  params: { slug: string }
}): Promise<Metadata> {
  const payload = await getPayloadClient()
  const { docs } = await payload.find({
    collection: "posts",
    where: { slug: { equals: params.slug } },
    limit: 1,
  })

  const post = docs[0]
  if (!post) return {}

  return {
    title: post.meta?.title || post.title,
    description: post.meta?.description || post.excerpt,
    openGraph: {
      title: post.meta?.title || post.title,
      description: post.meta?.description || post.excerpt,
      images: post.meta?.image ? [{ url: post.meta.image.url }] : [],
    },
  }
}

The critical pattern here: generateMetadata runs on the server, separately from the page component. This means the metadata and the page content are fetched independently. If they query different data (a common bug when copy-pasting between files), the metadata can diverge from the page content without anyone noticing.

XML sitemap generation

Payload doesn't generate XML sitemaps. You need to generate them from your Next.js frontend by querying Payload's API:

// app/sitemap.ts
import { getPayloadClient } from "@/lib/payload"
import type { MetadataRoute } from "next"

export default async function sitemap(): Promise<MetadataRoute.Sitemap> {
  const payload = await getPayloadClient()

  const posts = await payload.find({
    collection: "posts",
    where: { _status: { equals: "published" } },
    limit: 0, // Return all
  })

  const pages = await payload.find({
    collection: "pages",
    where: { _status: { equals: "published" } },
    limit: 0,
  })

  const baseUrl = "https://your-site.com"

  return [
    ...posts.docs.map((post) => ({
      url: `${baseUrl}/posts/${post.slug}`,
      lastModified: new Date(post.updatedAt),
      changeFrequency: "weekly" as const,
      priority: 0.7,
    })),
    ...pages.docs.map((page) => ({
      url: `${baseUrl}/${page.slug}`,
      lastModified: new Date(page.updatedAt),
      changeFrequency: "monthly" as const,
      priority: 0.8,
    })),
  ]
}

Ensure your sitemap only includes published pages — querying without the _status filter will include drafts, which shouldn't be indexed.

Structured data patterns

Payload's content model maps naturally to JSON-LD structured data. The key is to generate the schema from your Payload data rather than hardcoding it:

// components/StructuredData.tsx
export function ArticleSchema({
  post,
}: {
  post: Post
}) {
  const schema = {
    '@context': 'https://schema.org',
    '@type': 'Article',
    headline: post.title,
    datePublished: post.createdAt,
    dateModified: post.updatedAt,
    author: {
      '@type': 'Person',
      name: post.author?.name,
    },
    description: post.meta?.description || post.excerpt,
  }

  return (
    <script
      type="application/ld+json"
      dangerouslySetInnerHTML={{
        __html: JSON.stringify(schema),
      }}
    />
  )
}

Map the structured data type to the content type:

Payload collectionSchema.org typeKey properties
Blog postsArticleheadline, datePublished, author
Product pagesProductname, description, offers
FAQ pagesFAQPagemainEntity (Question/Answer)
How-to guidesHowTostep, totalTime
Team/about pagesOrganization or Personname, description, url

Common Payload SEO mistakes

These are the patterns that appear repeatedly on Payload-backed sites during audits. Each one silently damages search performance without producing obvious errors.

Mistake 1: Using API routes as page URLs

Payload exposes a REST API at /api/[collection]. Some developers link internal pages to these API endpoints instead of the rendered frontend URLs. Search engines will index the raw JSON responses if they can crawl them, creating duplicate content issues where the JSON API response competes with the actual page.

Fix: Ensure all internal links point to rendered pages, not API endpoints. Add noindex headers to API routes, or block them in robots.txt:

# robots.txt
User-agent: *
Disallow: /api/

Mistake 2: Missing metadata on collection list pages

Individual post pages often have proper metadata (via the SEO plugin). But collection list pages — /blog, /resources, /case-studies — are frequently rendered with default or missing metadata because they're coded as layout components rather than content pages.

Fix: Treat every page that appears in your sitemap as a page that needs a title tag, meta description, and canonical URL — including list pages and category pages.

Mistake 3: Drafts leaking into the rendered site

If your frontend queries Payload without filtering by _status: 'published', draft content appears on the live site. This content is often incomplete, unedited, or placeholder text. Google will crawl and index it.

Fix: Every frontend query that renders public pages must include the published status filter. Verify this by searching your codebase for all payload.find() calls and confirming each one includes the appropriate status filter.

Mistake 4: Rich text fields rendering without heading hierarchy

Payload's rich text editor lets editors insert headings at any level. Without constraints, editors create pages with multiple H1s, H4s appearing without preceding H3s, or heading hierarchies that skip levels. This doesn't break anything visually but creates a confusing document structure for search engines.

Fix: Configure the rich text editor to limit available heading levels. If the page already has an H1 from the title field, restrict the rich text editor to H2-H4 only.

Mistake 5: Image fields without alt text

Payload's upload collections store images but don't enforce alt text by default. Without a required alt field on your media collection, editors upload images without accessibility or SEO metadata.

Fix: Add a required alt text field to your media collection:

const Media: CollectionConfig = {
  slug: "media",
  upload: true,
  fields: [
    {
      name: "alt",
      type: "text",
      required: true,
      admin: {
        description: "Describe the image for screen readers and search engines",
      },
    },
  ],
}

How to audit a Payload-backed site with Evergreen

Evergreen's crawler treats Payload-backed sites the same as any other — it crawls the rendered frontend, not the CMS API. This means the audit captures exactly what Google sees: the HTML output after Next.js server rendering, with whatever metadata, structured data, and content the rendering layer produces.

The content audit table shows every crawled page with its title tag, meta description, H1, word count, internal links, and HTTP status. For a Payload site, common findings include:

  • Pages where the SEO plugin metadata was left at the auto-generated default instead of being customized by editors
  • Collection list pages with missing or generic metadata
  • Pages where the structured data doesn't match the visible content (a mismatch between what the schema says and what the page shows)
  • Orphaned pages — content that exists in Payload but isn't linked from any navigation or internal link

Running the audit on a Payload site takes the same time as any other site. Add the URL, let the crawler run, and the content audit table populates with every page and its SEO attributes. Filter to pages with missing meta descriptions, empty H1s, or thin content to find the highest-priority fixes.

Audit your Payload site → Start free

Related Topics in Headless CMS SEO