Simplify your stack and build anything. Or everything.
Build tomorrow’s web with a modern solution you truly own.
Code-based nature means you can build on top of it to power anything.
It’s time to take back your content infrastructure.
AuthorNick Vogel

How to use structured data (schema markup) for SEO in Payload and Next.js

Community Guide
AuthorNick Vogel

In this guide, we'll set up structured data for blog posts and images using Payload and Next.js, ensuring Google can generate rich results for your site.

Structured data (also known as schema markup) helps search engines understand the content on your website.

In this guide, we'll set up structured data for blog posts and images using Payload and Next.js, ensuring Google can generate rich results for your site.

1. Setting up the schema directory

The first step is to create a dedicated directory for our schema files.

Inside your components folder, create a new directory called Schema. In it, you'll create a new file called index.tsx.

Since we'll most likely use these schema types across different parts of our site, we’ll store all of our schema definitions in this file for easy reusability.

At the top of index.tsx, import the required types:

1
import type { Post, Media, User } from '@payload-types'

2. Creating article schema with blog data

Next, we'll create a schema specifically for blog posts.

Define the articleSchema function

1
import type { Post, Media, User } from '@payload-types'
2
3
export const articleSchema = (props: Post) => {
4
return {
5
"@context": "https://schema.org",
6
"@type": "BlogPosting", // You can also use Article or NewsArticle
7
headline: props.title,
8
datePublished: new Date(props.createdAt),
9
dateModified: new Date(props.updatedAt),
10
}
11
}

Breaking it down:

  • "@context" specifies schema.org as the standard.
  • "@type": "BlogPosting" tells search engines this is a blog post.
  • headline uses props.title for the article title.
  • datePublished and dateModified use the post’s timestamps.

We can then include an image URL in an array using the image property.

1
import type { Post, Media, User } from '@payload-types'
2
3
export const articleSchema = (props: Post) => {
4
const image: Media = props.meta?.image as Media
5
6
return {
7
'@context': 'https://schema.org',
8
'@type': 'BlogPosting',
9
headline: props.title,
10
datePublished: new Date(props.createdAt),
11
dateModified: new Date(props.updatedAt),
12
// This S3 endpoint was set up in a previous video to serve images.
13
// You can replace this with any URL structure that fits your media storage setup.
14
image: [`${process.env.S3_ENDPOINT}/${image.filename}`]
15
}
16
}

3. Adding authors to the schema

Next, we'll add our authors using the author property. Please note that this will require a dedicated author field in your posts, so you may need to add this into posts config file; commonly, this might look something like the following:

1
fields: [
2
{
3
name: 'authors',
4
type: 'relationship',
5
relationTo: 'users',
6
hasMany: true,
7
admin: {
8
position: 'sidebar'
9
}
10
}
11
]

Further, you may need to also adjust your Users.ts config and remove the postsByUser join field if applicable.

If you had to take any of the above steps, it's necessary to run payload generate:types.

Back in our Schema index.tsx file, we'll set a new const for authors.

1
import type { Post, Media, User } from '@payload-types'
2
const authors = props.authors as User[] // Expecting multiple authors
3
const url: string = getServerSideURL()
4
5
export const articleSchema = (props: Post) => {
6
const image: Media = props.meta?.image as Media
7
8
return {
9
'@context': 'https://schema.org',
10
'@type': 'BlogPosting',
11
headline: props.title,
12
datePublished: new Date(props.createdAt),
13
dateModified: new Date(props.updatedAt),
14
image: [`${process.env.S3_ENDPOINT}/${image.filename}`]
15
author: authors.map((author: User) => ({
16
type: 'Person',
17
name: author.name,
18
url: `${url}/authors/${author.slug}`
19
// You could also use the following instead of URL:
20
// sameAs: "https://www.youtube.com/c/example" // Replace with social link
21
}))
22
}
23
}

According to Schema.org, BlogPosting can also take the text of the article, but Google doesn’t use it, so you may not think it’s worth it— especially since you would need to create a function to take your Lexical rich text field and transpose it into something that's readable by web crawlers like plain text.

Next, we'll work on our image schema.

4. Adding image schema

We'll export a new constant called imageSchema. This constant will also take props, but it will take it from Media.

1
import type { Post, Media, User } from '@payload-types'
2
const authors = props.authors as User[] // Expecting multiple authors
3
const url: string = getServerSideURL()
4
5
export const articleSchema = (props: Post) => {
6
const image: Media = props.meta?.image as Media
7
8
return {
9
'@context': 'https://schema.org',
10
'@type': 'BlogPosting',
11
headline: props.title,
12
datePublished: new Date(props.createdAt),
13
dateModified: new Date(props.updatedAt),
14
image: [`${process.env.S3_ENDPOINT}/${image.filename}`]
15
author: authors.map((author: User) => ({
16
type: 'Person',
17
name: author.name,
18
url: `${url}/authors/${author.slug}`
19
// You could also use the following instead of URL:
20
// sameAs: "https://www.youtube.com/c/example" // Replace with social link
21
}))
22
}
23
}
24
25
export const imageSchema = (props: Media) => {
26
return {
27
"@context": "https://schema.org",
28
29
}
30
}

You only need to include:

  • contentUrl: The image URL
  • creditText: Some form of attribution (license, copyright, or credit)

Since "creditText" is required by the schema, we’ll also make it required on the backend.

Making creditText required for image schema

To ensure every media item has creditText, go to your Payload media uploads collection (Media.ts) and underneath alt text we'll just create a new textfield that will be called creditText.

This would look like the following:

1
export const Media: CollectionConfig = {
2
slug: 'media',
3
access: {
4
read: (): boolean => true,
5
},
6
fields: [
7
{
8
name: 'alt',
9
type: 'text',
10
required: true,
11
},
12
{
13
name: 'creditText', // This is where we've
14
type: 'text', // added the necessary fields
15
required: true, // and marked creditText as required
16
},
17
],
18
};

After this, navigate to your admin panel (http://localhost:3000/admin/login), go to media, and add a credit text to an existing image.

Once that's saved, we can come back to our schema file and finish our image schema.

1
import type { Post, Media, User } from '@payload-types'
2
const authors = props.authors as User[] // Expecting multiple authors
3
const url: string = getServerSideURL()
4
5
export const articleSchema = (props: Post) => {
6
const image: Media = props.meta?.image as Media
7
8
return {
9
'@context': 'https://schema.org',
10
'@type': 'BlogPosting',
11
headline: props.title,
12
datePublished: new Date(props.createdAt),
13
dateModified: new Date(props.updatedAt),
14
image: [`${process.env.S3_ENDPOINT}/${image.filename}`]
15
author: authors.map((author: User) => ({
16
type: 'Person',
17
name: author.name,
18
url: `${url}/authors/${author.slug}`
19
// You could also use the following instead of URL:
20
// sameAs: "https://www.youtube.com/c/example" // Replace with social link
21
}))
22
}
23
}
24
25
export const imageSchema = (props: Media) => {
26
return {
27
'@context': 'https://schema.org',
28
'@type': 'ImageObject',
29
contentUrl: `${process.env.S3_ENDPOINT}/${props.filename}`,
30
creditText: props.creditText
31
}
32
}


5. Rendering schema on the frontend

Now that we have our article and image schema, we can go to our (frontend)/posts/[slug]/page.tsx file. We'll create a new const below our const post that we'll call schema, and set it equal to an array.

It would look something like the following:

1
const post = postQuery.docs[0]
2
const schema = [
3
imageSchema(post.meta?.image as Media),
4
articleSchema(post)
5
]

Contrary to popular belief, schema does not need to be included in the <head> tag, and it does not need to be all in the same container. There will be no impact to performance or recognition by Google either way.

So, we’ll use Next.js's <Script> component, and not even try to get it into the <head>.

6. Validating the schema

Before we navigate to a page, make sure you have all the required information in a Payload post.

This would include:

  • Title
  • Authors
  • Image (with creditText)

Go to your Payload Admin UI, open a blog post, and ensure all necessary data is filled in. Now, navigate to the actual page and validate the schema by opening your browser's DevTools. Once you've located the script tag, copy it (Copy Element), and paste it into Google’s Rich Results Test.

If everything is set up correctly, Google will show you that your data has passed.

You might see non-critical warnings, like "Missing recommended fields" for images, but as long as no required fields are missing, your schema is valid.

Final thoughts

There’s even more you can do with structured data, like creating an automatic image schema for every media item, or building out more advanced schema using Schema.org documentation.