Hoe we een directoriumplatform met 137K vermeldingen bouwden met Next.js en Vercel ISR
Hoe we een directoryplatform met 137K vermeldingen bouwden met Next.js en Vercel ISR
Vorig jaar hebben we een directoryplatform gelanceerd. 137.000 vermeldingen. Dit was geen klein project. Het was een volledig gerealiseerd platform waarbij elke vermelding een eigen SEO-geoptimaliseerde pagina had. Zoekopdrachten moesten snel zijn, en ja, hosting moest betaalbaar blijven. Hoe hebben we dit gedaan met Next.js, Vercel en Incremental Static Regeneration (ISR)? Riem je vast; hier is het verhaal, inclusief waar het lastig werd.

Inhoudsopgave
- Waarom een directoryplatform moeilijker is dan het lijkt
- Architectuuroverzicht
- De ISR-strategie die werkelijk op schaal werkt
- 137K pagina's beheren zonder build-tijden te verwoesten
- Database- en zoeklaag
- SEO op schaal: Sitemaps, Structured Data en Crawl Budget
- Prestatiebanken
- Kostenoverzicht op Vercel
- Fouten die we hebben gemaakt en wat we zouden veranderen
- Veelgestelde vragen
Waarom een directoryplatform moeilijker is dan het lijkt
Directorysites lijken misschien eenvoudig. Je zou kunnen denken dat een lijstpagina, een detailpagina, wat filters toevoegen en klaar! Maar zodra je verder gaat dan een paar duizend vermeldingen, spiraliseert alles in complexiteit.
Dit is wat er werkelijk aan de hand is:
- 137.000+ unieke pagina's die elk doorzoekbaar en indexeerbaar moeten zijn
- Gefacetteerd zoeken over locatie, categorie en meer
- Verouderde gegevens beheren -- vermeldingen zijn voortdurend in flux met updates en verwijderingen
- SEO-eisen betekenen dat je niet alleen op client-side rendering kunt vertrouwen
- Hosting met een beperkt budget maakt het onmogelijk om alle pagina's bij build-tijd te genereren
Na het onderzoeken van veel methoden hebben we Next.js met ISR vastgesteld als onze eerste keuze. We hebben Astro ook overwogen (gebruikt in enkele van onze andere projecten--zie ons Astro-ontwikkelingswerk). Uiteindelijk was de dynamische capaciteit van Next.js met ISR de duidelijke keuze.
Architectuuroverzicht
Hier ziet onze architectuur eruit:
┌──────────────┐ ┌──────────────┐ ┌──────────────┐
│ Vercel │────▶│ Next.js App │────▶│ PostgreSQL │
│ Edge CDN │ │ (ISR) │ │ (Neon) │
└──────────────┘ └──────────────┘ └──────────────┘
│ │
▼ ▼
┌──────────────┐ ┌──────────────┐
│ Redis │ │ Meilisearch │
│ (Upstash) │ │ (Cloud) │
└──────────────┘ └──────────────┘
De stack
| Onderdeel | Technologie | Waarom |
|---|---|---|
| Framework | Next.js 14 (App Router) | ISR-ondersteuning, React Server Components, route handlers |
| Hosting | Vercel Pro | Edge CDN, ISR-infrastructuur, analytics |
| Database | Neon PostgreSQL | Serverless Postgres, branching voor previews |
| Zoeken | Meilisearch Cloud | Typofout-tolerant, gefacetteerd zoeken, snelle indexering |
| Cache | Upstash Redis | Rate limiting, sessie-cache, ISR-coördinatie |
| CMS (admin) | Custom admin + Payload CMS | Vermelding-beheer, bulkbewerkingen |
| CDN/Afbeeldingen | Vercel Image Optimization + Cloudinary | Vermeldingsfoto's op meerdere breakpoints |
Dit is een Next.js-ontwikkelingsproject in de kern, en ISR was de grote verkoper voor ons.

De ISR-strategie die werkelijk op schaal werkt
Laten we ter zake komen: als je probeert 137.000 pagina's statisch bij build-tijd te genereren, vraag je om problemen. Serius, nodig die hoofdpijn niet uit. Zelfs met de parallelle generatie van Next.js zouden builds langer dan 45 minuten kunnen duren, wat elke implementatie tot een nachtmerrie maakt.
ISR laat je pagina's genereren wanneer nodig en cacht ze aan de edge. Standaard ISR is prima, maar voor ons waren aanpassingen essentieel.
De driestaps paginastrategie
We verdeelden onze vermeldingen in drie categorieën:
// app/listing/[slug]/page.tsx
export async function generateStaticParams() {
// Stap 1: Pre-genereer de top 2.000 vermeldingen met het meeste verkeer
const topListings = await db.listing.findMany({
where: { tier: 'premium' },
orderBy: { monthlyViews: 'desc' },
take: 2000,
select: { slug: true },
});
return topListings.map((listing) => ({
slug: listing.slug,
}));
}
// Stap 2 & 3: Gegenereerd on-demand via ISR
export const revalidate = 3600; // 1 uur voor de meeste vermeldingen
export default async function ListingPage({ params }: { params: { slug: string } }) {
const listing = await getListingBySlug(params.slug);
if (!listing) {
notFound();
}
// Dynamische revalidatie op basis van vermelding-categorie
// Premium-vermeldingen revalideren elke 10 minuten
// Standaard-vermeldingen elke uur
// Gearchiveerde vermeldingen elke 24 uur
return <ListingDetail listing={listing} />;
}
Stap 1 (2.000 pagina's): Deze vermeldingen met veel verkeer worden bij build-tijd pre-gegenereerd. Ze zijn verantwoordelijk voor het meeste organische zoekverkeer. Ze zijn altijd klaar.
Stap 2 (35.000 pagina's): Gegenereerd wanneer eerst aangevraagd, in cache geplaatst voor een uur. Deze vermeldingen hebben stabiel verkeer, dus de eerste bezoeker na cache-verloop krijgt een server-gerenderde maar snelle pagina. Iedereen anders krijgt de gecachte versie.
Stap 3 (100.000 pagina's): Gegenereerd bij eerste aanvraag, in cache geplaatst voor 24 uur. Deze vermeldingen krijgen bijna geen verkeer, dus er is geen reden om resources te verspillen.
On-demand revalidatie voor realtime updates
Meeste gevallen worden gedekt door getimede revalidaties, maar wat gebeurt er als een restauranteigenaar zojuist haar openingstijden heeft bijgewerkt? Nou, we hebben de on-demand revalidatie van Next.js geïmplementeerd met behulp van route handlers:
// app/api/revalidate/route.ts
import { revalidatePath, revalidateTag } from 'next/cache';
import { NextRequest, NextResponse } from 'next/server';
export async function POST(request: NextRequest) {
const secret = request.headers.get('x-revalidation-secret');
if (secret !== process.env.REVALIDATION_SECRET) {
return NextResponse.json({ error: 'Invalid secret' }, { status: 401 });
}
const { slug, type } = await request.json();
if (type === 'listing') {
revalidatePath(`/listing/${slug}`);
revalidateTag(`listing-${slug}`);
} else if (type === 'category') {
revalidateTag(`category-${slug}`);
}
return NextResponse.json({ revalidated: true, now: Date.now() });
}
Met ons admin-paneel en webhooks die naar dit eindpunt spreken, krijgt elke vermelding-wijzigaar een versse pagina bij de volgende aanvraag. Snel, toch?
137K pagina's beheren zonder build-tijden te verwoesten
Build-tijden maakten ons echt bang! Dit hebben we gevonden:
| Strategie | Build-tijd | Latentie eerste aanvraag | Latentie cache hit |
|---|---|---|---|
| Volledige SSG (alle 137K pagina's) | ~52 minuten | ~40ms | ~40ms |
| ISR (2K pre-built) | ~3,5 minuten | ~180ms (cold) | ~40ms |
| Volledige SSR (geen caching) | ~45 seconden | ~250ms | N/A |
| Onze hybrid-aanpak | ~3,5 minuten | ~150ms (cold) | ~35ms |
Onze ISR-aanpak reduceerde build-tijden van een gruwelijk uur tot iets onder de 4 minuten. Dat is het verschil tussen implementaties vervloeken en, nou ja, koffie drinken terwijl ze lopen.
De `dynamicParams`-instelling
Hier is een cruciaal detail: zet dynamicParams = true om ISR pagina's buiten generateStaticParams te laten genereren. Het klinkt voor de hand liggend, maar je zou versteld staan hoe vaak dit wordt vergeten.
export const dynamicParams = true; // Sta on-demand generatie toe
Parallelle routesegmenten
Voor pagina's met categorieën en locaties hebben we parallelle routesegmenten gebruikt zodat het filter en de vermeldingsrasters onafhankelijk konden laden:
// app/directory/[category]/layout.tsx
export default function CategoryLayout({
children,
filters,
}: {
children: React.ReactNode;
filters: React.ReactNode;
}) {
return (
<div className="grid grid-cols-[280px_1fr] gap-6">
<aside>{filters}</aside>
<main>{children}</main>
</div>
);
}
Dit betekent dat je filters op zichzelf in cache kunnen worden geplaatst. Verander een filter, en alleen het vermeldingsraster wordt opnieuw gerenderd. Snel!
Database- en zoeklaag
PostgreSQL op Neon
We hebben Neon gekozen vanwege de serverless-voordelen zoals schaling en preview branches. Het soort dingen dat ons leven makkelijker maakte.
Onze tabel met vermeldingen is eenvoudig maar afhankelijk van veel indexering:
CREATE INDEX idx_listings_category ON listings(category_id);
CREATE INDEX idx_listings_location ON listings USING GIST(location);
CREATE INDEX idx_listings_rating ON listings(avg_rating DESC);
CREATE INDEX idx_listings_slug ON listings(slug);
CREATE INDEX idx_listings_status_tier ON listings(status, tier);
Waarom de GiST-index op locatie? Het gaat allemaal om die nauwkeurige georuimtelijke query's. "Koffiehuizen dicht bij mij" is niet zomaar wazig; het is een echte berekening.
Meilisearch voor zoeken
Als je lijst groeit zoals die van ons, zal PostgreSQL's tekstzoeken niet volstaan, en dat is waar Meilisearch je helpt. Het won van Algolia voor ons, vooral op prijs ($30/maand vs $200+) en de indrukwekkende typofout-tolerantie.
// lib/search.ts
import { MeiliSearch } from 'meilisearch';
const client = new MeiliSearch({
host: process.env.MEILISEARCH_HOST!,
apiKey: process.env.MEILISEARCH_API_KEY!,
});
export async function searchListings(query: string, filters: FilterParams) {
const index = client.index('listings');
return index.search(query, {
filter: buildFilterString(filters),
facets: ['category', 'city', 'priceRange', 'rating'],
limit: 24,
offset: filters.page * 24,
attributesToHighlight: ['name', 'description'],
});
}
Elke vijf minuten synchroniseren vermeldingen met een taak. We doen wekelijks een volledige herindexering, alleen voor de zekerheid. Beter voorzichtig, toch?
SEO op schaal: Sitemaps, Structured Data en Crawl Budget
Voor een platform met 137.000 pagina's is SEO niet zomaar nice-to-have; het is levensnoodzakelijk. Hier is hoe we dit hebben aangepakt:
Dynamische sitemaps
Je kunt 137.000 URL's niet in één sitemapbestand dumpen. De limiet is 50.000 URL's volgens de specificatie. Dus wat doen we? We genereren een sitemap-index die naar gesegmenteerde onderdelen wijst:
// app/sitemap.ts
import { MetadataRoute } from 'next';
export default async function sitemap(): Promise<MetadataRoute.Sitemap> {
// Dit genereert de sitemap-index
const totalListings = await db.listing.count({ where: { status: 'active' } });
const sitemapCount = Math.ceil(totalListings / 10000);
const sitemaps = [];
for (let i = 0; i < sitemapCount; i++) {
sitemaps.push({
url: `${process.env.NEXT_PUBLIC_URL}/sitemap/${i}.xml`,
lastModified: new Date(),
});
}
return sitemaps;
}
Gesegmenteerde sitemaps bevatten elk 10.000 vermeldingen, compleet met tijdstempels. Google graaft er ongeveer 8.000-12.000 pagina's dagelijks in.
Structured Data
Elke vermeldingspagina bevat LocalBusiness schema markup:
function generateStructuredData(listing: Listing) {
return {
'@context': 'https://schema.org',
'@type': 'LocalBusiness',
name: listing.name,
description: listing.description,
address: {
'@type': 'PostalAddress',
streetAddress: listing.address,
addressLocality: listing.city,
addressRegion: listing.state,
postalCode: listing.zip,
},
aggregateRating: listing.reviewCount > 0 ? {
'@type': 'AggregateRating',
ratingValue: listing.avgRating,
reviewCount: listing.reviewCount,
} : undefined,
geo: {
'@type': 'GeoCoordinates',
latitude: listing.lat,
longitude: listing.lng,
},
};
}
Dit soort structured data verhoogde onze rankings aanzienlijk, met Google die veel voorkeur geeft aan zulke precieze info.
Prestatiebanken
Echte metriek van onze live site vanaf begin 2025:
| Metriek | Waarde | Doel |
|---|---|---|
| Largest Contentful Paint (LCP) | 1,1s (p75) | < 2,5s |
| First Input Delay (FID) | 12ms (p75) | < 100ms |
| Cumulative Layout Shift (CLS) | 0,02 (p75) | < 0,1 |
| Time to First Byte (TTFB) | 85ms (gecacht) / 190ms (cold ISR) | < 200ms |
| Lighthouse Performance Score | 94-98 | > 90 |
| Build-tijd | 3 min 22 sec | < 5 min |
| Cache Hit Rate | 94,7% | > 90% |
Die hoge cache hit rate? Ja, maar liefst 94,7% van onze pagina's komen rechtstreeks van Vercel's edge CDN--geen extra computing nodig. Het is win-win voor snelheid en kosten.
Kostenoverzicht op Vercel
Laten we tot de dollars en cents komen. Wie houdt niet van een goed koopje?
| Service | Maandelijkse kosten (2025) | Opmerkingen |
|---|---|---|
| Vercel Pro | $20/zitplaats | Voor pro-level functies en limieten |
| Vercel bandwidth | ~$55 | ~600GB/maand met ISR-caching |
| Vercel serverless functions | ~$40 | Voor ISR-werk + API-spullen |
| Neon PostgreSQL | $19 (Scale plan) | 10GB opslag, schaalbare compute |
| Meilisearch Cloud | $30 | 500K docs, dedicated instance |
| Upstash Redis | $10 | 10K opdrachten/dag gemiddeld |
| Cloudinary | $25 | Afbeeldingsopslag en transformaties |
| Totaal | ~$199/maand | Voor 137K pagina's, ~200K maandelijkse bezoekers |
Onder de $200/maand om een beest met 137.000 pagina's uit te voeren. Versus een traditionele serverinstallatie? Je zou geld verspillen aan VM's, beheerde DB's, CDN's en een fulltime DevOps om alles in de gaten te houden.
Als je op deze schaal speelt en een chat wilt, neem contact op of bekijk ons pricing.
Fouten die we hebben gemaakt en wat we zouden veranderen
Fout 1: On-demand revalidatie niet meteen instellen
We vertrouwden aanvankelijk alleen op getimede revalidatie. Laat me je zeggen, slecht idee. Eigenaren van vermeldingen zouden hun info aanpassen en meteen controleren. Zien ze oude gegevens? Niet echt een vertrouwenbooster. Revalidatie moest MVP zijn.
Fout 2: De complexiteit van de sitemap onderschatten
Onze eerste poging bij een sitemap propte alles in één serverless functie. Timeout. Vercel geeft je 10 seconden (60 op Pro) voor timeout. We hebben geleerd. Segmenteer die dingen.
Fout 3: Afbeeldingsoptimalisatiekosten
Aanvankelijk verwerkte Vercel alle optimalisaties van vermeldingsfoto's. Een gek aantal afbeeldingen betekende wilde kosten. We hebben die taak met Cloudinary verdeeld, met Vercel's magie voorbehouden voor UI-must-haves.
Fout 4: React Server Components niet agressief genoeg gebruiken
Sommige initiële pagina's waren volgeladen met te veel 'use client' commando's. Gevolg? Te veel JavaScript verzonden. Heroriëntatie op Server Components maakte onze JavaScript-bundel licht als een veertje (62% verlaging!).
Wat we volgende keer anders zouden doen
Volgende keer zouden we absoluut Next.js met zoiets als een Payload CMS van het begin af aan gebruiken in plaats van van scratch een admin-paneel in elkaar zetten. Wat een tijdbesparaar dat zou zijn geweest!
We zouden ook de nieuwste unstable_cache van Vercel (of gewoon cache nu) voor queryresultaten nauw overwegen, verder dan de standaard ISR-caching.
Veelgestelde vragen
Kan Next.js ISR echt honderdduizenden pagina's aan?
Absoluut. We hebben het bewezen. Pre-genereer je pagina's met het meeste verkeer (meestal 1-5%) met behulp van generateStaticParams en laat ISR de rest verzorgen. Vercel's edge zorgt er vandaar voor, wat snelle laadtijden wereldwijd garandeert.
Hoeveel kost het om een groot directoryplatform op Vercel uit te voeren?
Voor ons ongeveer $199/maand voor 137K vermeldingen met 200.000 maandelijkse bezoekers. Kosten zullen variëren, natuurlijk, maar als je die zoete caching-modus raakt, kan ISR je veel besparen.
Wat is het verschil tussen ISR en SSR voor directorysites?
ISR genereert pagina's eenmaal per revalidatieinterval en cacht ze, terwijl SSR pagina's bij elke aanvraag van scratch genereert. ISR is efficiënter voor vermeldingen waarbij gegevens niet elke minuut veranderen.
Hoe handle je zoeken op een statisch gegenereerde directory?
Zoekinteracties gaan rechtstreeks naar Meilisearch, met API-oproepen om mee. Zoekresultaten worden client-side gerenderd, terwijl vermeldingspagina's ISR-ondersteuning hebben. Het is de beste mix van statisch en dynamisch.
Welk revalidatieinterval moet ik gebruiken voor ISR op een directorySite?
Hangt af van wijzigingsfrequentie. We gebruiken een gelaagde aanpak: 10 min voor premiums, 1 uur voor standaard, en 24 uur voor rustigere vermeldingen. Voeg on-demand revalidatie toe voor onmiddellijke wijzigingen.
Hoe genereer je sitemaps voor 137.000 pagina's zonder timeout?
Segmentatie is je vriend. Snijd ze in stukken van 10.000. Route ze door een sitemap-index. Elk stuk moet comfortabel binnen timeoutlimieten blijven.
Is Next.js het beste framework voor het bouwen van directoryplatforms?
Yep, voor zware gewichten--vooral met ISR. Voor ultra-eenvoudige, zelden veranderende lijsten? Astro kan een lichtgewicht optie zijn. We hebben beide gebouwd; de keuze hangt af van je werkbelasting en behoeften.
Hoe voorkom je dat verouderde gegevens de gebruikerservaring schaden met ISR?
Het mengen van op tijd gebaseerde en on-demand revalidatie helpt. Combineer dat met client-side SWR of React Query voor ultra-verse gegevens. ISR voert je shell en realtime glanst selectief door.