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
Set a base URL (Next.js:
metadataBasein root layout — required for relative URLs)- Without it, relative OG image paths cause build errors:
// app/layout.tsx export const metadata = { metadataBase: new URL('https://yoursite.com'), // ... }; - With
metadataBaseset,openGraph.images: '/og.png'resolves to absolute URLs automatically
- Without it, relative OG image paths cause build errors:
Static pages: use
export const metadata- Direct object, no function overhead
- Define
title,description,openGraph,twitter,robots - Quality: unique
title(50–60 chars) and150–160 chars) per route — duplicate or template-only titles across pages are a common ranking lossdescription(
Dynamic pages:
generateMetadatafunction- Async — can fetch the page data
- Rely on fetch memoization — the same fetch in
generateMetadataand the page render is deduplicated automatically - Don't wrap a static page in
generateMetadata(unnecessary overhead)
Define title template at the root layout
export const metadata = { title: { template: '%s | App Name', default: 'App Name' } }- Child routes set
title: 'About'→ rendersAbout | App Name title.defaultis required whentitle.templateis set — missing it will error at build time. Thedefaultcovers routes that don't set their own title.
- Child routes set
Open Graph (social previews)
og:title,og:description,og:imageper route- Image: 1200x630, < 1MB, hosted at a stable URL
- Complete the set:
og:type,og:url,og:site_name, imagealt, andtwitter.card: 'summary_large_image'— partial OG tags render poor previews - File-based: place
opengraph-image.pngnext topage.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 ongenerateMetadata— keep dynamic metadata generation fast. Rely on Next.js fetch memoization to dedupe data betweengenerateMetadataand the page render.
Block non-public pages from indexing
- Auth pages, admin, settings, search-result pages
metadata = { robots: { index: false, follow: false } }robots.txtDisallow ≠ noindex: Disallow blocks crawling, but a blocked URL can still be indexed (without content). To remove a page from the index use anoindexmeta — notDisallow- ⚠ Guard against a site-wide
noindex/Disallow: /reaching production (a staging config leaking to prod silently de-indexes everything) — verify prod in step 8
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 withi18n-localization)
Generate
sitemap.tsandrobots.ts- App Router supports these as conventional files in the app root
sitemap.tsexports a function returning all public URLsrobots.tsdeclares 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
- 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 (seerender-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) orgenerateMetadata(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.tsandrobots.tsexist; sitemap submitted to Search Console - Lighthouse SEO ≥ 90
- OG preview verified in social card validator
Output
- Per-route metadata:
export const metadata(static) orexport async function generateMetadata(dynamic) — each route file - Sitemap + robots:
app/sitemap.tsandapp/robots.ts(or framework equivalent) - OG image files:
opengraph-image.{png,tsx}next to relevantpage.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' }—defaultis REQUIRED whentemplateis set - Base URL:
metadataBase: new URL('https://...') - File-based OG image:
opengraph-image.pngnext topage.tsx(oropengraph-image.tsxfor 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.tsexports function returning URL list - Robots:
app/robots.tsexportsMetadataRoute.Robots
Other stacks
- Vue / Nuxt:
useSeoMeta({ title, description, ogTitle, ogImage })in<script setup>;useHead()for low-level control;nuxt-simple-sitemapmodule for sitemap;nuxt-simple-robotsfor robots - SvelteKit:
<svelte:head>in+page.sveltefor per-page meta;+server.tsroutes forsitemap.xml/robots.txt;svelte-meta-tagsfor higher-level API - Angular:
MetaandTitleservices injected; Universal SSR for server-rendered meta;ngx-metafor 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—generateMetadataruns at strategy time; align with route caching, and SSR/SSG so crawlers see the contenti18n-localization— hreflangalternates.languagesfor multi-locale sitesrendering-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 staticexport const metadataform. 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 rememberrobots.txtDisallow blocks crawling but does not remove a page from the index — usenoindexfor that. The classic disaster is a stagingnoindex/Disallow: /leaking to production.