A dental DSO with 50 practices has the same website architecture problem as a gym chain with 200 locations, a hotel group with 30 properties, and a church network with 15 campuses. They all need: centralized brand control, localized content per location, one admin dashboard, per-location SEO pages, and a deployment that updates everything simultaneously without breaking anything. The architecture is identical. The content is different.

I've built this pattern for dental groups, fitness franchises, veterinary networks, and restaurant chains. Every single time, I start with the same database schema, the same Next.js route structure, and the same role-based access control. What changes is the seed data and the component labels. "Services" becomes "Classes" at a gym or "Menu Items" at a restaurant. "Staff" becomes "Dentists" or "Trainers" or "Veterinarians." The plumbing underneath? Identical.

This post lays out the universal multi-location architecture pattern once, then shows how it adapts to five completely different industries. If you run any kind of multi-location business — or you're a developer building for one — this is the blueprint.

Table of Contents

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

The Core Problem Every Multi-Location Business Faces

Let's be blunt about what usually happens. A franchise or multi-location business starts with a single website. Then they open a second location. Someone spins up a second WordPress install. By the time there are 15 locations, you've got 15 separate WordPress sites, 15 different themes (some are three versions behind), 15 different sets of plugins, and zero centralized control.

The marketing director wants to update the brand's primary CTA across all locations. That's 15 logins, 15 edits, and a prayer that nobody has broken their template. The SEO team wants to see which locations are publishing blog content and which have gone dark for six months. There's no dashboard for that — just a spreadsheet someone forgot to update in March.

This is the same problem whether you're a dental support organization (DSO) managing 50 practices or a restaurant group with 200 locations. The symptoms are identical:

  • Brand drift. Locations go off-brand because nobody's enforcing consistency.
  • SEO fragmentation. No structured local SEO pages, no schema markup consistency, no centralized sitemap.
  • Admin chaos. Each location manages its own site (poorly), or corporate handles everything (slowly).
  • Deployment risk. Updating one location's site shouldn't be able to take down another's.

The fix isn't a better CMS theme. It's a different architecture entirely.

The Universal Database Schema

Everything starts with a locations table. This is the anchor for the entire system. I use Supabase as the database and auth layer because it gives you Postgres, Row-Level Security, real-time subscriptions, and a generous free tier — but the schema works with any relational database.

Here's the core schema:

-- The anchor table. Every piece of location-specific content
-- references this 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()
);

-- Content tables follow the SAME pattern:
-- location_id is NULLABLE.
-- NULL = shared across all locations
-- A value = specific to that location

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
);

The nullable location_id pattern is the key insight. When a blog post has location_id = NULL, it's a network-wide article ("5 Tips for Healthy Teeth" shared across all 50 dental practices). When location_id has a value, it's specific to that location ("Dr. Smith Joins Our Austin Practice"). Same table, same query patterns, but the content can be shared or localized with a single column.

The metadata JSONB column is where industry-specific fields live. A dental location might store {"insurance_accepted": ["Delta Dental", "Cigna"], "parking_info": "Free lot behind building"}. A gym stores {"equipment": ["squat racks", "rowing machines"], "peak_hours": "5-7 PM weekdays"}. No schema migration needed — just different JSON shapes.

Next.js Route Architecture

The Next.js App Router maps cleanly to this data model. Here's the route structure that works for every industry:

app/
├── page.tsx                          # Homepage
├── locations/
│   ├── page.tsx                      # Location finder (map + geo-search)
│   └── [slug]/
│       ├── page.tsx                  # Location detail page
│       ├── staff/page.tsx            # Staff listing for location
│       └── services/page.tsx         # Services for location
├── services/
│   └── [service]/page.tsx            # Shared service description
├── blog/
│   ├── page.tsx                      # All blog posts
│   └── [post]/page.tsx               # Individual blog post
├── about/page.tsx
└── contact/page.tsx

The location detail page (/locations/[slug]) is where the magic happens. A single generateStaticParams call queries every active location and pre-renders all of them at build time:

// 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 location page with all data
  // This is the same component structure regardless of industry
}

The services query uses that or filter — grab services where location_id is null (shared services) OR matches the current location. This means a dental DSO can define "Teeth Cleaning" once for all locations, then add "Invisalign" only for locations that offer it. No duplication.

For the location finder page, I store lat/lng coordinates and use Supabase's PostGIS extension for geo-queries:

-- Find locations within 25 miles of user's coordinates
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 and the Admin Dashboard

This is where the architecture really pays off. Supabase RLS policies let you define data access at the database level — not in your application code.

-- Location managers can only see their own location's data
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'
    )
  );

Network admins see everything. Location managers see only their location. This applies to every table — services, staff, blog posts, testimonials, events. One policy pattern, applied consistently.

The admin dashboard shows network-level metrics:

  • Content freshness: Which locations haven't updated their blog in 30+ days?
  • Traffic per location: Google Search Console data aggregated by location slug
  • Leads per location: Form submissions and booking requests by location
  • Brand compliance: Are all locations using the approved logo, colors, and CTA text?

Industry Variation 1: Dental DSOs

A DSO website needs to feel like a unified dental brand while letting each practice highlight its unique providers and specialties.

Services map to dental procedures: cleanings, fillings, crowns, implants, Invisalign, emergency dental care. Some are universal (every location does cleanings), others are location-specific (only three locations offer sedation dentistry).

Staff are dentists, hygienists, and office managers. Each gets a profile with credentials (DDS, DMD), specialties, education, and a professional photo. Parents choosing a pediatric dentist want to see who'll be treating their kid.

CTA is "Book an Appointment." This connects to Calendly, NexHealth, or a custom booking system. The booking widget pre-selects the location based on which location page the user came from.

Local SEO targets: "dentist in [city]", "[procedure] in [city]", "emergency dentist [city] [state]". Each location page gets structured data markup for Dentist and LocalBusiness schemas.

Metadata JSONB stores: insurance plans accepted, parking information, accessibility features, languages spoken, whether they accept new patients.

Industry Variation 2: Gym and Fitness Chains

Gym chains swap "services" for "classes" — but the data model is the same. A yoga class at Location A and a HIIT class at Location B are just rows in the services table with different location_id values.

Services are class types with schedule data. The metadata stores the weekly schedule as JSON, instructor assignment, capacity limits, and whether drop-ins are allowed.

Staff are trainers and instructors with certifications (NASM, ACE, CrossFit L2), specialties, and availability for personal training bookings.

CTA is "Join Now" — a Stripe subscription checkout that handles membership tiers and cross-location access. A member who signs up at the downtown location should be able to check in at the suburban location too.

Local SEO targets: "gym near me", "fitness classes [city]", "[class type] classes [city]", "personal trainer [city]".

Metadata JSONB stores: equipment list, class schedule, peak hours, amenities (sauna, pool, childcare), free parking availability.

Industry Variation 3: Hotel Groups

Boutique hotel groups and independent hotel chains benefit enormously from this pattern — especially because it enables direct bookings that bypass OTA commission fees (typically 15-25% per booking on Booking.com or Expedia).

Services become room types: Standard Room, King Suite, Penthouse. Each gets photos, amenity lists, square footage, and base pricing. Location-specific pricing lives in the metadata or a separate rates table with date ranges.

Staff is lighter here — maybe a featured general manager or concierge for the brand's storytelling.

CTA is "Book Direct" — the FME (Find, Match, Engage) pattern that gives guests a reason to book on the hotel's own site instead of an OTA. Typically a "best rate guarantee" or complimentary upgrade.

Local SEO targets: "hotels in [city]", "[hotel name] reviews", "boutique hotel [neighborhood] [city]", "hotels near [landmark]".

Metadata JSONB stores: amenities (pool, spa, restaurant, gym, EV charging), nearby attractions, local event calendar, check-in/check-out times, pet policy.

Industry Variation 4: Veterinary Clinic Chains

Vet chains are growing fast in 2025 — consolidation in veterinary medicine mirrors what happened with dental DSOs a decade ago. The same multi-location architecture applies perfectly.

Services are pet care services: wellness exams, vaccinations, dental cleaning, surgery, emergency care, boarding, grooming. Some locations offer exotic pet care; most don't.

Staff are veterinarians with species expertise (small animal, equine, exotic), board certifications, and education.

CTA is "Book an Appointment" with a twist — the intake form should capture pet information (species, breed, age, reason for visit) to route the appointment correctly.

Local SEO targets: "veterinarian in [city]", "emergency vet [city]", "[species] vet [city]", "pet dental cleaning [city]".

Metadata JSONB stores: accepted species, emergency hours (if different from regular hours), boarding capacity, whether they have on-site lab and imaging.

Industry Variation 5: Restaurant Chains

Services become menu sections: appetizers, mains, desserts, drinks. The critical thing here is that pricing can vary by location. A burger costs $14 in Austin and $19 in Manhattan. The metadata column handles this with location-specific pricing overrides.

Staff are featured chefs or pit masters — this works best for brands where the people behind the food are part of the story.

CTA is "Order Online" — a location-aware link that routes to the correct online ordering system (Toast, Square, ChowNow, or custom) for the user's nearest location.

Local SEO targets: "[restaurant name] [city] menu", "restaurants near me", "[cuisine type] restaurant [city]", "[restaurant name] hours".

Metadata JSONB stores: delivery radius, reservation availability (with OpenTable or Resy link), parking details, private dining capacity, happy hour times.

The Architecture Comparison Table

Component Dental DSO Gym Chain Hotel Group Vet Chain Restaurant
"Services" label Procedures Classes Room Types Pet Services Menu Items
"Staff" label Dentists Trainers Management Veterinarians Chefs
Primary CTA Book Appointment Join Membership Book Room Book Appointment Order Online
Booking integration NexHealth, Calendly Stripe Subscriptions Custom / Cloudbeds Custom + pet intake Toast, Square
Key local data Insurance, parking Schedule, equipment Amenities, attractions Species, emergency hrs Menu pricing, delivery
Primary SEO keyword "dentist in [city]" "gym near me" "hotels in [city]" "vet in [city]" "[brand] [city] menu"
Schema markup Dentist, LocalBusiness SportsActivityLocation Hotel, LodgingBusiness VeterinaryCare Restaurant, Menu
DB tables changed 0 0 0 0 0

That last row is the point. Zero database tables change between industries. You're using the same locations, services, staff, blog_posts, testimonials, and events tables. The labels in the UI change. The metadata shapes change. The architecture doesn't.

Deployment and Performance at Scale

We deploy this on Vercel with ISR (Incremental Static Regeneration). Each location page is statically generated at build time and revalidates every 60 seconds. For a 200-location chain, that's 200 static HTML pages that load in under 1 second on any device.

The numbers matter. Here's what we typically see:

  • Build time for 200 locations: ~45 seconds on Vercel Pro
  • TTFB per location page: < 50ms (served from edge CDN)
  • Lighthouse scores: 95+ across the board
  • ISR revalidation: 60-second stale-while-revalidate means content updates appear within a minute without a full rebuild

Adding a new location is a database insert plus an optional on-demand revalidation call. No new deployment needed. The generateStaticParams function picks up new locations on the next build or ISR cycle.

// API route to trigger revalidation when a location is added/updated
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 })
}

Cost Breakdown: What This Actually Costs in 2025

Let's talk real numbers. This is a common question we get during pricing conversations.

Component Monthly Cost (50 locations) Monthly Cost (200 locations)
Supabase Pro $25 $25 (same tier handles both)
Vercel Pro $20 $20
Vercel Bandwidth (overage) ~$0 ~$40
Domain + DNS (Cloudflare) $0 $0
Image CDN (Cloudflare R2) ~$5 ~$15
Monitoring (Sentry) $26 $26
Total infrastructure ~$76/mo ~$126/mo

Compare that to 50 separate WordPress sites at ~$30/month each for managed hosting — that's $1,500/month before you even think about maintenance, plugin licenses, or the person who has to keep them all updated.

The development investment is higher upfront — we typically quote multi-location builds in the $30K-$80K range depending on complexity — but the ongoing operational cost is a fraction of the WordPress multisite alternative. And you're not paying $500/month per location to some franchise website vendor who locks you into their platform.

For teams interested in exploring headless CMS integrations or considering Astro instead of Next.js for even faster static builds, the same database architecture applies. The frontend framework is swappable; the data model is not.

FAQ

Can this architecture handle locations in different time zones?

Absolutely. The hours JSONB column stores each location's operating hours in their local time zone. We include a timezone field (e.g., "America/Chicago") in the location metadata and use that for any time-sensitive displays like "Open Now" badges. All timestamps in the database are stored as UTC and converted on the frontend.

How do you handle locations that offer different services?

That's the nullable location_id pattern in action. Services with location_id = NULL are shared across all locations — they appear on every location's page. Services with a specific location_id only appear for that location. You can also use a junction table (location_services) for many-to-many relationships if shared services need per-location overrides like custom pricing or availability.

What happens when a new location opens?

A network admin adds the location via the dashboard. This creates a row in the locations table, fires a webhook that triggers ISR revalidation, and the new location page is live within 60 seconds. No developer needed, no deployment, no DNS changes. The location inherits all shared services and content immediately.

Is this better than WordPress Multisite for franchises?

For most multi-location businesses, yes. WordPress Multisite was the go-to answer for a decade, but it has real problems: a single plugin vulnerability can take down the entire network, performance degrades as you add sites, and you need a dedicated sysadmin to keep it healthy. This headless architecture gives you static site performance, database-level security, and zero shared runtime risk between locations.

How do location managers edit their own content without breaking other locations?

Row-Level Security at the database level ensures a location manager in Austin literally cannot see or modify data belonging to the Denver location. It's not enforced by application code that could have bugs — it's enforced by Postgres itself. Even if the admin UI had a bug that tried to query another location's data, the database would return empty results.

What about SEO — does each location get its own sitemap?

Each location page gets its own entry in a single dynamic sitemap generated at build time. We also generate per-location structured data (JSON-LD) with LocalBusiness schema, geo-coordinates, operating hours, and industry-specific types. Google treats each /locations/[slug] page as a distinct local business listing, which is exactly what you want for local pack rankings.

Can locations have their own blog posts while sharing network-wide content?

Yes — that's the nullable location_id pattern again. Blog posts with location_id = NULL appear on every location's blog feed. Posts with a specific location_id appear only on that location's feed. A location in Miami can publish a post about a local community event while the corporate team publishes network-wide thought leadership. Both show up in the Miami blog feed; only the corporate post shows up everywhere else.

How much does ongoing maintenance cost compared to managing 50 separate websites?

With this architecture, there's one codebase, one deployment, and one set of dependencies to maintain. Monthly infrastructure runs $75-$125 depending on scale. Compare that to 50 WordPress installs: $1,500/month in hosting alone, plus 10-20 hours per month in plugin updates, security patches, and troubleshooting the one location that broke after an auto-update. We've seen multi-location businesses cut their annual web operations budget by 60-70% after migrating to this pattern.