---
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 `` 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();
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 = `
${SITE_NAME}${SITE_URL}
${SITE_DESCRIPTION}
${items
.map(
(i) => `${i.link}
${i.pubDate}${i.guid}`,
)
.join("")}
`;
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,
): 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 `