JobPosting Schema: Required Fields, Mistakes & Where Not to Use It
TL;DR
JobPosting schema needs title, description, datePosted, hiringOrganization, and jobLocation (with addressCountry) to be eligible for Google's job rich result; validThrough is only required if the posting expires. TELECOMMUTE means fully remote only — never mark a hybrid role that way. The one rule that trips up job boards: JobPosting markup must only appear on a single job's detail page, never on a listings/index page showing multiple jobs.
If you run a single company careers page or you're building a multi-employer job board, the same JobPosting structured data markup applies to you — Google Jobs and Google's job-rich-result feature both key off it, and the schema itself doesn't care whether the page behind it belongs to one employer or ten thousand. What differs is scale: a careers page owner adds this once per posting by hand or through their ATS; a job board needs it generated correctly for every listing, automatically, from whatever data feeds the site. This guide covers both — the schema itself, what's required vs. optional, common mistakes, and the one placement rule that trips up job boards specifically.
What JobPosting Actually Does
JobPosting is a schema.org type that Google (and, to a lesser extent, Bing) reads to power the "job listings" rich-result panel in search — the box with a title, company, location, and salary that appears above normal blue links for job-related queries. Without it, your posting is just another indexed page; with it, you're eligible for that panel, which meaningfully outperforms a normal listing on click-through for job-search queries specifically.
It's implemented as JSON-LD, a <script type="application/ld+json"> block embedded in the page <head> or <body> — not visible content, just structured metadata describing the page.
Required Properties
Straight from Google's own JobPosting documentation:
title— "The title of the job (not the title of the posting)." Don't stuff it with company name or location; that's what the other fields are for.description— "The full description of the job in HTML format," a "complete representation of the job, including job responsibilities, qualifications, skills, working hours, education requirements, and experience requirements." A truncated summary doesn't satisfy this.datePosted— "The original date that employer posted the job in ISO 8601 format."hiringOrganization— "The organization offering the job position," minimally aname. IncludesameAs(the company's real website) andlogowhen you have them.jobLocation— required unless the role is genuinely remote (see below). Google's documentation is specific that "you must include theaddressCountryproperty" — a location object missing country will fail validation even if city/state are present.validThrough— required, but conditionally: Google's docs state this is required "for job postings that have an expiration date." If the role has no real closing date, it isn't required — see the Common Mistakes section for why serializing a fake ornullvalue is worse than omitting it entirely.
Recommended (But Not Required) Properties
Google's own recommended list: applicantLocationRequirements, baseSalary, directApply, employmentType, identifier, jobLocationType, validThrough (recommended even when not strictly required, per above).
employmentType— allowed values areFULL_TIME,PART_TIME,CONTRACTOR,TEMPORARY,INTERN,VOLUNTEER,PER_DIEM,OTHER— case-sensitive exact tokens, not a freeform string like"Full-time".baseSalary— aMonetaryAmountwith a nestedQuantitativeValue(minValue/maxValueor a singlevalue),currency(ISO code), andunitText(YEAR,MONTH,WEEK,DAY, orHOUR). Disclosing salary is one of the highest-leverage fields here, and increasingly a legal requirement in the regions you're likely posting for anyway.identifier— aPropertyValuewith your own internal req ID asvalueand your company name asname. Helps Google de-duplicate the same posting seen from multiple sources.jobLocationType+applicantLocationRequirements— for remote roles, don't just put "Remote" injobLocation.address.addressLocalityas a string. Google's guidance is explicit and stricter than most implementations assume: "Jobs marked asTELECOMMUTEmust be fully remote. Don't mark up jobs that allow occasional work-from-home." A hybrid role does not qualify forjobLocationType: "TELECOMMUTE"— use a normaljobLocationfor those, not the remote-specific fields. For a genuinely fully-remote posting, Google requires you to "specify a minimum of one country from which applicants are eligible to work, usingapplicantLocationRequirements(preferred)" — omitting this on aTELECOMMUTEposting is itself a validation gap, not just a missed nicety.directApply— boolean, whether a candidate can apply directly from the search result without leaving to a separate site. Set it accurately; misrepresenting it is a documented spam-policy violation, not just a missed optimization.
Common Mistakes
Letting validThrough silently expire your listing. Google's docs are direct on this: "If a job is filled before the expiration date occurs, remove the job posting" — and a posting with a missing or past validThrough is treated as expired. If you don't track a real closing date, omit validThrough entirely (it's not required in that case) rather than hardcoding a stale date at creation time and forgetting it.
Freeform strings where the schema wants an enum. employmentType: "Full Time" (with a space) or "Full-Time" isn't a valid token — Google's structured data won't reject the page outright, but it may silently drop the field from rich-result eligibility. Map your internal values to the exact enum tokens before serializing.
Marking a hybrid role as TELECOMMUTE. Per Google's own wording above, this is explicitly disallowed, not just imprecise — TELECOMMUTE means fully remote, full stop.
A jobLocation missing addressCountry, or holding a display string instead of real address parts. addressLocality: "Remote - US" is a common but incorrect pattern — it's not a real locality, and it misses the dedicated jobLocationType/applicantLocationRequirements fields built for exactly this case. And per the required-properties list above, addressCountry specifically is non-negotiable on any jobLocation object.
Reusing one JobPosting block across near-duplicate postings. If a company has five nearly identical roles open in five different cities, each needs its own page with its own JobPosting markup and its own jobLocation — not one shared block with a generic location, and not five pages pointing at the same canonical content.
Where JobPosting Should Not Be Used
This is the mistake that's specific to job boards and multi-listing pages, and Google states it about as unambiguously as a policy document gets: "The JobPosting markup must only be used on pages that contain a single job posting. We don't allow the use of JobPosting markup in any other page, including pages that do not list any job." Google adds that "structured data must appear on the same page as the job description that job seekers can read" — the markup has to describe content actually rendered on that page, not content it merely links to.
Concretely, that means:
- Never on a listings/index page. A
/jobspage showing a paginated list of 20+ postings is not "a single job posting" — it's a directory. Markup describing all 20 jobs asJobPostingon that single page (or, worse, one arbitrarily chosen job) is exactly the pattern Google's wording rules out. The index page should carry general page metadata (a normal<title>/<meta description>, maybeItemListschema pointing to the individual job URLs) — notJobPostingitself. - Never duplicated across a category or search-results page. Same logic — a
/jobs?location=austinfiltered view is still a list, not a single posting. - One
JobPostingblock per one job detail page. The only correct placement is the canonical detail page for that specific job, at its own URL, matching the content actually rendered on that page.
If you're building a job board with CleanJobData and rendering listings with a Next.js App Router pattern like the one in Building a Job Board in an Afternoon, this maps directly onto route structure: JobPosting markup goes in app/jobs/[id]/page.tsx (the detail route), never in the list route that renders the repeating group of cards.
Auditing a Real Implementation
Here's a real JobPosting block, structurally similar to what a CleanJobData-powered job detail page would emit:
{
"@context": "https://schema.org",
"@type": "JobPosting",
"title": "Senior Backend Engineer",
"description": "<p>We're looking for...</p>",
"datePosted": "2026-07-01T12:00:00.000Z",
"validThrough": null,
"employmentType": "FULL_TIME",
"hiringOrganization": {
"@type": "Organization",
"name": "Acme Inc",
"sameAs": "https://acme.com",
"logo": "https://acme.com/logo.png"
},
"jobLocation": {
"@type": "Place",
"address": { "@type": "PostalAddress", "addressLocality": "San Francisco, CA" }
},
"baseSalary": {
"@type": "MonetaryAmount",
"currency": "USD",
"value": { "@type": "QuantitativeValue", "minValue": 150000, "maxValue": 180000, "unitText": "YEAR" }
}
}Walking it against the checklist above, three real gaps show up that are easy to miss on a first pass:
validThrough: null— if the source data doesn't carry a real expiration (true for most ATS-sourced postings, since Greenhouse, Lever, Ashby, and Workday don't expose one directly — onlyis_active/expired_atonce a listing is detected as closed), serializing a literalnullinto the JSON-LD is worse than omitting the key. Drop the key entirely when there's no real value, rather than emittingnullor a fabricated date.jobLocation.addressis missingaddressCountry. This one isn't cosmetic — Google's docs listaddressCountryas a hard requirement onjobLocation, so a block like the example above risks failing structured data validation entirely, not just rendering imprecisely.jobLocation.address.addressLocalityholding a full free-text string ("San Francisco, CA"in one field, rather than split intoaddressLocality/addressRegion) — this doesn't help onceaddressCountryis fixed either; the fields still need to be split correctly. If your source data has structured location (CleanJobData'slocationsarray, resolved city/state/country — see the location normalization guide) use it:addressLocalityfor city,addressRegionfor state/province,addressCountryfor the ISO country code.
The missing addressCountry is the one that actually matters for eligibility; the other two are worth fixing but won't break the rich result on their own. All three are easy to get right once you're generating this markup at scale from normalized job data rather than by hand per posting.
How to Test Your Markup
Once you've written the JSON-LD, don't just eyeball it — validate it, and use more than one tool, because they don't check the same thing:
- Google's Rich Results Test — paste in the live URL (or raw code) and it tells you whether the page is actually eligible for the job-search rich result, not just whether the JSON is well-formed. This is the tool that matters most, since it's checking against Google's specific requirements (the ones covered above), not the full schema.org spec.
- A general schema.org validator (e.g. Schema Markup Validator) — checks your JSON-LD against the complete schema.org vocabulary, which is broader than what Google enforces. Worth knowing: these two tools can disagree. Google's Rich Results Test can pass markup that a strict schema.org validator flags, because Google only enforces a subset of the full spec (the required/recommended properties covered earlier in this guide) — a validator warning about a property Google doesn't check isn't necessarily something you need to fix for the rich result to work.
- Search Console's Enhancement report (once the page is live and indexed) — shows JobPosting errors and warnings across your whole site over time, not just one URL at a time. This is what catches drift — a field that validated fine at launch but started failing after a data-pipeline change six months later.
Run the Rich Results Test first, on the actual rendered page (not just the JSON in isolation) — it needs to see the markup the way Googlebot would fetch it, which catches cases where the JSON-LD is correct but isn't actually reaching the rendered HTML (a common gap in client-side-rendered pages where the structured data script gets injected after the initial server response).