--- 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) => ` <![CDATA[${i.title}]]>${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 `