Vacation Rental Schema Framework
Use conditional-logic schema generation to keep Vacation Rental markup aligned with real page state and avoid stale or invalid static templates.
When To Use It
Google Search CentralVacation Rental documentationUse for rental property detail pages where booking-relevant amenities and location signals are explicit.
Key Implementation Documentation Highlights
- Limited availability: vacation rental results are only open to sites that meet Google's eligibility criteria, which requires a Google Technical Account Manager, Hotel Center access and the vacation rental interest form. Acceptance into the Early Adopters Program is not guaranteed.
- Required properties: containsPlace (exactly one Accommodation with occupancy.value, the maximum number of guests), identifier, image, latitude, longitude, name.
- Recommended properties: additionalType, address, aggregateRating, brand, checkinTime, checkoutTime, description, knowsLanguage, review, plus on the Accommodation: additionalType (EntirePlace, PrivateRoom, SharedRoom), bed, amenityFeature, floorSize, numberOfBathroomsTotal, numberOfBedrooms, numberOfRooms.
- image needs at least 8 photos, including at least one bedroom, one bathroom and one common area. latitude and longitude need at least 5 decimal places. identifier must stay the same across languages and listing edits.
- Value formats: checkinTime and checkoutTime are ISO 8601 times (14:30:00+08:00), knowsLanguage uses BCP 47 codes (en-US), addressCountry is ISO 3166-1 alpha-2, floorSize.unitCode is FTK, SQFT, MTK or SQM, brand.name is the Hotel Center brand ID, and P.O. boxes are not valid addresses.
- amenityFeature names come from Google's list and are always in English: boolean amenities (wifi, kitchen, pool and others) take true or false; internetType and parkingType take Free, Paid or None; poolType takes Indoor, Outdoor or None; licenseNum is text and can repeat.
- Every review needs datePublished. For properties in France, every review also needs contentReferenceTime, the start date of the author's stay.
- Google expects content parity: every marked-up value must match what users can see on the page.
- Use the Rich Results Test and URL Inspection to validate rendering, eligibility, and crawlability.
Template Approach
const pageData = {
listingId: "VR-20418",
title: "Cedar Ridge Cottage",
photos: ["exterior", "living-room", "kitchen", "bedroom-1", "bedroom-2", "bathroom", "porch", "dock"],
latitude: 44.53817,
longitude: -72.81463,
maxGuests: 5
};
const staticTemplate = {
"@context": "https://schema.org",
"@type": "VacationRental",
"name": pageData.title,
"identifier": pageData.listingId,
"image": pageData.photos.map((p) => `https://www.example.com/img/cedar/${p}.jpg`),
"latitude": pageData.latitude,
"longitude": pageData.longitude,
"containsPlace": {
"@type": "Accommodation",
"occupancy": { "@type": "QuantitativeValue", "value": pageData.maxGuests }
}
};
{
"@context": "https://schema.org",
"@type": "VacationRental",
"name": "Cedar Ridge Cottage",
"identifier": "VR-20418",
"image": [
"https://www.example.com/img/cedar/exterior.jpg",
"https://www.example.com/img/cedar/living-room.jpg",
"https://www.example.com/img/cedar/kitchen.jpg",
"https://www.example.com/img/cedar/bedroom-1.jpg",
"https://www.example.com/img/cedar/bedroom-2.jpg",
"https://www.example.com/img/cedar/bathroom.jpg",
"https://www.example.com/img/cedar/porch.jpg",
"https://www.example.com/img/cedar/dock.jpg"
],
"latitude": 44.53817,
"longitude": -72.81463,
"containsPlace": {
"@type": "Accommodation",
"occupancy": {
"@type": "QuantitativeValue",
"value": 5
}
}
}
Conditional-logic Framework
const ISO_DATE_TIME = /^(\d{4})-(\d{2})-(\d{2})(?:T(\d{2}):(\d{2})(?::(\d{2}))?(Z|[+-]\d{2}:\d{2})?)?$/;
function isAbsoluteUrl(value) {
return typeof value === "string" && /^https?:\/\/[^\s/?#]+\.[^\s/?#]+(?:[/?#]\S*)?$/i.test(value);
}
// Returns the ISO 8601 string unchanged (timezone offset kept), or null if unparseable.
function toIsoDateTime(value) {
if (typeof value !== "string") return null;
const text = value.trim();
const m = text.match(ISO_DATE_TIME);
if (!m) return null;
const [, y, mo, d, h = "00", mi = "00", s = "00"] = m;
const date = new Date(Date.UTC(+y, +mo - 1, +d));
if (date.getUTCMonth() !== +mo - 1 || date.getUTCDate() !== +d) return null;
if (+h > 23 || +mi > 59 || +s > 59) return null;
return text;
}
function toIsoDate(value) {
const iso = toIsoDateTime(value);
return iso ? iso.slice(0, 10) : null;
}
// Drops null, undefined, "", [] and {} recursively.
function compact(value) {
if (Array.isArray(value)) {
const items = value.map(compact).filter((v) => v !== undefined);
return items.length ? items : undefined;
}
if (value && typeof value === "object") {
const out = {};
for (const [key, v] of Object.entries(value)) {
const c = compact(v);
if (c !== undefined) out[key] = c;
}
return Object.keys(out).length ? out : undefined;
}
return value === null || value === undefined || value === "" ? undefined : value;
}
function passesPageGates(source) {
if (source.indexable === false) return false;
if (source.canonicalUrl && source.url && source.canonicalUrl !== source.url) return false;
return source.contentVisible !== false;
}
function toNumber(value) {
if (value === null || value === undefined || value === "" || typeof value === "boolean") return null;
const num = Number(value);
return Number.isFinite(num) ? num : null;
}
const isCurrencyCode = (value) => typeof value === "string" && /^[A-Z]{3}$/.test(value);
// Rating or AggregateRating; null when ratingValue falls outside worstRating..bestRating.
function buildRating(input, type) {
if (!input) return null;
const best = toNumber(input.bestRating) ?? 5;
const worst = toNumber(input.worstRating) ?? 1;
const value = toNumber(input.ratingValue);
if (value === null || worst >= best || value < worst || value > best) return null;
const rating = { "@type": type, ratingValue: value, bestRating: best, worstRating: worst };
if (type === "AggregateRating") {
const ratingCount = toNumber(input.ratingCount);
const reviewCount = toNumber(input.reviewCount);
if (!(ratingCount > 0) && !(reviewCount > 0)) return null;
rating.ratingCount = ratingCount > 0 ? ratingCount : null;
rating.reviewCount = reviewCount > 0 ? reviewCount : null;
}
return rating;
}
// Review with a valid author name (under 100 characters) and an in-range rating.
function buildReview(input) {
const authorName = String(input?.authorName ?? "").trim();
const reviewRating = buildRating(input?.rating, "Rating");
if (!authorName || authorName.length >= 100 || !reviewRating) return null;
return {
"@type": "Review",
author: { "@type": input.authorType === "Organization" ? "Organization" : "Person", name: authorName },
reviewRating,
datePublished: toIsoDate(input.datePublished)
};
}
const BOOLEAN_AMENITIES = new Set([
"ac", "airportShuttle", "balcony", "beachAccess", "childFriendly", "crib", "elevator", "fireplace",
"freeBreakfast", "gymFitnessEquipment", "heating", "hotTub", "instantBookable", "ironingBoard", "kitchen",
"microwave", "outdoorGrill", "ovenStove", "patio", "petsAllowed", "pool", "privateBeachAccess",
"selfCheckinCheckout", "smokingAllowed", "tv", "washerDryer", "wheelchairAccessible", "wifi"
]);
const TEXT_AMENITIES = {
internetType: ["Free", "Paid", "None"],
parkingType: ["Free", "Paid", "None"],
poolType: ["Indoor", "Outdoor", "None"]
};
const RENTAL_TYPES = ["Apartment", "Bungalow", "Cabin", "Chalet", "Cottage", "Gite", "HolidayVillageRental", "House", "Villa", "VacationRental"];
const PLACE_TYPES = ["EntirePlace", "PrivateRoom", "SharedRoom"];
const TIME = /^\d{2}:\d{2}(:\d{2})?([+-]\d{2}:\d{2}|Z)?$/;
const oneOf = (value, allowed) => (allowed.includes(value) ? value : null);
// latitude and longitude need at least 5 decimal places in the emitted number.
function toCoordinate(value) {
const num = toNumber(value);
return num !== null && /^-?\d+\.\d{5,}$/.test(String(num)) ? num : null;
}
// Boolean amenities take true/false; typed amenities take their enum; licenseNum is repeatable text.
function buildAmenities(amenities = {}) {
const features = [];
for (const [name, value] of Object.entries(amenities)) {
if (BOOLEAN_AMENITIES.has(name) && typeof value === "boolean") features.push({ name, value });
else if (TEXT_AMENITIES[name]?.includes(value)) features.push({ name, value });
else if (name === "licenseNum") [].concat(value).filter(Boolean).forEach((v) => features.push({ name, value: String(v) }));
}
return features.map((f) => ({ "@type": "LocationFeatureSpecification", ...f }));
}
function buildAccommodation(unit = {}) {
const occupancy = toNumber(unit.maxGuests);
if (!Number.isInteger(occupancy) || occupancy < 1) return null;
const floorArea = toNumber(unit.floorSize?.value);
return {
"@type": "Accommodation",
additionalType: oneOf(unit.placeType, PLACE_TYPES),
occupancy: { "@type": "QuantitativeValue", value: occupancy },
numberOfBedrooms: toNumber(unit.bedrooms),
numberOfBathroomsTotal: toNumber(unit.bathrooms),
numberOfRooms: toNumber(unit.rooms),
floorSize: floorArea > 0 && ["FTK", "SQFT", "MTK", "SQM"].includes(unit.floorSize.unitCode)
? { "@type": "QuantitativeValue", value: floorArea, unitCode: unit.floorSize.unitCode }
: null,
bed: (unit.beds ?? [])
.filter((b) => Number.isInteger(b.count) && b.count > 0)
.map((b) => ({ "@type": "BedDetails", numberOfBeds: b.count, typeOfBed: b.type })),
amenityFeature: buildAmenities(unit.amenities)
};
}
function buildVacationRentalSchema(source) {
if (!passesPageGates(source)) return null;
// Required: name, identifier, 8+ images (bedroom, bathroom, common area), precise coordinates, one Accommodation.
const name = String(source.name ?? "").trim();
const identifier = String(source.listingId ?? "").trim();
const images = (source.images ?? []).filter((img) => isAbsoluteUrl(img.url));
const rooms = new Set(images.map((img) => img.room));
const hasRoomMix = ["bedroom", "bathroom", "common"].every((room) => rooms.has(room));
const latitude = toCoordinate(source.latitude);
const longitude = toCoordinate(source.longitude);
const containsPlace = buildAccommodation(source.unit);
if (!name || !identifier || images.length < 8 || !hasRoomMix || latitude === null || longitude === null || !containsPlace) return null;
const country = /^[A-Z]{2}$/.test(source.address?.country ?? "") ? source.address.country : null;
// Reviews need datePublished; properties in France also need contentReferenceTime (start of the stay).
const reviews = (source.reviews ?? []).map((r) => {
const review = buildReview(r);
const contentReferenceTime = toIsoDateTime(r.stayStartDate);
if (!review || !review.datePublished || (country === "FR" && !contentReferenceTime)) return null;
return { ...review, contentReferenceTime };
}).filter(Boolean);
return compact({
"@context": "https://schema.org",
"@type": "VacationRental",
additionalType: oneOf(source.rentalType, RENTAL_TYPES),
name,
identifier,
url: source.url,
description: source.description,
brand: source.hotelCenterBrandId ? { "@type": "Brand", name: source.hotelCenterBrandId } : null,
image: images.map((img) => img.url),
latitude,
longitude,
address: source.address && {
"@type": "PostalAddress",
streetAddress: source.address.street,
addressLocality: source.address.city,
addressRegion: source.address.region,
postalCode: source.address.postalCode,
addressCountry: country
},
checkinTime: TIME.test(source.checkinTime ?? "") ? source.checkinTime : null,
checkoutTime: TIME.test(source.checkoutTime ?? "") ? source.checkoutTime : null,
knowsLanguage: (source.hostLanguages ?? []).filter((l) => /^[A-Za-z]{2,3}(-[A-Za-z0-9]{2,8})*$/.test(l)),
containsPlace,
aggregateRating: buildRating(source.rating, "AggregateRating"),
review: reviews
});
}
// Usage:
// const source = {
// url: "https://www.example.com/rentals/cedar-ridge-cottage",
// canonicalUrl: "https://www.example.com/rentals/cedar-ridge-cottage",
// indexable: true,
// contentVisible: true,
// listingId: "VR-20418",
// name: "Cedar Ridge Cottage",
// rentalType: "Cottage",
// description: "Two-bedroom lakeside cottage with a wood stove, dock access and a screened porch.",
// hotelCenterBrandId: "cedar-ridge-rentals",
// images: [
// { url: "https://www.example.com/img/cedar/exterior.jpg", room: "exterior" },
// { url: "https://www.example.com/img/cedar/living-room.jpg", room: "common" },
// { url: "https://www.example.com/img/cedar/kitchen.jpg", room: "common" },
// { url: "https://www.example.com/img/cedar/bedroom-1.jpg", room: "bedroom" },
// { url: "https://www.example.com/img/cedar/bedroom-2.jpg", room: "bedroom" },
// { url: "https://www.example.com/img/cedar/bathroom.jpg", room: "bathroom" },
// { url: "https://www.example.com/img/cedar/porch.jpg", room: "common" },
// { url: "https://www.example.com/img/cedar/dock.jpg", room: "exterior" }
// ],
// latitude: "44.53817",
// longitude: "-72.81463",
// address: { street: "18 Ridge Road", city: "Stowe", region: "VT", postalCode: "05672", country: "US" },
// checkinTime: "16:00:00-04:00",
// checkoutTime: "10:00:00-04:00",
// hostLanguages: ["en-US", "fr-CA"],
// unit: {
// placeType: "EntirePlace",
// maxGuests: 5,
// bedrooms: 2,
// bathrooms: 1.5,
// rooms: 5,
// floorSize: { value: 1150, unitCode: "FTK" },
// beds: [{ type: "Queen", count: 1 }, { type: "Single", count: 2 }],
// amenities: { wifi: true, kitchen: true, fireplace: true, petsAllowed: false, parkingType: "Free", licenseNum: "Stowe: STR-2026-118" }
// },
// rating: { ratingValue: 4.9, reviewCount: 86 },
// reviews: [{ authorName: "Priya Raman", rating: { ratingValue: 5 }, datePublished: "2026-08-02", stayStartDate: "2026-07-24" }]
// };
// const jsonLd = buildVacationRentalSchema(source);
import re
from datetime import date, datetime
ISO_DATE_TIME = re.compile(r"^(\d{4})-(\d{2})-(\d{2})(?:T(\d{2}):(\d{2})(?::(\d{2}))?(Z|[+-]\d{2}:\d{2})?)?$")
ABSOLUTE_URL = re.compile(r"^https?://[^\s/?#]+\.[^\s/?#]+(?:[/?#]\S*)?$", re.I)
def is_absolute_url(value):
return isinstance(value, str) and bool(ABSOLUTE_URL.match(value))
# Returns the ISO 8601 string unchanged (timezone offset kept), or None if unparseable.
def to_iso_date_time(value):
if not isinstance(value, str):
return None
text = value.strip()
m = ISO_DATE_TIME.match(text)
if not m:
return None
y, mo, d = int(m[1]), int(m[2]), int(m[3])
try:
date(y, mo, d)
except ValueError:
return None
if int(m[4] or 0) > 23 or int(m[5] or 0) > 59 or int(m[6] or 0) > 59:
return None
return text
def to_iso_date(value):
iso = to_iso_date_time(value)
return iso[:10] if iso else None
# Drops None, "", [] and {} recursively.
def compact(value):
if isinstance(value, list):
items = [c for c in (compact(v) for v in value) if c is not None]
return items or None
if isinstance(value, dict):
out = {k: c for k, c in ((k, compact(v)) for k, v in value.items()) if c is not None}
return out or None
return None if value is None or value == "" else value
def passes_page_gates(source):
if source.get("indexable") is False:
return False
if source.get("canonicalUrl") and source.get("url") and source["canonicalUrl"] != source["url"]:
return False
return source.get("contentVisible") is not False
def to_number(value):
if value is None or value == "" or isinstance(value, bool):
return None
try:
num = float(value)
except (TypeError, ValueError):
return None
if num != num or num in (float("inf"), float("-inf")):
return None
return int(num) if num.is_integer() else num
def is_currency_code(value):
return isinstance(value, str) and re.fullmatch(r"[A-Z]{3}", value) is not None
# Rating or AggregateRating; None when ratingValue falls outside worstRating..bestRating.
def build_rating(data, rating_type):
if not data:
return None
best = to_number(data.get("bestRating"))
best = 5 if best is None else best
worst = to_number(data.get("worstRating"))
worst = 1 if worst is None else worst
value = to_number(data.get("ratingValue"))
if value is None or worst >= best or value < worst or value > best:
return None
rating = {"@type": rating_type, "ratingValue": value, "bestRating": best, "worstRating": worst}
if rating_type == "AggregateRating":
rating_count = to_number(data.get("ratingCount")) or 0
review_count = to_number(data.get("reviewCount")) or 0
if rating_count <= 0 and review_count <= 0:
return None
rating["ratingCount"] = rating_count if rating_count > 0 else None
rating["reviewCount"] = review_count if review_count > 0 else None
return rating
# Review with a valid author name (under 100 characters) and an in-range rating.
def build_review(data):
data = data or {}
author_name = str(data.get("authorName") or "").strip()
review_rating = build_rating(data.get("rating"), "Rating")
if not author_name or len(author_name) >= 100 or not review_rating:
return None
return {
"@type": "Review",
"author": {"@type": "Organization" if data.get("authorType") == "Organization" else "Person", "name": author_name},
"reviewRating": review_rating,
"datePublished": to_iso_date(data.get("datePublished")),
}
BOOLEAN_AMENITIES = {
"ac", "airportShuttle", "balcony", "beachAccess", "childFriendly", "crib", "elevator", "fireplace",
"freeBreakfast", "gymFitnessEquipment", "heating", "hotTub", "instantBookable", "ironingBoard", "kitchen",
"microwave", "outdoorGrill", "ovenStove", "patio", "petsAllowed", "pool", "privateBeachAccess",
"selfCheckinCheckout", "smokingAllowed", "tv", "washerDryer", "wheelchairAccessible", "wifi",
}
TEXT_AMENITIES = {
"internetType": ["Free", "Paid", "None"],
"parkingType": ["Free", "Paid", "None"],
"poolType": ["Indoor", "Outdoor", "None"],
}
RENTAL_TYPES = ["Apartment", "Bungalow", "Cabin", "Chalet", "Cottage", "Gite", "HolidayVillageRental", "House", "Villa", "VacationRental"]
PLACE_TYPES = ["EntirePlace", "PrivateRoom", "SharedRoom"]
TIME = re.compile(r"^\d{2}:\d{2}(:\d{2})?([+-]\d{2}:\d{2}|Z)?$")
def one_of(value, allowed):
return value if value in allowed else None
# latitude and longitude need at least 5 decimal places in the emitted number.
def to_coordinate(value):
num = to_number(value)
return num if num is not None and re.fullmatch(r"-?\d+\.\d{5,}", repr(num)) else None
# Boolean amenities take true/false; typed amenities take their enum; licenseNum is repeatable text.
def build_amenities(amenities):
features = []
for name, value in (amenities or {}).items():
if name in BOOLEAN_AMENITIES and isinstance(value, bool):
features.append({"name": name, "value": value})
elif value in TEXT_AMENITIES.get(name, []):
features.append({"name": name, "value": value})
elif name == "licenseNum":
for v in value if isinstance(value, list) else [value]:
if v:
features.append({"name": name, "value": str(v)})
return [{"@type": "LocationFeatureSpecification", **f} for f in features]
def build_accommodation(unit):
unit = unit or {}
occupancy = to_number(unit.get("maxGuests"))
if not isinstance(occupancy, int) or occupancy < 1:
return None
floor = unit.get("floorSize") or {}
floor_area = to_number(floor.get("value"))
return {
"@type": "Accommodation",
"additionalType": one_of(unit.get("placeType"), PLACE_TYPES),
"occupancy": {"@type": "QuantitativeValue", "value": occupancy},
"numberOfBedrooms": to_number(unit.get("bedrooms")),
"numberOfBathroomsTotal": to_number(unit.get("bathrooms")),
"numberOfRooms": to_number(unit.get("rooms")),
"floorSize": {"@type": "QuantitativeValue", "value": floor_area, "unitCode": floor["unitCode"]}
if floor_area and floor_area > 0 and floor.get("unitCode") in ("FTK", "SQFT", "MTK", "SQM") else None,
"bed": [
{"@type": "BedDetails", "numberOfBeds": b["count"], "typeOfBed": b.get("type")}
for b in unit.get("beds") or []
if isinstance(b.get("count"), int) and b["count"] > 0
],
"amenityFeature": build_amenities(unit.get("amenities")),
}
def build_vacation_rental_schema(source):
if not passes_page_gates(source):
return None
# Required: name, identifier, 8+ images (bedroom, bathroom, common area), precise coordinates, one Accommodation.
name = str(source.get("name") or "").strip()
identifier = str(source.get("listingId") or "").strip()
images = [img for img in source.get("images") or [] if is_absolute_url(img.get("url"))]
rooms = {img.get("room") for img in images}
has_room_mix = all(room in rooms for room in ("bedroom", "bathroom", "common"))
latitude = to_coordinate(source.get("latitude"))
longitude = to_coordinate(source.get("longitude"))
contains_place = build_accommodation(source.get("unit"))
if not name or not identifier or len(images) < 8 or not has_room_mix or latitude is None or longitude is None or not contains_place:
return None
address = source.get("address")
country = address.get("country") if address and re.fullmatch(r"[A-Z]{2}", address.get("country") or "") else None
# Reviews need datePublished; properties in France also need contentReferenceTime (start of the stay).
reviews = []
for r in source.get("reviews") or []:
review = build_review(r)
content_reference_time = to_iso_date_time(r.get("stayStartDate"))
if not review or not review["datePublished"] or (country == "FR" and not content_reference_time):
continue
reviews.append({**review, "contentReferenceTime": content_reference_time})
return compact({
"@context": "https://schema.org",
"@type": "VacationRental",
"additionalType": one_of(source.get("rentalType"), RENTAL_TYPES),
"name": name,
"identifier": identifier,
"url": source.get("url"),
"description": source.get("description"),
"brand": {"@type": "Brand", "name": source["hotelCenterBrandId"]} if source.get("hotelCenterBrandId") else None,
"image": [img["url"] for img in images],
"latitude": latitude,
"longitude": longitude,
"address": {
"@type": "PostalAddress",
"streetAddress": address.get("street"),
"addressLocality": address.get("city"),
"addressRegion": address.get("region"),
"postalCode": address.get("postalCode"),
"addressCountry": country,
} if address else None,
"checkinTime": source.get("checkinTime") if TIME.match(source.get("checkinTime") or "") else None,
"checkoutTime": source.get("checkoutTime") if TIME.match(source.get("checkoutTime") or "") else None,
"knowsLanguage": [l for l in source.get("hostLanguages") or [] if re.fullmatch(r"[A-Za-z]{2,3}(-[A-Za-z0-9]{2,8})*", l)],
"containsPlace": contains_place,
"aggregateRating": build_rating(source.get("rating"), "AggregateRating"),
"review": reviews,
})
{
"@context": "https://schema.org",
"@type": "VacationRental",
"additionalType": "Cottage",
"name": "Cedar Ridge Cottage",
"identifier": "VR-20418",
"url": "https://www.example.com/rentals/cedar-ridge-cottage",
"description": "Two-bedroom lakeside cottage with a wood stove, dock access and a screened porch.",
"brand": {
"@type": "Brand",
"name": "cedar-ridge-rentals"
},
"image": [
"https://www.example.com/img/cedar/exterior.jpg",
"https://www.example.com/img/cedar/living-room.jpg",
"https://www.example.com/img/cedar/kitchen.jpg",
"https://www.example.com/img/cedar/bedroom-1.jpg",
"https://www.example.com/img/cedar/bedroom-2.jpg",
"https://www.example.com/img/cedar/bathroom.jpg",
"https://www.example.com/img/cedar/porch.jpg",
"https://www.example.com/img/cedar/dock.jpg"
],
"latitude": 44.53817,
"longitude": -72.81463,
"address": {
"@type": "PostalAddress",
"streetAddress": "18 Ridge Road",
"addressLocality": "Stowe",
"addressRegion": "VT",
"postalCode": "05672",
"addressCountry": "US"
},
"checkinTime": "16:00:00-04:00",
"checkoutTime": "10:00:00-04:00",
"knowsLanguage": [
"en-US",
"fr-CA"
],
"containsPlace": {
"@type": "Accommodation",
"additionalType": "EntirePlace",
"occupancy": {
"@type": "QuantitativeValue",
"value": 5
},
"numberOfBedrooms": 2,
"numberOfBathroomsTotal": 1.5,
"numberOfRooms": 5,
"floorSize": {
"@type": "QuantitativeValue",
"value": 1150,
"unitCode": "FTK"
},
"bed": [
{
"@type": "BedDetails",
"numberOfBeds": 1,
"typeOfBed": "Queen"
},
{
"@type": "BedDetails",
"numberOfBeds": 2,
"typeOfBed": "Single"
}
],
"amenityFeature": [
{
"@type": "LocationFeatureSpecification",
"name": "wifi",
"value": true
},
{
"@type": "LocationFeatureSpecification",
"name": "kitchen",
"value": true
},
{
"@type": "LocationFeatureSpecification",
"name": "fireplace",
"value": true
},
{
"@type": "LocationFeatureSpecification",
"name": "petsAllowed",
"value": false
},
{
"@type": "LocationFeatureSpecification",
"name": "parkingType",
"value": "Free"
},
{
"@type": "LocationFeatureSpecification",
"name": "licenseNum",
"value": "Stowe: STR-2026-118"
}
]
},
"aggregateRating": {
"@type": "AggregateRating",
"ratingValue": 4.9,
"bestRating": 5,
"worstRating": 1,
"reviewCount": 86
},
"review": [
{
"@type": "Review",
"author": {
"@type": "Person",
"name": "Priya Raman"
},
"reviewRating": {
"@type": "Rating",
"ratingValue": 5,
"bestRating": 5,
"worstRating": 1
},
"datePublished": "2026-08-02",
"contentReferenceTime": "2026-07-24"
}
]
}
The Output As An Entity Graph
The conditional-logic output above, parsed in your browser and drawn as the graph a crawler builds from it. Every nested object is a node and the property that holds it is the edge. Tap a node to inspect its properties, or drag to rearrange.
Why Conditional Logic Is Better Than Static Templates
- Prevents emitting empty, null, or stale fields that frequently trigger rich result warnings.
- Supports multiple page states (for example: no rating yet, no image yet, no offers yet) without duplicate templates.
- Lets engineering teams centralize schema policy checks and validation in reusable code paths.
- Makes large-scale schema maintenance safer when content models evolve over time.
Property Reference
What Google's Vacation rental documentation requires and recommends, property by property. These are the same rules the SchemaCDN validator checks. Each name links to its schema.org definition.
Availability: Limited to sites that meet eligibility criteria: requires a Google Technical Account Manager, Hotel Center access, and the vacation rental interest form (Early Adopters Program acceptance not guaranteed).
Required, one of: In aggregateRating: at least one of ratingCount or reviewCount is required.
| Property | Expects | Notes | |
|---|---|---|---|
| containsPlace | Required | Accommodation | One Accommodation with bed, occupancy, room and amenity details. |
| containsPlace.occupancy | Required | QuantitativeValue | The maximum number of guests. |
| containsPlace.additionalType | Recommended | Text | EntirePlace, PrivateRoom or SharedRoom. One of: EntirePlace, PrivateRoom, SharedRoom. |
| containsPlace.bed | Recommended | BedDetails, can repeat | The beds in the rental. |
| containsPlace.amenityFeature | Recommended | LocationFeatureSpecification, can repeat | Amenities of the property. Names and values must be in English. |
| containsPlace.floorSize | Recommended | QuantitativeValue | With value and unitCode FTK/SQFT or MTK/SQM. |
| containsPlace.numberOfBathroomsTotal | Recommended | Number | RESO conventions; half baths count as 0.5. |
| containsPlace.numberOfBedrooms | Recommended | Integer | |
| containsPlace.numberOfRooms | Recommended | Integer | |
| identifier | Required | Text | A unique, content independent ID for the property, stable across languages and listing edits. |
| image | Required | URL, can repeat | At least 8 photos, including at least one each of bedroom, bathroom and common area. |
| latitude | Required | Number | With at least 5 decimal places of precision. |
| longitude | Required | Number | With at least 5 decimal places of precision. |
| name | Required | Text | The name of the vacation rental listing. |
| additionalType | Recommended | Text | The type of vacation rental. One of: Apartment, Bungalow, Cabin, Chalet, Cottage, Gite, and 4 more. |
| address | Recommended | PostalAddress | The full physical location (no P.O. boxes). |
| address.addressCountry | Recommended | Text | Two-letter ISO 3166-1 alpha-2 code. |
| address.addressLocality | Recommended | Text | The city. |
| address.addressRegion | Recommended | Text | State, region or province. |
| address.postalCode | Recommended | Text | |
| address.streetAddress | Recommended | Text | Including unit or apartment number. |
| aggregateRating | Recommended | AggregateRating | Follow the Review snippet guidelines. |
| aggregateRating.ratingValue | Required | Number or Text | The average rating for the item. |
| aggregateRating.ratingCount | One of | Number | The total number of ratings for the item on your site. |
| aggregateRating.reviewCount | One of | Number | The number of people who provided a review with or without an accompanying rating. |
| aggregateRating.bestRating | Recommended | Number | The highest value allowed in this rating system. If omitted, 5 is assumed. |
| aggregateRating.worstRating | Recommended | Number | The lowest value allowed in this rating system. If omitted, 1 is assumed. |
| brand | Recommended | Brand | The brand ID associated in Hotel Center, as {"@type": "Brand", "name": "brandIdName"}. |
| brand.name | Required | Text | The Hotel Center brand ID name. |
| checkinTime | Recommended | Time | Earliest check-in time in ISO 8601, for example 14:30:00+08:00. |
| checkoutTime | Recommended | Time | Latest checkout time in ISO 8601. |
| description | Recommended | Text | Of the vacation rental. |
| knowsLanguage | Recommended | Text, can repeat | Languages the host speaks, as IETF BCP 47 codes (en-US, fr-FR). |
| review | Recommended | Review, can repeat | User reviews. Follow the Review snippet guidelines; datePublished is required. |
| review.author | Required | Person | The author of the review. The reviewer's name must be a valid name. Up to 100 characters. |
| review.reviewRating | Required | Rating | The rating given in this review. |
| review.datePublished | Required | Date or DateTime | Required for vacation rental reviews (ISO 8601). |
| review.contentReferenceTime | Recommended | DateTime | The start date of the author's stay. Required for properties located in France. |