Search Engine Optimisation
The boilerplate implements SEO through three integrated layers: Payload admin fields, centralised slug configuration, and Next.js metadata generation. This page covers how each layer works and how to add SEO to a new collection.
Overview
Section titled “Overview”| Layer | Purpose |
|---|---|
| Payload Admin | SEO fields via @payloadcms/plugin-seo |
| Slug Config | Centralised URL path management |
| Frontend | Next.js metadata, sitemaps, robots.txt |
Slug Configuration
Section titled “Slug Configuration”import { defineSlugs } from '@/lib/config/slugs';
export const slugs = defineSlugs({ pages: '/', posts: '/posts/',});This maps each collection to its URL prefix. Having it in one place means you change a URL structure once and it updates across metadata, sitemaps, and internal links.
The getSlugPath() utility uses this map to build full paths:
export function getSlugPath( collection: SlugKeys, slug: string | null | undefined,) { const slugPrefix = slugs[collection]; const target = `${slugPrefix}${slug}`; return target.replace(/\/+/g, '/').trim();}The replace call collapses accidental double slashes — when slugPrefix is / and slug is home, you get /home not //home.
SEO Fields
Section titled “SEO Fields”Each content collection includes an SEO tab:
// src/collections/pages.ts & posts.ts{ label: 'SEO', name: 'meta', fields: [ OverviewField({ titlePath: 'meta.title', descriptionPath: 'meta.description', imagePath: 'meta.image', }), MetaTitleField({ hasGenerateFn: true }), MetaImageField({ hasGenerateFn: true, relationTo: 'media' }), MetaDescriptionField({ hasGenerateFn: true }), PreviewField({ hasGenerateFn: true, titlePath: 'meta.title', descriptionPath: 'meta.description', }), ],}Field Mappings
Section titled “Field Mappings”| Field | Source Property |
|---|---|
| Meta Title | meta.title |
| Meta Description | meta.description |
| Meta Image | meta.image (media relation) |
Auto-Generation
Section titled “Auto-Generation”The SEO plugin auto-populates fields when left empty:
export const generateTitle: GenerateTitle<Page> = ({ doc }) => { return doc?.title ? `${doc.title} - ${env.NEXT_PUBLIC_SITE_NAME}` : env.NEXT_PUBLIC_SITE_NAME;};
export const generateDescription: GenerateDescription<Page> = ({ doc }) => { return doc.excerpt;};
export const generateURL: GenerateURL<Page> = ({ doc }) => { const url = getServerSideURL(); return doc?.slug ? `${url}/${doc.slug}` : url;};
export const generateImage: GenerateImage<Page> = async ({ doc }) => { if (typeof doc.featuredImage === 'number') { return doc.featuredImage as unknown as string; } return '';};These are the functions the “Generate” buttons in the admin SEO tab call. If the editor leaves a field blank and saves, the beforeChange hook calls the relevant generator and fills it in automatically.
The beforeChange hook wires the generators to the save lifecycle:
hooks: { beforeChange: [ async (context) => autoGenerateHook<Page>( context, 'title', 'excerpt', 'featuredImage', { object: 'meta', title: 'title', description: 'description', image: 'image', }, ), ], afterChange: [ async ({ doc }) => { if (doc.slug) { revalidateCollection('pages', doc.slug); } }, ],},Frontend Metadata
Section titled “Frontend Metadata”Each page route implements generateMetadata():
// src/app/(frontend)/[slug]/page.tsxexport async function generateMetadata({ params }: Props): Promise<Metadata> { const page = await getPageBySlug(params.slug);
return { title: page.meta?.title, description: page.meta?.description, openGraph: { images: [page.meta?.image?.url || ''], }, };}The root layout sets the metadata base:
// src/app/(frontend)/layout.tsxexport const metadata = { metadataBase: new URL(getServerSideURL()),};Metadata Properties
Section titled “Metadata Properties”| Property | Source |
|---|---|
title | page.meta.title |
description | page.meta.description |
openGraph.images[0].url | page.meta.image.url |
alternates.canonical | Auto-generated from URL |
Sitemap
Section titled “Sitemap”The sitemap uses pagination with split sitemap files:
// src/app/(frontend)/sitemap.tsexport const siteMapIndex: Record<number, ValidCollections> = { 0: 'pages', 1: 'posts',};
export async function generateSitemaps() { const keys = Object.keys(siteMapIndex); return keys.map((key) => ({ id: key }));}
export default async function sitemap({ id,}: { id: number;}): Promise<MetadataRoute.Sitemap> { const config = siteMapIndex[id];
const results = await payload.find({ collection: config, select: { slug: true, updatedAt: true }, });
return results.docs.map((doc) => ({ url: getServerSideURL() + getSlugPath(config, doc.slug || ''), lastModified: doc.updatedAt, }));}This generates:
/sitemap_index.xml— Index of all sitemaps/sitemap-0.xml— Pages collection/sitemap-1.xml— Posts collection
Robots.txt
Section titled “Robots.txt”export default function robots(): MetadataRoute.Robots { return { rules: { userAgent: '*', allow: '/', disallow: '/admin/', }, sitemap: `${getServerSideURL()}/sitemap_index.xml`, };}404 Page Metadata
Section titled “404 Page Metadata”export const notFoundMetaData: Metadata = { title: `Not Found - ${env.NEXT_PUBLIC_SITE_NAME}`, description: 'Oops! This page doesn't exist. Head to our homepage to keep exploring.',};// src/app/(frontend)/not-found.tsxexport const metadata: Metadata = notFoundMetaData;Adding SEO to a New Collection
Section titled “Adding SEO to a New Collection”-
Configure slugs
src/config/slugs.ts export const slugs = defineSlugs({pages: '/',posts: '/posts/',events: '/events/', // Add new collection}); -
Add SEO fields to the collection
src/collections/events.ts import {MetaDescriptionField,MetaImageField,MetaTitleField,OverviewField,PreviewField,} from '@payloadcms/plugin-seo/fields';import { autoGenerateHook } from '@/plugins/payload-seo';export const events: CollectionConfig = {slug: 'events',fields: [{ name: 'title', type: 'text', required: true },...slugField('title'),{ name: 'excerpt', type: 'textarea' },{ name: 'featuredImage', type: 'upload', relationTo: 'media' },{type: 'tabs',tabs: [{label: 'SEO',name: 'meta',fields: [OverviewField({titlePath: 'meta.title',descriptionPath: 'meta.description',imagePath: 'meta.image',}),MetaTitleField({ hasGenerateFn: true }),MetaImageField({ hasGenerateFn: true, relationTo: 'media' }),MetaDescriptionField({ hasGenerateFn: true }),PreviewField({hasGenerateFn: true,titlePath: 'meta.title',descriptionPath: 'meta.description',}),],},],},],hooks: {beforeChange: [(context) =>autoGenerateHook<Events>(context,'title','excerpt','featuredImage',{object: 'meta',title: 'title',description: 'description',image: 'image',},),],afterChange: [({ doc }) => {if (doc.slug) {revalidateCollection('events', doc.slug);}},],},}; -
Add to the sitemap
// src/app/(frontend)/sitemap.tsexport const siteMapIndex: Record<number, ValidCollections> = {0: 'pages',1: 'posts',2: 'events', // Add new collection}; -
Add the frontend route
Create
src/app/(frontend)/events/[slug]/page.tsxwithgenerateMetadata():// src/app/(frontend)/events/[slug]/page.tsxexport async function generateMetadata({ params }: Props): Promise<Metadata> {const event = await getEventBySlug(params.slug);return {title: event.meta?.title,description: event.meta?.description,openGraph: {images: [event.meta?.image?.url || ''],},};}
Best Practices
Section titled “Best Practices”Meta Title
Section titled “Meta Title”- 50–60 characters maximum
- Format:
Page Title - Site Name - Unique per page
Meta Description
Section titled “Meta Description”- 150–160 characters maximum
- Include primary keyword naturally
- Compelling call-to-action
Meta Image
Section titled “Meta Image”- 1200×630 pixels (OpenGraph standard)
- High quality, branded
- Include text overlay for context
URL Structure
Section titled “URL Structure”- Use slug field for URL-friendly paths
- Keep URLs short and descriptive
- Use hyphens to separate words
Verification Tools
Section titled “Verification Tools”| Tool | Purpose |
|---|---|
| Google Search Console | Indexing, URL testing |
| Bing Webmaster Tools | Microsoft search indexing |
| opengraph.xyz | Social card preview |
| Lighthouse | SEO audit |
Troubleshooting
Section titled “Troubleshooting”Meta tags not appearing
Section titled “Meta tags not appearing”- Verify document is published (
_status: 'published') - Check
metafields are populated in admin - Ensure
generateMetadata()is fetching the correct collection
Sitemap missing pages
Section titled “Sitemap missing pages”- Confirm collection is in
siteMapIndex - Verify documents have
slugandupdatedAtfields - Check documents are published
Incorrect URLs
Section titled “Incorrect URLs”- Verify
src/config/slugs.tsmapping is correct - Check
slugfield values in documents - Ensure
NEXT_PUBLIC_SERVER_URLis set correctly