Een tandartsenorganisatie (DSO) met 50 praktijken heeft hetzelfde websitearchitectuurprobleem als een fitnessketen met 200 locaties, een hotelgroep met 30 properties en een kerknetwerk met 15 campussen. Ze hebben allemaal nodig: gecentraliseerde merkcontrole, gelokaliseerde inhoud per locatie, één beheerdashboard, SEO-pagina's per locatie en een implementatie die alles tegelijk bijwerkt zonder iets stuk te maken. De architectuur is identiek. De inhoud verschilt.

Ik heb dit patroon gebouwd voor tandartsengroepen, fitnessfranchises, dierenartsennetwerken en restaurantketens. Telkens begint ik met hetzelfde databaseschema, dezelfde Next.js-routestructuur en dezelfde op rollen gebaseerde toegangscontrole. Wat verandert is de seed-data en de componentlabels. "Services" wordt "Classes" in een fitnesscentrum of "Menu Items" in een restaurant. "Staff" wordt "Dentists" of "Trainers" of "Veterinarians." De onderliggende infrastructuur? Identiek.

Dit artikel legt het universele multi-location architectuurpatroon eenmaal uit en laat vervolgens zien hoe het zich aanpast aan vijf volledig verschillende industrieën. Of je nu een multi-location bedrijf runt — of je bent een developer die er voor bouwt — dit is de blauwdruk.

Inhoudsopgave

Multi-Site Architecture for DSOs, Vet Chains, Gyms & Franchises

Het kernprobleem waar elk multi-location bedrijf mee kampt

Laten we eerlijk zijn over wat meestal gebeurt. Een franchise of multi-location bedrijf begint met een enkele website. Vervolgens openen zij een tweede locatie. Iemand draait een tweede WordPress-installatie op. Op het moment dat er 15 locaties zijn, heb je 15 aparte WordPress-sites, 15 verschillende thema's (sommige zijn drie versies achtergelopen), 15 verschillende sets plugins en nul gecentraliseerde controle.

De marketing director wil de primaire CTA van het merk op alle locaties bijwerken. Dat zijn 15 logins, 15 bewerkingen en een gebed dat niemand hun sjabloon heeft verbroken. Het SEO-team wil zien welke locaties bloginhoud publiceren en welke zes maanden geleden offline zijn gegaan. Daarvoor is geen dashboard — alleen een spreadsheet die iemand in maart vergeten is bij te werken.

Dit is hetzelfde probleem, of je nu een tandartsenorganisatie (DSO) met 50 praktijken beheert of een restaurantgroep met 200 locaties. De symptomen zijn identiek:

  • Merkdrift. Locaties wijken af van het merk omdat niemand consistentie handhaaft.
  • SEO-fragmentatie. Geen gestructureerde lokale SEO-pagina's, geen consistentie van schemamarkeringen, geen gecentraliseerde sitemap.
  • Beheerchaos. Elke locatie beheert zijn eigen site (slecht), of de afdeling kantoor handelt alles af (langzaam).
  • Implementatierisico. Het bijwerken van de website van één locatie mag niet de site van een ander kunnen uitschakelen.

De oplossing is niet een beter CMS-thema. Het is een volledig verschillende architectuur.

Het universele databaseschema

Alles begint met een locations-tabel. Dit is het anker voor het gehele systeem. Ik gebruik Supabase als de database- en authenticatielaag omdat het Postgres, Row-Level Security, real-time abonnementen en een royale gratis laag biedt — maar het schema werkt met elke relationele database.

Hier is het kernschema:

-- De ankertabel. Elke stukje locatiespecifieke inhoud
-- verwijst hier naar via location_id.
CREATE TABLE locations (
  id UUID DEFAULT gen_random_uuid() PRIMARY KEY,
  name TEXT NOT NULL,
  slug TEXT UNIQUE NOT NULL,
  address TEXT NOT NULL,
  city TEXT NOT NULL,
  state TEXT NOT NULL,
  zip TEXT NOT NULL,
  lat DECIMAL(10, 8),
  lng DECIMAL(11, 8),
  phone TEXT,
  email TEXT,
  hours JSONB DEFAULT '{}',
  photos TEXT[] DEFAULT '{}',
  description TEXT,
  metadata JSONB DEFAULT '{}',
  is_active BOOLEAN DEFAULT true,
  created_at TIMESTAMPTZ DEFAULT now(),
  updated_at TIMESTAMPTZ DEFAULT now()
);

-- Contenttabellen volgen HETZELFDE patroon:
-- location_id is NULLABLE.
-- NULL = gedeeld over alle locaties
-- Een waarde = specifiek voor die locatie

CREATE TABLE services (
  id UUID DEFAULT gen_random_uuid() PRIMARY KEY,
  location_id UUID REFERENCES locations(id) ON DELETE CASCADE,
  name TEXT NOT NULL,
  slug TEXT NOT NULL,
  description TEXT,
  price_range TEXT,
  duration TEXT,
  category TEXT,
  sort_order INT DEFAULT 0,
  is_active BOOLEAN DEFAULT true,
  metadata JSONB DEFAULT '{}'
);

CREATE TABLE staff (
  id UUID DEFAULT gen_random_uuid() PRIMARY KEY,
  location_id UUID REFERENCES locations(id) ON DELETE CASCADE,
  name TEXT NOT NULL,
  slug TEXT NOT NULL,
  title TEXT,
  photo TEXT,
  bio TEXT,
  credentials TEXT[],
  specialties TEXT[],
  sort_order INT DEFAULT 0,
  is_active BOOLEAN DEFAULT true
);

CREATE TABLE blog_posts (
  id UUID DEFAULT gen_random_uuid() PRIMARY KEY,
  location_id UUID REFERENCES locations(id) ON DELETE CASCADE,
  title TEXT NOT NULL,
  slug TEXT UNIQUE NOT NULL,
  content TEXT,
  excerpt TEXT,
  author_id UUID REFERENCES staff(id),
  published_at TIMESTAMPTZ,
  is_published BOOLEAN DEFAULT false,
  tags TEXT[] DEFAULT '{}',
  metadata JSONB DEFAULT '{}'
);

CREATE TABLE testimonials (
  id UUID DEFAULT gen_random_uuid() PRIMARY KEY,
  location_id UUID REFERENCES locations(id) ON DELETE CASCADE,
  author_name TEXT NOT NULL,
  rating INT CHECK (rating >= 1 AND rating <= 5),
  content TEXT,
  is_approved BOOLEAN DEFAULT false,
  created_at TIMESTAMPTZ DEFAULT now()
);

CREATE TABLE events (
  id UUID DEFAULT gen_random_uuid() PRIMARY KEY,
  location_id UUID REFERENCES locations(id) ON DELETE CASCADE,
  title TEXT NOT NULL,
  description TEXT,
  event_date TIMESTAMPTZ,
  end_date TIMESTAMPTZ,
  is_active BOOLEAN DEFAULT true
);

Het nullable location_id-patroon is het sleutelinsicht. Als een blogpost location_id = NULL heeft, is het een netwerkwijd artikel ("5 Tips voor gezonde tanden" gedeeld over alle 50 tandartspraktijken). Als location_id een waarde heeft, is het specifiek voor die locatie ("Dr. Smith voegt zich bij onze praktijk in Austin"). Dezelfde tabel, dezelfde querypatronen, maar de inhoud kan worden gedeeld of gelokaliseerd met een enkele kolom.

De metadata JSONB-kolom is waar branchespecifieke velden wonen. Een tandartspraktijk kan opslaan {"insurance_accepted": ["Delta Dental", "Cigna"], "parking_info": "Free lot behind building"}. Een fitnesscenter slaat {"equipment": ["squat racks", "rowing machines"], "peak_hours": "5-7 PM weekdays"} op. Geen schemamigratiewerkzaamheden nodig — alleen verschillende JSON-vormen.

Next.js route-architectuur

De Next.js App Router maakt schoon in kaart met dit gegevensmodel. Hier is de routestructuur die voor elke industrie werkt:

app/
├── page.tsx                          # Homepage
├── locations/
│   ├── page.tsx                      # Locatiefinder (kaart + geo-zoeking)
│   └── [slug]/
│       ├── page.tsx                  # Locatiedetailpagina
│       ├── staff/page.tsx            # Personeelslijst voor locatie
│       └── services/page.tsx         # Services voor locatie
├── services/
│   └── [service]/page.tsx            # Gedeelde servicebeschrijving
├── blog/
│   ├── page.tsx                      # Alle blogposts
│   └── [post]/page.tsx               # Individuele blogpost
├── about/page.tsx
└── contact/page.tsx

De locatiedetailpagina (/locations/[slug]) is waar de magie gebeurt. Een enkele generateStaticParams-aanroep vraagt elke actieve locatie op en genereert deze allemaal vooraf op bouwmoment:

// app/locations/[slug]/page.tsx
import { createClient } from '@/lib/supabase/server'

export async function generateStaticParams() {
  const supabase = createClient()
  const { data: locations } = await supabase
    .from('locations')
    .select('slug')
    .eq('is_active', true)

  return locations?.map((loc) => ({ slug: loc.slug })) ?? []
}

export async function generateMetadata({ params }: { params: { slug: string } }) {
  const supabase = createClient()
  const { data: location } = await supabase
    .from('locations')
    .select('*')
    .eq('slug', params.slug)
    .single()

  if (!location) return {}

  return {
    title: `${location.name} | ${location.city}, ${location.state}`,
    description: location.description,
    openGraph: {
      title: `${location.name} - ${location.city}`,
      images: location.photos?.[0] ? [location.photos[0]] : [],
    },
  }
}

export default async function LocationPage({ params }: { params: { slug: string } }) {
  const supabase = createClient()
  
  const [{ data: location }, { data: staff }, { data: services }, { data: testimonials }] = 
    await Promise.all([
      supabase.from('locations').select('*').eq('slug', params.slug).single(),
      supabase.from('staff').select('*').eq('location_id', params.slug), // simplified
      supabase.from('services').select('*').or(`location_id.is.null,location_id.eq.${locationId}`),
      supabase.from('testimonials').select('*').eq('is_approved', true),
    ])

  // Render locatiepagina met alle gegevens
  // Dit is dezelfde componentstructuur ongeacht branche
}

De services-query gebruikt dat or-filter — pak services waar location_id null is (gedeelde services) OF overeenkomt met de huidige locatie. Dit betekent dat een tandarts-DSO "Tandenborstel" eenmaal voor alle locaties kan definiëren, en vervolgens "Invisalign" alleen toevoegen voor locaties die het aanbieden. Geen duplicatie.

Voor de locatiefinderpagina sla ik lat/lng-coördinaten op en gebruik PostGIS-extensie van Supabase voor geo-query's:

-- Vind locaties binnen 25 mijl van de coördinaten van gebruiker
SELECT *, 
  (point(lng, lat) <@> point($1, $2)) * 1.60934 AS distance_miles
FROM locations
WHERE is_active = true
ORDER BY point(lng, lat) <@> point($1, $2)
LIMIT 20;

Multi-Site Architecture for DSOs, Vet Chains, Gyms & Franchises - architecture

Row-Level Security en het beheerdashboard

Dit is waar de architectuur echt rendeert. Row-Level Security-beleidsregels van Supabase laten je gegevenstoegang op databaseniveau definiëren — niet in je toepassingscode.

-- Locatiebeheerders kunnen alleen hun eigen locatiegegevens zien
CREATE POLICY "Location managers see own data" ON services
  FOR ALL
  USING (
    location_id IN (
      SELECT location_id FROM user_locations
      WHERE user_id = auth.uid()
    )
    OR
    EXISTS (
      SELECT 1 FROM user_roles
      WHERE user_id = auth.uid() AND role = 'network_admin'
    )
  );

Netwerkbeheerders zien alles. Locatiebeheerders zien alleen hun locatie. Dit geldt voor elke tabel — services, staff, blog posts, getuigenissen, events. Eén beleidspatroon, consistent toegepast.

Het beheerdashboard toont netwerkbreedmetreken:

  • Inhoudsfrisheid: Welke locaties hebben hun blog de afgelopen 30+ dagen niet bijgewerkt?
  • Verkeer per locatie: Google Search Console-gegevens geaggregeerd per locatieslug
  • Leads per locatie: Formulierinzendingen en boekingopdrachten per locatie
  • Merknaleving: Gebruiken alle locaties het goedgekeurde logo, kleuren en CTA-tekst?

Branchevariatie 1: Tandartsenorganisaties

Een DSO-website moet voelen als een verenigd tandartsmerk terwijl elke praktijk zijn unieke providers en specialisaties benadrukt.

Services worden toegewezen aan tandheelkundige ingrepen: schoonmaakbeurt, vullingen, kronen, implantaten, Invisalign, spoedeisende tandheelkunde. Sommige zijn universeel (elke locatie doet schoonmaken), andere zijn locatiespecifiek (alleen drie locaties bieden sedeertandheelkunde aan).

Staff zijn tandartsen, hygiënisten en kantoormanagers. Elk krijgt een profiel met gegevens (DDS, DMD), specialisaties, onderwijs en een professionele foto. Ouders die een kinderandartsen kiezen, willen weten wie hun kind zal behandelen.

CTA is "Afspraak maken." Dit verbindt met Calendly, NexHealth of een aangepast boekingssysteem. De boekingswidget selecteert automatisch de locatie op basis van welke locatiepagina de gebruiker afkomstig is.

Lokale SEO-doelen: "tandarts in [stad]", "[ingreep] in [stad]", "spoedtandarts [stad] [staat]". Elke locatiepagina krijgt schemamarkeringen voor Dentist en LocalBusiness-schema's.

Metadata JSONB slaat op: geaccepteerde verzekeringsplannen, parkeringsinformatie, toegankelijkheidsfuncties, gesproken talen, of zij nieuwe patiënten accepteren.

Branchevariatie 2: Fitnessketens

Fitnessketens vervangen "services" door "classes" — maar het gegevensmodel is hetzelfde. Een yogales op Locatie A en een HIIT-les op Locatie B zijn slechts rijen in de services-tabel met verschillende location_id-waarden.

Services zijn klastypen met planningsgegevens. De metadata slaat de wekelijkse planning als JSON op, instructeurstoewijzing, capaciteitsgrenzen en of drop-ins zijn toegestaan.

Staff zijn trainers en instructeurs met certificaten (NASM, ACE, CrossFit L2), specialisaties en beschikbaarheid voor personal training-boekingen.

CTA is "Nu toetreden" — een Stripe-abonnementsafrekening die lidmaatschaptiers en cross-locatietoegang afhandelt. Een lid dat zich aanmeldt op de binnenstad-locatie moet kunnen inchecken op de buitenwijk-locatie.

Lokale SEO-doelen: "fitnesscenter bij mij in de buurt", "fitnesslessen [stad]", "[lestype] lessen [stad]", "personal trainer [stad]".

Metadata JSONB slaat op: apparaatlijst, lesschema, piektijden, faciliteiten (sauna, zwembad, kinderopvang), gratis parkeerbeschikbaarheid.

Branchevariatie 3: Hotelgroepen

Boutique-hotelgroepen en onafhankelijke hotelketens profiteren enorm van dit patroon — vooral omdat het directe boekingen mogelijk maakt die OTA-provisies omzeilen (meestal 15-25% per boeking op Booking.com of Expedia).

Services worden kamertypen: Standaardkamer, King Suite, Penthouse. Elk krijgt foto's, aanraatlijsten, vierkante meters en basisprijzen. Locatiespecifieke prijzen wonen in de metadata of een aparte tarifeventabel met datumbereiken.

Staff is lichter hier — misschien een voorgestelde algemeen directeur of concierge voor het verhaal van het merk.

CTA is "Direct boeken" — het FME (Find, Match, Engage)-patroon dat gasten een reden geeft om op de hotelsite zelf te boeken in plaats van op een OTA. Meestal een "best prijsgarantie" of gratis upgrade.

Lokale SEO-doelen: "hotels in [stad]", "[hotelnaam] reviews", "boutique hotel [buurt] [stad]", "hotels bij [oriëntatiepunt]".

Metadata JSONB slaat op: faciliteiten (zwembad, spa, restaurant, gym, EV-laadstation), nabijgelegen attracties, lokale evenementenkalender, in-/uitchecktijden, huisdierbeleidsregels.

Branchevariatie 4: Dierenartskliniekketens

Dierenartsenketens groeien snel in 2025 — consolidatie in dierengeneeskunde weerspiegelt wat een decennium geleden met tandarts-DSO's gebeurde. Dezelfde multi-location architectuur past perfect.

Services zijn dierverzorgingsdiensten: welnessconsulten, vaccinaties, tandschoonmaak, operatie, spoedeisende zorg, logeren, verzorging. Sommige locaties bieden zorg voor exotische huisdieren; de meeste niet.

Staff zijn dierenartsen met soortexpertise (kleine dieren, paarden, exotisch), bordcertificering en onderwijs.

CTA is "Afspraak maken" met een draai — het innamefomulier moet huisdiereninformatie (soort, ras, leeftijd, reden voor bezoek) vastleggen om de afspraak correct door te sturen.

Lokale SEO-doelen: "dierenarts in [stad]", "spoeddierenarts [stad]", "[soort] dierenarts [stad]", "tandschoonmaak huisdier [stad]".

Metadata JSONB slaat op: geaccepteerde soorten, spoeduurtijden (indien verschillend van reguliere uren), logeerscapaciteit, of zij een laboratorium en afbeeldingen ter plaatse hebben.

Branchevariatie 5: Restaurantketens

Services worden menusecties: voorgerechtjes, hoofdgerechten, nagerechten, dranken. Het kritieke punt hier is dat prijzen per locatie kunnen variëren. Een hamburger kost $14 in Austin en $19 in Manhattan. De metadata-kolom handelt dit af met locatiespecifieke prijsoverschrijvingen.

Staff zijn voorgestelde chefs of pit masters — dit werkt het best voor merken waar de mensen achter het eten deel uitmaken van het verhaal.

CTA is "Online bestellen" — een locatiebewuste link die verwijst naar het juiste online bestelsysteem (Toast, Square, ChowNow of aangepast) voor de dichtstbijzijnde locatie van de gebruiker.

Lokale SEO-doelen: "[restaurantnaam] [stad] menu", "restaurants bij mij in de buurt", "[keukentipo] restaurant [stad]", "[restaurantnaam] uren".

Metadata JSONB slaat op: bezorgingsradius, reserveringsbeschikbaarheid (met OpenTable of Resy-link), parkeersdetails, capaciteit voor privé-eetgedeelte, happy hour-tijden.

De architectuurvergelijkingstabel

Component Tandarts-DSO Fitnestketen Hotelgroep Dierenartsketen Restaurant
"Services"-label Ingrepen Lessen Kamertypen Dierverzorgingsdiensten Menu-items
"Staff"-label Tandartsen Trainers Management Dierenartsen Chefs
Primaire CTA Afspraak maken Toetreden lidmaatschap Kamer boeken Afspraak maken Online bestellen
Boeking-integratie NexHealth, Calendly Stripe Abonnementen Custom / Cloudbeds Custom + huisdiereninname Toast, Square
Sleutelgegevens per locatie Verzekering, parkeren Schema, apparatuur Faciliteiten, attracties Soort, spoeduren Menuprijzen, bezorging
Primair SEO-sleutelwoord "tandarts in [stad]" "fitnesscenter bij mij in de buurt" "hotels in [stad]" "dierenarts in [stad]" "[merk] [stad] menu"
Schemamarkeringen Dentist, LocalBusiness SportsActivityLocation Hotel, LodgingBusiness VeterinaryCare Restaurant, Menu
Databasetabellen gewijzigd 0 0 0 0 0

Die laatste rij is het hele punt. Nul databasetabellen veranderen tussen industrieën. Je gebruikt dezelfde locations, services, staff, blog_posts, testimonials en events-tabellen. De labels in de UI veranderen. De metadata-vormen veranderen. De architectuur niet.

Implementatie en prestaties op schaal

We implementeren dit op Vercel met ISR (Incremental Static Regeneration). Elke locatiepagina wordt statisch gegenereerd op bouwmoment en wordt elke 60 seconden opnieuw gevalideerd. Voor een keten met 200 locaties zijn dat 200 statische HTML-pagina's die in onder 1 seconde op elk apparaat laden.

De getallen tellen. Dit is wat we meestal zien:

  • Bouwtijd voor 200 locaties: ~45 seconden op Vercel Pro
  • TTFB per locatiepagina: < 50ms (bediend vanuit edge CDN)
  • Lighthouse-scores: 95+ overal
  • ISR-revalidatie: 60-seconde stale-while-revalidate betekent dat inhoudsupdates binnen een minuut verschijnen zonder volledige herbouw

Het toevoegen van een nieuwe locatie is een database-invoeg plus een optionele on-demand revalidatieaanroep. Geen nieuwe implementatie nodig. De generateStaticParams-functie neemt nieuwe locaties op bij de volgende build of ISR-cyclus.

// API-route om revalidatie te activeren wanneer een locatie wordt toegevoegd/bijgewerkt
import { revalidatePath } from 'next/cache'

export async function POST(request: Request) {
  const { slug } = await request.json()
  
  revalidatePath('/locations')
  revalidatePath(`/locations/${slug}`)
  
  return Response.json({ revalidated: true })
}

Kostenspecificatie: wat dit werkelijk kost in 2025

Laten we het over echte getallen hebben. Dit is een veel voorkomende vraag die we krijgen tijdens prijzen-gesprekken.

Component Maandelijkse kosten (50 locaties) Maandelijkse kosten (200 locaties)
Supabase Pro €25 €25 (dezelfde laag handelt beide af)
Vercel Pro €20 €20
Vercel-bandbreedte (extra) ~€0 ~€40
Domein + DNS (Cloudflare) €0 €0
Afbeeldings-CDN (Cloudflare R2) ~€5 ~€15
Monitoring (Sentry) €26 €26
Totale infrastructuur ~€76/mnd ~€126/mnd

Vergelijk dat met 50 aparte WordPress-sites à ~€30/maand elk voor beheerde hosting — dat is €1.500/maand voordat je zelfs maar aan onderhoud, pluginlicenties of de persoon denkt die ze allemaal up-to-date moet houden.

De ontwikkelingsinvestering is hoger aan het begin — we noteren multi-location-bouwprojecten meestal tussen €30K-€80K, afhankelijk van complexiteit — maar de lopende operationele kosten zijn een fractie van het WordPress multisite-alternatief. En je betaalt niet €500/maand per locatie aan een franchise-websiteleverancier die je aan hun platform vastbindt.

Voor teams geïnteresseerd in het verkennen van headless CMS-integraties of het overwegen van Astro in plaats van Next.js voor nog snellere statische builds, geldt dezelfde databasearchitectuur. Het frontend-raamwerk is verwisselbaar; het gegevensmodel niet.

Veelgestelde vragen

Kan deze architectuur locaties in verschillende tijdzones aan? Absoluut. De hours JSONB-kolom slaat de bedrijfsuren van elke locatie in hun lokale tijdzone op. We voegen een timezone-veld (bijv. "America/Chicago") toe in de locatiemetadata en gebruiken dat voor alle tijdgevoelige weergaven zoals "Open Now"-badges. Alle timestamps in de database worden opgeslagen als UTC en op de frontend omgezet.

Hoe ga je om met locaties die verschillende services aanbieden? Dat is het nullable location_id-patroon in actie. Services met location_id = NULL worden gedeeld over alle locaties — ze verschijnen op de pagina van elke locatie. Services met een specifieke location_id verschijnen alleen voor die locatie. Je kunt ook een junctietabel (location_services) gebruiken voor veel-op-veel-relaties als gedeelde services locatiespecifieke overschrijvingen zoals aangepaste prijzen of beschikbaarheid nodig hebben.

Wat gebeurt er als er een nieuwe locatie opent? Een netwerkbeheerder voegt de locatie via het dashboard toe. Dit maakt een rij aan in de locations-tabel, triggert een webhook die ISR-revalidatie activeert, en de pagina van de nieuwe locatie is binnen 60 seconden live. Geen developer nodig, geen implementatie, geen DNS-wijzigingen. De locatie erft onmiddellijk alle gedeelde services en inhoud.

Is dit beter dan WordPress Multisite voor franchises? Voor de meeste multi-location bedrijven, ja. WordPress Multisite was het gouden standaard voor een decennium, maar het heeft echte problemen: een enkele plugin-kwetsbaarheid kan het hele netwerk uitschakelen, de prestaties nemen af als je sites toevoegt, en je hebt een speciale systeembeheerder nodig om het gezond te houden. Deze headless-architectuur geeft je statische siteprestaties, databaseniveaubeveiliging en nul gedeeld runtimerisico tussen locaties.

Hoe kunnen locatiebeheerders hun eigen inhoud bewerken zonder andere locaties te breken? Row-Level Security op databaseniveau zorgt ervoor dat een locatiebeheerder in Austin letterlijk geen gegevens die deel uitmaken van de Denver-locatie kan zien of wijzigen. Dit wordt niet afgedwongen door toepassingscode die bugs kan hebben — het wordt afgedwongen door Postgres zelf. Zelfs als het beheerdashboard een bug had die probeerde gegevens van een andere locatie op te vragen, zou de database lege resultaten retourneren.

Wat over SEO — krijgt elke locatie zijn eigen sitemap? Elke locatiepagina krijgt een eigen vermelding in een enkele dynamische sitemap die op bouwmoment wordt gegenereerd. We genereren ook gestructureerde gegevens per locatie (JSON-LD) met LocalBusiness-schema, geo-coördinaten, openingstijden en branchespecifieke typen. Google behandelt elke /locations/[slug]-pagina als een afzonderlijke lokale bedrijfsvermelding, wat precies is wat je wilt voor lokale pack-rankings.

Kunnen locaties hun eigen blogposts hebben terwijl zij netwerkbrede inhoud delen? Ja — dat is opnieuw het nullable location_id-patroon. Blogposts met location_id = NULL verschijnen in elke locatie's blogfeed. Posts met een specifieke location_id verschijnen alleen in die locatie's feed. Een locatie in Miami kan een post over een lokaal gemeenschapsevenement publiceren terwijl het bedrijfsteam netwerkbrede gedachtenleiderschap publiceert. Beide verschijnen in de Miami-blogfeed; alleen de bedrijfspost verschijnt overal anders.

Hoeveel kosten lopend onderhoud vergeleken met het beheren van 50 aparte websites? Met deze architectuur is er één codebase, één implementatie en één set afhankelijkheden om onderhoud te geven. De maandelijkse infrastructuur loopt €75-€125, afhankelijk van schaal. Vergelijk dat met 50 WordPress-installaties: €1.500/maand alleen al in hosting, plus 10-20 uur per maand in plugin-updates, beveiligingspatches en het geval dat de ene locatie na een auto-update is stukgegaan. We hebben gezien dat multi-location bedrijven hun jaarlijks webbeheersbudget met 60-70% verminderen na migratie naar dit patroon.