← Back to Course Index

Track B — Payload (Headless / Code-First) Technical SEO

Phase 4 · Platform Track B

This track teaches you to own technical SEO completely in a modern TypeScript, headless, Next.js-native stack. There is no plugin to save you here. Every meta tag, every canonical, every sitemap entry, every structured-data block — you write it, in code, on purpose. That is exactly why Phase 0's JavaScript depth mattered.

What is Payload? Payload is a code-first, TypeScript headless CMS. In v3 it runs natively inside a Next.js app — the CMS and your React front end share a single project and a single codebase. Content is modeled in TypeScript config files, stored in a database (MongoDB or Postgres), and delivered to your Next.js pages via Payload's local API or REST/GraphQL endpoints. SEO is then implemented by you in the Next.js layer using App Router conventions.


Objectives


1. Payload Architecture — How Content Becomes HTML

Before writing a single line of SEO code you must internalise the data flow. Trace it carefully:

Editor types in Payload Admin UI
        ↓
Content saved to database (MongoDB / Postgres)
        ↓
Next.js page or route handler calls Payload Local API (server-side, zero HTTP overhead)
        ↓
React Server Component renders HTML on the server
        ↓
HTML (including <title>, meta, canonical, JSON-LD) is sent to the browser and to Googlebot
        ↓
React hydrates on the client (interactive JS attaches to existing HTML)

The critical insight: because Payload v3 lives inside the same Next.js process, your pages can query Payload data during server-side rendering without an HTTP round trip. This is the Local API pattern:

// app/blog/[slug]/page.tsx  (React Server Component)
import { getPayload } from 'payload'
import config from '@payload-config'

export default async function BlogPost({ params }: { params: { slug: string } }) {
  const payload = await getPayload({ config })

  const result = await payload.find({
    collection: 'posts',
    where: { slug: { equals: params.slug } },
    depth: 2,
  })

  const post = result.docs[0]
  // post.title, post.content, post.seo.metaTitle, etc. are available here
  // on the server, before any HTML is sent
  return <article>{/* render post */}</article>
}

The key SEO implication: because the query runs server-side inside a React Server Component or an async function, Googlebot receives the content, meta tags, and structured data in the initial HTML response — not via a client-side fetch that requires JavaScript execution.

Core Payload Concepts You Must Know


2. The Official SEO Plugin — @payloadcms/plugin-seo

What it does

The plugin adds a structured SEO field group to any collection or global you configure. Content authors see fields for meta title, meta description, OG image, and a live character-count preview. It handles the content-authoring layer so you can focus on the rendering layer.

Installation and registration

npm install @payloadcms/plugin-seo
// payload.config.ts
import { buildConfig } from 'payload'
import { seoPlugin } from '@payloadcms/plugin-seo'

export default buildConfig({
  collections: [/* ... */],
  plugins: [
    seoPlugin({
      collections: ['posts', 'pages', 'products'],
      globals: ['site-settings'],
      uploadsCollection: 'media',
      generateTitle: ({ doc }) => `${doc.title} | My Site`,
      generateDescription: ({ doc }) => doc.excerpt,
      generateURL: ({ doc, collectionSlug }) =>
        `https://example.com/${collectionSlug}/${doc.slug}`,
    }),
  ],
})

After registration, each configured collection document gains a meta group containing:

Scope and limits of the plugin

The plugin is a content-authoring convenience, not a rendering solution. It does not automatically inject <title> or <meta> tags into your pages. That is your job in the Next.js layer. The plugin also does not handle:

Everything beyond the fields themselves must be implemented in code — which the rest of this module covers.


3. Modeling SEO Into Your Schema

SEO requirements must be first-class citizens in your Payload collection definitions, not an afterthought. Design them up front.

Extended SEO field group

// collections/Posts.ts
import type { CollectionConfig } from 'payload'

export const Posts: CollectionConfig = {
  slug: 'posts',
  fields: [
    { name: 'title', type: 'text', required: true },
    { name: 'slug', type: 'text', unique: true, required: true },
    { name: 'excerpt', type: 'textarea' },
    { name: 'content', type: 'richText' },
    { name: 'publishedAt', type: 'date' },
    // The plugin adds meta.title / meta.description / meta.image automatically.
    // Add anything the plugin does not cover:
    {
      name: 'seoOverrides',
      type: 'group',
      label: 'SEO Overrides',
      fields: [
        {
          name: 'canonicalUrl',
          type: 'text',
          label: 'Canonical URL Override',
          admin: {
            description: 'Leave blank to use the default URL. Set only when this page consolidates another URL.',
          },
        },
        {
          name: 'robotsDirective',
          type: 'select',
          label: 'Robots',
          defaultValue: 'index, follow',
          options: [
            { label: 'Index, Follow (default)', value: 'index, follow' },
            { label: 'NoIndex, Follow', value: 'noindex, follow' },
            { label: 'NoIndex, NoFollow', value: 'noindex, nofollow' },
          ],
        },
        {
          name: 'schemaType',
          type: 'select',
          label: 'Structured Data Type',
          options: [
            { label: 'Article', value: 'Article' },
            { label: 'BlogPosting', value: 'BlogPosting' },
            { label: 'NewsArticle', value: 'NewsArticle' },
          ],
        },
      ],
    },
  ],
}

A site-wide SEO global

// globals/SiteSettings.ts
import type { GlobalConfig } from 'payload'

export const SiteSettings: GlobalConfig = {
  slug: 'site-settings',
  fields: [
    { name: 'siteName', type: 'text' },
    { name: 'siteUrl', type: 'text' },
    { name: 'defaultMetaDescription', type: 'textarea' },
    { name: 'twitterHandle', type: 'text' },
    {
      name: 'organizationSchema',
      type: 'group',
      label: 'Organization Structured Data',
      fields: [
        { name: 'legalName', type: 'text' },
        { name: 'logo', type: 'upload', relationTo: 'media' },
        { name: 'sameAs', type: 'array', fields: [{ name: 'url', type: 'text' }] },
      ],
    },
  ],
}

4. Rendering Strategy With Next.js App Router

The rendering strategy you choose for each route type determines your SEO ceiling. There is no universal right answer — it depends on how frequently content changes and how critical indexation latency is.

Strategy decision matrix

Using generateStaticParams for SSG

// app/blog/[slug]/page.tsx
export async function generateStaticParams() {
  const payload = await getPayload({ config })
  const posts = await payload.find({
    collection: 'posts',
    limit: 1000,
    select: { slug: true },
  })
  return posts.docs.map((post) => ({ slug: post.slug }))
}

// This tells Next.js to pre-build a static HTML file for every slug at deploy time.
// Googlebot will always receive real HTML — no rendering queue delay.

ISR pattern

// In your page data-fetching:
const result = await payload.find({
  collection: 'posts',
  where: { slug: { equals: params.slug } },
  // Tell Next.js to revalidate the cached page every hour:
  // This is set on the route segment config, not the query itself.
})

// app/blog/[slug]/page.tsx — route segment config
export const revalidate = 3600 // seconds

Handling draft/unpublished content

A common soft-404 source: a Payload document exists but is not published, and the front end returns HTTP 200 with an empty or minimal page. Fix this explicitly:

import { notFound } from 'next/navigation'

const result = await payload.find({
  collection: 'posts',
  where: {
    slug: { equals: params.slug },
    _status: { equals: 'published' }, // only fetch published docs
  },
})

if (!result.docs.length) {
  notFound() // triggers Next.js 404 — returns real HTTP 404 to crawlers
}

5. generateMetadata — Dynamic, Per-Page SEO Tags

This is the heart of headless SEO on Next.js. The generateMetadata export on a page or layout returns a Metadata object that Next.js serialises into <head> tags server-side, before any HTML is sent.

Full implementation example

// app/blog/[slug]/page.tsx
import type { Metadata } from 'next'
import { getPayload } from 'payload'
import config from '@payload-config'
import { notFound } from 'next/navigation'

type Props = { params: { slug: string } }

export async function generateMetadata({ params }: Props): Promise<Metadata> {
  const payload = await getPayload({ config })
  const result = await payload.find({
    collection: 'posts',
    where: {
      slug: { equals: params.slug },
      _status: { equals: 'published' },
    },
    depth: 1,
  })

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

  const siteUrl = 'https://example.com'
  const postUrl = `${siteUrl}/blog/${post.slug}`

  // Prefer editor override, fall back to generated values
  const metaTitle = post.meta?.title ?? `${post.title} | My Site`
  const metaDesc = post.meta?.description ?? post.excerpt ?? ''
  const ogImageUrl = post.meta?.image?.url ?? `${siteUrl}/og-default.jpg`
  const canonical = post.seoOverrides?.canonicalUrl || postUrl
  const robots = post.seoOverrides?.robotsDirective ?? 'index, follow'

  return {
    title: metaTitle,
    description: metaDesc,
    robots,
    alternates: {
      canonical,
    },
    openGraph: {
      title: metaTitle,
      description: metaDesc,
      url: canonical,
      type: 'article',
      publishedTime: post.publishedAt,
      images: [{ url: ogImageUrl, width: 1200, height: 630 }],
    },
    twitter: {
      card: 'summary_large_image',
      title: metaTitle,
      description: metaDesc,
      images: [ogImageUrl],
    },
  }
}

Root layout metadata — site-wide defaults

// app/layout.tsx
import type { Metadata } from 'next'
import { getPayload } from 'payload'
import config from '@payload-config'

export async function generateMetadata(): Promise<Metadata> {
  const payload = await getPayload({ config })
  const settings = await payload.findGlobal({ slug: 'site-settings' })

  return {
    metadataBase: new URL(settings.siteUrl),
    title: {
      default: settings.siteName,
      template: `%s | ${settings.siteName}`, // child pages use %s
    },
    description: settings.defaultMetaDescription,
    openGraph: {
      siteName: settings.siteName,
    },
  }
}

Hreflang for internationalised sites

// generateMetadata for a localised page
const locales = ['en', 'fr', 'de']
const languages: Record<string, string> = {}

for (const locale of locales) {
  languages[locale] = `https://example.com/${locale}/blog/${params.slug}`
}
languages['x-default'] = `https://example.com/en/blog/${params.slug}`

return {
  alternates: {
    canonical: `https://example.com/${params.locale}/blog/${params.slug}`,
    languages,
  },
}

Next.js serialises the languages object into <link rel="alternate" hreflang="..."> tags in <head> — server-rendered, visible in raw HTML.


6. Dynamic sitemap.xml From Payload Data

Next.js App Router has a first-class sitemap.ts file convention. Place it at app/sitemap.ts and export a default async function returning an array of URL objects. Next.js serialises it to valid XML at the /sitemap.xml route.

// app/sitemap.ts
import type { MetadataRoute } from 'next'
import { getPayload } from 'payload'
import config from '@payload-config'

export default async function sitemap(): Promise<MetadataRoute.Sitemap> {
  const payload = await getPayload({ config })
  const siteUrl = 'https://example.com'

  // Fetch all published posts
  const posts = await payload.find({
    collection: 'posts',
    where: { _status: { equals: 'published' } },
    limit: 5000,
    select: { slug: true, updatedAt: true },
  })

  // Fetch all published pages
  const pages = await payload.find({
    collection: 'pages',
    where: { _status: { equals: 'published' } },
    limit: 1000,
    select: { slug: true, updatedAt: true },
  })

  const postUrls: MetadataRoute.Sitemap = posts.docs.map((post) => ({
    url: `${siteUrl}/blog/${post.slug}`,
    lastModified: new Date(post.updatedAt),
    changeFrequency: 'weekly',
    priority: 0.8,
  }))

  const pageUrls: MetadataRoute.Sitemap = pages.docs.map((page) => ({
    url: `${siteUrl}/${page.slug}`,
    lastModified: new Date(page.updatedAt),
    changeFrequency: 'monthly',
    priority: page.slug === 'home' ? 1.0 : 0.6,
  }))

  return [
    { url: siteUrl, lastModified: new Date(), priority: 1.0 },
    ...pageUrls,
    ...postUrls,
  ]
}

Important: lastModified must reflect the actual time the content last changed, not the current timestamp. Payload's updatedAt field gives you this automatically. Inflating lastModified damages your crawl budget — Google learns to distrust your sitemap.

Sitemap index for large sites

If you have more than ~50,000 URLs, split by collection or content type:

// app/sitemap.ts — returns a sitemap index pointing to sub-sitemaps
export default async function sitemap(): Promise<MetadataRoute.Sitemap> {
  return [
    { url: 'https://example.com/sitemap/pages.xml' },
    { url: 'https://example.com/sitemap/posts.xml' },
    { url: 'https://example.com/sitemap/products.xml' },
  ]
}

// app/sitemap/posts.xml/route.ts — a route handler generating the posts sub-sitemap
// ... fetch and return XML manually for sub-sitemaps

7. Dynamic robots.txt

Use the Next.js robots.ts file convention:

// app/robots.ts
import type { MetadataRoute } from 'next'

export default function robots(): MetadataRoute.Robots {
  const siteUrl = 'https://example.com'
  return {
    rules: [
      {
        userAgent: '*',
        allow: '/',
        disallow: [
          '/admin',        // Payload admin UI — must never be indexed
          '/api/',         // Payload REST API endpoints
          '/preview/',     // Draft preview routes
        ],
      },
    ],
    sitemap: `${siteUrl}/sitemap.xml`,
    host: siteUrl,
  }
}

Critical rule: Never block /admin from being crawled would be fine for a public tool, but never allow it to be indexed. The disallow above prevents crawling of the admin UI entirely. Separately, confirm the Payload admin has a noindex directive in its own layout.


8. Programmatic Structured Data (JSON-LD)

Generate JSON-LD server-side in your React Server Components from Payload collection data. This directly applies the schema architecture you designed in Phase 3.

A reusable JSON-LD component

// components/JsonLd.tsx
// A simple, zero-client-JS component that injects a script tag into the HTML.
type Props = { data: Record<string, unknown> }

export function JsonLd({ data }: Props) {
  return (
    <script
      type="application/ld+json"
      dangerouslySetInnerHTML={{ __html: JSON.stringify(data) }}
    />
  )
}

Building the schema object from Payload data

// app/blog/[slug]/page.tsx
import { JsonLd } from '@/components/JsonLd'

export default async function BlogPost({ params }) {
  const payload = await getPayload({ config })
  const settings = await payload.findGlobal({ slug: 'site-settings' })

  const result = await payload.find({
    collection: 'posts',
    where: { slug: { equals: params.slug }, _status: { equals: 'published' } },
    depth: 2,
  })
  const post = result.docs[0]
  if (!post) notFound()

  const siteUrl = settings.siteUrl
  const postUrl = `${siteUrl}/blog/${post.slug}`

  const articleSchema = {
    '@context': 'https://schema.org',
    '@type': post.seoOverrides?.schemaType ?? 'BlogPosting',
    '@id': postUrl,
    headline: post.title,
    description: post.meta?.description ?? post.excerpt,
    datePublished: post.publishedAt,
    dateModified: post.updatedAt,
    url: postUrl,
    author: {
      '@type': 'Person',
      name: post.author?.name,
      url: post.author?.profileUrl,
    },
    publisher: {
      '@type': 'Organization',
      '@id': siteUrl,
      name: settings.siteName,
      logo: {
        '@type': 'ImageObject',
        url: settings.organizationSchema?.logo?.url,
      },
    },
    image: {
      '@type': 'ImageObject',
      url: post.meta?.image?.url,
      width: post.meta?.image?.width,
      height: post.meta?.image?.height,
    },
    breadcrumb: {
      '@type': 'BreadcrumbList',
      itemListElement: [
        { '@type': 'ListItem', position: 1, name: 'Home', item: siteUrl },
        { '@type': 'ListItem', position: 2, name: 'Blog', item: `${siteUrl}/blog` },
        { '@type': 'ListItem', position: 3, name: post.title, item: postUrl },
      ],
    },
  }

  const orgSchema = {
    '@context': 'https://schema.org',
    '@type': 'Organization',
    '@id': siteUrl,
    name: settings.organizationSchema?.legalName,
    url: siteUrl,
    logo: settings.organizationSchema?.logo?.url,
    sameAs: settings.organizationSchema?.sameAs?.map((s: { url: string }) => s.url),
  }

  return (
    <>
      <JsonLd data={articleSchema} />
      <JsonLd data={orgSchema} />
      <article>{/* render post.content */}</article>
    </>
  )
}

The <JsonLd> component renders server-side. The <script type="application/ld+json"> tag appears in the raw HTML response — Googlebot reads it without executing any JavaScript.


9. Performance in Next.js — Hitting Core Web Vitals

LCP — Largest Contentful Paint

The hero image is almost always the LCP element on content pages. Use next/image:

import Image from 'next/image'

<Image
  src={post.heroImage.url}
  alt={post.heroImage.alt}
  width={post.heroImage.width}
  height={post.heroImage.height}
  priority        // disables lazy-loading for above-the-fold images — critical for LCP
  sizes="(max-width: 768px) 100vw, 1200px"
  quality={85}
/>

next/image automatically: serves WebP/AVIF, generates a srcset, prevents CLS by reserving space, and CDN-caches resized images. The priority prop injects a <link rel="preload"> for the image in <head>. Never use priority on images below the fold — it wastes bandwidth and degrades LCP by competing with the actual hero.

CLS — Cumulative Layout Shift

// app/layout.tsx — self-hosted font with zero layout shift
import { Inter } from 'next/font/google'

const inter = Inter({
  subsets: ['latin'],
  display: 'swap',  // fallback font shown until Inter loads — no invisible text
  variable: '--font-inter',
})

INP — Interaction to Next Paint

TTFB and caching strategy


10. Internationalisation — Payload Locales + Next.js i18n + Hreflang

Step 1: Configure Payload localisation

// payload.config.ts
export default buildConfig({
  localization: {
    locales: ['en', 'fr', 'de'],
    defaultLocale: 'en',
    fallback: true,
  },
  // ...
})

With fallback: true, if a field is not translated in the requested locale, Payload falls back to the default locale instead of returning null. This prevents empty-page soft 404s during partial translation rollouts.

Step 2: Next.js routing for locales

// Using App Router subfolder pattern: /en/blog/slug, /fr/blog/slug
// app/[locale]/blog/[slug]/page.tsx

export async function generateStaticParams() {
  const payload = await getPayload({ config })
  const locales = ['en', 'fr', 'de']
  const params = []

  for (const locale of locales) {
    const posts = await payload.find({
      collection: 'posts',
      locale,
      where: { _status: { equals: 'published' } },
      select: { slug: true },
    })
    posts.docs.forEach((post) => {
      params.push({ locale, slug: post.slug })
    })
  }
  return params
}

Step 3: Hreflang in generateMetadata

export async function generateMetadata({ params }): Promise<Metadata> {
  const { locale, slug } = params
  const siteUrl = 'https://example.com'

  // Fetch the post in the current locale
  const payload = await getPayload({ config })
  const result = await payload.find({
    collection: 'posts',
    locale,
    where: { slug: { equals: slug }, _status: { equals: 'published' } },
  })
  const post = result.docs[0]
  if (!post) return {}

  const languages = {
    en: `${siteUrl}/en/blog/${slug}`,
    fr: `${siteUrl}/fr/blog/${slug}`,
    de: `${siteUrl}/de/blog/${slug}`,
    'x-default': `${siteUrl}/en/blog/${slug}`,
  }

  return {
    title: post.meta?.title ?? post.title,
    alternates: {
      canonical: `${siteUrl}/${locale}/blog/${slug}`,
      languages,
    },
  }
}

11. Crawl and Render QA — Proving Crawlers Get Everything

The entire purpose of SSG/SSR is that Googlebot receives real, complete HTML without executing JavaScript. You must verify this — not assume it.

The verification checklist

Running a raw HTML audit with curl

# Check that the title, canonical, and a schema type appear in raw HTML
curl -sL "https://example.com/blog/my-post" | grep -E '<title|canonical|application/ld\+json'

# Expected output should show all three — if any are missing they are client-rendered
# and invisible to Googlebot on the first crawl pass.

Common headless SEO failure modes


12. Hands-On Projects

Project 1 — Stand up, configure, and verify metadata

  1. Create a new Next.js project and install Payload v3 into it following the official create-payload-app flow.
  2. Create a posts collection with title, slug, excerpt, content, and publishedAt fields.
  3. Install @payloadcms/plugin-seo and register it for the posts collection.
  4. Add a seoOverrides group with canonical override and robots directive fields.
  5. Implement generateMetadata on the post page that outputs: unique title, meta description, canonical, OG tags, and Twitter card — all sourced from Payload data.
  6. Create three posts in the Payload admin. View Source on each and confirm all five elements appear in raw HTML. No two posts should share a title or description.

Project 2 — Dynamic sitemap and robots.txt

  1. Implement app/sitemap.ts that queries Payload for all published posts and pages and returns accurate lastModified values from updatedAt.
  2. Implement app/robots.ts that disallows /admin, /api, and /preview.
  3. Verify /sitemap.xml is valid XML. Run it through Google Search Console > Sitemaps to confirm it is accepted with zero errors.
  4. Add a new post and trigger ISR revalidation. Confirm the new URL appears in the sitemap without a full rebuild.

Project 3 — Programmatic JSON-LD per page

  1. Build a SiteSettings global in Payload with siteName, siteUrl, and an organizationSchema group.
  2. On your post page, generate a BlogPosting JSON-LD block using Payload data. Include author, publisher (referencing the Org by @id), image, and BreadcrumbList.
  3. Generate an Organization JSON-LD block from the global data and output it on every page via the root layout.
  4. Validate both blocks using the Schema Markup Validator and the Rich Results Test. Achieve zero errors.
  5. Confirm via View Source that both <script type="application/ld+json"> blocks appear in raw HTML.

Milestone — Shipping a Fully Optimised Payload + Next.js Site

You have completed Track B when you can deliver a site that satisfies all of the following without assistance:


Key Concepts Summary