JobPosting schema: required fields, validThrough and the errors that cost you Google Jobs
The five properties Google requires on a job page, the recommended ones worth adding, how validThrough and expired jobs work, and the Search Console policy messages job sites run into most.

Google's JobPosting schema needs five properties before a page can appear in the job search experience: datePosted, description, hiringOrganization, jobLocation and title. A fully remote job can drop jobLocation if it has applicantLocationRequirements and jobLocationType: "TELECOMMUTE". On top of that, Google wants every closed job expired in one of three ways (a past validThrough, a 404/410, or no markup at all), and it treats markup that doesn't match the visible page as a policy violation, not a formatting slip.
Below: every required and recommended field with the rules Google attaches to it, a complete example that validates, server-side code for Laravel and Next.js, and the four Search Console policy messages job sites get most. All of Google's rules are quoted from the JobPosting documentation (last updated 8 September 2026, checked 2 November 2026).
For the wider job board picture (filter URLs, thin employer pages, sitemaps at 30,000 URLs) see Job board SEO: the technical fixes from a 37k-page job site. This post stays on the markup.
A complete JobPosting example that validates
Start from something correct and delete what you don't have:
{
"@context": "https://schema.org",
"@type": "JobPosting",
"title": "Senior Accountant",
"description": "<p>We are hiring a Senior Accountant to own month-end close for our UAE entities.</p><ul><li>5+ years in accounting, IFRS experience</li><li>Working hours: Monday to Friday, 9:00 to 18:00</li><li>Bachelor's degree in accounting or finance</li></ul>",
"datePosted": "2026-10-28",
"validThrough": "2026-12-15T23:59:59+04:00",
"employmentType": "FULL_TIME",
"hiringOrganization": {
"@type": "Organization",
"name": "Example Trading LLC",
"sameAs": "https://www.example-trading.com",
"logo": "https://www.example-trading.com/logo.png"
},
"jobLocation": {
"@type": "Place",
"address": {
"@type": "PostalAddress",
"streetAddress": "Office 1204, Business Bay Tower",
"addressLocality": "Dubai",
"addressRegion": "Dubai",
"addressCountry": "AE"
}
},
"baseSalary": {
"@type": "MonetaryAmount",
"currency": "AED",
"value": {
"@type": "QuantitativeValue",
"minValue": 18000,
"maxValue": 22000,
"unitText": "MONTH"
}
},
"identifier": {
"@type": "PropertyValue",
"name": "Example Trading LLC",
"value": "FIN-2026-114"
},
"directApply": true
}
Put it in a <script type="application/ld+json"> block on the job's own page, and make sure every value in it is also visible on that page. The salary, the location and the closing date all have to appear in the HTML a job seeker reads. More on why below.
The five required properties, and the rules Google attaches to each
title
"The title of the job (not the title of the posting)." Google's own examples:
| Not recommended | Recommended |
|---|---|
| Apply now for IT job -FRENCH speaker in Bucharest | Market Specialist, French speaker |
*** WAREHOUSE HIRING NOW!! ON A BUS ROUTE!! *** | Shipping and Receiving Warehouse Associate |
No job codes, addresses, dates, salaries or company names. Overusing ! and * "might cause your structured data to be considered as Spammy Structured Markup". And title and name "aren't interchangeable": a template that sets name and leaves title empty fails the requirement.
If you're a third-party job board, Google says not to rewrite employer titles to fit these rules, "as it can make the title unreadable. Instead, provide the title as you received it from the employer."
description
Google wants "the full description of the job in HTML format", covering "job responsibilities, qualifications, skills, working hours, education requirements, and experience requirements". Three rules in this section trip up job boards:
- It "can't be the same as the title". A template that falls back to the title when the description field is empty produces exactly that.
- It must be HTML with at least paragraph breaks (
<br>,<p>or\n). The job feature recognises<p>,<ul>and<li>. Headings,<strong>and<em>are ignored for display but harmless. - A 200-character teaser in the markup while the page shows the full text is an incomplete description. The content policy says plainly: "We don't allow job postings with incomplete job descriptions."
datePosted
ISO 8601: "2026-10-28" or "2026-10-28T09:30:00+04:00". It's the date the employer first posted the job. Don't bump it every time you re-import the feed: a job that is "posted today" every day is the kind of mismatch the content policies cover.
hiringOrganization
"This must be the name of the company (for example, 'Starbucks, Inc'), and not the specific location that is hiring." Two options job boards need:
- Anonymous employers: set
"name": "confidential". Google documents that value for staffing agencies and employers hiring anonymously. - Logos: third-party job sites can supply
hiringOrganization.logo. ForJobPosting, "the image width and height ratio must be between 0.75 and 2.5". A wide horizontal wordmark at 4:1 doesn't qualify.
jobLocation
"The physical location(s) of the business where the employee will report to work", with addressCountry mandatory: "Note that you must include the addressCountry property." Include as many address properties as you have; Google says more of them makes a higher quality posting.
Several offices? Use an array. Google "will choose the best location to display based on the job seeker's query":
{
"jobLocation": [
{ "@type": "Place", "address": { "@type": "PostalAddress", "addressLocality": "Dubai", "addressCountry": "AE" } },
{ "@type": "Place", "address": { "@type": "PostalAddress", "addressLocality": "Abu Dhabi", "addressCountry": "AE" } }
]
}
Don't put "Dubai / Abu Dhabi / Remote" into one addressLocality string. Google's troubleshooting section lists "Job location is missing or incorrect" for values it can't match to a real place.
Remote jobs: TELECOMMUTE and applicantLocationRequirements
Remote markup has the strictest wording in the doc:
jobLocationType: "TELECOMMUTE"is only for jobs where the employee "may or must work remotely 100% of the time". Google adds: "Don't mark up jobs that allow occasional work-from-home, jobs for which remote work is a negotiable benefit, or have other arrangements that are not 100% remote."- The description must also say the job is 100% remote.
- You must name at least one country applicants can work from, with
applicantLocationRequirements(preferred) or by falling back to the country injobLocationwhen there's also a physical option. "If a remote job doesn't contain applicantLocationRequirements, Google shows the job to anyone in the country specified in jobLocation."
A fully remote role open to applicants in two countries:
{
"jobLocationType": "TELECOMMUTE",
"applicantLocationRequirements": [
{ "@type": "Country", "name": "United Arab Emirates" },
{ "@type": "Country", "name": "Saudi Arabia" }
]
}
A hybrid job (three office days a week) is not TELECOMMUTE. Mark it up with jobLocation only.
Recommended properties worth adding
| Property | What Google says | Notes |
|---|---|---|
validThrough | Expiry in ISO 8601. "Required for job postings that have an expiration date." | Omit it if the job never expires or you don't know. See the next section. |
baseSalary | MonetaryAmount with a QuantitativeValue; unitText is one of HOUR, DAY, WEEK, MONTH, YEAR (case-sensitive) | "The actual base salary for the job, as provided by the employer (not an estimate)." Use minValue/maxValue for ranges. |
employmentType | FULL_TIME, PART_TIME, CONTRACTOR, TEMPORARY, INTERN, VOLUNTEER, PER_DIEM, OTHER | Case-sensitive. An array is allowed: ["FULL_TIME", "CONTRACTOR"]. |
identifier | "The hiring organization's unique identifier for the job." | A PropertyValue with name and value. |
directApply | Whether the URL lets the user apply directly | Google's test: no clicking apply, filling a form, or logging in more than once. Google notes you "may not see any appearance or effect" yet. |
educationRequirements, experienceRequirements, experienceInPlaceOfEducation | Beta properties | monthsOfExperience for experience; credentialCategory such as "bachelor degree" for education. If experienceInPlaceOfEducation is true, both of the others are required. |
Two traps in that table. baseSalary must be the employer's own figure: a job board that fills it from a salary estimator is putting an estimate where Google asked for the actual salary. And unitText: "Monthly" or "month" is not a valid value; it has to be MONTH.
Estimated salary markup is a separate thing. The Occupation type that powered Google's estimated salary feature was phased out in 2025, and its documentation was removed on 9 September 2025 (documentation updates). baseSalary on a JobPosting is unaffected. Our post on Google's retired structured data has the full list.
validThrough and expired jobs
Google's wording is a requirement, not a suggestion: "Jobs that are no longer open for applications must be expired in one of the following ways. Failure to take timely action on expired jobs may result in a manual action."
- "Ensure the
validThroughproperty is populated and in the past." - "Remove the page entirely (so that requesting it returns a
404or410status code)." - "Remove
JobPostingstructured data from the page."
And for jobs filled early: "If the job is filled before the expiration date occurs, remove the job posting." A future validThrough doesn't protect you once the role is filled.
Formatting: ISO 8601, with a time zone if you include a time. "2026-12-15" and "2026-12-15T23:59:59+04:00" are both fine. "15/12/2026" and "Dec 15" are not.
After you expire a job, tell Google. The doc recommends the Indexing API over sitemaps for job URLs "because the Indexing API prompts Googlebot to crawl your page sooner", while still keeping a sitemap for coverage. Our Indexing API guide for Next.js has working code for URL_UPDATED and URL_DELETED.
The policy errors Search Console sends
These come from the troubleshooting section of Google's doc, with the exact Search Console message for each. All four are manual-action territory: fix, then submit a reconsideration request.
| Search Console message | What caused it | Fix |
|---|---|---|
| "Structured data policy violation - A list page should not include structured data for individual jobs" | JobPosting on a search, category or company page | Markup only on single-job pages. "Don't add structured data to pages intended to present a list of jobs." |
| "Structured data policy violation - Content on pages found to be different than structured data on the page" | Title, salary or other values in the markup that aren't on the page | Make every marked-up value visible. Google's example: "the job salary is in the markup but not on the job page." |
| "Structured data policy violation - JobPosting structured data on expired job" | Missing or future validThrough, page still live with markup | One of the three expiry methods above |
| "Structured data policy violation - Can't submit application on job offer page." | No way to apply from the page | Add an apply button, form or contact instructions |
The content policies behind these also rule out jobs that need a login to read the description, postings that charge applicants, career-fair invitations posing as jobs, and resume collection when nobody is hiring. The general structured data policies add one more consequence worth knowing: a structured data manual action "means that a page loses eligibility for appearance as a rich result; it doesn't affect how the page ranks in Google web search."
Generate it on the server, with a JSON encoder
Hand-built JSON in a template breaks on the first job title with a quote mark, and a description containing </script> can end your script block early. Both problems go away when you serialise a data structure and escape <.
Laravel (Blade)
@php
$job = $posting; // Eloquent model
$isOpen = $job->status === 'open' && (! $job->closes_at || $job->closes_at->isFuture());
$jsonLd = $isOpen ? array_filter([
'@context' => 'https://schema.org',
'@type' => 'JobPosting',
'title' => $job->title,
'description' => $job->description_html,
'datePosted' => $job->posted_at->toDateString(),
'validThrough' => $job->closes_at?->toIso8601String(),
'employmentType' => $job->employment_type, // FULL_TIME, PART_TIME...
'hiringOrganization' => [
'@type' => 'Organization',
'name' => $job->company_confidential ? 'confidential' : $job->company->name,
],
'jobLocation' => [
'@type' => 'Place',
'address' => [
'@type' => 'PostalAddress',
'addressLocality' => $job->city,
'addressCountry' => $job->country_code, // required
],
],
]) : null;
@endphp
@if ($jsonLd)
<script type="application/ld+json">{!! json_encode($jsonLd, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE | JSON_HEX_TAG) !!}</script>
@endif
array_filter drops validThrough when there's no closing date, which is what Google asks for. JSON_HEX_TAG encodes < and > as < and >, so HTML inside the description can't close the script tag.
Next.js (App Router)
The Next.js JSON-LD guide warns that JSON.stringify "does not sanitize malicious strings used in XSS injection" and suggests replacing < with <:
<script
type="application/ld+json"
dangerouslySetInnerHTML={{ __html: JSON.stringify(jsonLd).replace(/</g, "\\u003c") }}
/>
Render it in the server component for the job page, so the markup is in the initial HTML.
Check it before Google does
Pull the JSON-LD out of a live job page and test the required fields with nothing more than curl and Python:
curl -s https://example.com/jobs/senior-accountant-dubai | python3 -c '
import json, re, sys
html = sys.stdin.read()
blocks = re.findall(r"<script[^>]*application/ld\+json[^>]*>(.*?)</script>", html, re.S)
for raw in blocks:
data = json.loads(raw)
for item in data if isinstance(data, list) else data.get("@graph", [data]):
if item.get("@type") != "JobPosting":
continue
remote = item.get("jobLocationType") == "TELECOMMUTE" and item.get("applicantLocationRequirements")
need = ["title", "description", "datePosted", "hiringOrganization"] + ([] if remote else ["jobLocation"])
print("missing:", [k for k in need if not item.get(k)] or "none")
print("validThrough:", item.get("validThrough", "not set"))
print("description == title:", item.get("description") == item.get("title"))
'
Then, per template, run one open job, one closed job and one remote job through Google's Rich Results Test, and spot-check them with URL Inspection. Google recommends both. The Rich Results Test applies Google's syntax rules; it can't tell that a salary is missing from the visible page or that a job was filled yesterday. Those checks are on you.
On a whole site the markup tends to break by template, not by page. SEOFix reads the JSON-LD on every crawled page and reports STRUCTURED_DATA_INVALID (JSON that doesn't parse, or nodes without @context or @type) and STRUCTURED_DATA_MISSING_FIELD when a JobPosting lacks datePosted, description, hiringOrganization, title, or jobLocation (or applicantLocationRequirements for TELECOMMUTE jobs). The exact triggers are in the on-page checks reference. It doesn't judge expiry or content policy, so keep the Rich Results Test and URL Inspection in your routine.
FAQ
Is validThrough required for JobPosting?
Only for jobs that have an expiration date. Google lists it as recommended, with the note "This is required for job postings that have an expiration date." If a job never expires or you don't know when it will, leave it out, and expire the job another way (404/410 or removing the markup) when it closes.
Can I put JobPosting markup on a page that lists several jobs?
No. Google says the markup "must only be used on pages that contain a single job posting", and list pages with job markup trigger a specific policy violation. Give each job its own URL and mark that up.
Does JobPosting markup need to match what's on the page?
Yes, field by field. Google's example of a violation is a salary that appears in the markup but not on the job page. Everything you mark up should be readable in the rendered HTML.
What should a job board put in hiringOrganization when the employer is anonymous?
"name": "confidential". Google documents that value for staffing agencies and employers hiring anonymously.
Run a free SEOFix audit on your job site (first audit free, up to 500 pages) to find every job template with broken or incomplete JobPosting markup, grouped by URL pattern so you fix it once.
Keep reading

Next.js SEO checklist for the App Router: 14 technical checks with code and curl
Fourteen checks for a Next.js App Router site, each with the code that gets it right and the curl command that proves it, including the streaming behaviours that turn 404s into 200s and move canonicals into the body.
1,690 words · 8 min read

Sitemap couldn't fetch in Search Console: how to find the cause and fix it
Couldn't fetch means Google never received the sitemap file itself. How to reproduce the fetch with URL Inspection and curl, and the fix for each cause: wrong URL, robots.txt, firewall, timeouts, HTML instead of XML.
1,939 words · 9 min read

Redirect chains: how many hops Google follows, and how to find and flatten them
Google follows up to 10 redirect hops and recommends redirecting straight to the final URL. A curl loop that finds chains in bulk, a script that flattens your redirect map, and the server configs that stop chains coming back.
1,857 words · 9 min read