Job Posting
On this page. Title, description, dates, employment type, pay and location.
One open role at one store: a JobPosting with a clean title, pay range and closing date, hired by the Organization from the homepage and based at the store from its location page.
Use on the page for one open job that people can apply for. Each role gets its own page and its own JobPosting.
The employer is the Organization from the homepage, by @id. The work location is the store, with the same @id as its location page, and its address repeated because Google reads the job location on its own.
Each card opens the framework for that feature, with its own rules and property reference. Cards marked "Linked by @id" are defined on the homepage and only referenced here.
On this page. Title, description, dates, employment type, pay and location.
On this page. Home, Careers, the role.
Linked by @id. hiringOrganization, by @id from the homepage.
Linked by @id. The work site uses the store's @id from its location page.
Every node has a stable @id. A node is written out once, where it belongs, and everywhere else it is referenced by that @id alone. This is the map for this page, generated from the output below.
| @id | Defined on | Type | Referenced by |
|---|---|---|---|
/careers/store-associate-portland/#webpage | This page | WebPage | Top-level node |
/careers/store-associate-portland/#breadcrumb | This page | BreadcrumbList | WebPage.breadcrumb |
/careers/store-associate-portland/#job | This page | JobPosting | WebPage.mainEntity |
/stores/portland/#location | This page | Place | Nested in JobPosting.jobLocation |
/#organization | Homepage | OnlineStore | JobPosting.hiringOrganization |
/#website | Homepage | WebSite | WebPage.isPartOf |
One function turns the page's data into the whole graph. It drops anything Google would reject instead of shipping it half built. The input below includes rows it is meant to drop, so you can see the logic work.
// Job posting page schema: one open role at one of the store's locations.
// JobPosting with the hiring Organization from the homepage by @id, the work
// location as the same store @id used on the location page, salary and
// expiry, plus the page's WebPage and BreadcrumbList.
const ISO_DATE = /^\d{4}-\d{2}-\d{2}$/;
const COUNTRY = /^[A-Z]{2}$/;
const CURRENCY = /^[A-Z]{3}$/;
const clean = (v) => (typeof v === 'string' ? v.trim() : v);
const isUrl = (v) => typeof v === 'string' && /^https:\/\/[^\s]+$/.test(v);
const compact = (obj) => {
if (Array.isArray(obj)) {
const out = obj.map(compact).filter((v) => v !== undefined);
return out.length ? out : undefined;
}
if (obj && typeof obj === 'object') {
const out = {};
for (const [k, v] of Object.entries(obj)) {
const c = compact(v);
if (c !== undefined) out[k] = c;
}
return Object.keys(out).length ? out : undefined;
}
return obj === null || obj === '' ? undefined : obj;
};
function breadcrumb(trail, id) {
const items = (trail || []).filter((c) => c.name && isUrl(c.url));
if (items.length < 2) return undefined;
return {
'@type': 'BreadcrumbList',
'@id': id,
itemListElement: items.map((c, i) => ({ '@type': 'ListItem', position: i + 1, name: clean(c.name), item: c.url })),
};
}
const EMPLOYMENT = { full_time: 'FULL_TIME', part_time: 'PART_TIME', contract: 'CONTRACTOR', temporary: 'TEMPORARY', intern: 'INTERN', seasonal: 'TEMPORARY' };
const UNIT = { hour: 'HOUR', week: 'WEEK', month: 'MONTH', year: 'YEAR' };
// Pay: a single figure or a range, never a range that runs backwards.
function salary(pay) {
if (!pay || !CURRENCY.test(pay.currency || '') || !UNIT[pay.per]) return undefined;
const min = Number(pay.min);
const max = Number(pay.max);
let value;
if (Number.isFinite(min) && Number.isFinite(max) && min > 0 && max > min) value = { minValue: min, maxValue: max };
else if (Number.isFinite(min) && min > 0) value = { value: min };
else return undefined;
return { '@type': 'MonetaryAmount', currency: pay.currency, value: { '@type': 'QuantitativeValue', ...value, unitText: UNIT[pay.per] } };
}
function buildJobPostingPageSchema(source) {
const site = source.site || {};
const page = source.page || {};
const job = source.job || {};
const where = job.location || {};
if (!isUrl(site.url) || !isUrl(page.url) || !job.title || !job.descriptionHtml) return null;
if (!ISO_DATE.test(job.datePosted || '')) return null;
// A posting past its closing date is not marked up at all; Google asks for
// expired jobs to be removed, not left with a past validThrough.
const today = (source.today || new Date().toISOString()).slice(0, 10);
if (ISO_DATE.test(job.validThrough || '') && job.validThrough < today) return null;
// The title is the job title only: no codes, pay, dates or exclamation marks.
const title = clean(job.title).replace(/\s*[(#].*$/, '').replace(/!+/g, '').trim();
const remote = job.remoteCountries && job.remoteCountries.length;
const onsite = where.street && where.city && COUNTRY.test(where.country || '');
if (!remote && !onsite) return null;
const root = site.url.replace(/\/+$/, '');
const pageUrl = page.url;
const jobId = `${pageUrl}#job`;
const posting = compact({
'@type': 'JobPosting',
'@id': jobId,
title,
description: job.descriptionHtml.trim(),
identifier: job.reference ? { '@type': 'PropertyValue', name: clean(site.name) || 'Job reference', value: String(job.reference) } : undefined,
datePosted: job.datePosted,
validThrough: ISO_DATE.test(job.validThrough || '') ? `${job.validThrough}T23:59:59${job.timezoneOffset || 'Z'}` : undefined,
employmentType: [].concat(job.employmentType || []).map((t) => EMPLOYMENT[t]).filter(Boolean),
hiringOrganization: { '@id': `${root}/#organization` },
// The work site is the store from its location page: same @id, with the
// address repeated because Google reads jobLocation on its own.
jobLocation: onsite
? {
'@type': 'Place',
'@id': isUrl(where.locationPageUrl) ? `${where.locationPageUrl}#location` : undefined,
name: clean(where.name),
address: {
'@type': 'PostalAddress',
streetAddress: clean(where.street),
addressLocality: clean(where.city),
addressRegion: clean(where.region),
postalCode: clean(where.postalCode),
addressCountry: where.country,
},
}
: undefined,
jobLocationType: remote && !onsite ? 'TELECOMMUTE' : undefined,
applicantLocationRequirements: remote
? job.remoteCountries.filter((c) => COUNTRY.test(c)).map((c) => ({ '@type': 'Country', name: c }))
: undefined,
baseSalary: salary(job.pay),
directApply: typeof job.directApply === 'boolean' ? job.directApply : undefined,
experienceRequirements: Number.isInteger(job.monthsExperience) && job.monthsExperience > 0
? { '@type': 'OccupationalExperienceRequirements', monthsOfExperience: job.monthsExperience }
: undefined,
experienceInPlaceOfEducation: job.monthsExperience > 0 ? true : undefined,
});
const crumbs = breadcrumb(page.breadcrumbs, `${pageUrl}#breadcrumb`);
const webpage = compact({
'@type': 'WebPage',
'@id': `${pageUrl}#webpage`,
url: pageUrl,
name: clean(page.title) || title,
isPartOf: { '@id': `${root}/#website` },
breadcrumb: crumbs ? { '@id': crumbs['@id'] } : undefined,
mainEntity: { '@id': jobId },
});
return { '@context': 'https://schema.org', '@graph': [webpage, crumbs, posting].filter(Boolean) };
}
{
"site": {
"url": "https://www.example.com",
"name": "Harbor & Pine"
},
"page": {
"url": "https://www.example.com/careers/store-associate-portland/",
"title": "Store Associate, Portland | Harbor & Pine Careers",
"breadcrumbs": [
{
"name": "Home",
"url": "https://www.example.com/"
},
{
"name": "Careers",
"url": "https://www.example.com/careers/"
},
{
"name": "Store Associate, Portland",
"url": "https://www.example.com/careers/store-associate-portland/"
}
]
},
"job": {
"title": "Store Associate (Req #2219)!",
"reference": "2219",
"descriptionHtml": "<p>Help customers find the right gear for the trail, run the till and keep the floor stocked at our Commercial Street store.</p><ul><li>Product advice and fitting</li><li>Receiving and stocking</li><li>Opening and closing shifts</li></ul><p>Staff discount and paid time off for guided trips.</p>",
"datePosted": "2026-09-28",
"validThrough": "2026-11-30",
"timezoneOffset": "-05:00",
"employmentType": [
"part_time",
"seasonal"
],
"location": {
"name": "Harbor & Pine Portland",
"locationPageUrl": "https://www.example.com/stores/portland/",
"street": "214 Commercial Street",
"city": "Portland",
"region": "ME",
"postalCode": "04101",
"country": "US"
},
"pay": {
"currency": "USD",
"min": 18,
"max": 21,
"per": "hour"
},
"directApply": true,
"monthsExperience": 6
}
}
{
"@context": "https://schema.org",
"@graph": [
{
"@type": "WebPage",
"@id": "https://www.example.com/careers/store-associate-portland/#webpage",
"url": "https://www.example.com/careers/store-associate-portland/",
"name": "Store Associate, Portland | Harbor & Pine Careers",
"isPartOf": {
"@id": "https://www.example.com/#website"
},
"breadcrumb": {
"@id": "https://www.example.com/careers/store-associate-portland/#breadcrumb"
},
"mainEntity": {
"@id": "https://www.example.com/careers/store-associate-portland/#job"
}
},
{
"@type": "BreadcrumbList",
"@id": "https://www.example.com/careers/store-associate-portland/#breadcrumb",
"itemListElement": [
{
"@type": "ListItem",
"position": 1,
"name": "Home",
"item": "https://www.example.com/"
},
{
"@type": "ListItem",
"position": 2,
"name": "Careers",
"item": "https://www.example.com/careers/"
},
{
"@type": "ListItem",
"position": 3,
"name": "Store Associate, Portland",
"item": "https://www.example.com/careers/store-associate-portland/"
}
]
},
{
"@type": "JobPosting",
"@id": "https://www.example.com/careers/store-associate-portland/#job",
"title": "Store Associate",
"description": "<p>Help customers find the right gear for the trail, run the till and keep the floor stocked at our Commercial Street store.</p><ul><li>Product advice and fitting</li><li>Receiving and stocking</li><li>Opening and closing shifts</li></ul><p>Staff discount and paid time off for guided trips.</p>",
"identifier": {
"@type": "PropertyValue",
"name": "Harbor & Pine",
"value": "2219"
},
"datePosted": "2026-09-28",
"validThrough": "2026-11-30T23:59:59-05:00",
"employmentType": [
"PART_TIME",
"TEMPORARY"
],
"hiringOrganization": {
"@id": "https://www.example.com/#organization"
},
"jobLocation": {
"@type": "Place",
"@id": "https://www.example.com/stores/portland/#location",
"name": "Harbor & Pine Portland",
"address": {
"@type": "PostalAddress",
"streetAddress": "214 Commercial Street",
"addressLocality": "Portland",
"addressRegion": "ME",
"postalCode": "04101",
"addressCountry": "US"
}
},
"baseSalary": {
"@type": "MonetaryAmount",
"currency": "USD",
"value": {
"@type": "QuantitativeValue",
"minValue": 18,
"maxValue": 21,
"unitText": "HOUR"
}
},
"directApply": true,
"experienceRequirements": {
"@type": "OccupationalExperienceRequirements",
"monthsOfExperience": 6
},
"experienceInPlaceOfEducation": true
}
]
}
The output above was run through the SchemaCDN validator, which checks every rule in Google's documentation for each feature. It was validated together with the homepage graph, so the @id links resolve. Result: no errors, and eligible for:
The anchor every other page type points at: Organization, site name and store-wide policies.
ProductGroup with a priced Offer per variant, member pricing and links to store-wide policies.
Article with a real Person author, the Organization as publisher, video and a paywalled section.
One store location with address, geo and hours, linked to its parent brand.
Recipe with timed steps, nutrition, ratings and a video whose key moments match the steps.
An in-store workshop with times, a rescheduled date, the store as venue and a free ticket.
A community thread that becomes Q&A or a discussion posting, depending on the thread.