How I add structured data markups to my Next.js 14 website
If you're here I assume you already know what structured data markups are, and their importance for SEO, but in case you don't I highly recommend you read this guide.
Relevant links:
1. Google supported structured data
2. Schema.org
3. Next.js official documentation
4. Weasker public GitHub repository
5. Weasker user page + Code
6. Weasker question page + Code
7. Google's validation tool
8. Schema.org validation tool
9. JSON-LD Official
10. Schema DTS
11. Dangerouslysetinnerhtml
12. SEO META 1 COPY Chrome Extension | Firefox Add-on
How I add SEO schemas to my Next.JS code
The code examples below come from my project Weasker.com, that you can explore in this public GitHub repository. Since Weasker.com is an active project, the code presented here may differ from the updated version in the GitHub repo.
I will show you how I added markups for two Weasker page types:
1. Profile page (example)
2. Question page (example)
Profile page markup
The structured data markup I used for the "Profile page" was pretty obvious and it's a markup called profilePage.
Here are google's guidelines for the profilePage markup:
Once I had the markup I wanted to use I went ahead and added a JSON-LD object as so:
const jsonLd: WithContext<ProfilePage> = {
"@context": "https://schema.org",
"@type": "ProfilePage",
mainEntity: {
"@type": "Person",
name: user.displayName || userName,
jobTitle: badgesSingularNames,
image: pfp || defaultImages.defaultUserImage,
url: `https://www.weasker.com/user/${params.user}`,
award: badges.map((item) => {
return `${(item.badge as Badge).singularName} Badge`;
}),
},
};
If you're using typescript I recommend installing the schema-dts package. It will make your life much easier knowing which properties are available to you for each markup type.
JSON-LD is a way to add detailed information to web pages, so search engines can understand and display them better and you can read more about is here.
Let's break down the key-value pairs from the code above:
// The ProfilePage type comes from the schema-dts package
const jsonLd: WithContext<ProfilePage> = {
//@context - set to schema.org, this is always the same
"@context": "https://schema.org",
//@type - The markup you chose, in my case ProfilePage
"@type": "ProfilePage",
// mainEntity = object where you insert specific dynamic data for the page
mainEntity: {
// @type - usually a person, but could also be Avatar or a pet for example
"@type": "Person",
// name - of the profile holder
name: user.displayName || userName,
// jobTitle - in my case is the array of badges a user was awarded
jobTitle: badgesSingularNames,
// image - pfp of the user
image: pfp || defaultImages.defaultUserImage,
// url - Link to the user's page
url: `https://www.weasker.com/user/${params.user}`,
// award - in my case is the array of badges a user was awarded
award: badges.map((item) => {
return `${(item.badge as Badge).singularName} Badge`;
}),
},
};
Once the JSON-LD object is set up I can go ahead and insert it in my code as the first element under 'return' like such:
return (
<>
<script
type="application/ld+json"
dangerouslySetInnerHTML={{ __html: JSON.stringify(jsonLd) }}
/>
...
That's it! My markup is set up, and now I verify that Google can read it and it's working properly.
First let's look at the website itself.
If you go on https://www.weasker.com/user/trexrell44 for example and enter the browser's DevTools (F12)
Search the inspector for "schema.org" and you should see the JSON-LD object we created.
To check that Google can read my markups, I use this Google tool:
https://search.google.com/test/rich-results
After this we are good to go but if you want you can also use schema.org Schema Markup Validator to validate your structured data markup.
Question page markup
To set up a structured data markup for wesaker.com question page (example) I used the same technique as above. Only creating the object might have been a bit more complex.
At first I wasn't sure if I should use the QAPage
markup or the FAQ
markup.
However, after some research, it became clear that the correct choice was the QAPage
markup.
Here are google's guidelines for the QAPage
markup:
As you can see in this question page example. It's a page where users can submit answers to a single question.
Here is the JSON-LD object I created for it:
const jsonLd: WithContext<QAPage> = {
"@context": "https://schema.org",
"@type": "QAPage",
mainEntity: {
"@type": "Question",
name: relevantQuestion.mediumQuestion,
text: relevantQuestion.longQuestion,
answerCount: relevantAnswers.length,
suggestedAnswer: relevantAnswers.map((item) => {
return {
"@type": "Answer",
text: convert(item.answer.answer.textAnswer),
url: `https://www.weasker.com/question/${params.badge}/${
params.interview
}/${params.question}#${(item.user as User).seo.slug}`,
};
}),
},
};
The convert
function in my code is a custom utility that transforms text data into a format suitable for JSON-LD. It ensures that the text strings conform to the structured data requirements, such as replacing special characters or formatting dates correctly. This function is key to making sure that the data is both human-readable and machine-friendly.
Let's break down the jsonLd object here:
// The QAPage type comes from the schema-dts package
const jsonLd: WithContext<QAPage> = {
//@context - set to schema.org, this is always the same
"@context": "https://schema.org",
//@type - The markup you chose, in this case QAPage
"@type": "QAPage",
//mainEntity = object where you insert specific dynamic data for the page
mainEntity: {
// @type - The single question of this page
"@type": "Question",
// author - Person or Organization. Information about the author of the question.
author: {
"@type": "Organization",
name: "weasker",
url: `${process.env.SITE_URL}`,
},
// datePublished - The date and time the question was posted in ISO 8601 format.
datePublished: interview.createdAt,
// name - The full text of the short form of the question.
name: relevantQuestion.mediumQuestion,
// text - The full text of the long form of the question.
text: relevantQuestion.longQuestion,
// answerCount - The total number of answers to the question.
answerCount: relevantAnswers.length,
// suggestedAnswer -One possible answer, but not accepted as a top answer
suggestedAnswer: relevantAnswers.map((item) => {
return {
"@type": "Answer",
// text - The full text of the answer.
text: convert(item.answer.answer.textAnswer),
//url - A URL that links directly to this answer.
url: `https://www.weasker.com/question/${params.badge}/${
params.interview
}/${params.question}#${(item.user as User).seo.slug}`,
// author - Information about the author of the answer.
author: {
"@type": "Person",
name: item.user.displayName || item.user.userName,
url: `https://www.weasker.com/user/${item.user.seo.slug}`,
},
//The date and time the question was answered in ISO 8601 format.
datePublished: item.answer.answer.createdAt,
//The date and time the answer was edited in ISO 8601 format.
dateModified: item.answer.answer.updatedAt,
};
}),
},
};
Validation is then done similarly to the above, using the browser's DevTools (F12), Google's Rich Results Test, and the Schema Markup Validator.