Tutorials
Learn how to implement JSON-LD structured data with Agility CMS and Next.js to improve SEO, enable rich snippets, and boost search visibility.
JSON-LD (JavaScript Object Notation for Linked Data) structured data helps search engines better understand your content and can enhance your search results with rich snippets. Implementing JSON-LD with Agility CMS and Next.js improves your SEO and provides users with more informative search results.
The core concept is straightforward: fetch your dynamic page content from Agility CMS, analyze its content type and fields, then output a JSON-LD <script> tag in your page's HTML. While this guide uses Next.js and TypeScript, the same approach works with any framework or language—from PHP to Python, Vue to React, or even vanilla JavaScript. Since you're simply reading content from Agility CMS and rendering a meta tag, the implementation pattern translates universally: get the content, map the fields to the appropriate Schema.org structure, and inject the JSON-LD script into your page markup.
The implementation consists of two main parts:
Create a file at lib/cms-content/getRichSnippet.ts to generate JSON-LD based on your content type:
import { ContentItem } from "@agility/content-fetch"
import { AgilityPageProps } from "@agility/nextjs"
import { DateTime } from "luxon"
/**
* Get the JSON-LD Rich Snippet for the current page.
* Using recommendations from nextjs.org here:
* https://nextjs.org/docs/app/building-your-application/optimizing/metadata#json-ld
*
* Google docs on the SEO benefits:
* https://developers.google.com/search/docs/appearance/structured-data/intro-structured-data
*
* Testing Tool: https://search.google.com/test/rich-results
*/
export const getRichSnippet = ({
sitemapNode,
page,
languageCode,
dynamicPageItem
}: AgilityPageProps): string | null => {
if (!dynamicPageItem) return null
// Handle different content types
if (dynamicPageItem.properties.definitionName === "BlogPost") {
return generateBlogPostStructuredData(dynamicPageItem)
}
if (dynamicPageItem.properties.definitionName === "Event") {
return generateEventStructuredData(dynamicPageItem)
}
if (dynamicPageItem.properties.definitionName === "Article") {
return generateArticleStructuredData(dynamicPageItem)
}
return null
}
Blog posts benefit from BlogPosting schema, which can display author information, publish dates, and featured images in search results:
const generateBlogPostStructuredData = (post: ContentItem<IPost>): string => {
const category = post.fields.categories?.fields.title || undefined
const image = post.fields.postImage?.url || undefined
const author = post.fields.author?.fields.title || "Agility"
const authorImage = post.fields.author?.fields.image?.url || undefined
const datePublished = DateTime.fromISO(post.fields.date)
const dateModified = DateTime.fromISO(`${post.properties.modified}`)
const structData: any = {
"@context": "https://schema.org",
"@type": "BlogPosting",
mainEntityOfPage: {
"@type": "WebPage",
"@id": "https://google.com/article"
},
headline: post.fields.title,
datePublished: datePublished.toISO(),
dateModified: dateModified.toISO()
}
if (author) {
structData.author = [
{
"@type": "Person",
name: author,
url: authorImage
}
]
}
if (image) {
structData.image = [image]
}
return JSON.stringify(structData)
}
Events use the Event schema type, which can display event details like date, time, location, and registration information:
const generateEventStructuredData = (event: ContentItem<IEvent>): string => {
const startTime = DateTime.fromISO(event.fields.date)
const endTime = startTime.plus({ hours: 1 })
const validFrom = new Date()
const canonicalUrl = `https://agilitycms.com/events/${event.fields.uRL}`
const extLink = event.fields.externalLink || canonicalUrl
const presenters = event.fields.presenters?.map((p) => ({
"@type": "Person",
name: p.fields.title,
image: p.fields.image?.url
}))
const structData = {
"@context": "https://schema.org",
"@type": "Event",
name: event.fields.title,
startDate: startTime.toISO(),
endDate: endTime.toISO(),
eventAttendanceMode: "https://schema.org/OnlineEventAttendanceMode",
eventStatus: "https://schema.org/EventScheduled",
location: {
"@type": "VirtualLocation",
url: extLink
},
description: event.fields.description,
offers: {
"@type": "Offer",
url: canonicalUrl,
price: "0",
priceCurrency: "USD",
availability: "https://schema.org/InStock",
validFrom: validFrom.toISOString()
},
performer: presenters,
organizer: {
"@type": "Organization",
name: "Agility CMS",
url: "https://agilitycms.com"
},
image: [event.fields.mainImage?.url]
}
return JSON.stringify(structData)
}
In your dynamic page component (e.g., app/[...slug]/page.tsx), import and use the getRichSnippet function:
import { getRichSnippet } from "lib/cms-content/getRichSnippet"
export default async function AgilityPage({ params }: { params: { slug: string[] } }) {
// ... fetch your Agility data
const jsonLD = getRichSnippet(agilityData)
return (
<div
data-agility-page={agilityData.page?.pageID}
data-agility-dynamic-content={agilityData.sitemapNode.contentID}
>
{jsonLD && (
<script
type="application/ld+json"
dangerouslySetInnerHTML={{ __html: jsonLD }}
/>
)}
{/* Your page content */}
<AgilityPageTemplate {...agilityData} />
</div>
)
}
The implementation supports multiple content types with their respective schema.org types:
| Example Content Model | Schema.org Type | Use Case |
|---|---|---|
| BlogPost | BlogPosting | Blog articles and posts |
| Event | Event | Webinars, conferences, meetups |
| Resource | Article | Whitepapers, guides, documentation |
| CaseStudy | Article | Customer stories and case studies |
You can implement any schema with your content model as long as you have the requisite fields in place.
After implementing JSON-LD, it's crucial to validate that your structured data is correct:
Always include the minimum required properties for each schema type:
Ensure dates are in ISO 8601 format:
const datePublished = DateTime.fromISO(post.fields.date).toISO()
The structured data should match your visible page content:
// ❌ Don't do this
"headline": "Different title than the page"
// ✅ Do this
"headline": post.fields.title
Only include optional properties when data is available:
if (author) {
structData.author = [
{
"@type": "Person",
name: author,
url: authorImage
}
]
}
To add structured data support for additional content types:
Example for a Product content type:
if (dynamicPageItem.properties.definitionName === "Product") {
return generateProductStructuredData(dynamicPageItem)
}
const generateProductStructuredData = (product: ContentItem<IProduct>): string => {
const structData = {
"@context": "https://schema.org",
"@type": "Product",
name: product.fields.title,
image: [product.fields.image?.url],
description: product.fields.description,
offers: {
"@type": "Offer",
priceCurrency: "USD",
price: product.fields.price,
availability: "https://schema.org/InStock"
}
}
return JSON.stringify(structData)
}
Solution: Rich results can take several weeks to appear. Ensure:
Solution: Check the schema.org documentation for required properties and add them:
// Required for BlogPosting
structData.headline = post.fields.title
structData.datePublished = datePublished.toISO()
structData.image = [post.fields.postImage?.url]
Solution: Ensure dates are in ISO 8601 format:
// ❌ Wrong
"datePublished": "12/25/2023"
// ✅ Correct
"datePublished": "2023-12-25T10:00:00Z"
While this guide focuses on common content types like blog posts, events, and articles, Schema.org offers hundreds of structured data types that you can implement. Here are some additional schema types that may be relevant for your Agility CMS website:
Perfect for online courses, training programs, and educational content:
const generateCourseStructuredData = (course: ContentItem<ICourse>): string => {
const structData = {
"@context": "https://schema.org",
"@type": "Course",
name: course.fields.title,
description: course.fields.description,
provider: {
"@type": "Organization",
name: "Your Organization",
sameAs: "https://yourwebsite.com"
},
offers: {
"@type": "Offer",
category: "Paid" // or "Free"
}
}
return JSON.stringify(structData)
}
Learn more: https://schema.org/Course
For degree programs, certifications, and professional training:
const generateEducationalProgramStructuredData = (program: ContentItem<IProgram>): string => {
const structData = {
"@context": "https://schema.org",
"@type": "EducationalOccupationalProgram",
name: program.fields.title,
description: program.fields.description,
provider: {
"@type": "EducationalOrganization",
name: "Your Institution"
},
educationalCredentialAwarded: {
"@type": "EducationalOccupationalCredential",
credentialCategory: "Certificate" // or "Degree", "Badge", etc.
},
timeToComplete: "P6M", // ISO 8601 duration format (6 months)
occupationalCredentialAwarded: {
"@type": "EducationalOccupationalCredential",
credentialCategory: "Professional Certification"
}
}
return JSON.stringify(structData)
}
Learn more: https://schema.org/EducationalOccupationalProgram
For product pages with pricing and availability:
const generateProductStructuredData = (product: ContentItem<IProduct>): string => {
const structData = {
"@context": "https://schema.org",
"@type": "Product",
name: product.fields.title,
image: product.fields.images?.map((img) => img.url),
description: product.fields.description,
sku: product.fields.sku,
brand: {
"@type": "Brand",
name: product.fields.brand
},
offers: {
"@type": "Offer",
url: product.fields.url,
priceCurrency: "USD",
price: product.fields.price,
availability: "https://schema.org/InStock",
seller: {
"@type": "Organization",
name: "Your Company"
}
},
aggregateRating: {
"@type": "AggregateRating",
ratingValue: product.fields.averageRating,
reviewCount: product.fields.reviewCount
}
}
return JSON.stringify(structData)
}
Learn more: https://schema.org/Product
For physical locations, stores, and service areas:
const generateLocalBusinessStructuredData = (business: ContentItem<IBusiness>): string => {
const structData = {
"@context": "https://schema.org",
"@type": "LocalBusiness",
name: business.fields.name,
image: business.fields.image?.url,
address: {
"@type": "PostalAddress",
streetAddress: business.fields.streetAddress,
addressLocality: business.fields.city,
addressRegion: business.fields.state,
postalCode: business.fields.zipCode,
addressCountry: business.fields.country
},
geo: {
"@type": "GeoCoordinates",
latitude: business.fields.latitude,
longitude: business.fields.longitude
},
telephone: business.fields.phone,
openingHoursSpecification: [
{
"@type": "OpeningHoursSpecification",
dayOfWeek: ["Monday", "Tuesday", "Wednesday", "Thursday", "Friday"],
opens: "09:00",
closes: "17:00"
}
]
}
return JSON.stringify(structData)
}
Learn more: https://schema.org/LocalBusiness
For video content and tutorials:
const generateVideoStructuredData = (video: ContentItem<IVideo>): string => {
const structData = {
"@context": "https://schema.org",
"@type": "VideoObject",
name: video.fields.title,
description: video.fields.description,
thumbnailUrl: video.fields.thumbnail?.url,
uploadDate: video.fields.publishDate,
duration: video.fields.duration, // ISO 8601 format: "PT1H30M"
contentUrl: video.fields.videoUrl,
embedUrl: video.fields.embedUrl,
interactionStatistic: {
"@type": "InteractionCounter",
interactionType: { "@type": "WatchAction" },
userInteractionCount: video.fields.viewCount
}
}
return JSON.stringify(structData)
}
const generatePodcastStructuredData = (podcast: ContentItem<IPodcast>): string => {
const structData = {
"@context": "https://schema.org",
"@type": "PodcastEpisode",
name: podcast.fields.title,
description: podcast.fields.description,
datePublished: podcast.fields.publishDate,
associatedMedia: {
"@type": "MediaObject",
contentUrl: podcast.fields.audioUrl
},
partOfSeries: {
"@type": "PodcastSeries",
name: podcast.fields.seriesName,
url: podcast.fields.seriesUrl
},
actor: podcast.fields.hosts?.map((host) => ({
"@type": "Person",
name: host.fields.name
}))
}
return JSON.stringify(structData)
}
Learn more: https://schema.org/PodcastEpisode
For product or service reviews:
const generateReviewStructuredData = (review: ContentItem<IReview>): string => {
const structData = {
"@context": "https://schema.org",
"@type": "Review",
itemReviewed: {
"@type": "Product",
name: review.fields.productName
},
author: {
"@type": "Person",
name: review.fields.authorName
},
reviewRating: {
"@type": "Rating",
ratingValue: review.fields.rating,
bestRating: "5",
worstRating: "1"
},
reviewBody: review.fields.reviewText,
datePublished: review.fields.publishDate
}
return JSON.stringify(structData)
}
Learn more: https://schema.org/Review
For frequently asked questions pages:
const generateFAQStructuredData = (faq: ContentItem<IFAQ>): string => {
const structData = {
"@context": "https://schema.org",
"@type": "FAQPage",
mainEntity: faq.fields.questions?.map((q) => ({
"@type": "Question",
name: q.fields.question,
acceptedAnswer: {
"@type": "Answer",
text: q.fields.answer
}
}))
}
return JSON.stringify(structData)
}
Learn more: https://schema.org/FAQPage
For cooking recipes and food content:
const generateRecipeStructuredData = (recipe: ContentItem<IRecipe>): string => {
const structData = {
"@context": "https://schema.org",
"@type": "Recipe",
name: recipe.fields.title,
image: recipe.fields.images?.map((img) => img.url),
author: {
"@type": "Person",
name: recipe.fields.author
},
datePublished: recipe.fields.publishDate,
description: recipe.fields.description,
prepTime: recipe.fields.prepTime, // ISO 8601: "PT30M"
cookTime: recipe.fields.cookTime,
totalTime: recipe.fields.totalTime,
recipeYield: recipe.fields.servings,
recipeIngredient: recipe.fields.ingredients,
recipeInstructions: recipe.fields.instructions?.map((step, index) => ({
"@type": "HowToStep",
position: index + 1,
text: step
})),
nutrition: {
"@type": "NutritionInformation",
calories: recipe.fields.calories
}
}
return JSON.stringify(structData)
}
const generateJobPostingStructuredData = (job: ContentItem<IJobPosting>): string => {
const structData = {
"@context": "https://schema.org",
"@type": "JobPosting",
title: job.fields.title,
description: job.fields.description,
datePosted: job.fields.postDate,
validThrough: job.fields.expiryDate,
employmentType: job.fields.employmentType, // "FULL_TIME", "PART_TIME", etc.
hiringOrganization: {
"@type": "Organization",
name: "Your Company",
sameAs: "https://yourwebsite.com",
logo: "https://yourwebsite.com/logo.png"
},
jobLocation: {
"@type": "Place",
address: {
"@type": "PostalAddress",
streetAddress: job.fields.streetAddress,
addressLocality: job.fields.city,
addressRegion: job.fields.state,
postalCode: job.fields.zipCode,
addressCountry: job.fields.country
}
},
baseSalary: {
"@type": "MonetaryAmount",
currency: "USD",
value: {
"@type": "QuantitativeValue",
value: job.fields.salary,
unitText: "YEAR"
}
}
}
return JSON.stringify(structData)
}
Learn more: https://schema.org/JobPosting
When implementing any of these schema types:
Schema.org contains over 800 types covering a wide range of content. Visit https://schema.org/docs/full.html to explore the complete hierarchy and find the schema types that best match your content needs.