ADDING_NEW_COMPONENTS.md
This guide walks through the process of adding new JSON-LD structured data components to next-seo. We'll use the ArticleJsonLd component as a reference implementation.
Before implementing, thoroughly research the structured data specification:
Visit Google's Documentation
Analyze Schema Types
Review Existing Implementation
Create comprehensive TypeScript types in src/types/[component].types.ts:
// src/types/article.types.ts
import type { ImageObject, Person, Organization, Author } from "./common.types";
// Note: Common types like ImageObject, Person, Organization, and Author
// are now defined in common.types.ts to avoid duplication
// Base interface with common properties
export interface ArticleBase {
headline: string;
url?: string;
author?: Author | Author[];
datePublished?: string;
dateModified?: string;
image?: string | ImageObject | (string | ImageObject)[];
publisher?: Organization;
description?: string;
isAccessibleForFree?: boolean;
mainEntityOfPage?:
| string
| {
"@type": "WebPage";
"@id": string;
};
}
// Specific schema type interfaces
export interface Article extends ArticleBase {
"@type": "Article";
}
export interface NewsArticle extends ArticleBase {
"@type": "NewsArticle";
}
export interface BlogPosting extends ArticleBase {
"@type": "BlogPosting";
}
// Component props type
export type ArticleJsonLdProps = (
| Omit<Article, "@type">
| Omit<NewsArticle, "@type">
| Omit<BlogPosting, "@type">
) & {
type?: "Article" | "NewsArticle" | "BlogPosting";
scriptId?: string;
scriptKey?: string;
};
string | Person | Organization)scriptId and scriptKeycommon.types.ts for shared definitions like ImageObject, Person, Organization, and AuthorA core design principle of next-seo is that developers should not need to specify @type properties manually. This provides better developer experience while maintaining full Schema.org compliance.
Type Definitions: Use Omit<Type, "@type"> to create props that don't require @type:
export type ArticleJsonLdProps = (
| Omit<Article, "@type">
| Omit<NewsArticle, "@type">
| Omit<BlogPosting, "@type">
) & {
type?: "Article" | "NewsArticle" | "BlogPosting";
// ... other props
};
Process Functions: Automatically add the correct @type based on input:
// Developers can pass a simple string
author="John Doe"
// Process function converts it to a proper Person object
processAuthor("John Doe") // → { "@type": "Person", name: "John Doe" }
// Or pass an object without @type
author={{ name: "John Doe", url: "https://example.com" }}
// Process function adds @type intelligently
processAuthor({...}) // → { "@type": "Person", name: "John Doe", url: "..." }
Intelligent Type Detection: Process functions use property analysis to determine types:
logo, address, or contactPoint → OrganizationfamilyName or givenName → Person@type@type if providedCreate the component in src/components/[Component]JsonLd.tsx:
// src/components/ArticleJsonLd.tsx
import { JsonLdScript } from "~/core/JsonLdScript";
import type { ArticleJsonLdProps } from "~/types/article.types";
import { processAuthor, processImage } from "~/utils/processors";
// Note: Common processing functions like processAuthor and processImage
// are now available in ~/utils/processors.ts to avoid duplication
export default function ArticleJsonLd({
type = "Article",
scriptId,
scriptKey,
headline,
url,
author,
datePublished,
dateModified,
image,
publisher,
description,
isAccessibleForFree,
mainEntityOfPage,
}: ArticleJsonLdProps) {
const data = {
"@context": "https://schema.org",
"@type": type,
headline,
...(url && { url }),
...(author && {
author: Array.isArray(author)
? author.map(processAuthor)
: processAuthor(author),
}),
...(datePublished && { datePublished }),
...(dateModified && { dateModified }),
// Apply defaults where appropriate
...(!dateModified && datePublished && { dateModified: datePublished }),
...(image && {
image: Array.isArray(image) ? image.map(processImage) : processImage(image),
}),
...(publisher && { publisher }),
...(description && { description }),
...(isAccessibleForFree !== undefined && { isAccessibleForFree }),
...(mainEntityOfPage && { mainEntityOfPage }),
};
return (
<JsonLdScript
data={data}
id={scriptId}
scriptKey={scriptKey || `article-jsonld-${type}`}
/>
);
}
export type { ArticleJsonLdProps };
JsonLdScript component for rendering (now with TypeScript generics support)~/utils/processors.map()!== undefined@type)@type - the component should set the main @type from the type prop, and process functions should handle nested objectsUpdate src/index.ts to export your component:
export { JsonLdScript } from "./core/JsonLdScript";
export {
default as ArticleJsonLd,
type ArticleJsonLdProps,
} from "./components/ArticleJsonLd";
// Add your new component here
export const version = "7.0.0-alpha.0";
Create comprehensive tests in src/components/[Component]JsonLd.test.tsx:
import { render } from "@testing-library/react";
import { describe, it, expect } from "vitest";
import ArticleJsonLd from "./ArticleJsonLd";
describe("ArticleJsonLd", () => {
it("renders basic Article with minimal props", () => {
const { container } = render(
<ArticleJsonLd
headline="Test Article"
datePublished="2024-01-01T00:00:00.000Z"
/>
);
const script = container.querySelector('script[type="application/ld+json"]');
expect(script).toBeTruthy();
const jsonData = JSON.parse(script!.textContent!);
expect(jsonData).toEqual({
"@context": "https://schema.org",
"@type": "Article",
headline: "Test Article",
datePublished: "2024-01-01T00:00:00.000Z",
dateModified: "2024-01-01T00:00:00.000Z", // defaults to datePublished
});
});
// Test each schema type
it("renders NewsArticle type when specified", () => {
// ... test implementation
});
// Test flexible inputs
it("handles string author", () => {
// ... converts string to Person object
});
it("handles multiple authors", () => {
// ... test array handling
});
// Test all properties
it("handles all optional properties", () => {
// ... comprehensive test with all props
});
// Test edge cases
it("handles isAccessibleForFree as false", () => {
// ... ensure boolean false is included
});
});
Add comprehensive documentation to README.md:
### ArticleJsonLd
The `ArticleJsonLd` component helps you add structured data for articles, blog posts, and news articles to improve their appearance in search results.
#### Basic Usage
```tsx
import { ArticleJsonLd } from "next-seo";
<ArticleJsonLd
headline="My Amazing Article"
datePublished="2024-01-01T08:00:00+08:00"
author="John Doe"
image="https://example.com/article-image.jpg"
description="This article explains amazing things"
/>;
```
| Property | Type | Description |
|---|---|---|
type | "Article" | "NewsArticle" | "BlogPosting" | The type of article. Defaults to "Article" |
headline | string | Required. The headline of the article |
| ... | ... | ... |
## 7. Example Pages
Create example pages in `examples/app-router-showcase/app/[component]/page.tsx`:
```tsx
import { ArticleJsonLd } from "next-seo";
export default function ArticlePage() {
return (
<div className="container mx-auto p-8">
<ArticleJsonLd
headline="Understanding Next.js App Router"
url="https://example.com/articles/nextjs-app-router"
datePublished="2024-01-01T08:00:00+00:00"
author="Sarah Johnson"
image="https://example.com/images/nextjs-article.jpg"
description="A comprehensive guide to Next.js App Router"
/>
<article className="prose lg:prose-xl">
<h1>Understanding Next.js App Router</h1>
</article>
</div>
);
}
Create examples for:
Create Playwright tests in tests/e2e/[component]JsonLd.e2e.spec.ts:
ALL E2E tests must use real example pages! E2E tests should test the actual component behavior through real pages in the example app. Never mock or inject content in E2E tests.
❌ DO NOT use page.route() to inject mock HTML:
// BAD - This is not a real E2E test!
await page.route("/test-page", async (route) => {
await route.fulfill({
body: `<html>...</html>`,
});
});
✅ DO create real example pages and test them:
// GOOD - Test real pages with actual components
await page.goto("/article");
For every E2E test scenario, you must:
examples/app-router-showcase/app/import { test, expect } from "@playwright/test";
test.describe("ArticleJsonLd", () => {
test("renders basic Article structured data", async ({ page }) => {
// Navigate to the real example page
await page.goto("/article");
const jsonLdScript = await page
.locator('script[type="application/ld+json"]')
.textContent();
expect(jsonLdScript).toBeTruthy();
const jsonData = JSON.parse(jsonLdScript!);
// Verify all properties
expect(jsonData["@context"]).toBe("https://schema.org");
expect(jsonData["@type"]).toBe("Article");
expect(jsonData.headline).toBe("Understanding Next.js App Router");
// ... test all properties
});
test("properly escapes HTML entities in content", async ({ page }) => {
// Navigate to a real example page with special characters
await page.goto("/article-special-chars");
const jsonLdScript = await page
.locator('script[type="application/ld+json"]')
.textContent();
// Verify JSON is valid and content is properly escaped
const jsonData = JSON.parse(jsonLdScript!);
expect(jsonData.headline).toContain("Special & Characters");
// Check that dangerous content is escaped in the raw JSON
expect(jsonLdScript).toContain("\\u003C/script>");
});
});
Create new example pages for:
Example structure:
examples/app-router-showcase/app/
├── article/ # Basic article example
├── article-advanced/ # All features
├── news-article/ # NewsArticle type
├── blog-posting/ # BlogPosting type
└── article-special-chars/ # Special characters test
You should also add a valid JSON test in tests/e2e/jsonValidation.e2e.spec.ts
DO NOT add escape/security tests to individual component E2E tests!
Security testing for escaping dangerous sequences (like </script>, HTML comments, etc.) is handled centrally in tests/e2e/security.e2e.spec.ts. This test file comprehensively covers:
Individual component E2E tests should focus on:
The escaping functionality is a core library feature handled by the stringify utility, not something each component needs to test individually.
Before completing, run all quality checks:
# 1. Run unit tests
pnpm test:unit
# 2. Type checking
pnpm typecheck
# 3. Linting
pnpm lint
# 4. Build the package
pnpm build
Developer will run e2e manually as they can take a long time.
The library now provides shared utilities to avoid code duplication:
Common Types (~/types/common.types.ts):
ImageObject, Person, Organization, AuthorThingProcessing Functions (~/utils/processors.ts):
processAuthor(author: Author): Person | OrganizationprocessImage(image: string | ImageObject): string | ImageObjectUse the shared processing functions from ~/utils/processors:
import { processAuthor, processImage } from "~/utils/processors";
// These functions handle string-to-object conversions automatically
// and add the appropriate @type without developers needing to specify it
Important: Always create or use existing process functions for properties that can accept multiple formats. This maintains the pattern of not requiring developers to specify @type and ensures consistent behavior across all components.
Use object spread with conditional checks:
const data = {
"@context": "https://schema.org",
"@type": type,
headline,
...(url && { url }), // Only include if truthy
...(isAccessibleForFree !== undefined && { isAccessibleForFree }), // Include false values
};
Apply sensible defaults where appropriate:
// If dateModified is not provided but datePublished is, use datePublished
...(!dateModified && datePublished && { dateModified: datePublished }),
Support both single items and arrays:
...(author && {
author: Array.isArray(author)
? author.map(processAuthor)
: processAuthor(author),
}),
ESLint errors about unused React import
import React from 'react' - it's not needed with modern JSX transformTest failures with dateModified
Boolean properties not appearing
!== undefined check instead of truthy check for booleansType errors with union types
common.types.ts)~/utils/processorspnpm test:sweep)