
Seo Enhancer
- 2 installs
- 4 repo stars
- Updated May 2, 2026
- gashiartim/seo-enhancer
Audits and fixes SEO for Next.js, Remix, and React SPAs including meta tags, Open Graph, JSON-LD, sitemaps, robots.txt, hreflang, and image alt text.
About
An SEO specialist skill that detects the framework, audits pages for critical and enhancement-level SEO gaps, and implements fixes with framework-native patterns. A developer uses it when pages do not rank, social previews look wrong, or metadata is missing.
- Framework detection plus critical vs enhancement triage and a full SEO checklist
- JSON-LD schema auto-detection by route pattern and SPA rendering-risk guidance
Seo Enhancer by the numbers
- 2 all-time installs (skills.sh)
- Ranked #1,643 of 1,879 Marketing & SEO skills by installs in the Skillselion catalog
- Data as of Jul 24, 2026 (Skillselion catalog sync)
npx skills add https://github.com/gashiartim/seo-enhancer --skill seo-enhancerAdd your badge
Show developers this skill is listed on Skillselion. Paste this into your README.
| Installs | 2 |
|---|---|
| repo stars | ★ 4 |
| Last updated | May 2, 2026 |
| Repository | gashiartim/seo-enhancer ↗ |
What it does
Audits and fixes SEO for Next.js, Remix, and React SPAs including meta tags, Open Graph, JSON-LD, sitemaps, robots.txt, hreflang, and image alt text.
Files
SEO Enhancer
Audit and fix SEO for Next.js, Remix, and React SPAs. Covers: meta tags, Open Graph, Twitter cards, JSON-LD structured data, sitemaps, robots.txt, image SEO, i18n/hreflang, and pagination SEO.
What this skill covers
| Area | Supported |
|---|---|
| Next.js (app router + pages router) | Full |
| Remix | Full |
| React SPA (Vite, CRA) | Full |
| Vue / Nuxt | Not yet — contributions welcome |
| Gatsby | Not yet — contributions welcome |
| Core Web Vitals / performance | Out of scope |
| Content strategy / keyword research | Out of scope |
---
Workflow
Step 1: Detect Context
Before doing anything, establish:
1. Framework — read package.json:
next→ Next.js (check forapp/directory = app router,pages/= pages router)@remix-run/reactor@remix-run/node→ Remixreact+ (viteorreact-scripts) → React SPA
2. Scope — infer from the user's prompt:
- Single file/component mentioned → audit that file only
- Directory or route mentioned → audit that subtree
- "whole site", "entire app", "all pages" → full repo crawl
- No clear scope → ask: "Should I audit a specific page, or the whole project?"
3. Existing SEO setup — scan for: next-seo, react-helmet, react-helmet-async, next-sitemap. Check for metadata exports, generateMetadata, export const meta, or <Head> usage. Also check parent layouts — Next.js metadata inherits from ancestor layouts, so a page without its own metadata may still be covered. 4. i18n setup — check for next-intl, next-i18next, i18next, or locale-based routing ([locale] segments). If present, read references/i18n-seo.md.
Step 2: Audit
Run a tiered audit. Classify every finding as Critical or Enhancement.
Critical — must be fixed in this session. These represent genuine indexing risk or missing baseline SEO:
- Public page with no
<title>ormeta description(and none inherited from a parent layout) - Next.js page/layout with no
metadataexport orgenerateMetadataAND no ancestor layout providing coverage - Remix route missing
export const meta - Informational
<img>tags missingaltattributes (decorative images withalt=""are correct — do not flag) - No canonical URL on pages with real duplicate content risk (parameterized URLs, pagination, syndicated content)
- React SPA on an SEO-critical public site with no acknowledgment of rendering risk
- i18n site missing
hreflangalternate links
Enhancement — recommend + show example, ask before applying:
- Missing Open Graph / Twitter card tags
- Missing JSON-LD structured data
- No sitemap
- Missing
robots.txt(crawl is permitted by default; absence is not a blocker, but having one is good practice) - Using
next/headin app router (native metadata API is preferred, not required) - Dynamic pages with hardcoded or missing SEO
robots.txtmissing sitemap pointer- Paginated content with no canonical strategy
Step 3: Report
Single file/component: Report findings inline in conversation. No file written.
Full site audit: Write seo-audit.md at the project root:
# SEO Audit — [Project Name]
Generated: [date]
Framework: [Next.js / Remix / React SPA]
## Summary
- X critical issues
- Y enhancements
## Critical Issues
### [Issue title] — `path/to/file.tsx`
[What's wrong and why it matters for indexing/ranking]
[Fix applied / Fix: see below]
## Enhancements
### [Issue title] — `path/to/file.tsx`
[Recommendation + example snippet]Step 4: Implement Fixes
Fix all Critical issues. For Enhancements, show a code example and ask if they want it applied.
Output format depends on the size of the change, not the severity:
- Small targeted fix (adding one
alt, one missing meta tag): edit directly without preview - Structural change (adding
generateMetadata, wiring data flow, adding a new export): show the diff first, then apply — don't ask for a separate confirmation, just show what will change before writing it - New files (
sitemap.ts,robots.txt,seo-audit.md): create directly
The distinction matters because showing a diff for a one-line change is noise, but silently restructuring a file is surprising. Match the preview to the impact.
---
Framework-Specific Patterns
references/nextjs.md— Next.js app router, pages router, sitemap, robots.txtreferences/remix.md— Remixmetaexport, loader-driven metadata, sitemap resource routesreferences/react-spa.md— react-helmet-async setup, rendering risk guidance, static sitemapreferences/json-ld-schemas.md— JSON-LD templates (Article, Product, FAQ, Org, Event, etc.)references/i18n-seo.md— hreflang, locale-aware metadata, next-intl patterns
---
JSON-LD Schema Detection
Auto-detect schema type from route pattern, file name, and component content:
| Signal | Schema |
|---|---|
/blog/[slug], BlogPost, ArticleDetail | Article |
/products/[id], ProductPage, product.name | Product |
/faq, FAQSection, accordion with Q&A | FAQPage |
/, HomePage, root layout | Organization + WebSite |
/[...slug], catch-all, ambiguous | Ask user |
/about, /contact | WebPage + Organization |
/recipes/[slug], RecipeCard | Recipe |
/events/[id], EventDetail | Event |
When genuinely ambiguous, ask: "What type of content is on this page? (article, product, FAQ, event, etc.)"
JSON-LD is injected via a <script type="application/ld+json"> tag in the page component. It cannot be emitted from generateMetadata — that function only controls <head> meta elements, not arbitrary script tags.
---
Dynamic SEO — Wiring Data
When a page needs metadata from fetched data:
1. Read the file — find the data-fetching pattern:
- Next.js app router:
generateStaticParams+fetch→ addgenerateMetadatausing same fetch (Next.js deduplicates) - Next.js pages router:
getServerSideProps/getStaticProps→ extract data and pass to<Head> - Remix:
loaderfunction → read viadataargument inmetafunction - Client component (
useQuery/useSWR): server metadata not available → flag as critical, recommend server component orreact-helmet-async
2. Wire the metadata — use the same data source, don't introduce a second fetch.
3. If data source is unclear — ask: "Where does this page's content come from? (API, database, CMS?)"
---
i18n SEO
When the project uses locale-based routing or an i18n library, read references/i18n-seo.md for:
hreflangalternate links (prevents duplicate content penalties across locales)x-defaultfallback locale- Locale-aware canonical URLs
alternates.languagesin Next.js metadata API- next-intl integration patterns
---
Pagination SEO
rel="next" / rel="prev" link hints were deprecated by Google in 2019 and have no indexing effect — do not recommend them.
Modern pagination guidance:
- Page 1: clean canonical URL, no
?page=1parameter - Pages 2+: self-referencing canonical (each page is its own canonical)
- Thin paginated pages with little unique content: consider
noindex, follow - Sitemap: include page 1; include deeper pages only if they have genuinely distinct content worth indexing
---
Library Selection
Check package.json first. Then:
| Situation | Use |
|---|---|
| Next.js app router | Native metadata export / generateMetadata |
| Next.js pages router | next/head or next-seo if already installed |
| Remix | Native export const meta — no library needed |
| React SPA, nothing installed | Install react-helmet-async |
React SPA, react-helmet installed | Upgrade to react-helmet-async (thread-safe) |
| Sitemap needed (Next.js) | next-sitemap or native app/sitemap.ts |
| Sitemap needed (Remix) | Resource route returning XML |
| Sitemap needed (SPA) | Build-time generation script |
next-seo already installed | Use it — don't switch to native unless user asks |
Never introduce a new package when a native API or existing dep covers the need.
---
SPA Rendering Risk
When auditing a React SPA (no SSR) on a site where SEO matters, surface as Critical:
⚠️ Server-Side Rendering Risk
This is a client-side rendered SPA. Google does crawl and render JavaScript,
but with meaningful caveats: rendering is deferred (pages may be crawled before
JS executes), rendering resources are limited (complex SPAs may time out or
partially render), and dynamic meta tags have weaker indexing guarantees than
server-rendered HTML.
For reliable SEO, server-rendered metadata is the baseline. Options:
1. Migrate to Next.js — server components, native metadata API, SSG/SSR
2. Migrate to Remix — lightweight SSR with first-class meta export
3. Add a prerendering service for the current stack (prerender.io, rendertron)
react-helmet-async improves social sharing previews (Slack, Twitter, iMessage
all execute JS) but does not eliminate the rendering reliability gap for Google.Skip for internal tools, dashboards, and auth-gated apps.
---
Full SEO Checklist
Core
- [ ]
<title>— unique, under 60 chars, includes primary keyword - [ ]
meta description— under 160 chars, compelling, matches content - [ ]
canonical— present on pages with genuine duplicate content risk - [ ]
robotsmeta — not accidentallynoindexon public pages
Social
- [ ]
og:title,og:description,og:image,og:url,og:type - [ ]
twitter:card,twitter:title,twitter:description,twitter:image
Structured Data
- [ ] JSON-LD present in page component — schema type matches content
Images
- [ ] Informational
<img>have descriptivealttext - [ ] Decorative
<img>havealt=""(empty, not missing) - [ ]
next/imageconsidered for performance gains (not an SEO hard requirement)
Crawlability
- [ ]
sitemap.xmlexists and linked inrobots.txt - [ ]
robots.txtpresent and not blocking public routes - [ ] SPA rendering risk acknowledged if SEO-critical
i18n (if applicable)
- [ ]
hreflangalternate links for every locale - [ ]
x-defaultset to fallback locale - [ ] Canonical URLs include locale prefix consistently
Pagination (if applicable)
- [ ] Page 1 canonical is clean (no
?page=1) - [ ] Pages 2+ have self-referencing canonicals
- [ ] Thin paginated pages use
noindex, followif appropriate
.omc/
.DS_Store
*.skill
SEO Enhancer — AGENTS.md / OpenAI Codex
Rename this file to AGENTS.md and place it in your project root for use with OpenAI Codex and other agent frameworks that read AGENTS.md.Role
SEO specialist for this web project. When working on page components, route files, or layouts — or when the user mentions SEO, rankings, meta tags, sitemaps, social previews, or discoverability — apply the following rules.
Detect framework
Read package.json:
next→ Next.js.app/directory = app router;pages/= pages router@remix-run/react→ Remixreact+vite/react-scripts→ React SPA (client-side only)next-intl/next-i18next/[locale]path segments → i18n project, hreflang required
Scope
Infer from the user's request:
- Single file → audit that file
- "whole site" / "all pages" → full repo crawl, write
seo-audit.md - Unclear → ask
Audit tiers
Critical — fix without asking (genuine indexing risk):
- Public page with no
<title>ormeta description, with no coverage from a parent layout - Next.js page/layout missing
metadataorgenerateMetadataAND no ancestor layout providing coverage - Remix route missing
export const metaAND no inherited root defaults covering it - Informational
<img>missingalt— decorative images withalt=""are correct, do not flag - No canonical on pages with real duplicate content risk
- React SPA on an SEO-critical public site without acknowledging rendering risk
- i18n site missing
hreflang
Enhancement — show code example, ask before applying:
- Missing Open Graph / Twitter card tags
- Missing JSON-LD structured data
- No sitemap
- Missing
robots.txt(crawl is allowed by default; absence is not a blocker) next/headin app router (native metadata API is preferred, not required)next/imageinstead of raw<img>(performance improvement, not an SEO requirement)robots.txtmissing sitemap pointer
---
Implementation patterns
Next.js app router
// Static
import type { Metadata } from 'next'
export const metadata: Metadata = {
title: 'Page | Site',
description: 'Under 160 chars.',
alternates: { canonical: 'https://acme.com/page' },
openGraph: {
title: 'Page | Site',
images: [{ url: 'https://acme.com/og.png', width: 1200, height: 630 }],
type: 'website',
},
twitter: { card: 'summary_large_image' },
}
// Dynamic — reuse same fetch as page (Next.js deduplicates)
export async function generateMetadata({ params }: { params: { slug: string } }): Promise<Metadata> {
const item = await getData(params.slug)
return {
title: `${item.title} | Site`,
description: item.excerpt,
alternates: { canonical: `https://acme.com/blog/${params.slug}` },
openGraph: { title: item.title, type: 'article', images: [{ url: item.coverImage }] },
twitter: { card: 'summary_large_image' },
}
}
// Root layout — always set metadataBase
export const metadata: Metadata = {
metadataBase: new URL('https://acme.com'),
title: { default: 'Site Name', template: '%s | Site Name' },
robots: { index: true, follow: true },
}Next.js sitemap
// app/sitemap.ts
import { MetadataRoute } from 'next'
export default async function sitemap(): Promise<MetadataRoute.Sitemap> {
const posts = await getAllPosts()
return [
{ url: 'https://acme.com', changeFrequency: 'daily', priority: 1 },
...posts.map(p => ({
url: `https://acme.com/blog/${p.slug}`,
lastModified: p.updatedAt,
priority: 0.8,
})),
]
}Next.js robots.txt
// app/robots.ts
import { MetadataRoute } from 'next'
export default function robots(): MetadataRoute.Robots {
return {
rules: { userAgent: '*', allow: '/', disallow: ['/admin/', '/api/'] },
sitemap: 'https://acme.com/sitemap.xml',
}
}Remix
import type { MetaFunction } from '@remix-run/node'
export const meta: MetaFunction<typeof loader> = ({ data, params }) => {
if (!data) return [{ title: 'Not Found' }]
return [
{ title: `${data.post.title} | Blog` },
{ name: 'description', content: data.post.excerpt },
{ tagName: 'link', rel: 'canonical', href: `https://acme.com/blog/${params.slug}` },
{ property: 'og:title', content: data.post.title },
{ property: 'og:image', content: data.post.coverImage },
{ property: 'og:type', content: 'article' },
{ name: 'twitter:card', content: 'summary_large_image' },
]
}Remix sitemap: resource route at app/routes/sitemap[.xml].tsx returning an XML Response.
React SPA
// npm install react-helmet-async
// In root: <HelmetProvider><App /></HelmetProvider>
import { Helmet } from 'react-helmet-async'
export function Page({ data }) {
return (
<>
<Helmet>
<title>{data.title} | Site</title>
<meta name="description" content={data.description} />
<link rel="canonical" href={data.canonicalUrl} />
<meta property="og:title" content={data.title} />
<meta property="og:image" content={data.image} />
<meta property="og:type" content="website" />
<meta name="twitter:card" content="summary_large_image" />
</Helmet>
{/* content */}
</>
)
}Rendering risk on SEO-critical SPAs: Google renders JavaScript but with deferred timing, limited resources, and weaker guarantees for dynamic meta tags. react-helmet-async helps with social previews but does not eliminate the rendering reliability gap. Recommend Next.js or Remix for reliable server-rendered metadata.
JSON-LD
JSON-LD belongs in the page component as a <script> tag. It cannot be emitted from generateMetadata — that function only controls <head> meta elements.
const schema = {
'@context': 'https://schema.org',
'@type': 'Article', // Article | Product | FAQPage | Organization | Event | Recipe | WebPage
headline: data.title,
description: data.excerpt,
author: { '@type': 'Person', name: data.author },
datePublished: data.publishedAt,
image: data.coverImage,
url: data.canonicalUrl,
}
<script
type="application/ld+json"
dangerouslySetInnerHTML={{ __html: JSON.stringify(schema) }}
/>Schema type detection: /blog/[slug] → Article, /products/[id] → Product, /faq → FAQPage, / → Organization+WebSite, /events/[id] → Event.
i18n — hreflang
// In generateMetadata — for alternates/hreflang (not JSON-LD)
alternates: {
canonical: `https://acme.com/${locale}/page`,
languages: {
'x-default': 'https://acme.com/en/page',
en: 'https://acme.com/en/page',
fr: 'https://acme.com/fr/page',
},
},Every locale variant must link to all others (reciprocal). Always include x-default. Missing hreflang causes incorrect locale disambiguation and wrong-region serving — the real risk is Google misclustering locale variants, not a generic duplicate-content penalty.
---
SEO checklist
- [ ]
<title>— unique, under 60 chars, includes primary keyword - [ ]
meta description— under 160 chars, compelling - [ ]
canonical— on pages with duplicate content risk - [ ]
robotsmeta — not accidentallynoindexon public pages - [ ]
og:title,og:description,og:image,og:url,og:type - [ ]
twitter:card,twitter:title,twitter:description,twitter:image - [ ] JSON-LD in page component with correct schema type
- [ ] Informational
<img>have descriptivealt; decorative<img>usealt=""(empty, not missing) - [ ]
sitemap.xmlexists and referenced inrobots.txt - [ ]
robots.txtnot blocking public routes - [ ] hreflang on every locale variant (if i18n project)
- [ ] Page 1 canonical is clean, pages 2+ are self-referencing (if paginated)
SEO Enhancer — Cline Rules
Copy this file contents into your project's .clinerules file.SEO rules for Next.js, Remix, and React SPAs
Apply these rules when editing page, route, or layout files in a web project, or when the user mentions SEO, meta tags, rankings, sitemaps, Open Graph, Twitter cards, structured data, hreflang, or social previews.
Detect framework
Check package.json deps:
next→ Next.js.app/directory = app router,pages/= pages router@remix-run/react→ Remixreact+viteorreact-scripts→ React SPA (no SSR)next-intl/next-i18next/[locale]path segments → i18n project
Audit — two tiers
Critical (fix immediately — genuine indexing risk):
- Public page with no
<title>ormeta description, with no coverage from a parent layout - Next.js page/layout missing
metadataorgenerateMetadataAND no ancestor layout providing coverage - Remix route missing
export const metaAND no inherited root defaults covering it - Informational
<img>withoutalt— decorative images withalt=""are correct, do not flag - No
canonicalon pages with real duplicate content risk - React SPA on an SEO-critical public site with no acknowledgment of rendering risk
- i18n site missing
hreflangalternate links
Enhancement (show code example, ask before applying):
- Missing Open Graph / Twitter card tags
- Missing JSON-LD structured data
- No sitemap
- Missing
robots.txt(crawl is allowed by default; absence is not a blocker) next/headin app router (native metadata API is preferred, not required)next/imageinstead of raw<img>(performance improvement, not an SEO requirement)robots.txtmissing sitemap pointer
---
Patterns
Next.js app router
// Static
export const metadata: Metadata = {
title: 'Page | Site',
description: 'Under 160 chars.',
alternates: { canonical: 'https://acme.com/page' },
openGraph: { title: 'Page | Site', images: [{ url: 'https://acme.com/og.png', width: 1200, height: 630 }] },
twitter: { card: 'summary_large_image' },
}
// Dynamic — reuse the same fetch as the page (Next.js deduplicates)
export async function generateMetadata({ params }): Promise<Metadata> {
const item = await getData(params.slug)
return {
title: item.title,
alternates: { canonical: `https://acme.com/${params.slug}` },
openGraph: { title: item.title, images: [{ url: item.image }] },
}
}
// Root layout — always set metadataBase
export const metadata: Metadata = {
metadataBase: new URL('https://acme.com'),
title: { default: 'Site', template: '%s | Site' },
}Sitemap: app/sitemap.ts returning MetadataRoute.Sitemap. robots.txt: app/robots.ts returning MetadataRoute.Robots.
Remix
export const meta: MetaFunction<typeof loader> = ({ data, params }) => [
{ title: `${data?.title} | Site` },
{ name: 'description', content: data?.description },
{ tagName: 'link', rel: 'canonical', href: `https://acme.com/${params.slug}` },
{ property: 'og:title', content: data?.title },
{ property: 'og:image', content: data?.image },
{ name: 'twitter:card', content: 'summary_large_image' },
]Sitemap via resource route at app/routes/sitemap[.xml].tsx.
React SPA
import { Helmet } from 'react-helmet-async'
// Wrap root: <HelmetProvider><App /></HelmetProvider>
<Helmet>
<title>{title} | Site</title>
<meta name="description" content={description} />
<link rel="canonical" href={url} />
<meta property="og:title" content={title} />
<meta property="og:image" content={image} />
<meta name="twitter:card" content="summary_large_image" />
</Helmet>Rendering risk on SEO-critical SPAs: Google renders JavaScript but with deferred timing, limited resources, and weaker guarantees for dynamic meta tags. Next.js or Remix provide reliable server-rendered metadata. react-helmet-async helps with social previews but does not eliminate the rendering reliability gap.
JSON-LD
JSON-LD belongs in the page component as a <script> tag. It cannot be emitted from generateMetadata — that function only controls <head> meta elements.
const schema = {
'@context': 'https://schema.org',
'@type': 'Article', // Article | Product | FAQPage | Organization | Event | Recipe | WebPage
headline: title,
description: excerpt,
author: { '@type': 'Person', name: authorName },
datePublished: publishedAt,
image: coverImage,
}
<script type="application/ld+json" dangerouslySetInnerHTML={{ __html: JSON.stringify(schema) }} />i18n hreflang (Next.js)
alternates: {
canonical: `https://acme.com/${locale}/page`,
languages: {
'x-default': 'https://acme.com/en/page',
en: 'https://acme.com/en/page',
fr: 'https://acme.com/fr/page',
},
},Every locale variant must link to all others (reciprocal). Always include x-default. Missing hreflang causes incorrect locale disambiguation and wrong-region serving — not a generic duplicate-content penalty.
---
Checklist
- [ ]
<title>unique, under 60 chars, includes primary keyword - [ ]
meta descriptionunder 160 chars - [ ]
canonicalon pages with duplicate content risk - [ ]
robotsmeta not accidentallynoindex - [ ]
og:title,og:description,og:image,og:url,og:type - [ ]
twitter:card,twitter:title,twitter:description,twitter:image - [ ] JSON-LD in page component with schema type matching content
- [ ] Informational
<img>have descriptivealt; decorative<img>usealt=""(empty, not missing) - [ ]
sitemap.xmlexists and linked inrobots.txt - [ ]
robots.txtnot blocking public routes - [ ] hreflang on every locale variant (if i18n)
- [ ] Page 1 canonical clean, pages 2+ self-referencing (if paginated)
SEO Enhancer — GitHub Copilot Instructions
Copy this file to .github/copilot-instructions.md in your project root.When working on Next.js, Remix, or React SPA projects, apply these SEO rules automatically.
Trigger on
- Files in
app/,pages/,app/routes/, orsrc/pages/(page, layout, and route files — not generic UI components) - User mentions: SEO, meta tags, Open Graph, sitemap, robots.txt, structured data, hreflang, ranking, discoverability, social previews
- Page/layout/route files missing metadata exports with no inherited coverage
Framework detection
Read package.json:
next→ Next.js (app/= app router,pages/= pages router)@remix-run/react→ Remixreact+vite/react-scripts→ React SPA (no SSR)
Critical issues — fix without asking
These represent genuine indexing risk:
- Public page missing
<title>ormeta description, with no coverage from a parent layout - Next.js page/layout missing
metadataorgenerateMetadataAND no ancestor layout providing coverage - Remix route missing
export const metaAND no root defaults covering it - Informational
<img>missingalt(decorative images withalt=""are correct — do not flag) - No canonical on pages with real duplicate content risk
- React SPA on SEO-critical public site without acknowledging rendering risk
- i18n site missing
hreflangalternate links
Enhancements — show example, ask before applying
- Missing Open Graph / Twitter card tags
- Missing JSON-LD structured data
- No sitemap
- Missing
robots.txt(crawl is allowed by default; absence is not a blocker) next/headin app router (native metadata API is preferred, not required)next/imageinstead of raw<img>(performance improvement, not an SEO requirement)robots.txtmissing sitemap pointer
---
Next.js — app router
// Static
export const metadata: Metadata = {
title: 'Page | Site',
description: 'Under 160 chars.',
alternates: { canonical: 'https://acme.com/page' },
openGraph: {
title: 'Page | Site',
images: [{ url: 'https://acme.com/og.png', width: 1200, height: 630 }],
},
twitter: { card: 'summary_large_image' },
}
// Dynamic — reuse the same fetch as the page component (Next.js deduplicates)
export async function generateMetadata({ params }): Promise<Metadata> {
const data = await getData(params.slug)
return {
title: `${data.title} | Site`,
description: data.excerpt,
alternates: { canonical: `https://acme.com/${params.slug}` },
openGraph: { title: data.title, images: [{ url: data.image }] },
}
}
// Root layout — set metadataBase and title template
export const metadata: Metadata = {
metadataBase: new URL('https://acme.com'),
title: { default: 'Site Name', template: '%s | Site Name' },
robots: { index: true, follow: true },
}Sitemap: app/sitemap.ts returning MetadataRoute.Sitemap. robots.txt: app/robots.ts returning MetadataRoute.Robots.
Remix
export const meta: MetaFunction<typeof loader> = ({ data, params }) => [
{ title: `${data?.title} | Site` },
{ name: 'description', content: data?.excerpt },
{ tagName: 'link', rel: 'canonical', href: `https://acme.com/${params.slug}` },
{ property: 'og:title', content: data?.title },
{ property: 'og:image', content: data?.image },
{ name: 'twitter:card', content: 'summary_large_image' },
]React SPA
import { Helmet } from 'react-helmet-async'
// Wrap root: <HelmetProvider><App /></HelmetProvider>
<Helmet>
<title>{title} | Site</title>
<meta name="description" content={description} />
<link rel="canonical" href={url} />
<meta property="og:title" content={title} />
<meta property="og:image" content={image} />
<meta name="twitter:card" content="summary_large_image" />
</Helmet>Rendering risk on SEO-critical SPAs: Google renders JavaScript but with deferred timing, limited resources, and weaker guarantees for dynamic meta tags. Next.js or Remix provide reliable server-rendered metadata. react-helmet-async helps with social previews but does not eliminate the rendering reliability gap.
JSON-LD
JSON-LD belongs in the page component as a <script> tag. It cannot be emitted from generateMetadata — that function only controls <head> meta elements.
<script
type="application/ld+json"
dangerouslySetInnerHTML={{ __html: JSON.stringify({
'@context': 'https://schema.org',
'@type': 'Article', // or Product, FAQPage, Organization+WebSite, Event, Recipe
headline: title,
description: excerpt,
author: { '@type': 'Person', name: authorName },
datePublished: publishedAt,
image: coverImage,
})}}
/>Schema types: /blog/[slug] → Article, /products/[id] → Product, /faq → FAQPage, / → Organization + WebSite.
i18n hreflang
// Next.js generateMetadata — for alternates/hreflang (not JSON-LD)
alternates: {
canonical: `https://acme.com/${locale}/page`,
languages: {
'x-default': 'https://acme.com/en/page',
en: 'https://acme.com/en/page',
fr: 'https://acme.com/fr/page',
},
},Every locale variant must link to all others (reciprocal). Always include x-default. Missing hreflang causes incorrect locale disambiguation and wrong-region serving — not flagged as a duplicate-content penalty by Google.
---
description: SEO audit and implementation for Next.js, Remix, and React SPAs. Auto-attached to page, layout, and route files. Covers meta tags, Open Graph, Twitter cards, JSON-LD, sitemaps, robots.txt, i18n/hreflang, and image SEO.
globs: app/**/*.tsx,app/**/*.ts,pages/**/*.tsx,pages/**/*.ts,app/routes/**/*.tsx,src/pages/**/*.tsx
alwaysApply: false
---
# SEO Enhancer
Audit and fix SEO for Next.js, Remix, and React SPAs.
## Step 1 — Detect framework
Read `package.json`:
- `next` → Next.js. Check for `app/` (app router) vs `pages/` (pages router)
- `@remix-run/react` → Remix
- `react` + `vite` or `react-scripts` → React SPA (no SSR)
- `next-intl` / `next-i18next` / `[locale]` segments → i18n project, hreflang required
## Step 2 — Audit
**Critical** — must be fixed (genuine indexing risk):
- Public page missing `<title>` or `meta description`, with no coverage inherited from a parent layout
- Next.js page/layout missing `metadata` or `generateMetadata` AND no ancestor layout providing coverage
- Remix route missing `export const meta` AND no inherited root defaults covering the route
- Informational `<img>` missing `alt` — decorative images with `alt=""` are correct, do not flag
- No canonical on pages with real duplicate content risk
- React SPA on SEO-critical public site with no acknowledgment of rendering risk
- i18n site missing `hreflang` alternate links
**Enhancement** — show code, ask before applying:
- Missing Open Graph / Twitter card tags
- Missing JSON-LD structured data
- No sitemap
- Missing `robots.txt` (crawl is allowed by default; absence is not a blocker)
- `next/head` in app router (native metadata API is preferred, not required)
- `next/image` instead of raw `<img>` (performance improvement, not an SEO requirement)
- `robots.txt` missing sitemap pointer
- Paginated content with no canonical strategy
## Step 3 — Report
Single file → inline findings only.
Full site → write `seo-audit.md` at project root.
## Step 4 — Implement
- Small fix (one tag, one attribute): edit directly
- Structural change (adding `generateMetadata`, wiring data): show diff, then apply immediately — no separate confirmation
- New files (`sitemap.ts`, `robots.txt`): create directly
---
## Next.js patterns
**App router — static:**
```tsx
export const metadata: Metadata = {
title: 'Page Title | Site',
description: 'Under 160 chars.',
alternates: { canonical: 'https://acme.com/page' },
openGraph: {
title: 'Page Title | Site',
images: [{ url: 'https://acme.com/og.png', width: 1200, height: 630 }],
},
twitter: { card: 'summary_large_image' },
}
```
**App router — dynamic (reuse same fetch as page, Next.js deduplicates):**
```tsx
export async function generateMetadata({ params }): Promise<Metadata> {
const data = await getData(params.slug)
return {
title: data.title,
description: data.excerpt,
alternates: { canonical: `https://acme.com/${params.slug}` },
openGraph: { title: data.title, images: [{ url: data.image }] },
}
}
```
**Root layout — always set `metadataBase` and title template:**
```tsx
export const metadata: Metadata = {
metadataBase: new URL('https://acme.com'),
title: { default: 'Site Name', template: '%s | Site Name' },
robots: { index: true, follow: true },
}
```
**Sitemap:**
```ts
// app/sitemap.ts
export default async function sitemap(): Promise<MetadataRoute.Sitemap> {
const posts = await getAllPosts()
return [
{ url: 'https://acme.com', changeFrequency: 'daily', priority: 1 },
...posts.map(p => ({ url: `https://acme.com/blog/${p.slug}`, lastModified: p.updatedAt })),
]
}
```
**robots.txt:**
```ts
// app/robots.ts
export default function robots(): MetadataRoute.Robots {
return {
rules: { userAgent: '*', allow: '/', disallow: ['/admin/'] },
sitemap: 'https://acme.com/sitemap.xml',
}
}
```
---
## Remix patterns
```tsx
export const meta: MetaFunction<typeof loader> = ({ data, params }) => [
{ title: `${data?.title} | Site` },
{ name: 'description', content: data?.excerpt },
{ tagName: 'link', rel: 'canonical', href: `https://acme.com/${params.slug}` },
{ property: 'og:title', content: data?.title },
{ property: 'og:image', content: data?.image },
{ name: 'twitter:card', content: 'summary_large_image' },
]
```
Sitemap via resource route `app/routes/sitemap[.xml].tsx` returning an XML response.
---
## React SPA pattern
```tsx
import { Helmet } from 'react-helmet-async'
// Wrap root: <HelmetProvider><App /></HelmetProvider>
<Helmet>
<title>{title} | Site</title>
<meta name="description" content={description} />
<link rel="canonical" href={url} />
<meta property="og:title" content={title} />
<meta property="og:image" content={image} />
<meta name="twitter:card" content="summary_large_image" />
</Helmet>
```
**Rendering risk on SEO-critical SPAs:** Google renders JavaScript but with deferred timing, limited resources, and weaker guarantees for dynamic meta tags. Server-side rendering (Next.js or Remix) is the reliable baseline. react-helmet-async improves social sharing previews but does not eliminate the rendering reliability gap.
---
## JSON-LD
JSON-LD belongs in the **page component** as a `<script>` tag. It cannot be emitted from `generateMetadata` — that function only controls `<head>` meta elements.
```tsx
<script
type="application/ld+json"
dangerouslySetInnerHTML={{ __html: JSON.stringify({
'@context': 'https://schema.org',
'@type': 'Article', // or Product, FAQPage, Organization, Event, Recipe
headline: title,
description: excerpt,
author: { '@type': 'Person', name: authorName },
datePublished: publishedAt,
image: coverImage,
})}}
/>
```
Schema guide: `/blog/[slug]` → Article, `/products/[id]` → Product, `/faq` → FAQPage, `/` → Organization + WebSite.
---
## i18n — hreflang
When `next-intl`, `[locale]` routing, or similar detected — every public page needs hreflang. Missing hreflang on an i18n site = **Critical**.
Google's real risk is incorrect locale clustering and wrong-region serving, not a generic duplicate-content penalty.
```tsx
alternates: {
canonical: `https://acme.com/${params.locale}/page`,
languages: {
'x-default': 'https://acme.com/en/page',
en: 'https://acme.com/en/page',
fr: 'https://acme.com/fr/page',
},
},
```
---
## Checklist
- [ ] `<title>` unique, under 60 chars
- [ ] `meta description` under 160 chars
- [ ] `canonical` on duplicate-content pages
- [ ] `robots` not accidentally `noindex`
- [ ] OG: title, description, image, url, type
- [ ] Twitter card tags present
- [ ] JSON-LD in page component with correct schema
- [ ] Informational `<img>` have descriptive `alt`; decorative `<img>` have `alt=""`
- [ ] `sitemap.xml` linked in `robots.txt`
- [ ] `robots.txt` not blocking public routes
- [ ] hreflang on every locale (if i18n project)
- [ ] Page 1 canonical clean, pages 2+ self-referencing (if paginated)
SEO Enhancer — Windsurf Rules
Copy this file contents into your project's.windsurfrulesfile, or add to your global~/.codeium/windsurf/memories/global_rules.md.
SEO rules for Next.js, Remix, and React SPAs
Apply these rules when editing page, route, or layout files in a web project, or when the user mentions SEO, meta tags, rankings, sitemaps, Open Graph, Twitter cards, structured data, hreflang, or social previews.
Detect framework
Check package.json deps:
next→ Next.js.app/= app router,pages/= pages router@remix-run/react→ Remixreact+viteorreact-scripts→ React SPA (no SSR)next-intl/next-i18next/[locale]path segments → i18n project
Audit — two tiers
Critical (fix immediately — genuine indexing risk):
- Public page with no
<title>ormeta description, with no coverage from a parent layout - Next.js page/layout missing
metadataorgenerateMetadataAND no ancestor layout providing coverage - Remix route missing
export const metaAND no inherited root defaults covering it - Informational
<img>withoutalt— decorative images withalt=""are correct, do not flag - No
canonicalon pages with real duplicate content risk - React SPA on an SEO-critical public site with no acknowledgment of rendering risk
- i18n site missing
hreflangalternate links
Enhancement (show code example, ask before applying):
- Missing Open Graph / Twitter card tags
- Missing JSON-LD structured data
- No sitemap
- Missing
robots.txt(crawl is allowed by default; absence is not a blocker) next/headin app router (native metadata API is preferred, not required)next/imageinstead of raw<img>(performance improvement, not an SEO requirement)robots.txtmissing sitemap pointer
---
Implementations
Next.js app router — static:
export const metadata: Metadata = {
title: 'Page | Site',
description: 'Under 160 chars.',
alternates: { canonical: 'https://acme.com/page' },
openGraph: { title: 'Page | Site', images: [{ url: 'https://acme.com/og.png', width: 1200, height: 630 }] },
twitter: { card: 'summary_large_image' },
}Next.js app router — dynamic (reuse same fetch as page, Next.js deduplicates):
export async function generateMetadata({ params }): Promise<Metadata> {
const item = await getData(params.slug)
return {
title: item.title,
alternates: { canonical: `https://acme.com/${params.slug}` },
openGraph: { title: item.title, images: [{ url: item.image }] },
}
}Next.js root layout:
export const metadata: Metadata = {
metadataBase: new URL('https://acme.com'),
title: { default: 'Site Name', template: '%s | Site Name' },
}Next.js sitemap:
// app/sitemap.ts
export default async function sitemap(): Promise<MetadataRoute.Sitemap> {
const posts = await getAllPosts()
return [
{ url: 'https://acme.com', changeFrequency: 'daily', priority: 1 },
...posts.map(p => ({ url: `https://acme.com/blog/${p.slug}`, lastModified: p.updatedAt })),
]
}Remix:
export const meta: MetaFunction<typeof loader> = ({ data, params }) => [
{ title: `${data?.title} | Site` },
{ name: 'description', content: data?.description },
{ tagName: 'link', rel: 'canonical', href: `https://acme.com/${params.slug}` },
{ property: 'og:title', content: data?.title },
{ property: 'og:image', content: data?.image },
{ name: 'twitter:card', content: 'summary_large_image' },
]React SPA:
import { Helmet } from 'react-helmet-async'
// Wrap root: <HelmetProvider><App /></HelmetProvider>
<Helmet>
<title>{title} | Site</title>
<meta name="description" content={description} />
<link rel="canonical" href={canonicalUrl} />
<meta property="og:title" content={title} />
<meta property="og:image" content={ogImage} />
<meta name="twitter:card" content="summary_large_image" />
</Helmet>Rendering risk on SEO-critical SPAs: Google renders JavaScript but with deferred timing, limited resources, and weaker guarantees for dynamic meta tags. Next.js or Remix provide reliable server-rendered metadata.
JSON-LD — inject in the page component, not in `generateMetadata`:
<script type="application/ld+json" dangerouslySetInnerHTML={{ __html: JSON.stringify(schema) }} />Schema types: Article (blog), Product (ecommerce), FAQPage, Organization+WebSite (homepage).
i18n hreflang:
alternates: {
canonical: `https://acme.com/${locale}/page`,
languages: { 'x-default': 'https://acme.com/en/page', en: '...', fr: '...' },
},Missing hreflang causes incorrect locale disambiguation and wrong-region serving.
---
Checklist
- [ ]
<title>unique, under 60 chars - [ ]
meta descriptionunder 160 chars - [ ]
canonicalon duplicate-content pages - [ ] OG: title, description, image, url, type
- [ ] Twitter card tags present
- [ ] JSON-LD in page component with correct schema type
- [ ] Informational
<img>have descriptivealt; decorative<img>havealt="" - [ ]
sitemap.xmllinked inrobots.txt - [ ]
robots.txtnot blocking public routes - [ ] hreflang on every locale (if i18n project)
Contributing
Maintainer checklist — editing policy or content
Run through this before opening a PR that changes audit rules, wording, or examples.
Source of truth
SKILL.md is the canonical policy file. All other files derive from it.
When you change a rule in SKILL.md, update the same rule in every file it appears in:
| Changed in | Also update |
|---|---|
SKILL.md | INSTRUCTIONS.md, all adapters/, affected references/ |
adapters/*.md or adapters/*.mdc | Verify it matches SKILL.md — do not introduce new rules |
references/*.md | SKILL.md patterns section if the implementation changes |
Policy accuracy checklist
Before merging any change to audit rules or wording:
- [ ] Severity is correct — Critical = genuine indexing risk (missing title/description with no parent coverage, missing alt on informational images, hreflang absent on i18n site). Enhancement = show + ask.
- [ ] robots.txt absence is Enhancement — crawl is allowed by default; absence is not a blocker.
- [ ] `next/image` is Enhancement — performance improvement, not an SEO hard requirement.
- [ ] `next/head` in app router is Enhancement — native metadata API is preferred, not required.
- [ ] JSON-LD is Enhancement — and always goes in the page component
<script>tag, not ingenerateMetadata. - [ ] SPA rendering risk uses "deferred" — Google does execute JavaScript but with deferred timing and limited resources. Do not say "Google may not execute JS."
- [ ] No `rel=next/prev` — deprecated by Google in 2019. Use self-referencing canonicals on paginated pages instead.
- [ ] Remix — inherited coverage — a missing local
metaexport is only Critical when the route has no effective metadata coverage from root defaults. - [ ] i18n wording uses "locale disambiguation" — not "duplicate content penalty." The real risk is Google misclustering locale variants and serving the wrong regional page.
- [ ] Alt guidance is precise — informational images need descriptive alt. Decorative images use
alt=""(empty string, not missing). Do not flagalt=""as a problem. - [ ] Parent layout inheritance — a Next.js page missing a local
metadataexport is only Critical if no ancestor layout provides coverage.
Adapter consistency
After editing, verify each adapter file still matches SKILL.md:
adapters/cursor.mdc
adapters/copilot.md
adapters/windsurf.md
adapters/cline.md
adapters/agents.mdKey things to check in each:
- Trigger globs/conditions do not include generic component directories (
src/components/) - robots.txt listed under Enhancement, not Critical
next/imagelisted under Enhancement with "performance improvement, not SEO requirement" note- JSON-LD note: page component only, not
generateMetadata - i18n wording: "locale disambiguation" not "duplicate content penalty"
SEO Enhancer — Universal Instructions
Standalone SEO audit and implementation instructions. Paste into any AI tool's system prompt or custom instructions.
Compatible with: Claude Code, Cursor, GitHub Copilot, Windsurf, Cline, OpenAI Codex, and any AI coding assistant.
---
What to do
Audit and fix SEO for Next.js, Remix, and React SPA projects. Cover: meta tags, Open Graph, Twitter cards, JSON-LD structured data, sitemaps, robots.txt, image SEO, i18n/hreflang, and pagination SEO.
Scope
| Framework | Support level |
|---|---|
| Next.js (app router + pages router) | Full |
| Remix | Full |
| React SPA (Vite, CRA) | Full |
| Vue / Nuxt | Partial — apply general principles |
| Gatsby | Partial — apply general principles |
| Core Web Vitals / performance | Out of scope |
| Content strategy / keyword research | Out of scope |
---
Step 1 — Detect context
Read package.json and the project file structure:
nextin deps → Next.js. Check forapp/(app router) vspages/(pages router)@remix-run/reactor@remix-run/node→ Remixreact+viteorreact-scripts→ React SPA (no SSR)- Locale segments like
[locale], or deps likenext-intl/next-i18next→ i18n project, hreflang required
Infer scope from the request:
- Single file mentioned → audit that file only
- Directory or route mentioned → audit that subtree
- "whole site" / "entire app" / "all pages" → full crawl
- Unclear → ask: "Should I audit a specific page or the whole project?"
---
Step 2 — Audit (tiered)
Critical — must be fixed in this session (genuine indexing risk):
- Public page missing
<title>ormeta description— and no ancestor layout providing coverage - Next.js page/layout missing
metadataorgenerateMetadatawith no inherited coverage from parent layout - Remix route missing
export const meta - Informational
<img>missingalt— decorative images withalt=""are correct, do not flag - Missing canonical URL on pages with real duplicate content risk (parameterized URLs, syndicated content)
- React SPA on an SEO-critical public site with no acknowledgment of rendering risk
- i18n site missing
hreflangalternate links
Enhancement — show recommendation + code example, ask before applying:
- Missing Open Graph / Twitter card tags
- Missing JSON-LD structured data
- No sitemap
- Missing
robots.txt(crawl is allowed by default; absence is not a blocker) next/headused in app router (native metadata API is preferred, not required)- Dynamic pages with hardcoded or missing SEO
robots.txtmissing sitemap pointer- Paginated content with no canonical strategy
---
Step 3 — Report
Single file audit: report inline in conversation, no file created.
Full site audit: write seo-audit.md at the project root:
# SEO Audit — [Project Name]
Generated: [date]
Framework: [Next.js / Remix / React SPA]
## Summary
- X critical issues
- Y enhancements
## Critical Issues
### [Issue] — `path/to/file.tsx`
[What's wrong and why it hurts indexing]
[Fix applied or code snippet]
## Enhancements
### [Issue] — `path/to/file.tsx`
[Recommendation + example]---
Step 4 — Implement fixes
- Small fix (one alt tag, one meta tag): edit directly
- Structural change (adding
generateMetadata, rewiring data flow): show diff first, then apply — no separate confirmation needed, just show what changes before writing it - New files (
sitemap.ts,robots.txt): create directly
Fix all critical issues. For enhancements, present example code and ask.
---
Framework patterns
Next.js — app router (static)
// app/about/page.tsx
import type { Metadata } from 'next'
export const metadata: Metadata = {
title: 'About Us | Acme',
description: 'Learn about our mission.',
alternates: { canonical: 'https://acme.com/about' },
openGraph: {
title: 'About Us | Acme',
description: 'Learn about our mission.',
url: 'https://acme.com/about',
images: [{ url: 'https://acme.com/og.png', width: 1200, height: 630 }],
},
twitter: { card: 'summary_large_image', title: 'About Us | Acme' },
}Next.js — app router (dynamic)
// Next.js deduplicates the fetch between generateMetadata and the page component
export async function generateMetadata({ params }): Promise<Metadata> {
const post = await getPost(params.slug)
return {
title: post.title,
description: post.excerpt,
alternates: { canonical: `https://acme.com/blog/${params.slug}` },
openGraph: { title: post.title, type: 'article', images: [{ url: post.coverImage }] },
}
}Next.js — root layout defaults
// app/layout.tsx
export const metadata: Metadata = {
metadataBase: new URL('https://acme.com'),
title: { default: 'Acme', template: '%s | Acme' },
robots: { index: true, follow: true },
}Next.js — sitemap (app router)
// app/sitemap.ts
import { MetadataRoute } from 'next'
export default async function sitemap(): Promise<MetadataRoute.Sitemap> {
const posts = await getAllPosts()
return [
{ url: 'https://acme.com', changeFrequency: 'daily', priority: 1 },
...posts.map(p => ({ url: `https://acme.com/blog/${p.slug}`, lastModified: p.updatedAt })),
]
}Next.js — robots.txt (app router)
// app/robots.ts
import { MetadataRoute } from 'next'
export default function robots(): MetadataRoute.Robots {
return {
rules: { userAgent: '*', allow: '/', disallow: ['/admin/', '/api/'] },
sitemap: 'https://acme.com/sitemap.xml',
}
}Remix — meta export
// app/routes/blog.$slug.tsx
export const meta: MetaFunction<typeof loader> = ({ data, params }) => {
if (!data) return [{ title: 'Not Found' }]
return [
{ title: `${data.post.title} | Blog` },
{ name: 'description', content: data.post.excerpt },
{ tagName: 'link', rel: 'canonical', href: `https://acme.com/blog/${params.slug}` },
{ property: 'og:title', content: data.post.title },
{ property: 'og:type', content: 'article' },
{ property: 'og:image', content: data.post.coverImage },
{ name: 'twitter:card', content: 'summary_large_image' },
]
}Remix — sitemap resource route
// app/routes/sitemap[.xml].tsx
export async function loader({ request }) {
const posts = await getAllPosts()
const base = new URL(request.url).origin
const urls = posts.map(p => `<url><loc>${base}/blog/${p.slug}</loc></url>`)
return new Response(
`<?xml version="1.0" encoding="UTF-8"?><urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">${urls.join('')}</urlset>`,
{ headers: { 'Content-Type': 'application/xml' } }
)
}React SPA — react-helmet-async
// Install: npm install react-helmet-async
// Wrap root: <HelmetProvider><App /></HelmetProvider>
import { Helmet } from 'react-helmet-async'
export function BlogPost({ post }) {
return (
<>
<Helmet>
<title>{post.title} | Blog</title>
<meta name="description" content={post.excerpt} />
<link rel="canonical" href={`https://acme.com/blog/${post.slug}`} />
<meta property="og:title" content={post.title} />
<meta property="og:image" content={post.coverImage} />
<meta name="twitter:card" content="summary_large_image" />
</Helmet>
{/* content */}
</>
)
}---
JSON-LD — schema detection
Auto-detect from route/file/component signals:
| Signal | Schema type |
|---|---|
/blog/[slug], BlogPost | Article |
/products/[id], product.name | Product |
/faq, accordion Q&A | FAQPage |
/, root layout | Organization + WebSite |
/about, /contact | WebPage + Organization |
/recipes/[slug] | Recipe |
/events/[id] | Event |
| Catch-all / ambiguous | Ask the user |
Inject via <script type="application/ld+json"> in the page component — works in all frameworks. In Next.js, JSON-LD cannot be emitted from generateMetadata; that function only controls <head> meta elements.
---
i18n SEO
When locale routing or next-intl / i18next is detected, every public page needs hreflang:
// Next.js app router — in generateMetadata
alternates: {
canonical: `https://acme.com/${params.locale}/blog/${params.slug}`,
languages: {
'x-default': `https://acme.com/en/blog/${params.slug}`,
en: `https://acme.com/en/blog/${params.slug}`,
fr: `https://acme.com/fr/blog/${params.slug}`,
de: `https://acme.com/de/blog/${params.slug}`,
},
},Rules: every locale links to all others (reciprocal), always include x-default, canonical must match current locale URL.
---
Pagination SEO
- Page 1: clean canonical (no
?page=1) - Pages 2+: self-referencing canonical
- Thin pages 2+: add
<meta name="robots" content="noindex, follow"> - Sitemap: include page 1 only (unless each page has unique content)
---
SPA rendering risk
Show as Critical when: React SPA + no SSR + SEO-critical site:
⚠️ Crawlability Risk
This is a client-side rendered SPA. Google does crawl and render JavaScript,
but with meaningful caveats: rendering is deferred, resources are limited, and
dynamic meta tags have weaker indexing guarantees than server-rendered HTML.
Options:
1. Migrate to Next.js (recommended)
2. Migrate to Remix
3. Add a prerendering service (prerender.io, rendertron)
react-helmet-async manages meta tags for human visitors and social previews
but does not guarantee Google indexing of dynamic tags.Skip for internal tools, dashboards, auth-gated apps.
---
Library selection
Always check package.json first. Prefer native APIs over third-party deps.
| Situation | Use |
|---|---|
| Next.js app router | Native metadata / generateMetadata |
| Next.js pages router | next/head (or next-seo if already installed) |
| Remix | Native export const meta |
| React SPA | react-helmet-async |
| Sitemap (Next.js) | Native app/sitemap.ts or next-sitemap |
| Sitemap (Remix) | Resource route |
| Sitemap (SPA) | Build-time script writing public/sitemap.xml |
---
Checklist
Core
- [ ]
<title>— unique, under 60 chars - [ ]
meta description— under 160 chars - [ ]
canonical— on pages with duplicate content risk - [ ]
robotsmeta — not accidentallynoindexon public pages
Social
- [ ]
og:title,og:description,og:image,og:url,og:type - [ ]
twitter:card,twitter:title,twitter:description,twitter:image
Structured data
- [ ] JSON-LD present with correct schema type
Images
- [ ] Informational
<img>have descriptivealttext; decorative<img>havealt="" - [ ]
next/imageused in Next.js (performance improvement — not an SEO requirement)
Crawlability
- [ ]
sitemap.xmlexists and linked inrobots.txt - [ ]
robots.txtat root, not blocking public routes
i18n (if applicable)
- [ ]
hreflangon every locale variant - [ ]
x-defaultset - [ ] Canonical URLs include locale prefix consistently
Pagination (if applicable)
- [ ] Page 1 canonical is clean
- [ ] Pages 2+ have self-referencing canonicals
seo-enhancer
An SEO audit and implementation assistant for Next.js, Remix, and React SPA projects. Works with Claude Code, Cursor, GitHub Copilot, Windsurf, Cline, OpenAI Codex, and any AI coding tool.
What it does
- Audits pages for missing meta tags, broken sitemaps, crawlability issues, and more
- Fixes critical issues automatically — missing titles, alt tags, metadata exports
- Recommends enhancements like JSON-LD structured data, Open Graph tags, and sitemaps
- Detects your framework and existing setup — works with what you already have
- Warns about React SPA crawlability risks that react-helmet alone can't solve
Coverage
| Framework | Meta tags | OG/Twitter | JSON-LD | Sitemap | robots.txt | i18n/hreflang |
|---|---|---|---|---|---|---|
| Next.js (app router) | Full | Full | Full | Full | Full | Full |
| Next.js (pages router) | Full | Full | Full | Full | Full | Full |
| Remix | Full | Full | Full | Full | Full | Full |
| React SPA (Vite/CRA) | Full | Full | Full | Partial | Full | Partial |
| Vue / Nuxt | Contributions welcome | |||||
| Gatsby | Contributions welcome |
---
Install
Universal (all tools)
npx skills add gashiartim/seo-enhancerThe skills CLI auto-detects your AI tools (Claude Code, Cursor, Windsurf, Cline, Copilot, Codex, and more) and installs to the right place for each. Works globally or per-project.
Install globally for all your projects:
npx skills add gashiartim/seo-enhancer -gInstall for specific agents only:
npx skills add gashiartim/seo-enhancer --agent claude-code cursor windsurf---
Manual setup by tool
If you prefer to install manually, copy the adapter file for your tool:
| Tool | File | Destination |
|---|---|---|
| Claude Code (omc) | SKILL.md | ~/.claude/skills/seo-enhancer/ |
| Cursor | adapters/cursor.mdc | .cursor/rules/seo-enhancer.mdc |
| GitHub Copilot | adapters/copilot.md | .github/copilot-instructions.md |
| Windsurf | adapters/windsurf.md | .windsurfrules |
| Cline | adapters/cline.md | .clinerules |
| Codex / AGENTS.md | adapters/agents.md | AGENTS.md |
| Any other tool | INSTRUCTIONS.md | paste into system prompt |
---
Usage
Once set up, just describe what you need:
Audit the SEO on my homepage
Fix the meta tags on my blog post page
My site doesn't show up in Google — what's wrong?
Add structured data to my product pages
Set up a sitemap for my Next.js app
My social previews are broken
Add hreflang to my i18n site---
Examples
Next.js — full site audit
Audit the entire Next.js project for SEO issuesProduces a seo-audit.md at the project root with critical issues and enhancements, then fixes all critical ones.
Remix — add metadata to a route
Add SEO meta tags to app/routes/blog.$slug.tsxReads the existing loader, wires export const meta to the same data, adds OG/Twitter tags.
React SPA — baseline SEO setup
Set up SEO for my Vite React appInstalls react-helmet-async, adds a HelmetProvider, wires meta tags on each page, generates public/robots.txt, and flags the crawlability risk.
i18n site — add hreflang
My Next.js site uses next-intl with en/fr/de locales — add hreflangDetects locale routing, adds alternates.languages with x-default to generateMetadata for every route.
---
How it decides what to fix vs. recommend
Fixes immediately (Critical):
- Missing
<title>ormeta descriptionon a public page - Next.js page without
generateMetadataormetadataexport - Remix route without
export const meta <img>tags missingaltattributes- No
robots.txt - i18n site with no hreflang
Recommends (Enhancement):
- Open Graph / Twitter card tags
- JSON-LD structured data
- Sitemap generation
next/imagemigration- Pagination canonicals
---
File structure
seo-enhancer/
├── INSTRUCTIONS.md — universal instructions (any AI tool)
├── SKILL.md — Claude Code skill (oh-my-claudecode)
├── adapters/
│ ├── cursor.mdc — Cursor rules (.cursor/rules/)
│ ├── copilot.md — GitHub Copilot (.github/copilot-instructions.md)
│ ├── windsurf.md — Windsurf (.windsurfules)
│ ├── cline.md — Cline (.clinerules)
│ └── agents.md — OpenAI Codex / AGENTS.md
└── references/
├── nextjs.md — Next.js patterns (app router, pages router, sitemap)
├── remix.md — Remix meta export, loader-driven metadata, resource routes
├── react-spa.md — react-helmet-async setup, crawlability warning
├── json-ld-schemas.md — JSON-LD templates (Article, Product, FAQ, Event, etc.)
└── i18n-seo.md — hreflang, x-default, next-intl, locale-aware sitemaps---
Contributing
Contributions welcome. The most valuable additions:
- Vue / Nuxt support — add
references/nuxt.md+adapters/variant, update framework detection - Gatsby support — add
references/gatsby.mdwith Gatsby Head API patterns - New JSON-LD schemas — add templates to
references/json-ld-schemas.md(Course, JobPosting, Review, etc.) - New tool adapters — add to
adapters/following the existing format
Adding a new framework
1. Add references/<framework>.md with implementation patterns 2. Update framework detection in SKILL.md → Step 1 and INSTRUCTIONS.md 3. Update all adapter files with the new framework's patterns 4. Update the coverage table in this README
---
License
MIT
---
Made by Artim Gashi
Internationalization (i18n) SEO Patterns
Why i18n SEO Matters
Without hreflang, Google cannot reliably distinguish locale variants from one another. It may cluster /en/about and /fr/about together and serve the wrong regional page to users — or exclude variants from locale-specific search results. Hreflang tells Google: "these pages are translations of each other, serve the right one to the right user."
hreflang Rules
1. Every locale variant must link to all other variants (including itself) 2. Always include x-default — points to the fallback/default locale 3. Use BCP 47 language tags: en, en-US, fr, fr-FR, pt-BR 4. Canonical and hreflang must be consistent — canonical must match one of the hreflang URLs
Next.js App Router — with next-intl
// app/[locale]/blog/[slug]/page.tsx
import { getTranslations } from "next-intl/server"
import type { Metadata } from "next"
const locales = ["en", "fr", "de", "es"]
export async function generateMetadata({
params,
}: {
params: { locale: string; slug: string }
}): Promise<Metadata> {
const t = await getTranslations({ locale: params.locale, namespace: "Blog" })
const post = await getPost(params.slug, params.locale)
const baseUrl = "https://acme.com"
return {
title: post.title,
description: post.excerpt,
alternates: {
canonical: `${baseUrl}/${params.locale}/blog/${params.slug}`,
languages: Object.fromEntries(
locales.map((locale) => [
locale,
`${baseUrl}/${locale}/blog/${params.slug}`,
])
),
},
openGraph: {
title: post.title,
description: post.excerpt,
locale: params.locale,
alternateLocale: locales.filter((l) => l !== params.locale),
},
}
}The alternates.languages object in Next.js metadata API automatically renders as:
<link rel="alternate" hreflang="en" href="https://acme.com/en/blog/slug" />
<link rel="alternate" hreflang="fr" href="https://acme.com/fr/blog/slug" />
<link rel="canonical" href="https://acme.com/en/blog/slug" />Next.js App Router — x-default
Always add x-default to the languages object:
alternates: {
canonical: `${baseUrl}/${params.locale}/blog/${params.slug}`,
languages: {
"x-default": `${baseUrl}/en/blog/${params.slug}`,
en: `${baseUrl}/en/blog/${params.slug}`,
fr: `${baseUrl}/fr/blog/${params.slug}`,
de: `${baseUrl}/de/blog/${params.slug}`,
},
},Next.js Root Layout — Site-Wide hreflang
For static routes that exist in all locales, set hreflang in the root layout:
// app/[locale]/layout.tsx
export async function generateMetadata({
params,
}: {
params: { locale: string }
}): Promise<Metadata> {
const baseUrl = "https://acme.com"
return {
metadataBase: new URL(baseUrl),
alternates: {
languages: {
"x-default": `${baseUrl}/en`,
en: `${baseUrl}/en`,
fr: `${baseUrl}/fr`,
de: `${baseUrl}/de`,
},
},
}
}Next.js Pages Router — Manual hreflang
// pages/[locale]/about.tsx
import Head from "next/head"
const locales = ["en", "fr", "de"]
const baseUrl = "https://acme.com"
export default function About({ locale }: { locale: string }) {
return (
<>
<Head>
<link rel="canonical" href={`${baseUrl}/${locale}/about`} />
<link rel="alternate" hreflang="x-default" href={`${baseUrl}/en/about`} />
{locales.map((l) => (
<link key={l} rel="alternate" hreflang={l} href={`${baseUrl}/${l}/about`} />
))}
</Head>
</>
)
}Remix — hreflang in meta export
// app/routes/$locale.about.tsx
export const meta: MetaFunction<typeof loader> = ({ data, params }) => {
const baseUrl = "https://acme.com"
const locales = ["en", "fr", "de"]
return [
{ title: data?.title },
{ tagName: "link", rel: "canonical", href: `${baseUrl}/${params.locale}/about` },
{ tagName: "link", rel: "alternate", hreflang: "x-default", href: `${baseUrl}/en/about` },
...locales.map((locale) => ({
tagName: "link",
rel: "alternate",
hreflang: locale,
href: `${baseUrl}/${locale}/about`,
})),
]
}Sitemap with Locales
Next.js app/sitemap.ts
import { MetadataRoute } from "next"
const locales = ["en", "fr", "de"]
const baseUrl = "https://acme.com"
export default async function sitemap(): Promise<MetadataRoute.Sitemap> {
const posts = await getAllPosts()
// Static pages — all locales
const staticPages = ["/", "/about", "/contact"].flatMap((path) =>
locales.map((locale) => ({
url: `${baseUrl}/${locale}${path}`,
lastModified: new Date(),
changeFrequency: "monthly" as const,
alternates: {
languages: Object.fromEntries(
locales.map((l) => [l, `${baseUrl}/${l}${path}`])
),
},
}))
)
// Dynamic pages — blog posts per locale
const blogPages = posts.flatMap((post) =>
locales.map((locale) => ({
url: `${baseUrl}/${locale}/blog/${post.slug}`,
lastModified: post.updatedAt,
alternates: {
languages: Object.fromEntries(
locales.map((l) => [l, `${baseUrl}/${l}/blog/${post.slug}`])
),
},
}))
)
return [...staticPages, ...blogPages]
}Common i18n SEO Mistakes to Flag
| Mistake | Why it's a problem | Fix |
|---|---|---|
| No hreflang at all | Google cannot distinguish locale variants; may serve wrong region or exclude variants | Add hreflang to every locale variant |
| hreflang not reciprocal | Google ignores unreciprocated hreflang | Every locale must link to all others |
Missing x-default | No fallback for users whose locale isn't covered | Point x-default to your default locale |
| Canonical points to different locale | Contradicts hreflang, Google drops it | Canonical must match the current page's locale URL |
Using query params for locale (?lang=fr) | Less reliable for Google than path-based (/fr/) | Prefer path-based locale routing |
| Same content, different locale URL, no hreflang | Google may cluster variants incorrectly and serve the wrong locale | Add hreflang or consolidate |
Detecting i18n Setup
When auditing, check for these signals that hreflang is needed:
# Path-based locale routing
grep -r "\[locale\]" app/ pages/
grep -r "i18n" next.config.*
# Library usage
grep -E "next-intl|next-i18next|i18next|react-i18next" package.json
# Locale config
ls | grep -E "i18n|intl"If any match → check every public route for hreflang. Missing hreflang on an i18n site = Critical finding.
JSON-LD Schema Templates
Reference templates for common schema types. Adapt field values from the actual page data — never hardcode placeholder text.
Article (blog posts, news, guides)
{
"@context": "https://schema.org",
"@type": "Article",
"headline": "Post title here",
"description": "Post excerpt or summary",
"author": {
"@type": "Person",
"name": "Author Name",
"url": "https://acme.com/authors/name"
},
"publisher": {
"@type": "Organization",
"name": "Acme Corp",
"logo": {
"@type": "ImageObject",
"url": "https://acme.com/logo.png"
}
},
"datePublished": "2024-01-15T08:00:00Z",
"dateModified": "2024-01-20T10:00:00Z",
"image": {
"@type": "ImageObject",
"url": "https://acme.com/blog/cover.jpg",
"width": 1200,
"height": 630
},
"url": "https://acme.com/blog/post-slug",
"mainEntityOfPage": {
"@type": "WebPage",
"@id": "https://acme.com/blog/post-slug"
}
}Product (e-commerce, product pages)
{
"@context": "https://schema.org",
"@type": "Product",
"name": "Product Name",
"description": "Product description",
"image": ["https://acme.com/products/img1.jpg", "https://acme.com/products/img2.jpg"],
"sku": "PROD-001",
"brand": {
"@type": "Brand",
"name": "Acme"
},
"offers": {
"@type": "Offer",
"url": "https://acme.com/products/prod-001",
"priceCurrency": "USD",
"price": "29.99",
"priceValidUntil": "2025-12-31",
"availability": "https://schema.org/InStock",
"itemCondition": "https://schema.org/NewCondition"
},
"aggregateRating": {
"@type": "AggregateRating",
"ratingValue": "4.5",
"reviewCount": "128"
}
}FAQPage (FAQ sections, accordion content)
{
"@context": "https://schema.org",
"@type": "FAQPage",
"mainEntity": [
{
"@type": "Question",
"name": "What is your return policy?",
"acceptedAnswer": {
"@type": "Answer",
"text": "We offer a 30-day return policy on all items."
}
},
{
"@type": "Question",
"name": "How long does shipping take?",
"acceptedAnswer": {
"@type": "Answer",
"text": "Standard shipping takes 5-7 business days."
}
}
]
}Organization (homepage, about page)
{
"@context": "https://schema.org",
"@type": "Organization",
"name": "Acme Corp",
"url": "https://acme.com",
"logo": "https://acme.com/logo.png",
"description": "What the company does.",
"foundingDate": "2020",
"contactPoint": {
"@type": "ContactPoint",
"contactType": "customer support",
"email": "support@acme.com"
},
"sameAs": [
"https://twitter.com/acmecorp",
"https://linkedin.com/company/acmecorp"
]
}WebSite (homepage — enables sitelinks search)
{
"@context": "https://schema.org",
"@type": "WebSite",
"name": "Acme Corp",
"url": "https://acme.com",
"potentialAction": {
"@type": "SearchAction",
"target": {
"@type": "EntryPoint",
"urlTemplate": "https://acme.com/search?q={search_term_string}"
},
"query-input": "required name=search_term_string"
}
}BreadcrumbList (any page with breadcrumbs)
{
"@context": "https://schema.org",
"@type": "BreadcrumbList",
"itemListElement": [
{
"@type": "ListItem",
"position": 1,
"name": "Home",
"item": "https://acme.com"
},
{
"@type": "ListItem",
"position": 2,
"name": "Blog",
"item": "https://acme.com/blog"
},
{
"@type": "ListItem",
"position": 3,
"name": "Post Title",
"item": "https://acme.com/blog/post-slug"
}
]
}Recipe
{
"@context": "https://schema.org",
"@type": "Recipe",
"name": "Recipe Name",
"description": "Brief description",
"image": "https://acme.com/recipes/img.jpg",
"author": { "@type": "Person", "name": "Chef Name" },
"datePublished": "2024-01-01",
"prepTime": "PT15M",
"cookTime": "PT30M",
"totalTime": "PT45M",
"recipeYield": "4 servings",
"recipeIngredient": ["2 cups flour", "1 cup sugar"],
"recipeInstructions": [
{ "@type": "HowToStep", "text": "Preheat oven to 350°F." },
{ "@type": "HowToStep", "text": "Mix dry ingredients." }
],
"aggregateRating": {
"@type": "AggregateRating",
"ratingValue": "4.8",
"ratingCount": "45"
}
}Event
{
"@context": "https://schema.org",
"@type": "Event",
"name": "Event Name",
"description": "Event description",
"startDate": "2024-06-15T18:00:00Z",
"endDate": "2024-06-15T21:00:00Z",
"eventStatus": "https://schema.org/EventScheduled",
"eventAttendanceMode": "https://schema.org/OfflineEventAttendanceMode",
"location": {
"@type": "Place",
"name": "Venue Name",
"address": {
"@type": "PostalAddress",
"streetAddress": "123 Main St",
"addressLocality": "San Francisco",
"addressRegion": "CA",
"postalCode": "94105",
"addressCountry": "US"
}
},
"organizer": {
"@type": "Organization",
"name": "Acme Corp",
"url": "https://acme.com"
},
"offers": {
"@type": "Offer",
"url": "https://acme.com/events/event-slug",
"price": "50",
"priceCurrency": "USD",
"availability": "https://schema.org/InStock"
}
}WebPage (generic pages — use when no specific type fits)
{
"@context": "https://schema.org",
"@type": "WebPage",
"name": "Page Title",
"description": "Page description",
"url": "https://acme.com/page-slug",
"breadcrumb": {
"@type": "BreadcrumbList",
"itemListElement": [
{ "@type": "ListItem", "position": 1, "name": "Home", "item": "https://acme.com" },
{ "@type": "ListItem", "position": 2, "name": "Page Title", "item": "https://acme.com/page-slug" }
]
}
}Multiple schemas on one page
Wrap in an array when a page needs more than one schema type (e.g., Article + BreadcrumbList):
[
{
"@context": "https://schema.org",
"@type": "Article",
"headline": "..."
},
{
"@context": "https://schema.org",
"@type": "BreadcrumbList",
"itemListElement": [...]
}
]Next.js SEO Patterns
App Router — Static Metadata
// app/about/page.tsx
import type { Metadata } from 'next'
export const metadata: Metadata = {
title: 'About Us | Acme Corp',
description: 'Learn about our mission and team.',
alternates: { canonical: 'https://acme.com/about' },
openGraph: {
title: 'About Us | Acme Corp',
description: 'Learn about our mission and team.',
url: 'https://acme.com/about',
siteName: 'Acme Corp',
images: [{ url: 'https://acme.com/og-about.png', width: 1200, height: 630 }],
type: 'website',
},
twitter: {
card: 'summary_large_image',
title: 'About Us | Acme Corp',
description: 'Learn about our mission and team.',
images: ['https://acme.com/og-about.png'],
},
}App Router — Dynamic Metadata
// app/blog/[slug]/page.tsx
import type { Metadata } from 'next'
// Next.js deduplicates this fetch with the one in the component
async function getPost(slug: string) {
const res = await fetch(`https://api.example.com/posts/${slug}`, {
next: { revalidate: 3600 },
})
return res.json()
}
export async function generateMetadata({ params }: { params: { slug: string } }): Promise<Metadata> {
const post = await getPost(params.slug)
return {
title: `${post.title} | Blog`,
description: post.excerpt,
alternates: { canonical: `https://acme.com/blog/${params.slug}` },
openGraph: {
title: post.title,
description: post.excerpt,
type: 'article',
publishedTime: post.publishedAt,
authors: [post.author.name],
images: [{ url: post.coverImage, width: 1200, height: 630 }],
},
twitter: {
card: 'summary_large_image',
title: post.title,
description: post.excerpt,
images: [post.coverImage],
},
}
}App Router — Root Layout Defaults
// app/layout.tsx
import type { Metadata } from 'next'
export const metadata: Metadata = {
metadataBase: new URL('https://acme.com'), // required for relative OG image URLs
title: {
default: 'Acme Corp',
template: '%s | Acme Corp', // child pages use: title: 'About' → 'About | Acme Corp'
},
description: 'Default site description.',
robots: { index: true, follow: true },
openGraph: {
siteName: 'Acme Corp',
type: 'website',
},
}Pages Router — next/head
// pages/about.tsx
import Head from 'next/head'
export default function About() {
return (
<>
<Head>
<title>About Us | Acme Corp</title>
<meta name="description" content="Learn about our mission." />
<link rel="canonical" href="https://acme.com/about" />
<meta property="og:title" content="About Us | Acme Corp" />
<meta property="og:description" content="Learn about our mission." />
<meta property="og:image" content="https://acme.com/og-about.png" />
<meta property="og:url" content="https://acme.com/about" />
<meta name="twitter:card" content="summary_large_image" />
</Head>
{/* page content */}
</>
)
}Pages Router — Dynamic with getServerSideProps
// pages/blog/[slug].tsx
import Head from 'next/head'
export async function getServerSideProps({ params }) {
const post = await fetchPost(params.slug)
return { props: { post } }
}
export default function BlogPost({ post }) {
return (
<>
<Head>
<title>{post.title} | Blog</title>
<meta name="description" content={post.excerpt} />
<link rel="canonical" href={`https://acme.com/blog/${post.slug}`} />
<meta property="og:title" content={post.title} />
<meta property="og:type" content="article" />
<meta property="og:image" content={post.coverImage} />
</Head>
{/* content */}
</>
)
}Sitemap — App Router (dynamic)
// app/sitemap.ts
import { MetadataRoute } from 'next'
export default async function sitemap(): Promise<MetadataRoute.Sitemap> {
const posts = await fetchAllPosts()
const postEntries = posts.map((post) => ({
url: `https://acme.com/blog/${post.slug}`,
lastModified: post.updatedAt,
changeFrequency: 'weekly' as const,
priority: 0.8,
}))
return [
{ url: 'https://acme.com', lastModified: new Date(), changeFrequency: 'daily', priority: 1 },
{ url: 'https://acme.com/about', lastModified: new Date(), changeFrequency: 'monthly', priority: 0.5 },
...postEntries,
]
}Sitemap — next-sitemap
// next-sitemap.config.js
/** @type {import('next-sitemap').IConfig} */
module.exports = {
siteUrl: process.env.SITE_URL || 'https://acme.com',
generateRobotsTxt: true,
robotsTxtOptions: {
policies: [{ userAgent: '*', allow: '/' }],
},
exclude: ['/admin/*', '/api/*', '/404', '/500'],
changefreq: 'weekly',
priority: 0.7,
}Add to package.json:
"scripts": {
"postbuild": "next-sitemap"
}robots.txt — App Router (generated)
// app/robots.ts
import { MetadataRoute } from 'next'
export default function robots(): MetadataRoute.Robots {
return {
rules: { userAgent: '*', allow: '/', disallow: ['/admin/', '/api/'] },
sitemap: 'https://acme.com/sitemap.xml',
}
}robots.txt — Static file
# public/robots.txt
User-agent: *
Allow: /
Disallow: /admin/
Disallow: /api/
Sitemap: https://acme.com/sitemap.xmlnext/image
next/image is a performance and UX improvement — automatic format conversion, lazy loading, layout shift prevention. It's not a direct SEO ranking factor, but it helps Core Web Vitals (LCP in particular) which are a ranking signal. Prefer it where practical, but don't treat raw <img> as an SEO error.
import Image from 'next/image'
// Informational image — descriptive alt required
<Image src="/hero.jpg" alt="Product dashboard showing weekly sales overview" width={1200} height={630} priority />
// Decorative image — empty alt, not missing alt
<Image src="/divider.png" alt="" width={800} height={4} aria-hidden />
// priority on above-the-fold images improves LCP
// Omit priority below the fold (lazy loaded by default)JSON-LD in Next.js
// JSON-LD is injected via a script tag in the page component.
// It cannot be emitted from generateMetadata — that function only controls <head> meta elements.
// App router — in the page component:
export default function BlogPost({ post }) {
const jsonLd = {
'@context': 'https://schema.org',
'@type': 'Article',
headline: post.title,
description: post.excerpt,
author: { '@type': 'Person', name: post.author.name },
datePublished: post.publishedAt,
dateModified: post.updatedAt,
image: post.coverImage,
url: `https://acme.com/blog/${post.slug}`,
}
return (
<>
<script
type="application/ld+json"
dangerouslySetInnerHTML={{ __html: JSON.stringify(jsonLd) }}
/>
{/* page content */}
</>
)
}React SPA SEO Patterns
The Fundamental Problem
Client-side rendered SPAs (React + Vite, CRA, etc.) render content via JavaScript. Googlebot may not execute JS before indexing, meaning meta tags set dynamically via JS can be invisible to crawlers.
Always surface this as a Critical finding for SEO-critical public sites. Add the crawlability warning from the main skill. Skip for internal tools, dashboards, or auth-gated apps.
react-helmet-async Setup
Prefer react-helmet-async over react-helmet — it's thread-safe and actively maintained.
npm install react-helmet-asyncWrap the app root:
// main.tsx or App.tsx
import { HelmetProvider } from 'react-helmet-async'
function App() {
return (
<HelmetProvider>
<Router>
<Routes />
</Router>
</HelmetProvider>
)
}Static Page Meta Tags
import { Helmet } from 'react-helmet-async'
export function AboutPage() {
return (
<>
<Helmet>
<title>About Us | Acme Corp</title>
<meta name="description" content="Learn about our mission and team." />
<link rel="canonical" href="https://acme.com/about" />
<meta property="og:title" content="About Us | Acme Corp" />
<meta property="og:description" content="Learn about our mission and team." />
<meta property="og:image" content="https://acme.com/og-about.png" />
<meta property="og:url" content="https://acme.com/about" />
<meta property="og:type" content="website" />
<meta name="twitter:card" content="summary_large_image" />
<meta name="twitter:title" content="About Us | Acme Corp" />
<meta name="twitter:description" content="Learn about our mission and team." />
<meta name="twitter:image" content="https://acme.com/og-about.png" />
</Helmet>
{/* page content */}
</>
)
}Dynamic Page Meta Tags
import { Helmet } from 'react-helmet-async'
import { useQuery } from '@tanstack/react-query'
export function BlogPost({ slug }: { slug: string }) {
const { data: post } = useQuery({
queryKey: ['post', slug],
queryFn: () => fetchPost(slug),
})
if (!post) return <LoadingSkeleton />
return (
<>
<Helmet>
<title>{post.title} | Blog</title>
<meta name="description" content={post.excerpt} />
<link rel="canonical" href={`https://acme.com/blog/${slug}`} />
<meta property="og:title" content={post.title} />
<meta property="og:description" content={post.excerpt} />
<meta property="og:image" content={post.coverImage} />
<meta property="og:url" content={`https://acme.com/blog/${slug}`} />
<meta property="og:type" content="article" />
<meta name="twitter:card" content="summary_large_image" />
<meta name="twitter:image" content={post.coverImage} />
</Helmet>
{/* content */}
</>
)
}Default/Fallback Meta Tags
Add a default <Helmet> in the root layout — child <Helmet> tags override it per page:
// App.tsx or RootLayout.tsx
import { Helmet } from 'react-helmet-async'
export function RootLayout({ children }) {
return (
<>
<Helmet defaultTitle="Acme Corp" titleTemplate="%s | Acme Corp">
<meta name="description" content="Default site description." />
<meta property="og:site_name" content="Acme Corp" />
<meta name="twitter:card" content="summary_large_image" />
</Helmet>
{children}
</>
)
}JSON-LD in React SPA
import { Helmet } from 'react-helmet-async'
export function BlogPost({ post }) {
const jsonLd = {
'@context': 'https://schema.org',
'@type': 'Article',
headline: post.title,
description: post.excerpt,
author: { '@type': 'Person', name: post.author.name },
datePublished: post.publishedAt,
image: post.coverImage,
url: `https://acme.com/blog/${post.slug}`,
}
return (
<>
<Helmet>
<script type="application/ld+json">{JSON.stringify(jsonLd)}</script>
</Helmet>
{/* content */}
</>
)
}Static Sitemap for SPA
SPAs don't have a server to generate sitemaps dynamically. Options:
Option 1 — Static XML (for small sites with known routes):
<!-- public/sitemap.xml -->
<?xml version="1.0" encoding="UTF-8"?>
<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">
<url>
<loc>https://acme.com/</loc>
<lastmod>2024-01-01</lastmod>
<changefreq>daily</changefreq>
<priority>1.0</priority>
</url>
<url>
<loc>https://acme.com/about</loc>
<changefreq>monthly</changefreq>
<priority>0.5</priority>
</url>
</urlset>Option 2 — Build-time generation script (for dynamic routes fetched from an API):
// scripts/generate-sitemap.mjs
import { writeFileSync } from 'fs'
const BASE_URL = 'https://acme.com'
async function generateSitemap() {
const posts = await fetch(`${BASE_URL}/api/posts`).then(r => r.json())
const urls = [
`<url><loc>${BASE_URL}/</loc><priority>1.0</priority></url>`,
`<url><loc>${BASE_URL}/about</loc><priority>0.5</priority></url>`,
...posts.map(p => `<url><loc>${BASE_URL}/blog/${p.slug}</loc><lastmod>${p.updatedAt}</lastmod></url>`),
]
const xml = `<?xml version="1.0" encoding="UTF-8"?>
<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">
${urls.join('\n')}
</urlset>`
writeFileSync('public/sitemap.xml', xml)
console.log('Sitemap generated')
}
generateSitemap()Add to package.json:
"scripts": {
"build": "vite build && node scripts/generate-sitemap.mjs"
}robots.txt for SPA
# public/robots.txt
User-agent: *
Allow: /
Disallow: /admin/
Sitemap: https://acme.com/sitemap.xmlCrawlability Warning (when to show)
Show this warning when ALL of the following are true: 1. Framework is React SPA (Vite, CRA, no SSR) 2. Site has public-facing pages that should be indexed 3. No prerendering service detected in the codebase
⚠️ Crawlability Risk Detected
This is a client-side rendered React SPA. Meta tags added via react-helmet-async
will work for human visitors, but may NOT be indexed by Googlebot if it doesn't
execute JavaScript before crawling.
To fully solve this, consider:
1. Migrating to Next.js (recommended — full SSR/SSG, native metadata API)
2. Adding a prerendering service (prerender.io, rendertron)
3. Vite SSR (advanced, gives you SSR without changing frameworks)
react-helmet-async has been added for meta tag management. This improves
social sharing previews (Slack, Twitter, etc.) which do execute JS, but
does not guarantee Google indexing of dynamic meta tags.Remix SEO Patterns
How Remix Metadata Works
Remix uses a meta export function per route. It receives loader data, params, and parent matches — so dynamic metadata is first-class with no extra fetches.
// app/routes/blog.$slug.tsx
import type { MetaFunction, LoaderFunctionArgs } from "@remix-run/node"
import { json } from "@remix-run/node"
import { useLoaderData } from "@remix-run/react"
export async function loader({ params }: LoaderFunctionArgs) {
const post = await getPost(params.slug!)
if (!post) throw new Response("Not Found", { status: 404 })
return json({ post })
}
export const meta: MetaFunction<typeof loader> = ({ data, params }) => {
if (!data) return [{ title: "Post Not Found" }]
const { post } = data
return [
{ title: `${post.title} | Blog` },
{ name: "description", content: post.excerpt },
{ tagName: "link", rel: "canonical", href: `https://acme.com/blog/${params.slug}` },
{ property: "og:title", content: post.title },
{ property: "og:description", content: post.excerpt },
{ property: "og:image", content: post.coverImage },
{ property: "og:url", content: `https://acme.com/blog/${params.slug}` },
{ property: "og:type", content: "article" },
{ name: "twitter:card", content: "summary_large_image" },
{ name: "twitter:title", content: post.title },
{ name: "twitter:description", content: post.excerpt },
{ name: "twitter:image", content: post.coverImage },
]
}Root Route — Default / Fallback Meta
// app/root.tsx
export const meta: MetaFunction = () => [
{ title: "Acme Corp" },
{ name: "description", content: "Default site description." },
{ property: "og:site_name", content: "Acme Corp" },
{ name: "twitter:card", content: "summary_large_image" },
]Child route meta arrays replace the parent's by default. To merge with parent meta (e.g. keep root defaults and add page-specific tags):
export const meta: MetaFunction<typeof loader> = ({ data, matches }) => {
const rootMeta = matches.find(m => m.id === "root")?.meta ?? []
return [
...rootMeta,
{ title: `${data?.post.title} | Blog` },
{ name: "description", content: data?.post.excerpt },
]
}Static Page Meta
// app/routes/about.tsx
export const meta: MetaFunction = () => [
{ title: "About Us | Acme Corp" },
{ name: "description", content: "Learn about our mission and team." },
{ tagName: "link", rel: "canonical", href: "https://acme.com/about" },
{ property: "og:title", content: "About Us | Acme Corp" },
{ property: "og:description", content: "Learn about our mission and team." },
{ property: "og:image", content: "https://acme.com/og-about.png" },
{ property: "og:url", content: "https://acme.com/about" },
{ property: "og:type", content: "website" },
]JSON-LD in Remix
Inject via a <script> tag in the component — Remix renders it server-side so Google sees it:
export default function BlogPost() {
const { post } = useLoaderData<typeof loader>()
const jsonLd = {
"@context": "https://schema.org",
"@type": "Article",
headline: post.title,
description: post.excerpt,
author: { "@type": "Person", name: post.author.name },
datePublished: post.publishedAt,
image: post.coverImage,
url: `https://acme.com/blog/${post.slug}`,
}
return (
<>
<script
type="application/ld+json"
dangerouslySetInnerHTML={{ __html: JSON.stringify(jsonLd) }}
/>
{/* page content */}
</>
)
}Sitemap — Resource Route
// app/routes/sitemap[.xml].tsx
import type { LoaderFunctionArgs } from "@remix-run/node"
export async function loader({ request }: LoaderFunctionArgs) {
const posts = await getAllPosts()
const baseUrl = new URL(request.url).origin
const urls = [
`<url><loc>${baseUrl}/</loc><priority>1.0</priority></url>`,
`<url><loc>${baseUrl}/about</loc><priority>0.5</priority></url>`,
...posts.map(p =>
`<url><loc>${baseUrl}/blog/${p.slug}</loc><lastmod>${p.updatedAt}</lastmod><priority>0.8</priority></url>`
),
]
const xml = `<?xml version="1.0" encoding="UTF-8"?>
<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">
${urls.join("\n")}
</urlset>`
return new Response(xml, {
headers: {
"Content-Type": "application/xml",
"Cache-Control": "public, max-age=3600",
},
})
}robots.txt — Resource Route
// app/routes/robots[.txt].tsx
export async function loader() {
const content = `User-agent: *
Allow: /
Disallow: /admin/
Disallow: /api/
Sitemap: https://acme.com/sitemap.xml`
return new Response(content, {
headers: { "Content-Type": "text/plain" },
})
}Canonical URLs
Remix doesn't have a built-in canonical helper. Add via tagName: "link" in the meta export:
export const meta: MetaFunction = ({ request }) => [
{ tagName: "link", rel: "canonical", href: "https://acme.com/page" },
]For dynamic canonicals, build from loader data or params:
export const meta: MetaFunction<typeof loader> = ({ data, params }) => [
{ tagName: "link", rel: "canonical", href: `https://acme.com/blog/${params.slug}` },
]robots Meta Tag
// Prevent indexing on specific pages (e.g. admin, preview)
export const meta: MetaFunction = () => [
{ name: "robots", content: "noindex, nofollow" },
]Checking for Missing meta Exports
When auditing a Remix project, look for route files under app/routes/ that:
- Render public-facing content (not
_index, notapi., not$) - Are missing an
export const metafunction - Have no inherited metadata coverage from a root route
A missing local meta export is only a Critical finding when the route has no effective metadata coverage. If the root route (root.tsx) exports a meta function with sensible defaults — title template, description, robots — child routes may be adequately covered. Flag the gap only when the page would render with a missing or empty <title> and no meta description.