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.
generateMetadata, generateStaticParams, route handlers — to produce correct, crawlable outputsitemap.xml and robots.txt from live Payload datahreflang outputBefore 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.
text, richText, relationship, array, group, tabs, checkbox, select, and more.beforeChange, afterRead, etc.) that let you derive or validate data at the API layer.@payloadcms/plugin-seoThe 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.
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:
meta.title — overrides the auto-generated titlemeta.description — overrides the auto-generated descriptionmeta.image — relationship to the media collection (used for OG)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.
SEO requirements must be first-class citizens in your Payload collection definitions, not an afterthought. Design them up front.
// 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' },
],
},
],
},
],
}
// 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' }] },
],
},
],
}
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.
revalidate interval. Best for: product pages, news articles where content changes but not on every request. Use next: { revalidate: 3600 } on your fetch/query.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.
// 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
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
}
generateMetadata — Dynamic, Per-Page SEO TagsThis 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.
// 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],
},
}
}
// 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,
},
}
}
// 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.
sitemap.xml From Payload DataNext.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.
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
robots.txtUse 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.
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.
// 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) }}
/>
)
}
// 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.
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.
width and height (or use fill with a positioned wrapper) on next/image. The component reserves the space before the image loads.next/font for web fonts. It downloads fonts at build time, self-hosts them, and injects CSS variables — eliminating FOUT and the layout shift caused by font-swap.// 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',
})
'use client' directive only to components that need browser APIs or interactivity.next/dynamic and ssr: false only when appropriate.startTransition for non-urgent state updates.Cache-Control headers. Vercel handles this automatically for SSG/ISR; custom infrastructure requires explicit configuration.// 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.
// 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
}
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,
},
}
}
The entire purpose of SSG/SSR is that Googlebot receives real, complete HTML without executing JavaScript. You must verify this — not assume it.
<title>, meta description, canonical, and robots are populated on every template type, not just manually authored pages.# 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.
generateMetadata must be in a Server Component file (no 'use client' directive). If your page accidentally becomes a client component, metadata is not exported and <head> tags are missing.'use client' component with useEffect — injecting the script tag via useEffect means it only appears after JavaScript runs. Move it to a Server Component.notFound() calls — a page that returns 200 with "Post not found" text is a soft 404. Googlebot indexes it as real content._status: 'published' in production data fetches./admin route must be blocked in robots.txt and should also carry a noindex header or meta tag as a belt-and-braces measure.create-payload-app flow.posts collection with title, slug, excerpt, content, and publishedAt fields.@payloadcms/plugin-seo and register it for the posts collection.seoOverrides group with canonical override and robots directive fields.generateMetadata on the post page that outputs: unique title, meta description, canonical, OG tags, and Twitter card — all sourced from Payload data.app/sitemap.ts that queries Payload for all published posts and pages and returns accurate lastModified values from updatedAt.app/robots.ts that disallows /admin, /api, and /preview./sitemap.xml is valid XML. Run it through Google Search Console > Sitemaps to confirm it is accepted with zero errors.SiteSettings global in Payload with siteName, siteUrl, and an organizationSchema group.BlogPosting JSON-LD block using Payload data. Include author, publisher (referencing the Org by @id), image, and BreadcrumbList.Organization JSON-LD block from the global data and output it on every page via the root layout.<script type="application/ld+json"> blocks appear in raw HTML.You have completed Track B when you can deliver a site that satisfies all of the following without assistance:
<title>, meta description, canonical, OG tags, and correct robots directive — all in raw HTML./sitemap.xml is generated dynamically from Payload data, contains only published URLs, and has accurate lastModified values. GSC accepts it with no errors./robots.txt correctly disallows the Payload admin, API, and preview routes.Organization entity is present site-wide with @id cross-referencing.next/image with priority. Fonts use next/font. Lighthouse scores in the green on a simulated connection. No CLS from images or fonts.<link rel="alternate" hreflang> tags in raw HTML, with x-default set.generateMetadata — Next.js App Router's mechanism for server-rendered <head> tags; the headless equivalent of a WordPress SEO plugin's front-end outputgenerateStaticParams — pre-generates static routes at build time; eliminates render-queue delays for Googlebotnext/image with priority — automated LCP and CLS optimisation for imagesnotFound() — the correct way to return a real HTTP 404 for missing or unpublished content