Back to skills
SKILL.md
Seo Metadata
ASecuritySEO patterns for React apps — Next.js Metadata API, Open Graph, structured data (JSON-LD), sitemap generation, and Core Web Vitals optimization.
- 8 stars
- 0 votes
- 0 copies
- 0 views
- Added September 13, 2026
Works with
Security analysis
92/100- Uses curl or wget to download content
- Installs packages at runtime which could introduce malicious dependencies
npx -y skills add PMDevSolutions/Aurelius --skill seo-metadata --agent claude-codeAre you the author of Seo Metadata?
Add the live security badge to your README. It updates with every re-scan.
[](https://www.skillsdirectory.com/skills/pmdevsolutions-seo-metadata)---
name: seo-metadata
description: SEO patterns for React apps — Next.js Metadata API, Open Graph, structured data (JSON-LD), sitemap generation, and Core Web Vitals optimization.
triggers:
- "SEO"
- "metadata"
- "open graph"
- "og image"
- "sitemap"
- "structured data"
- "json-ld"
- "meta tags"
- "social sharing"
---
# SEO & Metadata — React Patterns
## 1. Next.js Static Metadata
Define static metadata in any `layout.tsx` or `page.tsx` using the exported `metadata` object. The `title.template` pattern in the root layout ensures consistent page titles site-wide.
```tsx
// app/layout.tsx
import { type Metadata } from "next";
export const metadata: Metadata = {
metadataBase: new URL("https://example.com"),
title: {
default: "My App — Build faster with React",
template: "%s | My App",
},
description:
"A modern React application built with Next.js, TypeScript, and Tailwind CSS.",
openGraph: {
type: "website",
locale: "en_US",
url: "https://example.com",
siteName: "My App",
title: "My App — Build faster with React",
description:
"A modern React application built with Next.js, TypeScript, and Tailwind CSS.",
images: [
{
url: "/og-default.png",
width: 1200,
height: 630,
alt: "My App",
},
],
},
twitter: {
card: "summary_large_image",
title: "My App — Build faster with React",
description:
"A modern React application built with Next.js, TypeScript, and Tailwind CSS.",
creator: "@myapp",
images: ["/og-default.png"],
},
robots: {
index: true,
follow: true,
googleBot: {
index: true,
follow: true,
"max-video-preview": -1,
"max-image-preview": "large",
"max-snippet": -1,
},
},
alternates: {
canonical: "https://example.com",
},
};
```
Page-level metadata merges with the layout metadata and uses the `title.template`:
```tsx
// app/about/page.tsx
import { type Metadata } from "next";
export const metadata: Metadata = {
title: "About Us", // Renders as "About Us | My App"
description: "Learn about our team and mission.",
alternates: {
canonical: "https://example.com/about",
},
};
export default function AboutPage() {
return <main>...</main>;
}
```
---
## 2. Dynamic Metadata
Use `generateMetadata` for pages where metadata depends on route params or fetched data.
```tsx
// app/blog/[slug]/page.tsx
import { type Metadata } from "next";
import { notFound } from "next/navigation";
import { getPost } from "@/lib/posts";
interface PageProps {
params: Promise<{ slug: string }>;
}
export async function generateMetadata({
params,
}: PageProps): Promise<Metadata> {
const { slug } = await params;
const post = await getPost(slug);
if (!post) {
return { title: "Post Not Found" };
}
return {
title: post.title,
description: post.excerpt,
authors: [{ name: post.author.name }],
openGraph: {
type: "article",
title: post.title,
description: post.excerpt,
url: `https://example.com/blog/${slug}`,
publishedTime: post.publishedAt,
modifiedTime: post.updatedAt,
authors: [post.author.name],
images: [
{
url: post.coverImage,
width: 1200,
height: 630,
alt: post.title,
},
],
tags: post.tags,
},
twitter: {
card: "summary_large_image",
title: post.title,
description: post.excerpt,
images: [post.coverImage],
},
alternates: {
canonical: `https://example.com/blog/${slug}`,
},
};
}
export default async function BlogPostPage({ params }: PageProps) {
const { slug } = await params;
const post = await getPost(slug);
if (!post) {
notFound();
}
return (
<article>
<h1>{post.title}</h1>
<div dangerouslySetInnerHTML={{ __html: post.content }} />
</article>
);
}
```
---
## 3. Dynamic OG Image Generation
Use `ImageResponse` from `next/og` to generate Open Graph images on demand. This produces shareable social cards with dynamic content.
```tsx
// app/api/og/route.tsx
import { ImageResponse } from "next/og";
import { type NextRequest } from "next/server";
export const runtime = "edge";
export async function GET(request: NextRequest) {
const { searchParams } = request.nextUrl;
const title = searchParams.get("title") ?? "My App";
const description = searchParams.get("description") ?? "";
return new ImageResponse(
(
<div
style={{
display: "flex",
flexDirection: "column",
justifyContent: "center",
alignItems: "flex-start",
width: "100%",
height: "100%",
padding: "60px 80px",
backgroundColor: "#0f172a",
color: "#f8fafc",
fontFamily: "Inter, sans-serif",
}}
>
<div
style={{
fontSize: 64,
fontWeight: 700,
lineHeight: 1.2,
maxWidth: "80%",
}}
>
{title}
</div>
{description && (
<div
style={{
fontSize: 28,
color: "#94a3b8",
marginTop: 24,
maxWidth: "70%",
lineHeight: 1.4,
}}
>
{description}
</div>
)}
<div
style={{
display: "flex",
alignItems: "center",
marginTop: "auto",
fontSize: 24,
color: "#64748b",
}}
>
example.com
</div>
</div>
),
{
width: 1200,
height: 630,
}
);
}
```
Reference the OG image in metadata:
```tsx
export async function generateMetadata({ params }: PageProps): Promise<Metadata> {
const { slug } = await params;
const post = await getPost(slug);
return {
title: post.title,
openGraph: {
images: [
{
url: `/api/og?title=${encodeURIComponent(post.title)}&description=${encodeURIComponent(post.excerpt)}`,
width: 1200,
height: 630,
alt: post.title,
},
],
},
};
}
```
---
## 4. Structured Data (JSON-LD)
Structured data helps search engines understand your content. Use JSON-LD format, which Google recommends.
### Reusable JsonLd Component
```tsx
// components/JsonLd.tsx
interface JsonLdProps {
data: Record<string, unknown>;
}
export function JsonLd({ data }: JsonLdProps) {
return (
<script
type="application/ld+json"
dangerouslySetInnerHTML={{ __html: JSON.stringify(data) }}
/>
);
}
```
### Article Schema
```tsx
// app/blog/[slug]/page.tsx
import { JsonLd } from "@/components/JsonLd";
export default async function BlogPostPage({ params }: PageProps) {
const { slug } = await params;
const post = await getPost(slug);
const articleJsonLd = {
"@context": "https://schema.org",
"@type": "Article",
headline: post.title,
description: post.excerpt,
image: post.coverImage,
datePublished: post.publishedAt,
dateModified: post.updatedAt,
author: {
"@type": "Person",
name: post.author.name,
url: post.author.url,
},
publisher: {
"@type": "Organization",
name: "My App",
logo: {
"@type": "ImageObject",
url: "https://example.com/logo.png",
},
},
mainEntityOfPage: {
"@type": "WebPage",
"@id": `https://example.com/blog/${slug}`,
},
};
return (
<>
<JsonLd data={articleJsonLd} />
<article>
<h1>{post.title}</h1>
<div dangerouslySetInnerHTML={{ __html: post.content }} />
</article>
</>
);
}
```
### Product Schema
```tsx
const productJsonLd = {
"@context": "https://schema.org",
"@type": "Product",
name: product.name,
description: product.description,
image: product.images,
sku: product.sku,
brand: {
"@type": "Brand",
name: product.brand,
},
offers: {
"@type": "Offer",
url: `https://example.com/products/${product.slug}`,
priceCurrency: "USD",
price: product.price,
availability: product.inStock
? "https://schema.org/InStock"
: "https://schema.org/OutOfStock",
seller: {
"@type": "Organization",
name: "My Store",
},
},
aggregateRating: product.reviewCount > 0
? {
"@type": "AggregateRating",
ratingValue: product.averageRating,
reviewCount: product.reviewCount,
}
: undefined,
};
```
### Organization Schema (Root Layout)
```tsx
// app/layout.tsx
import { JsonLd } from "@/components/JsonLd";
const organizationJsonLd = {
"@context": "https://schema.org",
"@type": "Organization",
name: "My App",
url: "https://example.com",
logo: "https://example.com/logo.png",
sameAs: [
"https://twitter.com/myapp",
"https://github.com/myapp",
"https://linkedin.com/company/myapp",
],
contactPoint: {
"@type": "ContactPoint",
email: "support@example.com",
contactType: "customer support",
},
};
export default function RootLayout({ children }: { children: React.ReactNode }) {
return (
<html lang="en">
<body>
<JsonLd data={organizationJsonLd} />
{children}
</body>
</html>
);
}
```
---
## 5. Sitemap Generation
Next.js supports `sitemap.ts` files that generate sitemaps at build time or on request.
```tsx
// app/sitemap.ts
import { type MetadataRoute } from "next";
import { getAllPosts } from "@/lib/posts";
import { getAllProducts } from "@/lib/products";
export default async function sitemap(): Promise<MetadataRoute.Sitemap> {
const baseUrl = "https://example.com";
// Static pages
const staticPages: MetadataRoute.Sitemap = [
{
url: baseUrl,
lastModified: new Date(),
changeFrequency: "daily",
priority: 1.0,
},
{
url: `${baseUrl}/about`,
lastModified: new Date(),
changeFrequency: "monthly",
priority: 0.8,
},
{
url: `${baseUrl}/blog`,
lastModified: new Date(),
changeFrequency: "daily",
priority: 0.9,
},
{
url: `${baseUrl}/contact`,
lastModified: new Date(),
changeFrequency: "yearly",
priority: 0.5,
},
];
// Dynamic blog posts
const posts = await getAllPosts();
const blogPages: MetadataRoute.Sitemap = posts.map((post) => ({
url: `${baseUrl}/blog/${post.slug}`,
lastModified: new Date(post.updatedAt),
changeFrequency: "weekly" as const,
priority: 0.7,
}));
// Dynamic product pages
const products = await getAllProducts();
const productPages: MetadataRoute.Sitemap = products.map((product) => ({
url: `${baseUrl}/products/${product.slug}`,
lastModified: new Date(product.updatedAt),
changeFrequency: "daily" as const,
priority: 0.8,
}));
return [...staticPages, ...blogPages, ...productPages];
}
```
For large sites with more than 50,000 URLs, split into multiple sitemaps:
```tsx
// app/sitemap/[id]/route.ts
import { type MetadataRoute } from "next";
export async function generateSitemaps() {
const totalProducts = await getProductCount();
const sitemapCount = Math.ceil(totalProducts / 50000);
return Array.from({ length: sitemapCount }, (_, i) => ({ id: i }));
}
export default async function sitemap({
id,
}: {
id: number;
}): Promise<MetadataRoute.Sitemap> {
const products = await getProducts({ offset: id * 50000, limit: 50000 });
return products.map((product) => ({
url: `https://example.com/products/${product.slug}`,
lastModified: product.updatedAt,
}));
}
```
---
## 6. Robots.txt
```tsx
// app/robots.ts
import { type MetadataRoute } from "next";
export default function robots(): MetadataRoute.Robots {
const baseUrl = "https://example.com";
return {
rules: [
{
userAgent: "*",
allow: "/",
disallow: ["/api/", "/admin/", "/private/"],
},
{
userAgent: "GPTBot",
disallow: "/",
},
],
sitemap: `${baseUrl}/sitemap.xml`,
};
}
```
---
## 7. Vite SPA SEO
SPAs cannot use the Next.js Metadata API. Use `react-helmet-async` for client-side meta tags, or consider Vike (formerly vite-plugin-ssr) for SSR.
### react-helmet-async
```bash
pnpm add react-helmet-async
```
```tsx
// main.tsx
import { HelmetProvider } from "react-helmet-async";
createRoot(document.getElementById("root")!).render(
<HelmetProvider>
<App />
</HelmetProvider>
);
```
```tsx
// components/SEO.tsx
import { Helmet } from "react-helmet-async";
interface SEOProps {
title: string;
description: string;
url: string;
image?: string;
type?: "website" | "article";
}
export function SEO({
title,
description,
url,
image = "/og-default.png",
type = "website",
}: SEOProps) {
const siteName = "My App";
const fullTitle = `${title} | ${siteName}`;
return (
<Helmet>
<title>{fullTitle}</title>
<meta name="description" content={description} />
<link rel="canonical" href={url} />
{/* Open Graph */}
<meta property="og:type" content={type} />
<meta property="og:title" content={fullTitle} />
<meta property="og:description" content={description} />
<meta property="og:url" content={url} />
<meta property="og:image" content={image} />
<meta property="og:site_name" content={siteName} />
{/* Twitter */}
<meta name="twitter:card" content="summary_large_image" />
<meta name="twitter:title" content={fullTitle} />
<meta name="twitter:description" content={description} />
<meta name="twitter:image" content={image} />
</Helmet>
);
}
```
Usage:
```tsx
function AboutPage() {
return (
<>
<SEO
title="About Us"
description="Learn about our team and mission."
url="https://example.com/about"
/>
<main>...</main>
</>
);
}
```
**SPA Limitations:** Client-side meta tags only work if the crawler executes JavaScript. For full SEO support with a Vite app, use Vike for SSR/SSG or a pre-rendering service.
### Vike (SSR for Vite)
```bash
pnpm add vike vike-react
```
Vike provides file-based routing with SSR, enabling proper meta tags in the initial HTML response. See the Vike documentation for full setup.
---
## 8. SEO Checklist
Every page shipped to production must satisfy these requirements:
| Requirement | How to Verify |
|-------------|---------------|
| Unique `<title>` per page | Each page has distinct, descriptive title (50-60 characters) |
| Meta description | Each page has a unique description (150-160 characters) |
| Open Graph tags | `og:title`, `og:description`, `og:image`, `og:url` present |
| Twitter card tags | `twitter:card`, `twitter:title`, `twitter:image` present |
| Canonical URL | `<link rel="canonical">` on every page to prevent duplicates |
| JSON-LD structured data | Article, Product, Organization, or BreadcrumbList as appropriate |
| Sitemap | `sitemap.xml` generated and submitted to Google Search Console |
| Robots.txt | Exists, allows indexing of public pages, blocks private routes |
| Image alt text | Every `<img>` has a descriptive `alt` attribute |
| Heading hierarchy | Single `<h1>` per page, logical `<h2>`-`<h6>` nesting |
| Core Web Vitals | LCP < 2.5s, INP < 200ms, CLS < 0.1 |
| Mobile-friendly | Responsive layout, `<meta name="viewport">` present |
| HTTPS | All pages served over HTTPS |
| No broken links | Internal and external links return 200 |
| `lang` attribute | `<html lang="en">` (or appropriate language code) |
### Quick Validation Commands
```bash
# Lighthouse SEO audit
npx lighthouse https://example.com --only-categories=seo --output=json
# Check Open Graph tags
curl -s https://example.com | grep -E 'og:|twitter:'
# Validate structured data
# Use Google's Rich Results Test: https://search.google.com/test/rich-results
# Check sitemap
curl -s https://example.com/sitemap.xml | head -50
# Check robots.txt
curl -s https://example.com/robots.txt
```
Attribution
Comments
Loading comments…
Newsletter
Get the weekly list of safe new skills, and the ones we pulled.