CUSTOM_COMPONENTS.md
This guide shows you how to create your own structured data components using next-seo's core utilities, maintaining the same excellent developer experience as the built-in components.
Create a custom JSON-LD component in just a few lines:
import { JsonLdScript, processors } from "next-seo";
export function PodcastEpisodeJsonLd({ name, author, duration, url }) {
const data = {
"@context": "https://schema.org",
"@type": "PodcastEpisode",
name,
...(url && { url }),
...(duration && { duration }),
...(author && { author: processors.processAuthor(author) }),
};
return <JsonLdScript data={data} scriptKey="podcast-episode" />;
}
// Usage - no @type needed!
<PodcastEpisodeJsonLd
name="Episode 1: Getting Started"
author="Jane Doe" // Simple string works!
duration="PT30M"
url="https://example.com/episode-1"
/>;
The JsonLdScript component is the foundation for rendering structured data:
import { JsonLdScript } from "next-seo";
<JsonLdScript
data={yourStructuredData}
id={optionalId} // Optional: HTML id attribute
scriptKey={requiredKey} // Required: React key for the script element
/>;
Next SEO's key principle: developers should never need to specify @type manually. This is achieved through intelligent processors that automatically add the correct Schema.org types.
// Your users write this:
author="John Doe"
// Your processor converts it to:
{ "@type": "Person", name: "John Doe" }
Processors are functions that transform flexible inputs into properly typed Schema.org objects:
import { processors } from "next-seo";
// Use built-in processors for common types
const author = processors.processAuthor("John Doe");
const image = processors.processImage({ url: "image.jpg", width: 800 });
const address = processors.processAddress("123 Main St");
Next SEO provides 60+ processors for common Schema.org types:
import { processors } from "next-seo";
// Flexible author input
processors.processAuthor("Jane Doe"); // → Person
processors.processAuthor({ name: "ACME Corp", logo: "..." }); // → Organization
// Other people/org processors
processors.processPublisher("Tech Publishing");
processors.processOrganizer({ name: "Event Co", url: "..." });
processors.processPerformer("Band Name");
// Images - string URL or ImageObject
processors.processImage("https://example.com/image.jpg");
processors.processImage({ url: "...", width: 800, height: 600 });
// Videos
processors.processVideo({
name: "Tutorial",
uploadDate: "2024-01-01",
thumbnailUrl: "...",
});
// Other media processors
processors.processLogo("logo.jpg");
processors.processScreenshot({ url: "...", caption: "App screenshot" });
// Simple string becomes PostalAddress
processors.processAddress("123 Main St, City, Country");
// Object with more details
processors.processAddress({
streetAddress: "123 Main St",
addressLocality: "San Francisco",
addressRegion: "CA",
postalCode: "94105",
addressCountry: "US",
});
// Places with geo coordinates
processors.processPlace({
name: "Office",
geo: { latitude: 37.7749, longitude: -122.4194 },
});
// Product offers
processors.processProductOffer({
price: 29.99,
priceCurrency: "USD",
availability: "https://schema.org/InStock",
});
// Return policies
processors.processMerchantReturnPolicy({
returnPolicyCategory: "https://schema.org/MerchantReturnFiniteReturnWindow",
merchantReturnDays: 30,
});
Create processors for your specific needs:
import { processors } from "next-seo";
// Custom processor for a podcast host
function processHost(host: string | { name: string; bio?: string }) {
if (typeof host === "string") {
return {
"@type": "Person",
name: host,
};
}
// Use the generic helper for objects
return processors.processSchemaType(host, "Person");
}
// Use in your component
export function PodcastJsonLd({ hosts, ...props }) {
const data = {
"@context": "https://schema.org",
"@type": "PodcastSeries",
...(hosts && {
host: Array.isArray(hosts) ? hosts.map(processHost) : processHost(hosts),
}),
};
return <JsonLdScript data={data} scriptKey="podcast" />;
}
Intelligently determine the type based on input properties:
function processCreativeWork(work: string | Record<string, any>) {
if (typeof work === "string") {
return {
"@type": "CreativeWork",
name: work,
};
}
// Already has @type? Return as-is
if (work["@type"]) {
return work;
}
// Detect type based on properties
let type = "CreativeWork";
if ("isbn" in work) type = "Book";
else if ("director" in work) type = "Movie";
else if ("artist" in work) type = "MusicRecording";
return {
"@type": type,
...work,
};
}
Process nested structures recursively:
function processEventWithVenue(event: {
name: string;
venue?: string | { name: string; address?: string };
organizer?: string | { name: string };
}) {
return {
"@type": "Event",
name: event.name,
...(event.venue && {
location:
typeof event.venue === "string"
? processors.processPlace(event.venue)
: processors.processPlace({
...event.venue,
...(event.venue.address && {
address: processors.processAddress(event.venue.address),
}),
}),
}),
...(event.organizer && {
organizer: processors.processOrganizer(event.organizer),
}),
};
}
Include properties only when they have values:
export function CustomProductJsonLd({
name,
description,
price,
image,
brand,
reviews,
aggregateRating,
...props
}) {
const data = {
"@context": "https://schema.org",
"@type": "Product",
name,
...(description && { description }),
...(price && {
offers: {
"@type": "Offer",
price,
priceCurrency: "USD",
},
}),
...(image && {
image: Array.isArray(image)
? image.map(processors.processImage)
: processors.processImage(image),
}),
...(brand && { brand: processors.processBrand(brand) }),
...(reviews && {
review: Array.isArray(reviews)
? reviews.map(processors.processReview)
: processors.processReview(reviews),
}),
...(aggregateRating && {
aggregateRating: processors.processAggregateRating(aggregateRating),
}),
};
return <JsonLdScript data={data} scriptKey={props.scriptKey || "product"} />;
}
Support different schema types with a type prop:
type ScholarlyArticleType =
| "ScholarlyArticle"
| "MedicalScholarlyArticle"
| "TechArticle";
export function ScholarlyArticleJsonLd({
type = "ScholarlyArticle",
headline,
author,
datePublished,
journal,
doi,
...props
}: {
type?: ScholarlyArticleType;
headline: string;
author: string | Array<string | { name: string }>;
datePublished: string;
journal?: string;
doi?: string;
}) {
const data = {
"@context": "https://schema.org",
"@type": type,
headline,
datePublished,
...(author && {
author: Array.isArray(author)
? author.map(processors.processAuthor)
: processors.processAuthor(author),
}),
...(journal && {
isPartOf: {
"@type": "PublicationIssue",
name: journal,
},
}),
...(doi && { identifier: processors.processIdentifier(doi) }),
};
return (
<JsonLdScript
data={data}
scriptKey={props.scriptKey || `article-${type}`}
/>
);
}
// ✅ Good - uses processor
author: processors.processAuthor(author)
// ❌ Bad - requires user to specify @type
author: { "@type": "Person", ...author }
// Support both single and array inputs
...(tags && {
keywords: Array.isArray(tags) ? tags.join(', ') : tags
})
// Default dateModified to datePublished if not provided
const data = {
datePublished,
dateModified: dateModified || datePublished,
};
interface ServiceJsonLdProps {
name: string;
provider?: string | Organization;
areaServed?: string | string[];
serviceType?: string;
scriptId?: string;
scriptKey?: string;
}
/**
* ServiceJsonLd - Structured data for service offerings
*
* @example
* <ServiceJsonLd
* name="Web Development"
* provider="Tech Agency"
* areaServed={["US", "CA", "UK"]}
* serviceType="Professional Service"
* />
*/
export function ServiceJsonLd({ ... }) { ... }
import { JsonLdScript, processors } from "next-seo";
interface PodcastSeriesProps {
name: string;
description?: string;
host?: string | Array<string | { name: string; url?: string }>;
episodes?: Array<{
name: string;
url?: string;
duration?: string;
datePublished?: string;
}>;
image?: string | { url: string; width?: number; height?: number };
scriptKey?: string;
}
export function PodcastSeriesJsonLd({
name,
description,
host,
episodes,
image,
scriptKey = "podcast-series",
}: PodcastSeriesProps) {
const data = {
"@context": "https://schema.org",
"@type": "PodcastSeries",
name,
...(description && { description }),
...(host && {
host: Array.isArray(host)
? host.map((h) =>
typeof h === "string"
? { "@type": "Person", name: h }
: processors.processAuthor(h),
)
: typeof host === "string"
? { "@type": "Person", name: host }
: processors.processAuthor(host),
}),
...(image && { image: processors.processImage(image) }),
...(episodes && {
episode: episodes.map((ep, index) => ({
"@type": "PodcastEpisode",
name: ep.name,
position: index + 1,
...(ep.url && { url: ep.url }),
...(ep.duration && { duration: ep.duration }),
...(ep.datePublished && { datePublished: ep.datePublished }),
})),
}),
};
return <JsonLdScript data={data} scriptKey={scriptKey} />;
}
import { JsonLdScript, processors, type ImageObject } from "next-seo";
interface RealEstateListingProps {
name: string;
description?: string;
price: number;
priceCurrency?: string;
address: string | Record<string, any>;
images?: Array<string | ImageObject>;
numberOfRooms?: number;
floorSize?: { value: number; unitCode: string };
yearBuilt?: number;
scriptKey?: string;
}
export function RealEstateListingJsonLd({
name,
description,
price,
priceCurrency = "USD",
address,
images,
numberOfRooms,
floorSize,
yearBuilt,
scriptKey = "real-estate",
}: RealEstateListingProps) {
const data = {
"@context": "https://schema.org",
"@type": "RealEstateListing",
name,
...(description && { description }),
offers: {
"@type": "Offer",
price,
priceCurrency,
},
address: processors.processAddress(address),
...(images && {
image: images.map(processors.processImage),
}),
...(numberOfRooms && { numberOfRooms }),
...(floorSize && {
floorSize: processors.processQuantitativeValue(floorSize),
}),
...(yearBuilt && { yearBuilt }),
};
return <JsonLdScript data={data} scriptKey={scriptKey} />;
}
import { JsonLdScript, processors } from "next-seo";
interface ServiceWithPricingProps {
name: string;
provider: string | { name: string; url?: string };
description?: string;
pricingTiers?: Array<{
name: string;
price: number | { min: number; max: number };
features?: string[];
}>;
areaServed?: string | string[];
scriptKey?: string;
}
export function ServiceWithPricingJsonLd({
name,
provider,
description,
pricingTiers,
areaServed,
scriptKey = "service",
}: ServiceWithPricingProps) {
const data = {
"@context": "https://schema.org",
"@type": "Service",
name,
provider: processors.processOrganization(provider),
...(description && { description }),
...(pricingTiers && {
hasOfferCatalog: {
"@type": "OfferCatalog",
name: `${name} Pricing`,
itemListElement: pricingTiers.map((tier) => ({
"@type": "Offer",
name: tier.name,
...(typeof tier.price === "number"
? { price: tier.price }
: {
priceSpecification: {
"@type": "PriceSpecification",
minPrice: tier.price.min,
maxPrice: tier.price.max,
priceCurrency: "USD",
},
}),
...(tier.features && {
description: tier.features.join(", "),
}),
})),
},
}),
...(areaServed && {
areaServed: Array.isArray(areaServed) ? areaServed : [areaServed],
}),
};
return <JsonLdScript data={data} scriptKey={scriptKey} />;
}
import { JsonLdScript, processors } from "next-seo";
interface CourseWithModulesProps {
name: string;
description: string;
provider: string | { name: string; url?: string };
instructor?: string | Array<string | { name: string }>;
modules?: Array<{
name: string;
description?: string;
duration?: string;
}>;
price?: number;
startDate?: string;
endDate?: string;
scriptKey?: string;
}
export function CourseWithModulesJsonLd({
name,
description,
provider,
instructor,
modules,
price,
startDate,
endDate,
scriptKey = "course",
}: CourseWithModulesProps) {
const data = {
"@context": "https://schema.org",
"@type": "Course",
name,
description,
provider: processors.processProvider(provider),
...(instructor && {
instructor: Array.isArray(instructor)
? instructor.map(processors.processAuthor)
: processors.processAuthor(instructor),
}),
...(modules && {
hasCourseInstance: modules.map((module, index) => ({
"@type": "CourseInstance",
name: module.name,
courseMode: "online",
position: index + 1,
...(module.description && { description: module.description }),
...(module.duration && { duration: module.duration }),
})),
}),
...(price !== undefined && {
offers: {
"@type": "Offer",
price,
priceCurrency: "USD",
...(startDate && { validFrom: startDate }),
...(endDate && { validThrough: endDate }),
},
}),
};
return <JsonLdScript data={data} scriptKey={scriptKey} />;
}
import { render } from "@testing-library/react";
import { ServiceJsonLd } from "./ServiceJsonLd";
describe("ServiceJsonLd", () => {
it("renders service with basic props", () => {
const { container } = render(
<ServiceJsonLd name="Consulting Service" provider="Tech Solutions Inc" />,
);
const script = container.querySelector(
'script[type="application/ld+json"]',
);
const data = JSON.parse(script.textContent);
expect(data["@type"]).toBe("Service");
expect(data.name).toBe("Consulting Service");
expect(data.provider["@type"]).toBe("Organization");
});
});
Use Google's Rich Results Test to validate your structured data:
If you're migrating from inline JSON-LD to next-seo custom components:
<script
type="application/ld+json"
dangerouslySetInnerHTML={{
__html: JSON.stringify({
"@context": "https://schema.org",
"@type": "Service",
name: "My Service",
provider: {
"@type": "Organization",
name: "My Company",
},
}),
}}
/>
import { JsonLdScript, processors } from "next-seo";
export function ServiceJsonLd({ name, provider }) {
const data = {
"@context": "https://schema.org",
"@type": "Service",
name,
provider: processors.processOrganization(provider),
};
return <JsonLdScript data={data} scriptKey="service" />;
}
// Usage - cleaner and type-safe!
<ServiceJsonLd
name="My Service"
provider="My Company" // No @type needed!
/>;
For a complete list of available processors, see the processors export file. Key processors include:
processSchemaType(value, type) - Generic processor for any schema typeprocessAuthor(author) - Person or OrganizationprocessImage(image) - String URL or ImageObjectprocessAddress(address) - String or PostalAddressprocessPlace(place) - String or Place with addressprocessOffer(offer) - Offer with price and availabilityprocessReview(review) - Review with rating and authorprocessAggregateRating(rating) - Aggregate rating with countCreating custom JSON-LD components with next-seo is simple:
JsonLdScript and processorsThis approach gives you: