Skip to content
Sigtrip.

Structured data for hotels

A playbook for adding schema.org markup to a hotel’s own pages — amenities, rooms, rates, ratings and meeting space — by extending the markup that is already there rather than publishing a second, competing description of the same property. Published by Sigtrip, AI distribution infrastructure for hotels. It is public, needs no login, and is complete on its own.

What this change is

Part 1 of 8. In the hotelier's words.

The hotel’s website already says what it is — the room types, what is included, whether there is parking, how big the function room is. But it says it in sentences and photographs, and every machine that reads the page has to infer the rest. Structured data is the same information written a second time in a form that leaves nothing to infer: a small block of JSON in the page source saying, in so many words, this is a hotel, these are its rooms, this is what it costs, this is what it has.

The trap is that the page almost certainly has some already. A theme, an SEO plugin or a booking widget will have emitted a Hotel or LocalBusiness block that nobody at the hotel knows about. Adding a second one does not double the signal — it creates two descriptions of one property that disagree, and a reader that cannot tell which is current. So the job is to find what is there and extend it, in place, to one entity per property.

The rules this follows

Seven, each with the reason it exists. Rule 4 — extend, do not duplicate — is the spine of this entire playbook.

  1. 01

    Read before you write.

    A section written without reading the page arrives in the wrong voice, at the wrong heading level, in class names the site does not use — and the next person to edit that page deletes it.

  2. 02

    Match what is there.

    A block that looks bolted on reads to a guest as an ad and to an assistant as promotional filler, instead of as the property describing itself.

  3. 03

    Facts only.

    A hotel is accountable at the front desk for every number on its site, so an invented rate, distance or capacity becomes a complaint at check-in — leave a marked placeholder instead of a guess.

  4. 04

    Extend, do not duplicate.

    Two descriptions of the same hotel force every reader, human or machine, to decide which one is true — and they may not pick yours.

  5. 05

    Plan first.

    The difference between a drafted section and a mangled homepage is a person saying yes before anything is saved.

  6. 06

    Validate.

    “It should work” is not a check: name the validator you used and what it returned.

  7. 07

    Report back.

    Whoever asked you has to finish the job, and they need the list of placeholders and blocked steps more than they need a confident summary.

Before you start

Part 2 of 8. The inventory here is mostly forensic: find every piece of markup the site already emits, including the pieces nobody added on purpose.

  • Every existing JSON-LD block. View the rendered source of the page and search for application/ld+json. There is often more than one block: a theme emits a WebSite, an SEO plugin emits an Organization, a booking widget emits something of its own. Read them all before you write anything.
  • Markup that is not JSON-LD. Search the HTML for itemtype and itemscope (microdata) and vocab or typeof (RDFa). An old template often carries microdata that describes the same hotel — that counts as an existing entity.
  • Where the markup comes from. A theme file, a plugin’s settings screen, a tag manager, or hand-written in the template. You have to edit it at the source, not paste a corrected copy underneath — that is how a page ends up with two.
  • Whether the markup is injected by JavaScript. If a block only exists after scripts run, some readers will never see it. Prefer markup that is in the HTML the server sends. Compare the page source with the rendered DOM to find out.
  • What the page visibly says. Structured data describes the page it is on. Every property you mark up should correspond to something a person can see on that page — that is both the rule the search platforms enforce and the reason the markup is trustworthy at all.
  • Which page is the hotel’s page. The full Hotel entity belongs on the page about the property, usually the home page. Other pages reference it by @id; they do not each carry their own copy.
  • The facts, and their sources. Room counts, bed types, occupancy, rates, opening times, capacities, policies — read them off the hotel’s own pages and keep a list of where each came from. Anything you cannot find is a placeholder, not an estimate.
  • Whether the hotel actually owns the page. A property on a brand or parent-company domain usually cannot edit its markup at all. If that is the case, stop and say so — your output becomes a draft for whoever can.

Match the site you are in

Part 3 of 8. The house-style rule applies to markup as much as to prose.

Your block has to look like the site wrote it. Follow the conventions already in the file: the same @context form, the same key order, the same indentation, the same quoting of URLs, and the same use of @graph if the existing markup uses one. If the site already puts every node in a single graph, add yours to that graph — do not append a fifth standalone script tag beneath it.

Match the naming too. If the site calls a room type “Courtyard Suite” on the rooms page, that exact string is the name in the markup — not “Deluxe Courtyard Suite” because it sounds better. Structured data that disagrees with the page it is on is worse than none: a reader that catches one contradiction discounts everything else in the block.

And keep the identifiers stable. Once a node has an @id, that string is the property’s name in machine terms. Changing it later silently breaks every reference to it, so pick one form — https://www.example.com/#hotel — and write it down in your report.

Steps

Part 4 of 8. Step 6 is a hard stop; step 7 is the one this playbook exists for.

  1. 01

    Confirm you can edit the markup, and where.

    Find the file, plugin setting or template that emits the existing block. If you cannot reach it, the deliverable is a draft block plus instructions for whoever can — not a second block pasted somewhere you can reach.

  2. 02

    Find every entity the page already publishes.

    JSON-LD, microdata and RDFa. List what you found, its type, and where it comes from. Assume there is one until you have proved otherwise; there usually is.

  3. 03

    Choose the one canonical Hotel node and give it a stable @id.

    One Hotel per property, on the page about the property. If two already exist, merging them is part of this change — keep the one that is best referenced, fold the other’s correct properties into it, and delete the duplicate at its source.

  4. 04

    Check what the page visibly says.

    Mark up only what is on the page. If you want the markup to state a check-in time or a room count that the page never mentions, the page needs that sentence first — say so in your plan rather than asserting it in the markup alone.

  5. 05

    Collect the facts, with sources, and mark the gaps.

    Occupancy, bed types, floor sizes, capacities and rates all come from the hotel’s own pages. Every one you cannot source becomes [VERIFY: what is missing — where it would come from].

  6. 06

    Write the plan, and stop.

    List the blocks you found, the node you will extend, the properties you will add, the duplicates you would remove, and the placeholders that remain. Then wait for a person to say go. Broken markup is easy to ship and hard to notice.

  7. 07

    Extend the existing node — never add a sibling.

    Add your properties to the Hotel that is already there. Adding a second Hotel, or a LocalBusiness describing the same property, is the single most common failure in this work and it is worse than doing nothing.

  8. 08

    Add amenities as amenityFeature.

    Each one a LocationFeatureSpecification with a name and a boolean value. Use the names the site itself uses. An explicit false is useful for the things guests ask about and the hotel does not have — it answers the question rather than leaving it open.

  9. 09

    Add room types as containsPlace, with occupancy and beds.

    A HotelRoom per room type — not per physical room — each with its own @id, an occupancy as a QuantitativeValue, a bed as BedDetails, and a floorSize where the site publishes one.

  10. 10

    Attach rates as makesOffer with a priceSpecification.

    An Offer per bookable room type, pointing at the room by @id, with a UnitPriceSpecification carrying price, priceCurrency, unitCode: "DAY" and whether tax is included. Only publish a rate the site actually shows, and only if someone will keep it current — a stale price is a false statement about what a guest will pay.

  11. 11

    Add meeting and event space as MeetingRoom.

    Inside containsPlace, a MeetingRoom per named room, with maximumAttendeeCapacity and floorSize. Capacity depends on the layout, so say which layout the number is for in the room’s description and take both from the meetings page.

  12. 12

    Add aggregateRating only if it is genuinely the hotel's own.

    The rating must be collected by the hotel, shown to visitors on that page, and match what is displayed. Do not copy a score from a review site or an OTA into the hotel’s own markup, and do not mark up a rating that appears nowhere on the page. If neither is true, leave aggregateRating out entirely — it is optional, and a fabricated one is the fastest way to lose the trust of every reader of the block.

  13. 13

    Validate, then report.

    Parse the JSON, run a schema validator, re-check that exactly one Hotel node exists on the rendered page, and name what you ran in the report.

Worked example

Part 5 of 8. A fictional property — The Cedarline Hotel, cedarline.example. Real, parseable JSON-LD: copy the shape, not the facts.

What was already on the page:

The existing block, emitted by the theme. It is thin, but it is the entity — it already has an @id, and that @id is what everything else on the site points at.
<script type="application/ld+json">
{
  "@context": "https://schema.org",
  "@type": "Hotel",
  "@id": "https://cedarline.example/#hotel",
  "name": "The Cedarline Hotel",
  "url": "https://cedarline.example/",
  "telephone": "+1-555-0100",
  "address": {
    "@type": "PostalAddress",
    "streetAddress": "104 Cedar Street",
    "addressLocality": "Springfield",
    "addressRegion": "OR",
    "postalCode": "97477",
    "addressCountry": "US"
  }
}
</script>

What the inventory found — every fact below, and where it came from:

No value enters the markup without a line in this list. The three at the bottom are why the extended block has placeholders in it.
Existing markup:  1 JSON-LD block (theme), type Hotel, @id #hotel
                  no microdata, no RDFa, nothing injected by JavaScript
Emitted from:     theme file header.php, line 31
Second source:    booking widget setting "advanced > schema" was ON and
                  emitting its own Hotel block. Turned off in this change.

Facts read off cedarline.example, with the page they came from:
  42 rooms                                     (/rooms/)
  check-in 15:00, check-out 11:00              (/faq/)
  pets under 20kg welcome                      (/faq/)
  free Wi-Fi, on-site parking, EV charging     (/amenities/)
  no swimming pool (stated, not assumed)       (/amenities/)
  Courtyard Suite: king bed, sitting room,
    sleeps up to 4                             (/rooms/)
  from-rate $329/night, room only, tax extra   (/rooms/, shown on page)
  guest rating 4.6 from 213 reviews, collected
    by the hotel and displayed on the page     (/ home page)

Not stated anywhere on the site:
  Courtyard Suite floor area
  Cedar Room floor area
  Cedar Room seated capacity

The same block, extended in place:

One Hotel node, same @id, same context, same key order — with amenities, room types, an offer, meeting space and a rating added to it. Nothing here is a second entity. The three facts the hotel's own pages did not state are left as placeholders; this block is not publishable until they are replaced with real values or their properties removed.
<script type="application/ld+json">
{
  "@context": "https://schema.org",
  "@type": "Hotel",
  "@id": "https://cedarline.example/#hotel",
  "name": "The Cedarline Hotel",
  "url": "https://cedarline.example/",
  "telephone": "+1-555-0100",
  "address": {
    "@type": "PostalAddress",
    "streetAddress": "104 Cedar Street",
    "addressLocality": "Springfield",
    "addressRegion": "OR",
    "postalCode": "97477",
    "addressCountry": "US"
  },
  "checkinTime": "15:00",
  "checkoutTime": "11:00",
  "petsAllowed": true,
  "numberOfRooms": {
    "@type": "QuantitativeValue",
    "value": 42,
    "unitCode": "ROM"
  },
  "amenityFeature": [
    {
      "@type": "LocationFeatureSpecification",
      "name": "Free high-speed Wi-Fi",
      "value": true
    },
    {
      "@type": "LocationFeatureSpecification",
      "name": "On-site parking",
      "value": true
    },
    {
      "@type": "LocationFeatureSpecification",
      "name": "Electric vehicle charging",
      "value": true
    },
    {
      "@type": "LocationFeatureSpecification",
      "name": "Swimming pool",
      "value": false
    }
  ],
  "containsPlace": [
    {
      "@type": "HotelRoom",
      "@id": "https://cedarline.example/rooms/#courtyard-suite",
      "name": "Courtyard Suite",
      "url": "https://cedarline.example/rooms/",
      "description": "A suite with a separate sitting room and a courtyard view.",
      "occupancy": {
        "@type": "QuantitativeValue",
        "minValue": 1,
        "maxValue": 4,
        "unitCode": "C62"
      },
      "bed": {
        "@type": "BedDetails",
        "typeOfBed": "King",
        "numberOfBeds": 1
      },
      "floorSize": {
        "@type": "QuantitativeValue",
        "value": "[VERIFY: suite floor area — the rooms page does not state it]",
        "unitCode": "FTK"
      },
      "amenityFeature": [
        {
          "@type": "LocationFeatureSpecification",
          "name": "Separate sitting room",
          "value": true
        }
      ]
    },
    {
      "@type": "MeetingRoom",
      "@id": "https://cedarline.example/meetings/#cedar-room",
      "name": "The Cedar Room",
      "url": "https://cedarline.example/meetings/",
      "description": "Ground-floor function room; capacity below is for a seated banquet layout.",
      "maximumAttendeeCapacity": "[VERIFY: seated banquet capacity from the meetings page]",
      "floorSize": {
        "@type": "QuantitativeValue",
        "value": "[VERIFY: function room floor area]",
        "unitCode": "FTK"
      }
    }
  ],
  "makesOffer": [
    {
      "@type": "Offer",
      "name": "Courtyard Suite, room only",
      "itemOffered": {
        "@id": "https://cedarline.example/rooms/#courtyard-suite"
      },
      "url": "https://cedarline.example/book/",
      "availability": "https://schema.org/InStock",
      "priceSpecification": {
        "@type": "UnitPriceSpecification",
        "price": "329.00",
        "priceCurrency": "USD",
        "unitCode": "DAY",
        "valueAddedTaxIncluded": false
      }
    }
  ],
  "aggregateRating": {
    "@type": "AggregateRating",
    "ratingValue": "4.6",
    "reviewCount": 213,
    "bestRating": 5,
    "worstRating": 1
  }
}
</script>

Three things about that block are worth stating outright. The rate is only correct because the page shows it — the hotel publishes a from-rate of $329 on its rooms page, and if that number moves the markup moves with it or comes out. The rating is only correct because it is the hotel’s own — it is collected by the hotel, displayed on the same page, and matches what a visitor sees; a score lifted from a review site does not belong here. The placeholders are load-bearing — the two floor areas and the banquet capacity were nowhere on the site, so they stay marked. The block goes live only once they are filled in from a real source or those properties are dropped.

And the mistake this playbook exists to prevent:

What gets added when nobody checks the page source first — a second entity for the same property, in a second script tag, with a different id, a different name and a rate nobody verified. Now two blocks describe one hotel and disagree about it.
<!-- already in the <head>, from the theme -->
<script type="application/ld+json">
{ "@type": "Hotel", "@id": "https://cedarline.example/#hotel",
  "name": "The Cedarline Hotel", ... }
</script>

<!-- appended by an assistant that did not look -->
<script type="application/ld+json">
{
  "@context": "https://schema.org",
  "@type": "Hotel",
  "name": "Cedarline Hotel Springfield",
  "priceRange": "$$$",
  "starRating": { "@type": "Rating", "ratingValue": 4 },
  "aggregateRating": {
    "@type": "AggregateRating",
    "ratingValue": "4.8",
    "reviewCount": 1204
  }
}
</script>

  two entities  — no @id, so nothing connects it to the existing node
  two names     — "Cedarline Hotel Springfield" appears nowhere on the site
  invented      — the star rating, the price range and the review count were
                  not read off the page; the review count is an OTA's
  net effect    — the page now states two different things about one hotel

Validate

Part 6 of 8. Markup fails silently — a trailing comma removes the whole block and nothing on the page looks different. Check it properly and say what you checked with.

  • The JSON parses. Run it through a JSON parser before anything else. One trailing comma, one smart quote pasted in from a document, and the entire block is ignored.
  • A schema validator returns no errors. validator.schema.org checks the vocabulary; Google’s Rich Results Test checks eligibility for its own features. Run both, and quote what they said — including warnings you chose not to act on.
  • Exactly one Hotel node exists. On the rendered page, not the template: search the DOM for "@type": "Hotel" and for LocalBusiness describing the same property. Two is the failure this whole playbook is about.
  • Every marked-up fact is visible on the page. Walk the properties one by one and point at where each appears in the page text. A property you cannot point at should not be in the block.
  • No placeholder survives. Search for [VERIFY:. A placeholder in published markup is a false statement in a machine-readable format, which is worse than the missing property it stands in for.
  • The block is in the served HTML. Fetch the page without running JavaScript — curl and read the source. If the markup only exists in the rendered DOM, some readers will never see it.
  • References resolve. Every @id you point at exists in the same graph or on the same page, and no id changed in this edit.

Report back

Part 7 of 8. Include what you removed — a deleted duplicate is the most important line in the report.

Four headings, in this order.
WHAT I CHANGED
  - extended the existing Hotel node at https://cedarline.example/#hotel
    (theme file header.php, line 31) with: amenityFeature (4),
    containsPlace (1 HotelRoom, 1 MeetingRoom), makesOffer (1),
    aggregateRating, checkinTime, checkoutTime, petsAllowed,
    numberOfRooms
  - removed the second Hotel block added by the booking widget
    (widget settings > "advanced > schema", now off)
  - no other files touched

WHAT I LEFT AS A PLACEHOLDER
  - [VERIFY: suite floor area] — floorSize on #courtyard-suite
  - [VERIFY: function room floor area] — floorSize on #cedar-room
  - [VERIFY: seated banquet capacity] — maximumAttendeeCapacity
  Do not publish until these are filled in or the properties removed.

WHAT I COULD NOT DO
  - the rate in makesOffer is the from-rate shown on /rooms/. Nobody owns
    keeping it current; if that is not going to happen, drop makesOffer
    rather than let it go stale.

WHAT I VALIDATED WITH
  - JSON.parse: ok
  - validator.schema.org: 0 errors, 1 warning (no image on HotelRoom)
  - Rich Results Test: Hotel detected, 0 errors
  - searched the rendered page for '"@type": "Hotel"': 1 match

Common mistakes

Part 8 of 8. What this particular change goes wrong as.

  • A second Hotel. The page already had one from a plugin nobody remembered installing. Two entities describing one property is the default outcome of not looking, and it is worse than no markup at all.
  • Marking up what the page does not say. Structured data describes its page. A property asserted only in the markup is unverifiable by anyone reading the page, and platforms treat it that way.
  • A rating that is not the hotel’s. Copying a score and a review count from an OTA or a review site into the hotel’s own aggregateRating misattributes someone else’s data. Leave it out unless the hotel collects and displays the rating itself.
  • A stale rate. A price in markup is a statement about what a guest will pay. If no one will keep it current, omitting makesOffer is the honest choice.
  • The Hotel node on every page. The full entity belongs on the page about the property; other pages reference it by @id. Repeating it site-wide produces dozens of near-identical entities that drift apart.
  • Wrong or invented types and properties. HotelRoom, not a bare Room, for a room type; makesOffer on the hotel, not a loose offers; LocationFeatureSpecification for amenities. If schema.org does not define a property, inventing one does not make it readable.
  • One room node per physical room. Room types are the unit. Forty-two HotelRoom nodes for a forty-two-room hotel is noise, and no two of them will stay in sync.
  • Broken JSON. A trailing comma or a curly quote silently voids the whole block, and the page looks identical. Parse it before you ship it.
  • Changing an @id. It is the property’s machine-readable name. Renaming it breaks every reference at once, quietly.
  • Markup that contradicts the page copy. Pets allowed in one and not the other, a different room name, a different check-in time. Fix both in the same change or flag the conflict — do not pick a side silently.