10 stareezy-1

frontend-seo Skill

一套可移植、框架无关的 SEO 系统,适用于任何 React 或 React Native for Web 前端。将站点元数据集中在一个 constants 模块,从单一 base 派生 canonical URL,为每个路由构建元数据(title、description、canonical、Open Graph、Twitter/X 卡片),从真实内容生成 sitemap.xml、robots.txt 和 RSS feed,并输出带类型的 JSON-LD 结构化数据(Person、WebSite、BlogPosting、CreativeWork、BreadcrumbList、FAQPage)。纯函数式可测试的构建器,上层只有薄薄的框架适配层。给站点添加 SEO、接入路由元数据、生成站点地图时使用此 skill。

安装方式:把技能目录放入 ~/.claude/skills/(Claude Code)或在 claude.ai 设置中启用;也可复制右侧安装命令一键添加。

查看源码

技能指令原文(SKILL.md)

Frontend SEO (portable, builder-based)

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.

// 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.

// 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.

// 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.

// 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.

// 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.

// 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.

// 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;
}
// 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

// 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

// 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,
    };
  });
}
// 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

// 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

// 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