Apogée Consult
Retour au blog
Mathieu Ponton
Mathieu PontonCo-Founder & ingénieur logiciel

Je suis Mathieu Ponton, Co-Founder & ingénieur logiciel chez Apogée Consult à Lyon. Ingénieur diplômé de Polytech Lyon (Informatique), j'ai fait trois ans en apprentissage, partagés entre la Métropole de Lyon (inclusion numérique avec Res'in et sobriété énergétique avec Écolyo) et Superwyze, une startup medtech (POCs, dont certains aujourd'hui industrialisés, et travail sur des codebases existantes). J'ai livré plus de 10 projets en production (web, mobile et IA / RAG) pour des PME, startups et organisations publiques.

Sitemap dynamique pour 100 000 URLs sur Next.js : la pagination qu'on a fini par adopter

  • seo-tech

Générer un sitemap pour un catalogue de 100 000 produits en Next.js demande plus qu'une route API. Voici l'architecture de sitemap index paginé que nous avons mis en production.

Sitemap dynamique pour 100 000 URLs sur Next.js : la pagination qu'on a fini par adopter

La limite officielle de Google pour un fichier sitemap est 50 000 URLs ou 50 Mo non compressé. Pour un catalogue e-commerce avec 100 000 références, cette limite est atteinte. La solution standard est le sitemap index, un fichier XML qui référence d'autres fichiers sitemap. Next.js App Router fournit une API pour ça, mais elle ne gère pas la pagination nativement.

Ce que Next.js fournit par défaut

Next.js App Router propose deux mécanismes pour les sitemaps :

  • Un fichier sitemap.ts à la racine de app/ qui exporte une fonction renvoyant un tableau d'URLs
  • Un fichier sitemap.xml statique dans public/

La première option est dynamique et suffisante pour des catalogues de quelques milliers d'URLs. Mais sitemap.ts génère un seul fichier sitemap. Si vous renvoyez 100 000 URLs, Next.js produit un seul fichier XML de potentiellement plus de 50 Mo, ce que Google refusera.

L'approche par routes API dynamiques contourne cette limite.

L'architecture sitemap index paginé

Le principe : un fichier sitemap-index.xml référence N fichiers sitemap-{n}.xml. Chaque fichier couvre un lot de 10 000 URLs. Pour 100 000 produits : 1 index + 10 sitemaps.

Sitemap index

// app/sitemap-index.xml/route.ts
import { NextResponse } from "next/server";

const URLS_PER_SITEMAP = 10_000;

export async function GET() {
  const totalProducts = await db.products.count({ where: { published: true } });
  const sitemapCount = Math.ceil(totalProducts / URLS_PER_SITEMAP);
  
  const sitemaps = Array.from({ length: sitemapCount }, (_, i) => ({
    index: i,
    lastmod: new Date().toISOString().split("T")[0],
  }));

  const xml = `<?xml version="1.0" encoding="UTF-8"?>
<sitemapindex xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">
${sitemaps
  .map(
    ({ index, lastmod }) => `  <sitemap>
    <loc>https://exemple.fr/sitemap-products-${index}.xml</loc>
    <lastmod>${lastmod}</lastmod>
  </sitemap>`
  )
  .join("\n")}
</sitemapindex>`;

  return new NextResponse(xml, {
    headers: {
      "Content-Type": "application/xml",
      "Cache-Control": "public, max-age=3600, s-maxage=3600",
    },
  });
}

Sitemaps paginés

// app/sitemap-products-[index].xml/route.ts
import { NextResponse } from "next/server";

const URLS_PER_SITEMAP = 10_000;

export async function GET(
  _req: Request,
  { params }: { params: { index: string } }
) {
  const pageIndex = parseInt(params.index, 10);
  
  if (isNaN(pageIndex) || pageIndex < 0) {
    return new NextResponse("Not found", { status: 404 });
  }

  const products = await db.products.findMany({
    where: { published: true },
    select: { slug: true, updatedAt: true },
    orderBy: { updatedAt: "desc" },
    take: URLS_PER_SITEMAP,
    skip: pageIndex * URLS_PER_SITEMAP,
  });

  if (products.length === 0) {
    return new NextResponse("Not found", { status: 404 });
  }

  const xml = `<?xml version="1.0" encoding="UTF-8"?>
<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">
${products
  .map(
    ({ slug, updatedAt }) => `  <url>
    <loc>https://exemple.fr/produits/${slug}</loc>
    <lastmod>${updatedAt.toISOString().split("T")[0]}</lastmod>
    <changefreq>weekly</changefreq>
    <priority>0.7</priority>
  </url>`
  )
  .join("\n")}
</urlset>`;

  return new NextResponse(xml, {
    headers: {
      "Content-Type": "application/xml",
      "Cache-Control": "public, max-age=7200, s-maxage=7200",
    },
  });
}

Le robots.txt

Le robots.txt doit référencer le sitemap index, pas les sitemaps individuels. Google découvre les sitemaps enfants automatiquement à partir du fichier index.

// app/robots.ts
import type { MetadataRoute } from "next";

export default function robots(): MetadataRoute.Robots {
  return {
    rules: {
      userAgent: "*",
      allow: "/",
      disallow: ["/api/", "/admin/", "/_next/"],
    },
    sitemap: "https://exemple.fr/sitemap-index.xml",
  };
}

La mise en cache et la fraîcheur

Les sitemaps sont lus par Googlebot selon un calendrier qu'il détermine seul. La fréquence de recrawl d'un sitemap dépend de la fréquence de mise à jour détectée et de l'autorité du domaine. Sur un catalogue actif, Google recrawl généralement les sitemaps toutes les 24 à 48 heures.

Deux stratégies de cache selon le cas :

Catalogue stable (ajouts rares) : cache HTTP long, 12 à 24 heures côté CDN. Le sitemap index et les sitemaps enfants sont mis en cache. Un nouveau produit n'apparaît dans le sitemap que lors du prochain revalidate.

Catalogue dynamique (ajouts fréquents) : cache court, 1 à 2 heures, et revalidation à la demande via revalidatePath("/sitemap-index.xml") déclenchée par le webhook du CMS quand un produit est publié.

// app/api/webhooks/cms/route.ts
import { revalidatePath } from "next/cache";

export async function POST(req: Request) {
  const payload = await req.json();
  
  if (payload.event === "product.published") {
    // Invalider le sitemap index et le dernier sitemap (qui contient le nouveau produit)
    revalidatePath("/sitemap-index.xml");
    
    const totalProducts = await db.products.count();
    const lastSitemapIndex = Math.floor(totalProducts / 10_000);
    revalidatePath(`/sitemap-products-${lastSitemapIndex}.xml`);
  }

  return new Response("OK");
}

Ce qu'on surveille en Search Console

Après déploiement, les métriques à surveiller dans Google Search Console > Sitemaps :

  • URLs soumises : nombre total d'URLs référencées dans l'ensemble des sitemaps
  • URLs indexées : nombre d'URLs effectivement indexées
  • Erreurs : URLs 404, URLs bloquées par robots.txt, erreurs de serveur

Le ratio URLs indexées / URLs soumises est le signal le plus fiable. Un ratio inférieur à 50% sur un catalogue de qualité homogène suggère un problème de budget de crawl, Googlebot ne dispose pas de suffisamment de crawl budget pour traiter toutes vos pages.

Dans ce cas, la solution n'est pas technique : il faut prioriser. Exclure du sitemap les pages à faible valeur (pages de filtres, pages de tri, pages sans trafic historique) pour que Googlebot concentre son budget sur les pages qui comptent.

La limite de cette approche : Google prend en moyenne 4 à 6 semaines pour indexer un nouveau sitemap soumis sur un domaine de taille moyenne. Il n'existe pas de mécanisme pour forcer une indexation immédiate. La soumission via l'API Indexing de Google ne couvre que les URLs de type NewsArticle et BroadcastEvent dans les guidelines officielles, l'utiliser pour des pages produit est techniquement contre les CGU.

Disponible pour de nouveaux projets

Un projet à concrétiser ?
Parlons-en, sans engagement.

Un échange de 30 minutes pour cadrer votre besoin, qualifier la faisabilité et vous proposer une trajectoire claire.

1// kick-off : réponse sous 24h
2const project = await apogee.scope({
3 type: 'web | mobile | IA',
4 timeline: '6 à 16 semaines',
5 approach: 'sur-mesure'
6})
7// → cadrage offert
Sitemap Next.js dynamique 100k URLs : pagination | Apogée Consult