Skip to content

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.

LayerPurpose
Payload AdminSEO fields via @payloadcms/plugin-seo
Slug ConfigCentralised URL path management
FrontendNext.js metadata, sitemaps, robots.txt
src/config/slugs.ts
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:

src/lib/slug.ts
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.

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',
}),
],
}
FieldSource Property
Meta Titlemeta.title
Meta Descriptionmeta.description
Meta Imagemeta.image (media relation)

The SEO plugin auto-populates fields when left empty:

src/plugins/payload-seo.ts
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:

src/collections/pages.ts
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);
}
},
],
},

Each page route implements generateMetadata():

// src/app/(frontend)/[slug]/page.tsx
export 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.tsx
export const metadata = {
metadataBase: new URL(getServerSideURL()),
};
PropertySource
titlepage.meta.title
descriptionpage.meta.description
openGraph.images[0].urlpage.meta.image.url
alternates.canonicalAuto-generated from URL

The sitemap uses pagination with split sitemap files:

// src/app/(frontend)/sitemap.ts
export 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
src/app/robots.ts
export default function robots(): MetadataRoute.Robots {
return {
rules: {
userAgent: '*',
allow: '/',
disallow: '/admin/',
},
sitemap: `${getServerSideURL()}/sitemap_index.xml`,
};
}
src/lib/errors/not-found.ts
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.tsx
export const metadata: Metadata = notFoundMetaData;
  1. Configure slugs

    src/config/slugs.ts
    export const slugs = defineSlugs({
    pages: '/',
    posts: '/posts/',
    events: '/events/', // Add new collection
    });
  2. 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);
    }
    },
    ],
    },
    };
  3. Add to the sitemap

    // src/app/(frontend)/sitemap.ts
    export const siteMapIndex: Record<number, ValidCollections> = {
    0: 'pages',
    1: 'posts',
    2: 'events', // Add new collection
    };
  4. Add the frontend route

    Create src/app/(frontend)/events/[slug]/page.tsx with generateMetadata():

    // src/app/(frontend)/events/[slug]/page.tsx
    export 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 || ''],
    },
    };
    }
  • 50–60 characters maximum
  • Format: Page Title - Site Name
  • Unique per page
  • 150–160 characters maximum
  • Include primary keyword naturally
  • Compelling call-to-action
  • 1200×630 pixels (OpenGraph standard)
  • High quality, branded
  • Include text overlay for context
  • Use slug field for URL-friendly paths
  • Keep URLs short and descriptive
  • Use hyphens to separate words
ToolPurpose
Google Search ConsoleIndexing, URL testing
Bing Webmaster ToolsMicrosoft search indexing
opengraph.xyzSocial card preview
LighthouseSEO audit
  1. Verify document is published (_status: 'published')
  2. Check meta fields are populated in admin
  3. Ensure generateMetadata() is fetching the correct collection
  1. Confirm collection is in siteMapIndex
  2. Verify documents have slug and updatedAt fields
  3. Check documents are published
  1. Verify src/config/slugs.ts mapping is correct
  2. Check slug field values in documents
  3. Ensure NEXT_PUBLIC_SERVER_URL is set correctly