Visit Agility Academy to take courses and earn certifications. It's free, and you can learn at your own pace. Learn More
Implementing JSON-LD Structured Data with Next.js
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.
Why Use JSON-LD?
- Improved SEO: Search engines can better understand and index your content
- Rich Snippets: Enhanced search results with images, ratings, dates, and more
- Better Click-Through Rates: Rich snippets are more visually appealing and informative
- Content Discovery: Helps search engines categorize and surface your content appropriately
Understanding the Implementation
The implementation consists of two main parts:
- Content Analysis Function: A utility function that examines your Agility CMS content and generates appropriate JSON-LD markup
- Page Rendering: Integration with Next.js App Router to include the JSON-LD in your page's HTML
Step 1: Create the JSON-LD Generator Function
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
}
Step 2: Implement Structured Data for Blog Posts
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)
}
Step 3: Implement Structured Data for Events
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)
}
Step 4: Integrate with Next.js App Router
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>
)
}
Example Content Types
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.
Testing Your Structured Data
After implementing JSON-LD, it's crucial to validate that your structured data is correct:
- Google Rich Results Test:
- Schema.org Validator:
- Google Search Console: Monitor your rich results performance
Best Practices
1. Include Required Properties
Always include the minimum required properties for each schema type:
- BlogPosting: headline, image, datePublished, dateModified
- Event: name, startDate, location
- Article: headline, image, datePublished, dateModified
2. Use Accurate Dates
Ensure dates are in ISO 8601 format:
const datePublished = DateTime.fromISO(post.fields.date).toISO()
3. Provide High-Quality Images
- Use images at least 1200px wide
- Ensure images are relevant to the content
- Include multiple image sizes when possible
4. Keep Content Synchronized
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
5. Handle Missing Data Gracefully
Only include optional properties when data is available:
if (author) {
structData.author = [
{
"@type": "Person",
name: author,
url: authorImage
}
]
}
Extending to Other Content Types
To add structured data support for additional content types:
- Identify the appropriate Schema.org type: Visit
- Create a generator function: Follow the pattern established for existing types
- Add a condition in getRichSnippet: Check for your content type's
- Test thoroughly: Use Google's Rich Results Test
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)
}
Common Issues and Solutions
Issue: Structured Data Not Appearing in Search Results
Solution: Rich results can take several weeks to appear. Ensure:
- Your structured data passes validation
- Your page is indexed by Google
- Your content meets Google's quality guidelines
Issue: "Missing required field" errors
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]
Issue: Dates not recognized
Solution: Ensure dates are in ISO 8601 format:
// ❌ Wrong
"datePublished": "12/25/2023"
// ✅ Correct
"datePublished": "2023-12-25T10:00:00Z"
Other JSON-LD Schema Types
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:
Educational Content
Course Schema
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
EducationalOccupationalProgram
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
E-Commerce and Products
Product Schema
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
Business and Organizations
LocalBusiness Schema
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
Media and Creative Works
VideoObject Schema
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
Reviews and Ratings
Review Schema
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
FAQ and Q&A
FAQPage Schema
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
Recipe Content
Recipe Schema
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
Implementation Tips for Custom Schema Types
When implementing any of these schema types:
- Check Google's Support: Not all Schema.org types are supported by Google for rich results. Check
- Use Required Properties: Each schema type has required and recommended properties. Always include required properties and as many recommended ones as possible.
- Test Thoroughly: Use the
- Match Content to Schema: Choose the schema type that most closely matches your content. Don't force content into an inappropriate schema type.
- Keep It Updated: Schema.org evolves over time. Check the
Exploring More 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.