711 lines
24 KiB
Markdown
711 lines
24 KiB
Markdown
---
|
|
name: frontend-seo
|
|
description: A portable, framework-agnostic SEO system for any React or React Native-for-web frontend. Centralizes site metadata in one constants module, derives canonical URLs from a single base, builds per-route metadata (title, description, canonical, Open Graph, Twitter/X cards), generates...
|
|
risk: unknown
|
|
source: https://github.com/stareezy-1/frontend-architecture-skill/tree/main/skills/frontend-seo
|
|
source_repo: stareezy-1/frontend-architecture-skill
|
|
source_type: community
|
|
date_added: 2026-07-01
|
|
license: MIT
|
|
license_source: https://github.com/stareezy-1/frontend-architecture-skill/blob/main/LICENSE
|
|
---
|
|
|
|
# Frontend SEO (portable, builder-based)
|
|
## When to Use
|
|
|
|
Use this skill when you need a portable, framework-agnostic SEO system for any React or React Native-for-web frontend. Centralizes site metadata in one constants module, derives canonical URLs from a single base, builds per-route metadata (title, description, canonical, Open Graph, Twitter/X cards), generates...
|
|
|
|
|
|
> Portable skill — readable by Claude Code, OpenCode, Codex, Cursor, Windsurf, and others.
|
|
> This skill describes an **SEO system** — a set of pure builder functions plus a thin
|
|
> framework adapter — not a component library or a visual style.
|
|
> It pairs with the **frontend-architecture** skill: the SEO system lives in a single
|
|
> service module (`services/seo/`) and is consumed through one barrel.
|
|
|
|
The goal: every route ships **correct, consistent, machine-readable metadata** without
|
|
anyone copy-pasting `<meta>` tags. Site identity lives in **one** constants module, URLs are
|
|
**always absolute and canonical**, and search engines get a **sitemap, robots rules, an RSS
|
|
feed, and typed JSON-LD** derived from the same content the app already renders.
|
|
|
|
---
|
|
|
|
## 0. The five core ideas
|
|
|
|
1. **One source of truth for identity.** Site URL, name, description, keywords, author, social handles, OG image, and verification tokens live in a single `constants/seo` module. Nothing about the site's identity is hardcoded anywhere else.
|
|
2. **URLs are always absolute and canonical.** A single `canonicalUrl(path)` function turns any path into an absolute, trailing-slash-normalized URL. Every sitemap entry, RSS link, OG URL, and JSON-LD `@id` flows through it.
|
|
3. **Builders are pure; the adapter is thin.** Metadata, sitemap, robots, RSS, and JSON-LD are produced by pure functions that take data and return plain objects. Only one small function touches the framework's metadata type. Pure functions are trivially unit-testable.
|
|
4. **Structured data is typed and reused.** JSON-LD objects share a `JsonLd` type and a small set of `schema.org` builders (`Person`, `WebSite`, `BlogPosting`, `CreativeWork`, `BreadcrumbList`, `FAQPage`). Entities cross-reference each other by stable `@id`.
|
|
5. **Discovery surfaces are generated from content.** `sitemap.xml`, `robots.txt`, and the RSS feed are built from the same content collections the app renders — never maintained by hand, never drifting.
|
|
|
|
Everything below is the mechanical application of these five ideas.
|
|
|
|
---
|
|
|
|
## 1. Directory layout
|
|
|
|
The SEO system is one service module plus its constants and types. It slots directly into the
|
|
`frontend-architecture` shape (`shared/` or `services/`).
|
|
|
|
```
|
|
src/
|
|
├── constants/
|
|
│ └── seo.ts ← SINGLE source of truth for site identity
|
|
├── types/
|
|
│ └── seo.ts ← SchemaType, RouteDescriptor, SitemapEntry,
|
|
│ RobotsConfig, RssItem, Redirect, JsonLd
|
|
├── services/seo/
|
|
│ ├── index.ts ← barrel: canonicalUrl, buildMetadata,
|
|
│ │ sitemapEntries, robots, rssItems,
|
|
│ │ structuredData, redirects
|
|
│ └── structured-data.ts ← per-type JSON-LD builders (Person, WebSite, …)
|
|
└── app/ (or routes/) ← THIN adapter: route files call the builders
|
|
├── layout.tsx ← global default metadata (from constants/seo)
|
|
├── sitemap.ts ← mounts sitemapEntries()
|
|
├── robots.ts ← mounts robots()
|
|
└── feed.xml/route.ts ← mounts rssItems()
|
|
```
|
|
|
|
Rule of thumb: **builders never import the framework** (except the one `buildMetadata` adapter);
|
|
**route files never build SEO data inline** — they call a builder and mount the result.
|
|
|
|
---
|
|
|
|
## 2. One source of truth for identity (`constants/seo`)
|
|
|
|
Everything about the site's identity is a named constant. No bare strings scattered across
|
|
route files, no second copy of the description, no hardcoded base URL.
|
|
|
|
```ts
|
|
// constants/seo.ts
|
|
export const SITE_URL = "https://example.com"; // no trailing slash
|
|
export const SITE_NAME = "Jane Doe";
|
|
export const SITE_HANDLE = "@janedoe";
|
|
export const SITE_LOCALE = "en_US";
|
|
|
|
export const SITE_TITLE_DEFAULT = "Jane Doe — Senior Engineer";
|
|
export const SITE_TITLE_TEMPLATE = "%s | Jane Doe"; // child pages fill %s
|
|
|
|
export const SITE_DESCRIPTION =
|
|
"Senior engineer building cross-platform products with React and TypeScript.";
|
|
|
|
export const SITE_KEYWORDS = ["Jane Doe", "React", "TypeScript", "Engineer"];
|
|
|
|
export const AUTHOR_NAME = "Jane Doe";
|
|
export const AUTHOR_EMAIL = "jane@example.com";
|
|
export const AUTHOR_GITHUB = "https://github.com/janedoe";
|
|
export const AUTHOR_LINKEDIN = "https://www.linkedin.com/in/janedoe/";
|
|
|
|
export const OG_IMAGE_PATH = "/og-image.png"; // relative; canonicalized at use
|
|
export const OG_IMAGE_WIDTH = 1200;
|
|
export const OG_IMAGE_HEIGHT = 630;
|
|
|
|
export const GOOGLE_SITE_VERIFICATION = "your-search-console-token";
|
|
```
|
|
|
|
Why: changing the description or the OG image touches **one line**. Structured data, OG tags, and
|
|
Twitter cards all read the same values, so they can never disagree.
|
|
|
|
---
|
|
|
|
## 3. Typed data models (`types/seo`)
|
|
|
|
Minimal but typed. These are the contracts every builder honors.
|
|
|
|
```ts
|
|
// types/seo.ts
|
|
export type SchemaType =
|
|
| "Person"
|
|
| "WebSite"
|
|
| "BlogPosting"
|
|
| "CreativeWork"
|
|
| "BreadcrumbList"
|
|
| "FAQPage";
|
|
|
|
/** Describes a route for metadata generation. */
|
|
export interface RouteDescriptor {
|
|
path: string; // e.g. "/blog/my-post"
|
|
title: string;
|
|
description: string;
|
|
ogImage?: string; // falls back to OG_IMAGE_PATH
|
|
indexable?: boolean; // whether it appears in the sitemap
|
|
}
|
|
|
|
export interface SitemapEntry {
|
|
url: string; // absolute
|
|
lastModified?: string;
|
|
changeFrequency?:
|
|
| "always"
|
|
| "hourly"
|
|
| "daily"
|
|
| "weekly"
|
|
| "monthly"
|
|
| "yearly"
|
|
| "never";
|
|
priority?: number;
|
|
}
|
|
|
|
export interface RobotsConfig {
|
|
rules: Array<{ userAgent: string; allow?: string[]; disallow?: string[] }>;
|
|
sitemap: string; // absolute
|
|
}
|
|
|
|
export interface RssItem {
|
|
title: string;
|
|
link: string; // absolute
|
|
description: string;
|
|
pubDate: string; // ISO-8601
|
|
guid: string;
|
|
}
|
|
|
|
export interface Redirect {
|
|
source: string;
|
|
destination: string;
|
|
permanent: boolean; // 301 when true
|
|
}
|
|
|
|
/** A JSON-LD object: always a schema.org context + type, plus type-specific fields. */
|
|
export interface JsonLd {
|
|
"@context": "https://schema.org";
|
|
"@type": SchemaType;
|
|
[key: string]: unknown;
|
|
}
|
|
```
|
|
|
|
Note the `I`-prefix convention from `frontend-architecture` applies to **stateful UI interfaces**;
|
|
these SEO data models are plain DTOs and follow the source project's existing convention
|
|
(here, unprefixed). Keep whichever convention the host project already uses — consistency wins.
|
|
|
|
---
|
|
|
|
## 4. Canonical URLs (the spine of the system)
|
|
|
|
One function, used everywhere. It guarantees absolute, normalized, double-slash-free URLs so
|
|
search engines never see two URLs for the same page.
|
|
|
|
```ts
|
|
// services/seo/index.ts
|
|
import { SITE_URL } from "@/constants/seo";
|
|
|
|
export function canonicalUrl(path: string): string {
|
|
const normalized = path.startsWith("/") ? path : `/${path}`;
|
|
if (normalized === "/") return SITE_URL; // root → base, no trailing slash
|
|
const withoutTrailing = normalized.endsWith("/")
|
|
? normalized.slice(0, -1)
|
|
: normalized;
|
|
return `${SITE_URL}${withoutTrailing}`;
|
|
}
|
|
```
|
|
|
|
**Hard rules:**
|
|
|
|
- Never concatenate `SITE_URL + path` by hand — always `canonicalUrl(path)`.
|
|
- Pick one trailing-slash policy (this skill: **no trailing slash**) and apply it everywhere.
|
|
- Every OG `url`, sitemap `url`, RSS `link`, and JSON-LD `@id`/`url` goes through `canonicalUrl`.
|
|
|
|
---
|
|
|
|
## 5. Per-route metadata
|
|
|
|
### 5.1 The pure builder
|
|
|
|
`buildMetadata` is the **only** function allowed to know about the framework's metadata type.
|
|
Everything else is framework-free.
|
|
|
|
```ts
|
|
// services/seo/index.ts (Next.js example — swap the return type for other frameworks)
|
|
import type { Metadata } from "next";
|
|
import { OG_IMAGE_PATH } from "@/constants/seo";
|
|
import type { RouteDescriptor } from "@/types/seo";
|
|
|
|
export function buildMetadata(route: RouteDescriptor): Metadata {
|
|
const canonical = canonicalUrl(route.path);
|
|
const ogImageUrl = canonicalUrl(route.ogImage ?? OG_IMAGE_PATH);
|
|
|
|
return {
|
|
title: route.title,
|
|
description: route.description,
|
|
alternates: { canonical },
|
|
openGraph: { images: [ogImageUrl] },
|
|
};
|
|
}
|
|
```
|
|
|
|
### 5.2 Global defaults live in the root layout
|
|
|
|
Set the title template, default OG/Twitter cards, robots policy, icons, manifest, and
|
|
verification **once** at the root. Child routes only override what differs.
|
|
|
|
```tsx
|
|
// app/layout.tsx — global metadata, all values from constants/seo
|
|
import type { Metadata } from "next";
|
|
import {
|
|
SITE_URL,
|
|
SITE_NAME,
|
|
SITE_HANDLE,
|
|
SITE_LOCALE,
|
|
SITE_TITLE_DEFAULT,
|
|
SITE_TITLE_TEMPLATE,
|
|
SITE_DESCRIPTION,
|
|
SITE_KEYWORDS,
|
|
AUTHOR_NAME,
|
|
OG_IMAGE_PATH,
|
|
OG_IMAGE_WIDTH,
|
|
OG_IMAGE_HEIGHT,
|
|
GOOGLE_SITE_VERIFICATION,
|
|
} from "@/constants/seo";
|
|
|
|
export const metadata: Metadata = {
|
|
metadataBase: new URL(SITE_URL),
|
|
title: { default: SITE_TITLE_DEFAULT, template: SITE_TITLE_TEMPLATE },
|
|
description: SITE_DESCRIPTION,
|
|
keywords: SITE_KEYWORDS,
|
|
authors: [{ name: AUTHOR_NAME, url: SITE_URL }],
|
|
alternates: {
|
|
canonical: SITE_URL,
|
|
types: { "application/rss+xml": `${SITE_URL}/feed.xml` },
|
|
},
|
|
openGraph: {
|
|
type: "website",
|
|
locale: SITE_LOCALE,
|
|
url: SITE_URL,
|
|
siteName: SITE_NAME,
|
|
title: SITE_TITLE_DEFAULT,
|
|
description: SITE_DESCRIPTION,
|
|
images: [
|
|
{ url: OG_IMAGE_PATH, width: OG_IMAGE_WIDTH, height: OG_IMAGE_HEIGHT },
|
|
],
|
|
},
|
|
twitter: {
|
|
card: "summary_large_image",
|
|
site: SITE_HANDLE,
|
|
creator: SITE_HANDLE,
|
|
title: SITE_TITLE_DEFAULT,
|
|
description: SITE_DESCRIPTION,
|
|
images: [OG_IMAGE_PATH],
|
|
},
|
|
robots: {
|
|
index: true,
|
|
follow: true,
|
|
googleBot: {
|
|
index: true,
|
|
follow: true,
|
|
"max-image-preview": "large",
|
|
"max-snippet": -1,
|
|
},
|
|
},
|
|
verification: { google: GOOGLE_SITE_VERIFICATION },
|
|
manifest: "/manifest.webmanifest",
|
|
};
|
|
```
|
|
|
|
### 5.3 Per-route override (dynamic pages)
|
|
|
|
A dynamic route reads its entity and returns route-specific metadata. The title template fills
|
|
`%s` automatically, so just pass the page title.
|
|
|
|
```tsx
|
|
// app/blog/[slug]/page.tsx
|
|
import { buildMetadata } from "@/services/seo";
|
|
|
|
export async function generateMetadata({ params }) {
|
|
const post = await loadPost(params.slug);
|
|
return buildMetadata({
|
|
path: `/blog/${post.slug}`,
|
|
title: post.title,
|
|
description: post.description,
|
|
ogImage: post.heroImage,
|
|
});
|
|
}
|
|
```
|
|
|
|
**Hard rules:**
|
|
|
|
- Set defaults once in the layout; override per route only where it differs.
|
|
- Use a title **template** so child pages don't repeat the site name.
|
|
- Every page resolves a single `canonical` — never emit duplicate or relative canonicals.
|
|
|
|
---
|
|
|
|
## 6. Discovery surfaces (generated from content)
|
|
|
|
### 6.1 Sitemap
|
|
|
|
Build entries from the **same content collections** the app renders, deduped, all absolute.
|
|
|
|
```ts
|
|
// services/seo/index.ts
|
|
import { ROUTES } from "@/constants/routes";
|
|
import type { SitemapEntry } from "@/types/seo";
|
|
|
|
const PRIMARY_ROUTES: Array<{
|
|
path: string;
|
|
changeFrequency: SitemapEntry["changeFrequency"];
|
|
priority: number;
|
|
}> = [
|
|
{ path: ROUTES.HOME, changeFrequency: "weekly", priority: 1.0 },
|
|
{ path: ROUTES.BLOG, changeFrequency: "daily", priority: 0.9 },
|
|
// …other primary routes
|
|
];
|
|
|
|
export function sitemapEntries(options: {
|
|
blogSlugs: string[];
|
|
projectSlugs: string[];
|
|
}): SitemapEntry[] {
|
|
const seen = new Set<string>();
|
|
const entries: SitemapEntry[] = [];
|
|
const add = (e: SitemapEntry) => {
|
|
if (!seen.has(e.url)) {
|
|
seen.add(e.url);
|
|
entries.push(e);
|
|
}
|
|
};
|
|
const today = new Date().toISOString().split("T")[0];
|
|
|
|
for (const r of PRIMARY_ROUTES)
|
|
add({
|
|
url: canonicalUrl(r.path),
|
|
lastModified: today,
|
|
changeFrequency: r.changeFrequency,
|
|
priority: r.priority,
|
|
});
|
|
for (const slug of options.blogSlugs)
|
|
add({
|
|
url: canonicalUrl(`/blog/${slug}`),
|
|
lastModified: today,
|
|
changeFrequency: "monthly",
|
|
priority: 0.7,
|
|
});
|
|
for (const slug of options.projectSlugs)
|
|
add({
|
|
url: canonicalUrl(`/projects/${slug}`),
|
|
lastModified: today,
|
|
changeFrequency: "monthly",
|
|
priority: 0.8,
|
|
});
|
|
|
|
return entries;
|
|
}
|
|
```
|
|
|
|
```ts
|
|
// app/sitemap.ts — thin adapter
|
|
import type { MetadataRoute } from "next";
|
|
import { sitemapEntries } from "@/services/seo";
|
|
|
|
export default function sitemap(): MetadataRoute.Sitemap {
|
|
const entries = sitemapEntries({
|
|
blogSlugs: loadPublishedBlogSlugs(),
|
|
projectSlugs: loadProjectSlugs(),
|
|
});
|
|
return entries.map((e) => ({
|
|
url: e.url,
|
|
lastModified: e.lastModified ? new Date(e.lastModified) : new Date(),
|
|
changeFrequency:
|
|
e.changeFrequency as MetadataRoute.Sitemap[0]["changeFrequency"],
|
|
priority: e.priority,
|
|
}));
|
|
}
|
|
```
|
|
|
|
### 6.2 Robots
|
|
|
|
```ts
|
|
// app/robots.ts
|
|
import type { MetadataRoute } from "next";
|
|
import { SITE_URL } from "@/constants/seo";
|
|
|
|
export default function robots(): MetadataRoute.Robots {
|
|
return {
|
|
rules: [
|
|
{ userAgent: "*", allow: "/", disallow: ["/api/", "/_next/", "/admin/"] },
|
|
{ userAgent: "Googlebot", allow: "/" },
|
|
],
|
|
sitemap: `${SITE_URL}/sitemap.xml`,
|
|
host: SITE_URL,
|
|
};
|
|
}
|
|
```
|
|
|
|
Always **disallow private surfaces** (`/api/`, `/admin/`, build internals) and **point at the sitemap**.
|
|
|
|
### 6.3 RSS feed
|
|
|
|
```ts
|
|
// services/seo/index.ts
|
|
import type { RssItem } from "@/types/seo";
|
|
|
|
export function rssItems(posts: BlogPost[]): RssItem[] {
|
|
return posts.map((post) => {
|
|
const link = canonicalUrl(`/blog/${post.slug}`);
|
|
return {
|
|
title: post.title,
|
|
link,
|
|
description: post.description,
|
|
pubDate: post.publishDate,
|
|
guid: link,
|
|
};
|
|
});
|
|
}
|
|
```
|
|
|
|
```ts
|
|
// app/feed.xml/route.ts — sort newest-first, CDATA-wrap free text
|
|
import { rssItems } from "@/services/seo";
|
|
import { SITE_NAME, SITE_URL, SITE_DESCRIPTION } from "@/constants/seo";
|
|
|
|
export function GET(): Response {
|
|
const items = rssItems(loadPublishedPostsNewestFirst());
|
|
const xml = `<?xml version="1.0" encoding="UTF-8"?>
|
|
<rss version="2.0"><channel>
|
|
<title>${SITE_NAME}</title><link>${SITE_URL}</link>
|
|
<description>${SITE_DESCRIPTION}</description>
|
|
${items
|
|
.map(
|
|
(i) => `<item>
|
|
<title><![CDATA[${i.title}]]></title><link>${i.link}</link>
|
|
<description><![CDATA[${i.description}]]></description>
|
|
<pubDate>${i.pubDate}</pubDate><guid>${i.guid}</guid>
|
|
</item>`,
|
|
)
|
|
.join("")}
|
|
</channel></rss>`;
|
|
return new Response(xml, {
|
|
headers: { "Content-Type": "application/rss+xml; charset=utf-8" },
|
|
});
|
|
}
|
|
```
|
|
|
|
CDATA-wrap titles/descriptions so apostrophes and markup never break the feed.
|
|
|
|
---
|
|
|
|
## 7. Structured data (typed JSON-LD)
|
|
|
|
### 7.1 The generic builder
|
|
|
|
```ts
|
|
// services/seo/index.ts
|
|
import type { JsonLd, SchemaType } from "@/types/seo";
|
|
|
|
export function structuredData(
|
|
type: SchemaType,
|
|
data: Record<string, unknown>,
|
|
): JsonLd {
|
|
return { "@context": "https://schema.org", "@type": type, ...data };
|
|
}
|
|
```
|
|
|
|
### 7.2 Per-type builders, cross-referenced by stable `@id`
|
|
|
|
```ts
|
|
// services/seo/structured-data.ts
|
|
import { structuredData } from "./index";
|
|
import { SITE_URL, AUTHOR_NAME, SITE_DESCRIPTION } from "@/constants/seo";
|
|
import type { JsonLd } from "@/types/seo";
|
|
|
|
export function personJsonLd(): JsonLd {
|
|
return structuredData("Person", {
|
|
"@id": `${SITE_URL}/#person`, // stable identity others reference
|
|
name: AUTHOR_NAME,
|
|
url: SITE_URL,
|
|
description: SITE_DESCRIPTION,
|
|
sameAs: [
|
|
/* social profile URLs */
|
|
],
|
|
});
|
|
}
|
|
|
|
export function websiteJsonLd(): JsonLd {
|
|
return structuredData("WebSite", {
|
|
"@id": `${SITE_URL}/#website`,
|
|
url: SITE_URL,
|
|
name: AUTHOR_NAME,
|
|
author: { "@id": `${SITE_URL}/#person` }, // reference, not a copy
|
|
potentialAction: {
|
|
"@type": "SearchAction",
|
|
target: {
|
|
"@type": "EntryPoint",
|
|
urlTemplate: `${SITE_URL}/blog?q={search_term_string}`,
|
|
},
|
|
"query-input": "required name=search_term_string",
|
|
},
|
|
});
|
|
}
|
|
|
|
export function blogPostingJsonLd(post: BlogPost, url: string): JsonLd {
|
|
return structuredData("BlogPosting", {
|
|
"@id": url,
|
|
headline: post.title,
|
|
description: post.description,
|
|
datePublished: post.publishDate,
|
|
dateModified: post.publishDate,
|
|
author: {
|
|
"@type": "Person",
|
|
"@id": `${SITE_URL}/#person`,
|
|
name: post.author,
|
|
},
|
|
publisher: {
|
|
"@type": "Person",
|
|
"@id": `${SITE_URL}/#person`,
|
|
name: AUTHOR_NAME,
|
|
},
|
|
url,
|
|
mainEntityOfPage: { "@type": "WebPage", "@id": url },
|
|
image: {
|
|
"@type": "ImageObject",
|
|
url: post.heroImage,
|
|
width: 1200,
|
|
height: 630,
|
|
},
|
|
keywords: post.tags.join(", "),
|
|
});
|
|
}
|
|
|
|
export function breadcrumbListJsonLd(
|
|
items: Array<{ name: string; url: string }>,
|
|
): JsonLd {
|
|
return structuredData("BreadcrumbList", {
|
|
itemListElement: items.map((item, i) => ({
|
|
"@type": "ListItem",
|
|
position: i + 1,
|
|
name: item.name,
|
|
item: item.url,
|
|
})),
|
|
});
|
|
}
|
|
|
|
export function faqPageJsonLd(
|
|
faqs: Array<{ question: string; answer: string }>,
|
|
): JsonLd {
|
|
return structuredData("FAQPage", {
|
|
mainEntity: faqs.map((f) => ({
|
|
"@type": "Question",
|
|
name: f.question,
|
|
acceptedAnswer: { "@type": "Answer", text: f.answer },
|
|
})),
|
|
});
|
|
}
|
|
```
|
|
|
|
`CreativeWork` follows the same shape for projects/portfolio items (name, description, author by
|
|
`@id`, `keywords`, optional `codeRepository`/`sameAs`/`image`).
|
|
|
|
### 7.3 Injecting JSON-LD into a page
|
|
|
|
Render a `<script type="application/ld+json">` with `JSON.stringify`. Match the breadcrumb trail
|
|
to the page's real position.
|
|
|
|
```tsx
|
|
// app/blog/[slug]/page.tsx
|
|
import { canonicalUrl } from "@/services/seo";
|
|
import {
|
|
blogPostingJsonLd,
|
|
breadcrumbListJsonLd,
|
|
} from "@/services/seo/structured-data";
|
|
|
|
export default async function Page({ params }) {
|
|
const post = await loadPost(params.slug);
|
|
const url = canonicalUrl(`/blog/${post.slug}`);
|
|
const postLd = blogPostingJsonLd(post, url);
|
|
const crumbLd = breadcrumbListJsonLd([
|
|
{ name: "Home", url: canonicalUrl("/") },
|
|
{ name: "Blog", url: canonicalUrl("/blog") },
|
|
{ name: post.title, url },
|
|
]);
|
|
return (
|
|
<>
|
|
<script
|
|
type="application/ld+json"
|
|
dangerouslySetInnerHTML={{ __html: JSON.stringify(postLd) }}
|
|
suppressHydrationWarning
|
|
/>
|
|
<script
|
|
type="application/ld+json"
|
|
dangerouslySetInnerHTML={{ __html: JSON.stringify(crumbLd) }}
|
|
suppressHydrationWarning
|
|
/>
|
|
{/* …page content */}
|
|
</>
|
|
);
|
|
}
|
|
```
|
|
|
|
**Hard rules:**
|
|
|
|
- Give each entity a **stable `@id`** (e.g. `${SITE_URL}/#person`) and **reference** it elsewhere instead of duplicating fields.
|
|
- One `BlogPosting`/`CreativeWork` per detail page; a `BreadcrumbList` on every nested page.
|
|
- `Person` + `WebSite` belong on the home page; `FAQPage` only where real Q&A is shown.
|
|
- Validate output with Google's Rich Results Test / Schema Markup Validator before shipping.
|
|
|
|
---
|
|
|
|
## 8. Framework adapters
|
|
|
|
The builders are framework-free. Only the mounting layer changes.
|
|
|
|
| Framework | Per-route metadata | sitemap / robots / feed |
|
|
| ---------------------- | ----------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------ |
|
|
| **Next.js App Router** | `export const metadata` (static) or `generateMetadata` (dynamic) returning `buildMetadata(...)` | `app/sitemap.ts`, `app/robots.ts`, `app/feed.xml/route.ts` |
|
|
| **Remix** | `meta` export per route, mapping `buildMetadata`'s fields to `<meta>` descriptors | resource routes (`routes/sitemap[.]xml.ts`, etc.) returning the builder output as XML/text |
|
|
| **Astro** | `<head>` in a layout reading the same constants; per-page frontmatter overrides | `src/pages/sitemap.xml.ts`, `robots.txt.ts`, `rss.xml.ts` endpoints |
|
|
| **React + Vite (SPA)** | `react-helmet-async` (or a head manager) fed by `buildMetadata`'s plain object | build-time script that writes `sitemap.xml`/`robots.txt` from the same builders |
|
|
| **Expo Router (web)** | static head config / `expo-router` head; SEO matters only on the web target | a small web-only build step calling `sitemapEntries`/`rssItems` |
|
|
|
|
For a `buildMetadata` that must stay framework-neutral, return a plain shape
|
|
(`{ title, description, canonical, ogImage }`) and let each adapter translate it — keep the
|
|
Next.js `Metadata` return type only in a Next.js project.
|
|
|
|
---
|
|
|
|
## 9. Conventions checklist (enforce in review)
|
|
|
|
- [ ] All site identity (URL, name, description, keywords, author, handles, OG image, verification) lives in **one** `constants/seo` module — no duplicates, no hardcoded base URL.
|
|
- [ ] Every absolute URL is produced by `canonicalUrl()` — no manual `SITE_URL + path`.
|
|
- [ ] One trailing-slash policy, applied everywhere.
|
|
- [ ] Global metadata (title template, default OG/Twitter, robots, verification, manifest) is set **once** in the root layout.
|
|
- [ ] Dynamic routes override metadata via `buildMetadata` (or the framework adapter), passing only what differs.
|
|
- [ ] Every page resolves exactly one `canonical`; no relative or duplicate canonicals.
|
|
- [ ] `sitemap.xml`, `robots.txt`, and the RSS feed are **generated from content collections**, deduped, all absolute. Private routes are disallowed in robots.
|
|
- [ ] JSON-LD uses the shared `structuredData`/`JsonLd` builders; entities cross-reference by stable `@id`.
|
|
- [ ] Each detail page emits its primary schema (`BlogPosting`/`CreativeWork`) + a `BreadcrumbList`; home emits `Person` + `WebSite`.
|
|
- [ ] Builder functions are pure and unit-tested; framework code stays in the thin adapter (route files).
|
|
- [ ] OG image, locale, and Twitter handle are present and consistent across OG + Twitter cards.
|
|
- [ ] Structured data validated with Google's Rich Results Test before release.
|
|
|
|
---
|
|
|
|
## 10. How to apply this skill
|
|
|
|
**Adding SEO to a site:** create `constants/seo`, `types/seo`, and `services/seo/` (barrel +
|
|
`structured-data.ts`). Wire global metadata in the root layout, then add `sitemap`, `robots`, and
|
|
`feed.xml` adapters that mount the builders.
|
|
|
|
**Adding a new content type (e.g. case studies):** add its slugs to `sitemapEntries`, add a
|
|
JSON-LD builder if it needs its own schema, and give its detail page a `generateMetadata` + a
|
|
`BreadcrumbList`.
|
|
|
|
**Debugging duplicate-content / indexing issues:** check that every page goes through
|
|
`canonicalUrl`, that the trailing-slash policy is uniform, and that the sitemap contains only
|
|
indexable, absolute URLs. Confirm robots isn't blocking what should be indexed.
|
|
|
|
**Reviewing SEO coverage:** run the checklist in §9. The highest-value catches are hardcoded URLs
|
|
that bypass `canonicalUrl` (duplicate-content risk) and JSON-LD that duplicates entity fields
|
|
instead of referencing a stable `@id`.
|
|
|
|
---
|
|
|
|
## Publishing / installing this skill
|
|
|
|
This skill follows the Anthropic `SKILL.md` format and is portable across agents.
|
|
|
|
1. Keep it under `skills/frontend-seo/SKILL.md` in a public GitHub repo.
|
|
2. Keep the frontmatter `name` and high-signal `description` — discovery indexes match against it.
|
|
3. Install with: `npx skills add <org>/<repo> --skill "frontend-seo"`.
|
|
4. Non-`SKILL.md` agents can be pointed here from `AGENTS.md` / `CLAUDE.md`; Kiro can mirror it as a steering file.
|
|
|
|
## Limitations
|
|
|
|
- Use this skill only when the task clearly matches its upstream source and local project context.
|
|
- Verify commands, generated code, dependencies, credentials, and external service behavior before applying changes.
|
|
- Do not treat examples as a substitute for environment-specific tests, security review, or user approval for destructive or costly actions.
|