From atum-stack-web
Technical SEO pattern library for modern web stacks — server-side meta tags (Next.js generateMetadata, Remix meta, Nuxt useHead), JSON-LD structured data (schema.org Product, Article, Organization, BreadcrumbList, FAQPage, LocalBusiness), XML sitemaps (dynamic generation with next-sitemap, automatic Shopify/WordPress/Sanity feeds), robots.txt rules, canonical URLs, hreflang for multi-region/multi-language, Open Graph and Twitter Card meta, Core Web Vitals optimization (LCP/CLS/INP), and Google Search Console integration. Use whenever implementing SEO on a website: setting up meta tags per route, adding structured data, generating sitemaps, managing redirects, auditing Core Web Vitals, or preparing a site for Google Search Console. Differentiates from generic frontend-patterns by focusing exclusively on search engine discoverability and ranking factors.
npx claudepluginhub arnwaldn/atum-plugins-collection --plugin atum-stack-webThis skill uses the workspace's default tool permissions.
Patterns SEO technique pour projets web modernes (Next.js App Router, Remix, Nuxt 4, Astro). Ces patterns sont agency-grade : implémenter ces 8 chapitres donne une base SEO solide sans audit Yoast / RankMath / SEMrush nécessaire.
Searches, retrieves, and installs Agent Skills from prompts.chat registry using MCP tools like search_skills and get_skill. Activates for finding skills, browsing catalogs, or extending Claude.
Searches prompts.chat for AI prompt templates by keyword or category, retrieves by ID with variable handling, and improves prompts via AI. Use for discovering or enhancing prompts.
Guides agent creation for Claude Code plugins with file templates, frontmatter specs (name, description, model), triggering examples, system prompts, and best practices.
Patterns SEO technique pour projets web modernes (Next.js App Router, Remix, Nuxt 4, Astro). Ces patterns sont agency-grade : implémenter ces 8 chapitres donne une base SEO solide sans audit Yoast / RankMath / SEMrush nécessaire.
// app/blog/[slug]/page.tsx
import type { Metadata } from 'next'
type Props = { params: { slug: string } }
export async function generateMetadata({ params }: Props): Promise<Metadata> {
const post = await getPost(params.slug)
if (!post) {
return { title: 'Not found' }
}
return {
title: `${post.title} | My Blog`,
description: post.excerpt,
alternates: {
canonical: `https://example.com/blog/${post.slug}`,
languages: {
'en-US': `https://example.com/en/blog/${post.slug}`,
'fr-FR': `https://example.com/fr/blog/${post.slug}`,
},
},
openGraph: {
title: post.title,
description: post.excerpt,
url: `https://example.com/blog/${post.slug}`,
siteName: 'My Blog',
type: 'article',
publishedTime: post.publishedAt,
authors: [post.author.name],
images: [
{
url: post.coverImage,
width: 1200,
height: 630,
alt: post.title,
},
],
},
twitter: {
card: 'summary_large_image',
title: post.title,
description: post.excerpt,
images: [post.coverImage],
},
robots: {
index: post.published,
follow: true,
},
}
}
// app/routes/blog.$slug.tsx
import type { MetaFunction } from '@remix-run/node'
export const meta: MetaFunction<typeof loader> = ({ data }) => {
if (!data?.post) return []
return [
{ title: `${data.post.title} | My Blog` },
{ name: 'description', content: data.post.excerpt },
{ tagName: 'link', rel: 'canonical', href: `https://example.com/blog/${data.post.slug}` },
{ property: 'og:title', content: data.post.title },
{ property: 'og:description', content: data.post.excerpt },
{ property: 'og:image', content: data.post.coverImage },
{ name: 'twitter:card', content: 'summary_large_image' },
]
}
<script setup lang="ts">
const route = useRoute()
const { data: post } = await useFetch(`/api/posts/${route.params.slug}`)
useSeoMeta({
title: () => `${post.value?.title} | My Blog`,
description: () => post.value?.excerpt,
ogTitle: () => post.value?.title,
ogDescription: () => post.value?.excerpt,
ogImage: () => post.value?.coverImage,
twitterCard: 'summary_large_image',
})
useHead({
link: [
{ rel: 'canonical', href: () => `https://example.com/blog/${post.value?.slug}` },
],
})
</script>
const articleSchema = {
'@context': 'https://schema.org',
'@type': 'Article',
headline: post.title,
description: post.excerpt,
image: post.coverImage,
datePublished: post.publishedAt,
dateModified: post.updatedAt,
author: {
'@type': 'Person',
name: post.author.name,
url: `https://example.com/authors/${post.author.slug}`,
},
publisher: {
'@type': 'Organization',
name: 'My Blog',
logo: {
'@type': 'ImageObject',
url: 'https://example.com/logo.png',
},
},
}
// Next.js App Router — inject via <script type="application/ld+json">
// Use a helper that escapes JSON output safely (e.g. react-schemaorg or serialize-javascript).
// Avoid building the script tag manually with raw string concatenation.
export default function Page() {
return (
<>
<JsonLd data={articleSchema} /> {/* component provided by react-schemaorg or similar */}
<article>...</article>
</>
)
}
Note sécurité : ne jamais construire le tag <script type="application/ld+json"> par concaténation de chaînes avec du contenu user-generated — utiliser un helper de sérialisation sûre (serialize-javascript, react-schemaorg, ou équivalent) qui échappe </script>, U+2028, U+2029.
const productSchema = {
'@context': 'https://schema.org',
'@type': 'Product',
name: product.name,
description: product.description,
image: product.images,
sku: product.sku,
brand: { '@type': 'Brand', name: product.brand },
offers: {
'@type': 'Offer',
url: `https://example.com/products/${product.slug}`,
priceCurrency: 'EUR',
price: product.price,
availability: product.inStock
? 'https://schema.org/InStock'
: 'https://schema.org/OutOfStock',
priceValidUntil: '2026-12-31',
},
aggregateRating: product.reviewCount > 0
? {
'@type': 'AggregateRating',
ratingValue: product.rating,
reviewCount: product.reviewCount,
}
: undefined,
}
const breadcrumbSchema = {
'@context': 'https://schema.org',
'@type': 'BreadcrumbList',
itemListElement: [
{ '@type': 'ListItem', position: 1, name: 'Home', item: 'https://example.com' },
{ '@type': 'ListItem', position: 2, name: 'Blog', item: 'https://example.com/blog' },
{ '@type': 'ListItem', position: 3, name: post.title },
],
}
const faqSchema = {
'@context': 'https://schema.org',
'@type': 'FAQPage',
mainEntity: faqs.map((faq) => ({
'@type': 'Question',
name: faq.question,
acceptedAnswer: {
'@type': 'Answer',
text: faq.answer,
},
})),
}
const orgSchema = {
'@context': 'https://schema.org',
'@type': 'Organization',
name: 'ATUM SAS',
url: 'https://example.com',
logo: 'https://example.com/logo.png',
sameAs: [
'https://twitter.com/atum',
'https://linkedin.com/company/atum',
'https://github.com/arnwaldn',
],
contactPoint: {
'@type': 'ContactPoint',
telephone: '+33-1-00-00-00-00',
contactType: 'customer service',
areaServed: 'FR',
availableLanguage: ['French', 'English'],
},
}
// app/sitemap.ts
import { MetadataRoute } from 'next'
export default async function sitemap(): Promise<MetadataRoute.Sitemap> {
const posts = await getAllPosts()
const staticRoutes = [
{ url: 'https://example.com', lastModified: new Date(), priority: 1 },
{ url: 'https://example.com/blog', lastModified: new Date(), priority: 0.8 },
{ url: 'https://example.com/about', lastModified: new Date(), priority: 0.5 },
]
const postRoutes = posts.map((post) => ({
url: `https://example.com/blog/${post.slug}`,
lastModified: new Date(post.updatedAt),
changeFrequency: 'monthly' as const,
priority: 0.6,
}))
return [...staticRoutes, ...postRoutes]
}
Quand un sitemap dépasse 50 000 URLs (limite Google), créer un sitemap index qui référence plusieurs sitemaps :
// app/sitemaps/posts.xml/route.ts
export async function GET() {
const posts = await getAllPosts()
const xml = `<?xml version="1.0" encoding="UTF-8"?>
<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">
${posts.map((p) => `<url><loc>https://example.com/blog/${p.slug}</loc></url>`).join('')}
</urlset>`
return new Response(xml, { headers: { 'Content-Type': 'application/xml' } })
}
// app/robots.ts
import { MetadataRoute } from 'next'
export default function robots(): MetadataRoute.Robots {
return {
rules: [
{
userAgent: '*',
allow: '/',
disallow: ['/api/', '/admin/', '/_next/', '/preview'],
},
{
userAgent: 'GPTBot',
disallow: '/', // Bloque ChatGPT si le client le veut
},
],
sitemap: 'https://example.com/sitemap.xml',
host: 'https://example.com',
}
}
Règle absolue : chaque page doit avoir une seule URL canonique, définie dans le <head> via rel="canonical".
?utm_source=x) → canonical sans le paramètre/blog?page=2) → canonical vers la même page paginée (PAS vers la page 1)// Next.js dans generateMetadata
alternates: {
canonical: `https://example.com${pathname.split('?')[0]}`,
}
// Next.js
alternates: {
canonical: 'https://example.com/en-us/blog/post-slug',
languages: {
'en-US': 'https://example.com/en-us/blog/post-slug',
'fr-FR': 'https://example.com/fr-fr/blog/post-slug',
'de-DE': 'https://example.com/de-de/blog/post-slug',
'x-default': 'https://example.com/blog/post-slug',
},
}
x-default = la version à afficher quand aucune locale ne matche.
loading="eager" + priority (Next.js <Image priority>)display: none sur l'image LCP — elle doit être rendue dès le premier paint// Next.js
import Image from 'next/image'
<Image
src="/hero.webp"
alt="Hero"
width={1920}
height={1080}
priority // <- critique pour LCP
sizes="100vw"
/>
font-display: swap + size-adjust pour minimiser le FOIT/FOUTuseTransition / startTransition pour les updates React non-urgentsChecklist de setup :
/sitemap.xml)Sitemap: directivedocument.title) → les crawlers les ignorentalt="" sur une image informative → perte SEO + accessibilité<title> sur plusieurs pages → cannibalisationaccessibility-auditor (dans atum-stack-web)high-perf-browser (dans atum-stack-web)sanity-seo-aeo (dans atum-cms-ecom)shopify-expert (dans atum-cms-ecom)wordpress-patterns pour les patterns serveur