Een dynamische sitemap bouwen voor 91.000 pagina's met Next.js en Supabase
Vorige maand bereikten we 91.000 pagina's op Deluxe Astrology. Sterrenkaarten van beroemdheden, blogberichten, gelokaliseerde inhoud in zes talen -- de site was veel verder gegroeid dan wat een enkel sitemapbestand kon verwerken. Google's sitemapprotocol beperkt je tot 50.000 URL's per bestand en 50MB ongecomprimeerd. We hadden een sitemap-index nodig met gesegmenteerde sub-sitemaps, allemaal dynamisch gegenereerd uit Supabase, gecached met ISR op Vercel, en ingediend bij Google Search Console als één index-URL.
Dit is de exacte implementatie die we hebben uitgerold. Geen theoretische uitleg -- werkelijke productiecode die vandaag 91K URL's verwerkt en zonder wijzigingen schaalbaar is tot 500K.
Inhoudsopgave
- Sitemap-limieten en architectuur begrijpen
- De sitemapstructuur voor Deluxe Astrology
- Supabase-query's instellen met offset-paginering
- De sitemap-index-route bouwen
- Individuele gesegmenteerde sitemaps bouwen
- Statische pagina's sitemap
- Gelokaliseerde sitemaps met hreflang
- ISR-revalidatiestrategie
- Prioriteit en wijzigingsfrequentie per inhoudstype
- Google Search Console-indiening
- Debuggen wanneer Google je pagina's niet indexeert
- Prestatie- en kostenbenchmarks
- Veelgestelde vragen

Sitemap-limieten en architectuur begrijpen
Hier zijn de harde limieten die je moet kennen:
| Beperking | Limiet | Bron |
|---|---|---|
| URL's per sitemapbestand | 50.000 | sitemaps.org-protocol |
| Bestandsgrootte per sitemap | 50MB ongecomprimeerd | sitemaps.org-protocol |
| Sitemaps per sitemap-index | 50.000 | sitemaps.org-protocol |
Supabase .range() max per query |
1.000 rijen (standaard) | Supabase PostgREST-config |
| Vercel serverless-functie timeout (Pro) | 60 seconden | Vercel docs 2025 |
| Vercel response-body-groottelimiet | 10MB | Vercel edge-caching |
Voor 91.000 URL's heb je minimaal twee sitemapbestanden nodig. Maar we dumpen niet alles in twee 50K-URL-buckets. We splitsen op inhoudstype -- beroemdheden, blogberichten, statische pagina's, gelokaliseerde pagina's -- omdat elk type andere changefreq, priority en update-patronen heeft. Dit geeft ons beter beheer en maakt debugging in GSC veel makkelijker als iets fout gaat.
De sitemapstructuur voor Deluxe Astrology
Hier is hoe de uiteindelijke sitemaparchitectuur eruit ziet:
/sitemap.xml → Sitemap-index (verwijst naar alle sub-sitemaps)
/sitemap-pages.xml → Statische pagina's (~30 URL's)
/sitemap-blog-0.xml → Blogberichten chunk 0 (tot 50K)
/sitemap-blog-1.xml → Blogberichten chunk 1 (overloop)
/sitemap-celebrities-0.xml → Beroemdhedenpagina's chunk 0 (tot 50K)
/sitemap-celebrities-1.xml → Beroemdhedenpagina's chunk 1 (overloop)
/sitemap-locale-es.xml → Spaanse gelokaliseerde pagina's
/sitemap-locale-fr.xml → Franse gelokaliseerde pagina's
/sitemap-locale-de.xml → Duitse gelokaliseerde pagina's
/sitemap-locale-pt.xml → Portugese gelokaliseerde pagina's
/sitemap-locale-ja.xml → Japanse gelokaliseerde pagina's
Elke sub-sitemap is een Next.js App Router route-handler die Supabase bij runtime raadpleegt, XML genereert en via ISR cached met revalidate = 3600 (elk uur). De sitemap-index zelf is ook een route-handler.
Supabase-query's instellen met offset-paginering
Dit is het kritieke onderdeel dat de meeste tutorials verkeerd doen: je kunt niet zomaar supabase.from('celebrities').select('*') doen en 91.000 rijen verwachten terug. De PostgREST-laag van Supabase retourneert standaard maximaal 1.000 rijen. Je moet pagineren.
We gebruiken paginering op basis van bereik in batches van 1.000:
// lib/supabase-sitemap.ts
import { createClient } from '@supabase/supabase-js';
const supabase = createClient(
process.env.NEXT_PUBLIC_SUPABASE_URL!,
process.env.SUPABASE_SERVICE_ROLE_KEY! // Service role gebruiken voor server-side
);
const BATCH_SIZE = 1000;
export interface SitemapEntry {
slug: string;
updated_at: string;
}
export async function fetchAllSlugs(
table: string,
selectColumns: string = 'slug, updated_at'
): Promise<SitemapEntry[]> {
const allRows: SitemapEntry[] = [];
let offset = 0;
let hasMore = true;
while (hasMore) {
const { data, error } = await supabase
.from(table)
.select(selectColumns)
.order('updated_at', { ascending: false })
.range(offset, offset + BATCH_SIZE - 1);
if (error) {
console.error(`Sitemap fetch error for ${table}:`, error.message);
break;
}
if (data && data.length > 0) {
allRows.push(...data);
offset += BATCH_SIZE;
hasMore = data.length === BATCH_SIZE;
} else {
hasMore = false;
}
}
return allRows;
}
export async function fetchSlugsChunked(
table: string,
chunkIndex: number,
chunkSize: number = 50000
): Promise<{ entries: SitemapEntry[]; totalCount: number }> {
// Eerst totaal aantal ophalen voor sitemap-index
const { count } = await supabase
.from(table)
.select('*', { count: 'exact', head: true });
const totalCount = count || 0;
const startOffset = chunkIndex * chunkSize;
const entries: SitemapEntry[] = [];
let offset = startOffset;
const endOffset = Math.min(startOffset + chunkSize, totalCount);
while (offset < endOffset) {
const batchEnd = Math.min(offset + BATCH_SIZE - 1, endOffset - 1);
const { data, error } = await supabase
.from(table)
.select('slug, updated_at')
.order('updated_at', { ascending: false })
.range(offset, batchEnd);
if (error || !data || data.length === 0) break;
entries.push(...data);
offset += data.length;
}
return { entries, totalCount };
}
export function getChunkCount(totalCount: number, chunkSize: number = 50000): number {
return Math.ceil(totalCount / chunkSize);
}
Een paar dingen om op te merken hier. We gebruiken de SUPABASE_SERVICE_ROLE_KEY -- niet de anon-sleutel -- omdat deze route-handlers server-side worden uitgevoerd en we willen niet dat RLS-policies onze sitemap-query's vertragen. De functie fetchSlugsChunked haalt alleen de specifieke chunk op die nodig is voor een bepaald sitemapbestand, niet de hele dataset. Dat maakt uit wanneer je op de 60-seconde-timeout van Vercel voor functies loopt.

De sitemap-index-route bouwen
De sitemap-index is de enkele URL die je bij Google indient. Het verwijst naar al je sub-sitemaps.
// app/sitemap.xml/route.ts
import { NextResponse } from 'next/server';
import { createClient } from '@supabase/supabase-js';
export const revalidate = 3600; // ISR: regenereer elk uur
const supabase = createClient(
process.env.NEXT_PUBLIC_SUPABASE_URL!,
process.env.SUPABASE_SERVICE_ROLE_KEY!
);
const CHUNK_SIZE = 50000;
const SITE_URL = 'https://deluxeastrology.com';
const LOCALES = ['es', 'fr', 'de', 'pt', 'ja'];
async function getTableCount(table: string): Promise<number> {
const { count } = await supabase
.from(table)
.select('*', { count: 'exact', head: true });
return count || 0;
}
export async function GET() {
const blogCount = await getTableCount('blog_posts');
const celebrityCount = await getTableCount('celebrities');
const blogChunks = Math.ceil(blogCount / CHUNK_SIZE);
const celebrityChunks = Math.ceil(celebrityCount / CHUNK_SIZE);
const now = new Date().toISOString();
let sitemaps = '';
// Sitemap voor statische pagina's
sitemaps += `
<sitemap>
<loc>${SITE_URL}/sitemap-pages.xml</loc>
<lastmod>${now}</lastmod>
</sitemap>`;
// Blog-sitemaps
for (let i = 0; i < blogChunks; i++) {
sitemaps += `
<sitemap>
<loc>${SITE_URL}/sitemap-blog-${i}.xml</loc>
<lastmod>${now}</lastmod>
</sitemap>`;
}
// Beroemdhedenssitemaps
for (let i = 0; i < celebrityChunks; i++) {
sitemaps += `
<sitemap>
<loc>${SITE_URL}/sitemap-celebrities-${i}.xml</loc>
<lastmod>${now}</lastmod>
</sitemap>`;
}
// Locale-sitemaps
for (const locale of LOCALES) {
sitemaps += `
<sitemap>
<loc>${SITE_URL}/sitemap-locale-${locale}.xml</loc>
<lastmod>${now}</lastmod>
</sitemap>`;
}
const xml = `<?xml version="1.0" encoding="UTF-8"?>
<sitemapindex xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">${sitemaps}
</sitemapindex>`;
return new NextResponse(xml, {
headers: {
'Content-Type': 'application/xml',
'Cache-Control': 'public, s-maxage=3600, stale-while-revalidate=600',
},
});
}
Let op: we doen hier alleen count-query's -- head: true betekent dat Supabase alleen het aantal retourneert zonder enige rijgegevens. Dit maakt sitemap-index-generatie bijna onmiddellijk.
Individuele gesegmenteerde sitemaps bouwen
Hier is de beroemdhedenssitemap-handler met volledige paginering:
// app/sitemap-celebrities-[chunk].xml/route.ts
import { NextResponse } from 'next/server';
import { fetchSlugsChunked } from '@/lib/supabase-sitemap';
export const revalidate = 3600;
const SITE_URL = 'https://deluxeastrology.com';
export async function GET(
request: Request,
{ params }: { params: Promise<{ chunk: string }> }
) {
const { chunk } = await params;
const chunkIndex = parseInt(chunk, 10);
if (isNaN(chunkIndex) || chunkIndex < 0) {
return new NextResponse('Invalid chunk index', { status: 400 });
}
const { entries } = await fetchSlugsChunked('celebrities', chunkIndex);
const urls = entries
.map(
(entry) => `
<url>
<loc>${SITE_URL}/celebrities/${entry.slug}</loc>
<lastmod>${new Date(entry.updated_at).toISOString()}</lastmod>
<changefreq>monthly</changefreq>
<priority>0.6</priority>
</url>`
)
.join('');
const xml = `<?xml version="1.0" encoding="UTF-8"?>
<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">${urls}
</urlset>`;
return new NextResponse(xml, {
headers: {
'Content-Type': 'application/xml',
'Cache-Control': 'public, s-maxage=3600, stale-while-revalidate=600',
},
});
}
De blog-sitemap volgt hetzelfde patroon maar met verschillende prioriteit en changefreq:
// app/sitemap-blog-[chunk].xml/route.ts
import { NextResponse } from 'next/server';
import { fetchSlugsChunked } from '@/lib/supabase-sitemap';
export const revalidate = 3600;
const SITE_URL = 'https://deluxeastrology.com';
export async function GET(
request: Request,
{ params }: { params: Promise<{ chunk: string }> }
) {
const { chunk } = await params;
const chunkIndex = parseInt(chunk, 10);
const { entries } = await fetchSlugsChunked('blog_posts', chunkIndex);
const urls = entries
.map(
(entry) => `
<url>
<loc>${SITE_URL}/blog/${entry.slug}</loc>
<lastmod>${new Date(entry.updated_at).toISOString()}</lastmod>
<changefreq>weekly</changefreq>
<priority>0.8</priority>
</url>`
)
.join('');
const xml = `<?xml version="1.0" encoding="UTF-8"?>
<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">${urls}
</urlset>`;
return new NextResponse(xml, {
headers: {
'Content-Type': 'application/xml',
'Cache-Control': 'public, s-maxage=3600, stale-while-revalidate=600',
},
});
}
Je moet je Next.js-routing configureren om het dynamische segment te verwerken. In App Router gebruikt de mapnaam haakjes:
app/
sitemap.xml/
route.ts
sitemap-pages.xml/
route.ts
sitemap-blog-[chunk].xml/
route.ts
sitemap-celebrities-[chunk].xml/
route.ts
sitemap-locale-[lang].xml/
route.ts
Als de benadering met haakjes in mapnaam je problemen oplevert met je bestandssysteem of IDE (dat gebeurt soms), gebruik in plaats daarvan route-rewrites in next.config.ts:
// next.config.ts
const nextConfig = {
async rewrites() {
return [
{
source: '/sitemap-blog-:chunk(\\d+).xml',
destination: '/api/sitemap-blog/:chunk',
},
{
source: '/sitemap-celebrities-:chunk(\\d+).xml',
destination: '/api/sitemap-celebrities/:chunk',
},
{
source: '/sitemap-locale-:lang.xml',
destination: '/api/sitemap-locale/:lang',
},
];
},
};
export default nextConfig;
Statische pagina's sitemap
Voor de sitemap met statische pagina's hardcoden we de URL's omdat ze zelden veranderen:
// app/sitemap-pages.xml/route.ts
import { NextResponse } from 'next/server';
export const revalidate = 86400; // Eenmaal per dag is prima voor statische pagina's
const SITE_URL = 'https://deluxeastrology.com';
const staticPages = [
{ path: '/', priority: '1.0', changefreq: 'daily' },
{ path: '/about', priority: '0.7', changefreq: 'monthly' },
{ path: '/solutions/birth-chart', priority: '0.9', changefreq: 'weekly' },
{ path: '/solutions/compatibility', priority: '0.9', changefreq: 'weekly' },
{ path: '/solutions/transit-report', priority: '0.9', changefreq: 'weekly' },
{ path: '/blog', priority: '0.8', changefreq: 'daily' },
{ path: '/celebrities', priority: '0.8', changefreq: 'daily' },
{ path: '/contact', priority: '0.5', changefreq: 'yearly' },
{ path: '/pricing', priority: '0.7', changefreq: 'monthly' },
];
export async function GET() {
const now = new Date().toISOString();
const urls = staticPages
.map(
(page) => `
<url>
<loc>${SITE_URL}${page.path}</loc>
<lastmod>${now}</lastmod>
<changefreq>${page.changefreq}</changefreq>
<priority>${page.priority}</priority>
</url>`
)
.join('');
const xml = `<?xml version="1.0" encoding="UTF-8"?>
<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">${urls}
</urlset>`;
return new NextResponse(xml, {
headers: {
'Content-Type': 'application/xml',
'Cache-Control': 'public, s-maxage=86400, stale-while-revalidate=3600',
},
});
}
Gelokaliseerde sitemaps met hreflang
Dit is waar het interessant wordt. Voor meertalige inhoud heb je xhtml:link-elementen met hreflang-attributen nodig. Elke gelokaliseerde sitemap verwijst naar alle alternatieve taalversies van elke pagina:
// app/sitemap-locale-[lang].xml/route.ts
import { NextResponse } from 'next/server';
import { fetchAllSlugs } from '@/lib/supabase-sitemap';
export const revalidate = 3600;
const SITE_URL = 'https://deluxeastrology.com';
const ALL_LOCALES = ['en', 'es', 'fr', 'de', 'pt', 'ja'];
export async function GET(
request: Request,
{ params }: { params: Promise<{ lang: string }> }
) {
const { lang } = await params;
if (!ALL_LOCALES.includes(lang)) {
return new NextResponse('Invalid locale', { status: 404 });
}
const entries = await fetchAllSlugs('localized_pages');
// Filteren op pagina's die deze locale hebben
const localeEntries = entries.filter((e: any) => e.locale === lang);
const urls = localeEntries
.map((entry: any) => {
const alternates = ALL_LOCALES.map(
(loc) =>
` <xhtml:link rel="alternate" hreflang="${loc}" href="${SITE_URL}/${loc}/${entry.slug}" />`
).join('\n');
return `
<url>
<loc>${SITE_URL}/${lang}/${entry.slug}</loc>
<lastmod>${new Date(entry.updated_at).toISOString()}</lastmod>
<changefreq>monthly</changefreq>
<priority>0.6</priority>
${alternates}
</url>`;
})
.join('');
const xml = `<?xml version="1.0" encoding="UTF-8"?>
<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9"
xmlns:xhtml="http://www.w3.org/1999/xhtml">${urls}
</urlset>`;
return new NextResponse(xml, {
headers: {
'Content-Type': 'application/xml',
'Cache-Control': 'public, s-maxage=3600, stale-while-revalidate=600',
},
});
}
ISR-revalidatiestrategie
We stellen revalidate = 3600 in op alle sitemap-routes. Dit betekent dat Vercel de gecachte XML tot een uur aanbiedt, en het daarna in de achtergrond regenereert op het volgende verzoek. Voor 91K pagina's is dit het zoete plekje -- frequent genoeg dat nieuwe inhoud dezelfde dag verschijnt, maar niet zo agressief dat we Supabase bombarderen.
Voor on-demand revalidatie wanneer inhoud wordt gepubliceerd, voeg je een revalidatie-eindpunt toe:
// app/api/revalidate-sitemap/route.ts
import { revalidatePath } from 'next/cache';
import { NextResponse } from 'next/server';
export async function POST(request: Request) {
const { secret, paths } = await request.json();
if (secret !== process.env.REVALIDATION_SECRET) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
}
// Revalideer specifieke sitemap-paden
const targetPaths = paths || ['/sitemap.xml'];
for (const path of targetPaths) {
revalidatePath(path);
}
return NextResponse.json({ revalidated: true, paths: targetPaths });
}
Stel vervolgens een Supabase Database Webhook in (of een Postgres-trigger via pg_net) om dit eindpunt aan te roepen wanneer je celebrities- of blog_posts-tabellen worden bijgewerkt.
Prioriteit en wijzigingsfrequentie per inhoudstype
Hier is de prioriteitsmatrix die we gebruiken. Google heeft gezegd dat ze grotendeels priority en changefreq negeren, maar andere crawlers (Bing, Yandex) gebruiken ze nog steeds, en ze richten geen schade aan:
| Inhoudstype | Prioriteit | Wijzigingsfrequentie | Rationale |
|---|---|---|---|
| Homepage | 1.0 | daily | Hoogste belang, regelmatig bijgewerkt |
| Solutions/Features | 0.9 | weekly | Kern productpagina's |
| Blog-listing | 0.8 | daily | Regelmatig nieuwe berichten |
| Blogberichten | 0.8 | weekly | Inhoud af en toe bijgewerkt |
| Beroemdhedenpagina's | 0.6 | monthly | Zelden veranderd na creatie |
| Gelokaliseerde pagina's | 0.6 | monthly | Vertalingsupdates zijn zeldzaam |
| Contact/Legal | 0.5 | yearly | Bijna nooit gewijzigd |
De waarde lastmod is kritiek en moet altijd afkomstig zijn uit de kolom updated_at van je database -- nooit hardcoden naar new Date(). Google gebruikt lastmod om re-crawling te prioriteren, en als elke pagina zegt dat deze net nu is gewijzigd, zal Google uiteindelijk je lastmod helemaal negeren.
Google Search Console-indiening
Dit is het rechttoe-rechtaan deel. In GSC:
- Ga naar Sitemaps in de linkersidebar
- Voer
https://yourdomain.com/sitemap.xmlin (alleen de index-URL) - Klik Indienen
Dat is het. Dien geen individuele sub-sitemaps in. Google leest de index en ontdekt alle onderliggende items automatisch. Je zou binnen enkele uren de status "Success" moeten zien, en geïndexeerde URL-aantallen zullen stijgen in de komende 2-4 weken.
Voor 91K URL's kun je verwachten dat Google 70-90% indexeert in de eerste maand. De resterende pagina's hebben meestal dunne inhoud, dubbele inhoudsproblemen of zijn gewoon laag geprioriteerd in Google's crawl budget-toewijzing.
Voeg je sitemap ook toe aan robots.txt:
# robots.txt
User-agent: *
Allow: /
Sitemap: https://deluxeastrology.com/sitemap.xml
Debuggen wanneer Google je pagina's niet indexeert
Dit is waar de meeste mensen vast lopen. Je hebt 91K URL's ingediend maar GSC toont slechts 40K geïndexeerd. Hier is de systematische debugging-checklist die we volgen:
Controleer op per ongeluk noindex-tags
Dit is de #1 oorzaak. Voer een steekproefcontrole uit:
curl -s https://deluxeastrology.com/celebrities/some-slug | grep -i 'noindex'
Controleer ook je Next.js-layout of pagina-metagegevens. Een veel gemaakte fout is het instellen van noindex in een layout die van toepassing is op duizenden pagina's:
// SLECHT: Dit indexeert alle pagina's die deze layout gebruiken niet
export const metadata = {
robots: { index: false, follow: true },
};
Verifieer dat robots.txt niet het crawlen blokkeert
Controleer https://yourdomain.com/robots.txt in een browser. Zorg ervoor dat je je dynamische routes niet per ongeluk blokkeert. Op Vercel, controleer ook op middleware die mogelijk 403's retourneert aan Googlebot.
Inspecteer crawlfouten in GSC
Ga naar Pages → Why pages aren't indexed. Veel voorkomende problemen:
- "Crawled - currently not indexed": Google zag de pagina maar besloot deze niet te indexeren. Meestal dunne inhoud.
- "Discovered - currently not indexed": Google weet dat de URL bestaat maar heeft deze nog niet gecrawld. Crawl budget-probleem.
- "Excluded by noindex tag": Voor de hand liggend. Los de tag op.
- "Duplicate without canonical": Voeg juiste canonieke tags toe.
Repareer weespaagina's met interne koppelingen
Dit is enorm voor grote sites. Als je beroemdhedenpagina's alleen ontdekt kunnen worden via de sitemap en geen interne koppelingen ernaartoe hebben, zal Google het crawlen ervan deprioritiseren. Voeg toe:
- Categorie-/listing-pagina's die naar groepen beroemdhedenpagina's verwijzen
- Gerelateerde beroemdhedenkoppelingen op elke beroemdhedenpagina
- "Trending" of "Recently Updated"-secties op pagina's met veel verkeer
- Navigatie met broodkruimels met gestructureerde data
Valideer individuele URL's
Gebruik GSC's URL Inspection-tool op specifieke pagina's die niet geïndexeerd zijn. Het toont je precies wat Google ziet -- de weergegeven HTML, eventuele fouten, mobiele bruikbaarheid en indexeringsstatus.
Controleer sitemap-reactieheaders
Zorg ervoor dat je sitemap-routes juiste headers retourneren:
curl -I https://deluxeastrology.com/sitemap.xml
Je zou Content-Type: application/xml en een 200-status moeten zien. Als je 304 Not Modified-reacties krijgt van verouderde caches, kan Google je sitemap overslaan.
Prestatie- en kostenbenchmarks
Hier zijn werkelijke getallen uit onze productie-implementatie vanaf begin 2025:
| Metriek | Waarde |
|---|---|
| Totale URL's in sitemap | 91.247 |
| Sitemap-index-generatietijd | ~120ms (alleen count-query's) |
| Individuele sitemap-generatie (50K URL's) | ~4.2 seconden |
| Supabase-querykosten per sitemap-regeneratie | ~$0.01 |
| Totale sitemap XML-grootte (alle bestanden samen) | ~8.4MB ongecomprimeerd |
| Vercel-bandbreedte voor sitemaps per maand | ~2.1GB (grotendeels Googlebot) |
| Vercel Pro-plan kosten | $20/gebruiker/maand |
| Supabase Pro-plan kosten | $25/maand |
| GSC-indexeringspercentage na 30 dagen | 84% van ingediende URL's |
| Tijd van inhoudspublicatie tot sitemap-update | ≤1 uur (ISR) of ~5 seconden (on-demand) |
De grote afspraak: deze volledige setup kost vrijwel niets om te runnen. Sitemap-generatie is een afrondingsfout op je Vercel- en Supabase-rekeningen.
Als je een soortgelijk grootschalig project bouwt en hulp nodig hebt bij de architectuur, hebben we dit op meerdere clientsites gedaan. Bekijk onze Next.js-ontwikkelingsmogelijkheden of ons headless CMS-ontwikkelingswerk. Voor Astro-sites met vergelijkbare schaalbaarheids vereisten hebben we vergelijkbare oplossingen gebouwd met behulp van Astro's endpoint-benadering.
De volledige werkende code is beschikbaar als GitHub gist: alle route-handlers, de Supabase-querybibliotheek en de next.config.ts-rewrites. Als je project iets meer op maat nodig heeft -- multi-tenant sitemaps, realtime revalidatie of sitemaps voor 1M+ pagina's -- neem contact met ons op en we zorgen ervoor.
Veelgestelde vragen
Hoeveel URL's kan een enkel sitemapbestand bevatten? Het sitemapprotocol staat maximaal 50.000 URL's per bestand toe en een ongecomprimeerde bestandsgrootte van 50MB. Voor sites met meer dan 50K pagina's heb je een sitemap-index nodig die naar meerdere gesegmenteerde sitemapbestanden verwijst. In de praktijk segmenteren de meeste sitemap-generatoren op 45.000-50.000 URL's om wat marge over te houden.
Moet ik next-sitemap gebruiken of aangepaste route-handlers bouwen? next-sitemap (v4+) is geweldig voor eenvoudigere setups en verwerkt auto-chunking goed. Maar voor 91K+ dynamische pagina's met inhoudstypespecifieke prioriteiten, gelokaliseerde sitemaps met hreflang, en fijnkorrelig ISR-beheer geven aangepaste route-handlers je meer controle. We gingen voor custom omdat we verschillende revalidatie-intervallen per inhoudstype nodig hadden en wilden dat de sitemapstructuur aansloot bij onze GSC-debugging-workflow.
Dien ik elk afzonderlijk sitemapbestand in bij Google Search Console?
Nee. Dien alleen de sitemap-index-URL in (bijv. https://yourdomain.com/sitemap.xml). Google leest de index en ontdekt automatisch alle waarnaar verwezen sub-sitemaps en verwerkt deze. Het indienen van afzonderlijke bestanden is onnodig en maakt je GSC-dashboard rommelig.
Hoe vaak moeten sitemaps voor grote dynamische sites worden geregenereerd?
Voor de meeste inhoudsrijke sites is regeneratie elk uur via ISR (revalidate = 3600) een goed standaard. Als je zeer regelmatig inhoud publiceert, combineer dit met on-demand revalidatie geactiveerd door database webhooks. Regenereer niet op elke aanvraag -- dat verslaat caching en verhoogt de Supabase-belasting onnodig.
Waarom indexeert Google niet al mijn sitemap-URL's? De meest voorkomende oorzaken zijn: per ongeluk noindex-metatags, robots.txt blokkeert, dunne/dubbele inhoud, weespaagina's zonder interne koppelingen, en crawl budget-beperkingen. Controleer GSC's "Pages"-rapport onder "Why pages aren't indexed" voor specifieke redenen. Concentreer je voor grote sites op het verbeteren van interne koppelingen naar weespaagina's -- dit is vaak de enige grootste hefboom.
Beïnvloedt de waarde priority in sitemaps eigenlijk Google-rankings?
Google heeft openbaar verklaard dat ze grotendeels priority- en changefreq-waarden negeren. Echter, Bing en andere zoekmachines gebruiken ze wel. Het veld lastmod is het meest belangrijke sitemap-signaal -- zorg ervoor dat het werkelijke inhoudsveranderingen uit je database weerspiegelt, niet de huidige timestamp.
Hoe ga ik om met de 1.000-rijlimiet van Supabase voor sitemap-query's?
Gebruik Supabase's .range(offset, offset + batchSize - 1)-methode voor paginering in batches van 1.000. Loop totdat je alle rijen voor de huidige sitemap-chunk hebt opgehaald. Voor alleen-count-query's (gebruikt in de sitemap-index), gebruik .select('*', { count: 'exact', head: true }) die alleen het aantal retourneert zonder rijgegevens over te dragen.
Kan deze benadering 500K of 1 miljoen pagina's verwerken? Ja, met kleine aanpassingen. De gesegmenteerde architectuur schaalt lineair -- 1 miljoen pagina's zou ongeveer 20 sub-sitemaps produceren. De voornaamste zorg is de 60-seconde-timeout van Vercel voor het genereren van individuele 50K-URL-sitemaps. Als je deze limiet bereikt, verkleind je de chunkgrootte naar 25.000 of 10.000 URL's per bestand. Het sitemapprotocol staat tot 50.000 sitemaps in een enkele index toe, dus je loopt niet snel tegen index-niveau-limieten aan.