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:

  1. Content Analysis Function: A utility function that examines your Agility CMS content and generates appropriate JSON-LD markup
  2. 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 ModelSchema.org TypeUse Case
BlogPostBlogPostingBlog articles and posts
EventEventWebinars, conferences, meetups
ResourceArticleWhitepapers, guides, documentation
CaseStudyArticleCustomer 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:

  1. Google Rich Results Test: 
  2. Schema.org Validator: 
  3. 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:

  1. Identify the appropriate Schema.org type: Visit 
  2. Create a generator function: Follow the pattern established for existing types
  3. Add a condition in getRichSnippet: Check for your content type's 
  4. 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)
}

Learn more: https://schema.org/VideoObject

Podcast Schema

For podcast episodes and series:

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&amp;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)
}

Learn more: https://schema.org/Recipe

Job Postings

JobPosting Schema

For career and recruitment pages:

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:

  1. Check Google's Support: Not all Schema.org types are supported by Google for rich results. Check 
  2. Use Required Properties: Each schema type has required and recommended properties. Always include required properties and as many recommended ones as possible.
  3. Test Thoroughly: Use the 
  4. Match Content to Schema: Choose the schema type that most closely matches your content. Don't force content into an inappropriate schema type.
  5. 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.