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 documentation

Use 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 }
  }
};

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

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.

Vacation rental properties in Google's documentation
PropertyGoogleExpectsNotes
containsPlaceRequiredAccommodationOne Accommodation with bed, occupancy, room and amenity details.
containsPlace.occupancyRequiredQuantitativeValueThe maximum number of guests.
containsPlace.additionalTypeRecommendedTextEntirePlace, PrivateRoom or SharedRoom. One of: EntirePlace, PrivateRoom, SharedRoom.
containsPlace.bedRecommendedBedDetails, can repeatThe beds in the rental.
containsPlace.amenityFeatureRecommendedLocationFeatureSpecification, can repeatAmenities of the property. Names and values must be in English.
containsPlace.floorSizeRecommendedQuantitativeValueWith value and unitCode FTK/SQFT or MTK/SQM.
containsPlace.numberOfBathroomsTotalRecommendedNumberRESO conventions; half baths count as 0.5.
containsPlace.numberOfBedroomsRecommendedInteger
containsPlace.numberOfRoomsRecommendedInteger
identifierRequiredTextA unique, content independent ID for the property, stable across languages and listing edits.
imageRequiredURL, can repeatAt least 8 photos, including at least one each of bedroom, bathroom and common area.
latitudeRequiredNumberWith at least 5 decimal places of precision.
longitudeRequiredNumberWith at least 5 decimal places of precision.
nameRequiredTextThe name of the vacation rental listing.
additionalTypeRecommendedTextThe type of vacation rental. One of: Apartment, Bungalow, Cabin, Chalet, Cottage, Gite, and 4 more.
addressRecommendedPostalAddressThe full physical location (no P.O. boxes).
address.addressCountryRecommendedTextTwo-letter ISO 3166-1 alpha-2 code.
address.addressLocalityRecommendedTextThe city.
address.addressRegionRecommendedTextState, region or province.
address.postalCodeRecommendedText
address.streetAddressRecommendedTextIncluding unit or apartment number.
aggregateRatingRecommendedAggregateRatingFollow the Review snippet guidelines.
aggregateRating.ratingValueRequiredNumber or TextThe average rating for the item.
aggregateRating.ratingCountOne ofNumberThe total number of ratings for the item on your site.
aggregateRating.reviewCountOne ofNumberThe number of people who provided a review with or without an accompanying rating.
aggregateRating.bestRatingRecommendedNumberThe highest value allowed in this rating system. If omitted, 5 is assumed.
aggregateRating.worstRatingRecommendedNumberThe lowest value allowed in this rating system. If omitted, 1 is assumed.
brandRecommendedBrandThe brand ID associated in Hotel Center, as {"@type": "Brand", "name": "brandIdName"}.
brand.nameRequiredTextThe Hotel Center brand ID name.
checkinTimeRecommendedTimeEarliest check-in time in ISO 8601, for example 14:30:00+08:00.
checkoutTimeRecommendedTimeLatest checkout time in ISO 8601.
descriptionRecommendedTextOf the vacation rental.
knowsLanguageRecommendedText, can repeatLanguages the host speaks, as IETF BCP 47 codes (en-US, fr-FR).
reviewRecommendedReview, can repeatUser reviews. Follow the Review snippet guidelines; datePublished is required.
review.authorRequiredPersonThe author of the review. The reviewer's name must be a valid name. Up to 100 characters.
review.reviewRatingRequiredRatingThe rating given in this review.
review.datePublishedRequiredDate or DateTimeRequired for vacation rental reviews (ISO 8601).
review.contentReferenceTimeRecommendedDateTimeThe start date of the author's stay. Required for properties located in France.

Explore Other Schema Framework Pages

Retired Vehicle Listing Use for inventory pages selling vehicles with standardized listing fields and offer information. Car Open Video Use for pages where a primary video is embedded and key media metadata is visible on-page. VideoObject Open Article Use for editorial pages where the visible content is a complete article with a clear byline and publication timestamps. Article Open Limited Book Actions Use when your page helps users read, preview, or purchase books through clearly described actions. Book Open Breadcrumb Use on pages that belong to a clear hierarchy and display navigational trail links users can follow. BreadcrumbList Open Carousel Use when a page lists multiple related entities in a scannable sequence that can be surfaced as cards. ItemList Open Course Use for educational offerings where curriculum details, provider identity, and delivery context are explicit. Course Open Dataset Use for dataset landing pages so datasets are discoverable in Google Dataset Search with license, creator and download details. Dataset Open Discussion Forum Use for forum and community threads where a primary post and subsequent replies are publicly visible. DiscussionForumPosting Open Limited Education Q&A Use for educational question-answer pages where a single canonical answer and supporting context are visible. Quiz Open Employer Aggregate Rating Use on pages that publish employer rating aggregates from a clear and policy-compliant review source. EmployerAggregateRating Open Retired Estimated Salary Use for compensation insight pages that present transparent salary ranges for specific occupations and locations. Occupation Open Event Use for upcoming events with a real-world or virtual occurrence, schedule, and location context. Event Open Retired Fact Check Use for fact-checking pages that evaluate a specific claim with a transparent rating methodology. ClaimReview Open Retired Home Activities Use for virtual classes and activities where participation details and timing are clearly listed. Event Open Retired How-To Use for instructional content that teaches a process through ordered steps and actionable detail. HowTo Open Image Metadata Use on image pages and image hosts that expose ownership and licensing information to search engines. ImageObject Open Job Posting Use for active job listings that include role details, hiring entity, and location/salary context. JobPosting Open Retired Learning Video Use for educational videos where the learning objective and pedagogical structure are explicit. LearningResource Open Local Business Use for physical businesses serving local customers with publicly verifiable contact and location details. LocalBusiness Open Loyalty Program Use to publish a loyalty program and its tiers once at the organization level, so member prices and member shipping can reference them. MemberProgram Open Math Solvers Use for pages that solve mathematical expressions and present step-by-step computational guidance. MathSolver Open Merchant Listing Use on pages where a product can be bought, with price, availability, shipping and return details. Product Open Limited Movie Use for movie detail pages with canonical film information and transparent metadata attribution. Movie Open Organization Use for official organization pages to clarify brand identity and authoritative entity signals. Organization Open Retired Practice Problems Use for educational practice content that presents solvable problems and answer validation structure. Quiz Open Product Snippet Use for product pages where pricing, availability, and product identity are visible to users. Product Open Product Variants Use when one product comes in variants such as size or color, grouped with ProductGroup. ProductGroup Open Profile Page Use for creator or contributor profile pages where identity attributes and ownership are explicit. ProfilePage Open Q&A Use for pages where users submit questions and community or editorial answers are displayed. QAPage Open Recipe Use for recipe pages with complete ingredients, instructions, and preparation details. Recipe Open Return Policy Use to publish a merchant return policy once at the organization level, with offer-level overrides. MerchantReturnPolicy Open Review Snippet Use when a page publishes genuine reviews tied to a clearly identified product, service, or entity. Review Open Shipping Policy Use to publish shipping rates, delivery times and destinations once at the organization level. ShippingService Open Site Name Use on the home page so Google shows the preferred site name in search results. WebSite Open Retired Sitelinks Search Box Use on sites with a functioning internal search endpoint that can accept URL query parameters. WebSite Open Software App (Beta) Use for software product pages that describe platform support and application category clearly. SoftwareApplication Open Limited Speakable Use for news-style pages where specific text segments are appropriate for text-to-speech playback. SpeakableSpecification Open Subscription and Paywalled Content Use for pages with restricted sections so Google can distinguish free previews from protected content. CreativeWork Open

Implement Vacation Rental Schema Frameworks Correctly

Get implementation support for conditional schema architecture, validation, and deployment governance.