# SEO Metadata

> Apply Next.js Metadata API per route — title template, og:image, generateMetadata for dynamic pages, JSON-LD structured data, robots, sitemap, canonical, hreflang. Use when adding a new route, when search visibility drops, when rich results are needed, or before shipping. Not for choosing a route's render mode (use render-strategy-decision); align generateMetadata with that route caching choice.

- Skill: `jaykim88/seo-metadata` (Agent Skill)
- Install (CLI): `npx skillmds@latest add jaykim88/seo-metadata`
- Raw SKILL.md: https://api.skillmd.com/api/skills/jaykim88/seo-metadata/raw
- Safety review: pending
- Works with: Claude Code, Claude.ai, OpenAI Codex
- Category: Marketing & Growth
- License: MIT
- Author: JayKim88 (https://skillmd.com/u/jaykim88)
- Updated: 2026-09-10
- Page: https://skillmd.com/skills/jaykim88/seo-metadata

---


# SEO and Metadata

## Purpose
Every public route is correctly indexable, shows good social previews, and signals canonical URLs to crawlers. Bad metadata costs traffic — and is invisible until traffic data shows the loss.

**Universal fields, platform-bound Procedure** — the metadata *fields* (title, description, og:*, robots, canonical, sitemap, robots.txt) are HTML/HTTP standards. But the Procedure below is written concretely against the **Next.js Metadata API** (`export const metadata`, `generateMetadata`, `sitemap.ts`); the Other stacks section maps the equivalent API per framework (Nuxt `useSeoMeta`, SvelteKit `<svelte:head>`, Angular `Meta`/`Title`).

## Procedure

0. **Set a base URL** (Next.js: `metadataBase` in root layout — required for relative URLs)
   - Without it, relative OG image paths cause build errors:
     ```ts
     // app/layout.tsx
     export const metadata = {
       metadataBase: new URL('https://yoursite.com'),
       // ...
     };
     ```
   - With `metadataBase` set, `openGraph.images: '/og.png'` resolves to absolute URLs automatically

1. **Static pages: use `export const metadata`**
   - Direct object, no function overhead
   - Define `title`, `description`, `openGraph`, `twitter`, `robots`
   - Quality: unique `title` (~50–60 chars) and `description` (~150–160 chars) per route — duplicate or template-only titles across pages are a common ranking loss

2. **Dynamic pages: `generateMetadata` function**
   - Async — can fetch the page data
   - Rely on fetch memoization — the same fetch in `generateMetadata` and the page render is deduplicated automatically
   - Don't wrap a static page in `generateMetadata` (unnecessary overhead)

3. **Define title template at the root layout**
   ```ts
   export const metadata = {
     title: { template: '%s | App Name', default: 'App Name' }
   }
   ```
   - Child routes set `title: 'About'` → renders `About | App Name`
   - **`title.default` is required when `title.template` is set** — missing it will error at build time. The `default` covers routes that don't set their own title.

4. **Open Graph (social previews)**
   - `og:title`, `og:description`, `og:image` per route
   - Image: 1200x630, < 1MB, hosted at a stable URL
   - Complete the set: `og:type`, `og:url`, `og:site_name`, image `alt`, and `twitter.card: 'summary_large_image'` — partial OG tags render poor previews
   - File-based: place `opengraph-image.png` next to `page.tsx`
   - Test the rendered preview (Twitter card validator, LinkedIn post inspector, OpenGraph.xyz)
   - **Streaming metadata note (Next.js 15.2+)**: metadata may stream into `<body>` for JS-capable bots, but HTML-limited bots (e.g., `facebookexternalhit`) still block on `generateMetadata` — keep dynamic metadata generation fast. Rely on Next.js fetch memoization to dedupe data between `generateMetadata` and the page render.

5. **Block non-public pages from indexing**
   - Auth pages, admin, settings, search-result pages
   - `metadata = { robots: { index: false, follow: false } }`
   - **`robots.txt` Disallow ≠ noindex**: Disallow blocks crawling, but a blocked URL can still be indexed (without content). To remove a page from the index use a `noindex` meta — not `Disallow`
   - ⚠ **Guard against a site-wide `noindex` / `Disallow: /` reaching production** (a staging config leaking to prod silently de-indexes everything) — verify prod in step 8

6. **Canonical URLs and locale alternates**
   - Set a self-referencing canonical on every indexable page; for pages reachable by multiple URLs (query-string variants), point them all to one canonical
   - `metadata = { alternates: { canonical: '/the-canonical-path' } }`
   - Don't canonicalize to a `noindex`/redirected URL, and don't canonical paginated pages back to page 1 (each page is its own canonical)
   - **Multi-locale**: declare `alternates.languages` (hreflang) so crawlers serve the right locale (coordinate with `i18n-localization`)

7. **Generate `sitemap.ts` and `robots.ts`**
   - App Router supports these as conventional files in the app root
   - `sitemap.ts` exports a function returning all public URLs
   - `robots.ts` declares allow/disallow rules
   - Include only indexable, canonical, 200-status URLs (never noindex/redirected ones); add `lastModified`; for > 50k URLs use a sitemap index

7b. **Add structured data (JSON-LD) for rich results**
   - Inject a `<script type="application/ld+json">` with the schema.org type that fits the page: `Article`, `Product`, `BreadcrumbList`, `FAQPage`, `Organization`
   - This powers rich snippets (stars, breadcrumbs, FAQ accordions) — a major CTR lever that metadata tags alone don't provide
   - Validate with Google's Rich Results Test; keep the JSON-LD consistent with the visible page content

8. **Verify**
   - Lighthouse SEO ≥ 90
   - **View page source (not DevTools Elements)**: title, description, OG, and critical content must be in the server-rendered HTML — content injected client-side (e.g., title set in `useEffect`) is unreliable for crawlers, so align the render strategy to SSR/SSG (see `render-strategy-decision`)
   - Confirm production is NOT accidentally `noindex` / `Disallow: /`
   - Social preview validators show the right image; validate JSON-LD in the Rich Results Test
   - Search Console: submit the sitemap; no crawl errors on new routes
   - SEO ≠ metadata alone: Core Web Vitals and mobile-friendliness are ranking signals (see `rendering-performance`, `responsive-design`)

## Completion Criteria
- [ ] Every public route has `metadata` (static) or `generateMetadata` (dynamic)
- [ ] Title template set at root layout; titles/descriptions unique per route
- [ ] Open Graph image configured for every public route
- [ ] JSON-LD structured data on rich-result-eligible pages (validated in Rich Results Test)
- [ ] Self-referencing canonical on indexable pages; hreflang for multi-locale
- [ ] Metadata + critical content present in server-rendered HTML (not client-injected)
- [ ] Non-public routes have `robots: { index: false }`; production NOT accidentally noindexed
- [ ] `sitemap.ts` and `robots.ts` exist; sitemap submitted to Search Console
- [ ] Lighthouse SEO ≥ 90
- [ ] OG preview verified in social card validator

## Output
- **Per-route metadata**: `export const metadata` (static) or `export async function generateMetadata` (dynamic) — each route file
- **Sitemap + robots**: `app/sitemap.ts` and `app/robots.ts` (or framework equivalent)
- **OG image files**: `opengraph-image.{png,tsx}` next to relevant `page.tsx`, 1200x630, < 1MB
- **Root layout**: title template + `metadataBase`
- **Audit report** (paste into PR): table of route / metadata present (Y/N) / OG image (Y/N) / robots policy / verification (PageSpeed + Card validator screenshot or URL)

## Implementation

### React + Next.js (default)
- Static page: `export const metadata = { ... }`
- Dynamic page: `export async function generateMetadata({ params }) { ... }` (returns same shape)
- Title template: `title: { template: '%s | App', default: 'App' }` — `default` is REQUIRED when `template` is set
- Base URL: `metadataBase: new URL('https://...')`
- File-based OG image: `opengraph-image.png` next to `page.tsx` (or `opengraph-image.tsx` for dynamic)
- Twitter: `twitter: { card: 'summary_large_image', ... }`
- hreflang: `alternates: { languages: { 'en-US': '/en', 'ko-KR': '/ko' } }`
- JSON-LD: render `<script type="application/ld+json" dangerouslySetInnerHTML={{ __html: JSON.stringify(schema) }} />` in a Server Component (app-controlled schema object — safe, not user input)
- Sitemap: `app/sitemap.ts` exports function returning URL list
- Robots: `app/robots.ts` exports `MetadataRoute.Robots`

### Other stacks
- **Vue / Nuxt**: `useSeoMeta({ title, description, ogTitle, ogImage })` in `<script setup>`; `useHead()` for low-level control; `nuxt-simple-sitemap` module for sitemap; `nuxt-simple-robots` for robots
- **SvelteKit**: `<svelte:head>` in `+page.svelte` for per-page meta; `+server.ts` routes for `sitemap.xml` / `robots.txt`; `svelte-meta-tags` for higher-level API
- **Angular**: `Meta` and `Title` services injected; Universal SSR for server-rendered meta; `ngx-meta` for declarative API
- **Universal**: og:image dimensions (1200x630, < 1MB), title template pattern, sitemap.xml + robots.txt structure are HTML/HTTP standards regardless of framework

## Related skills
- `render-strategy-decision` — `generateMetadata` runs at strategy time; align with route caching, and SSR/SSG so crawlers see the content
- `i18n-localization` — hreflang `alternates.languages` for multi-locale sites
- `rendering-performance` — Core Web Vitals are a ranking signal, not just UX

## Reference
- **Key insight encoded**: Don't wrap static pages in `generateMetadata` (unnecessary overhead) — use the static `export const metadata` form. For dynamic pages, rely on Next.js fetch memoization to dedupe data calls between metadata generation and page render (free win, no manual caching needed). Metadata is necessary but not sufficient: add JSON-LD for rich results, make sure metadata + content are in the *server-rendered* HTML (client-injected metadata is unreliable for crawlers), and remember `robots.txt` Disallow blocks crawling but does not remove a page from the index — use `noindex` for that. The classic disaster is a staging `noindex`/`Disallow: /` leaking to production.

