DEV Community

Citation Builder
Citation Builder

Posted on

Mapping 39 Local Business Categories to schema.org LocalBusiness Types (with a Builder)

Most local business sites ship "@type": "LocalBusiness" because that is what the first snippet they copied said. Schema.org has dozens of more specific subtypes (Dentist, Plumber, AutoDealer, HVACBusiness), and the subtype is a free category signal: one token that tells a crawler or an assistant what the page is about before it reads a word of body copy.

We build directory citations for 39 local industries, so we needed a lookup that turns our industry key into the right schema.org type, and a builder that emits a correct LocalBusiness block for both storefronts and service-area businesses (the ones that hide their street address). Here is the whole thing in plain Node, with the outputs it actually produces.

The lookup table

The map below is the one we use. Where schema.org has a leaf type we use it; where it only has a parent group we use that; and where nothing fits we fall back to LocalBusiness. The rule is "go as deep as is true", never "invent a type": "@type": "PestControl" validates as an unknown type and is ignored.

const SCHEMA_TYPE = {
  'dentists': 'Dentist',
  'doctors': 'Physician',
  'chiropractors': 'MedicalBusiness',        // no leaf; add medicalSpecialty
  'therapists': 'MedicalBusiness',
  'veterinarians': 'VeterinaryCare',
  'law-firms': 'LegalService',
  'accountants': 'AccountingService',
  'insurance-agents': 'InsuranceAgency',
  'real-estate-agents': 'RealEstateAgent',
  'restaurants': 'Restaurant',
  'bars-pubs': 'BarOrPub',
  'catering': 'FoodEstablishment',           // no Caterer leaf
  'hotels': 'Hotel',
  'plumbers': 'Plumber',
  'electricians': 'Electrician',
  'hvac': 'HVACBusiness',
  'contractors': 'GeneralContractor',
  'builders': 'GeneralContractor',
  'locksmiths': 'Locksmith',
  'movers': 'MovingCompany',
  'landscapers': 'HomeAndConstructionBusiness',
  'cleaners': 'HomeAndConstructionBusiness',
  'pest-control': 'HomeAndConstructionBusiness',
  'home-services': 'HomeAndConstructionBusiness',
  'salons-spas': 'BeautySalon',
  'gyms': 'ExerciseGym',
  'yoga-studios': 'ExerciseGym',
  'auto-repair': 'AutoRepair',
  'car-dealerships': 'AutoDealer',
  'florists': 'Florist',
  'childcare': 'ChildCare',
  'architects': 'ProfessionalService',
  'it-services': 'ProfessionalService',
  'marketing-agencies': 'ProfessionalService',
  'security-companies': 'ProfessionalService',
  'photographers': 'LocalBusiness',
  'wedding-services': 'LocalBusiness',
  'taxi': 'LocalBusiness',                   // TaxiService is a Service, not a business
  'driving-schools': 'LocalBusiness',
};

function pickType(industry) {
  return SCHEMA_TYPE[industry] ?? 'LocalBusiness';
}
Enter fullscreen mode Exit fullscreen mode

Counting it: 39 industries resolve to 27 distinct types. Only 4 fall all the way back to LocalBusiness, and 11 stop at a parent group (MedicalBusiness, HomeAndConstructionBusiness, ProfessionalService, FoodEstablishment). So for most verticals there is a specific type waiting to be used.

The builder, with the service-area rule

The second half of the problem is the address. A plumber, a mover or a photographer usually works from a home or a yard that customers never visit, and hides the street address on Google and the other profiles. The schema on their site has to follow the same decision: publishing a street address in JSON-LD that the profiles hide is a NAP conflict on your own domain. The builder below drops streetAddress and adds areaServed for those industries by default, and lets the caller override it.

const USUALLY_SAB = new Set([
  'plumbers', 'electricians', 'hvac', 'contractors', 'builders', 'locksmiths',
  'movers', 'landscapers', 'cleaners', 'pest-control', 'home-services',
  'catering', 'photographers', 'taxi',
]);

function localBusinessJsonLd(biz) {
  const type = pickType(biz.industry);
  const hide = biz.hideAddress ?? USUALLY_SAB.has(biz.industry);
  const address = {
    '@type': 'PostalAddress',
    addressLocality: biz.city,
    addressRegion: biz.region,
    postalCode: biz.postalCode,
    addressCountry: biz.country,
  };
  if (!hide) address.streetAddress = biz.street;
  const out = {
    '@context': 'https://schema.org',
    '@type': type,
    '@id': new URL('/#business', biz.url).toString(),
    name: biz.name,
    url: biz.url,
    telephone: biz.phone,
    address,
  };
  if (hide && biz.areaServed?.length) {
    out.areaServed = biz.areaServed.map((n) => ({ '@type': 'City', name: n }));
  }
  return out;
}
Enter fullscreen mode Exit fullscreen mode

For a plumber in Austin serving three towns, it emits a Plumber block with no street and an areaServed array:

{
  "@context": "https://schema.org",
  "@type": "Plumber",
  "@id": "https://www.riversideplumbing.example/#business",
  "name": "Riverside Plumbing",
  "url": "https://www.riversideplumbing.example",
  "telephone": "+1 512-555-0134",
  "address": {
    "@type": "PostalAddress",
    "addressLocality": "Austin",
    "addressRegion": "TX",
    "postalCode": "78703",
    "addressCountry": "US"
  },
  "areaServed": [
    { "@type": "City", "name": "Austin" },
    { "@type": "City", "name": "Round Rock" },
    { "@type": "City", "name": "Cedar Park" }
  ]
}
Enter fullscreen mode Exit fullscreen mode

For a dental practice the same call keeps the street, because patients visit:

{
  "@context": "https://schema.org",
  "@type": "Dentist",
  "@id": "https://www.northsidedental.example/#business",
  "name": "Northside Dental",
  "url": "https://www.northsidedental.example",
  "telephone": "+1 512-555-0100",
  "address": {
    "@type": "PostalAddress",
    "addressLocality": "Austin",
    "addressRegion": "TX",
    "postalCode": "78703",
    "addressCountry": "US",
    "streetAddress": "1200 N Lamar Blvd, Suite 210"
  }
}
Enter fullscreen mode Exit fullscreen mode

Both outputs above were produced by running the code, not typed by hand.

Three things the code cannot decide for you

  1. The values. The block is only useful if name, the address and telephone are byte-identical to the business's Google profile and its directory listings. A perfectly valid Dentist block with a phone number the practice stopped using is a contradiction published on the most trusted source the business owns. We wrote up the full property list, the openingHoursSpecification and sameAs patterns, and the mistakes we see most in the LocalBusiness schema markup guide.
  2. The hide/show decision for edge cases. A photographer with a studio shows the address; one working from home does not. The USUALLY_SAB set is a default, and hideAddress exists to override it. The platform-by-platform rules for hidden addresses (Google, Apple, Bing, Foursquare, OpenStreetMap, which has no hidden mode at all) are in service area business citations.
  3. Where the type sits in the wider plan. For a dentist the schema type is a small piece next to Healthgrades-tier profiles and reviews; for a plumber it sits next to emergency-hours consistency and the free lead-platform profiles. We wrote one guide per industry, for example local SEO for dentists and local SEO for plumbers, and they use exactly this table.

Validating it

Two checks, in this order. First, syntax: paste the page into the Schema Markup Validator at validator.schema.org, which checks against the full vocabulary (Google's Rich Results Test only reports types Google surfaces, so "no items detected" there does not mean invalid). Second, values: compare the block with what the directories publish. Our free NAP checker parses every JSON-LD block on a page, reads the LocalBusiness or Organization name, address and telephone, and reports where the site and the listings disagree; it is how we found that the most common schema failure is not a missing property but a phone number nobody updated.

If you maintain a similar map for industries we did not cover, or you disagree with a mapping (the MedicalBusiness fallback for chiropractors is the one we debated longest), the comments are open.

Top comments (0)