Appearance
Google for Jobs on any website
If your job pages are rendered by your own stack — Next.js, Rails, Laravel, Django, a headless CMS — pull jobs from the Public Jobs API and emit the JobPosting JSON-LD server-side. Google must see the markup in the initial HTML response; injecting it client-side after load (for example from a JavaScript jobs widget) is unreliable for crawling — which is exactly why an embedded widget alone doesn't get you listed.
1. Fetch your published jobs
The Public Jobs API returns every job you've published to your website, with the full advert content:
GET https://api.recruitly.io/api/nova/public/jobs?apiKey=YOUR_API_KEY
GET https://api.recruitly.io/api/nova/public/jobs/{jobIdOrReference}?apiKey=YOUR_API_KEYSee the API reference for the full endpoint list (counts, sectors, industries, locations, per-client filters) and Authentication for API keys.
The fields you need for JobPosting, as returned by the API:
| API field | JSON-LD use |
|---|---|
title | title |
description | description (full HTML advert — Google allows HTML) |
postedOn | datePosted |
estClosingDate / cutOffDate | validThrough |
reference | identifier |
companyName | hiringOrganization.name (fall back to your agency name) |
jobType | map to Google's employmentType values |
remoteWorking | jobLocationType: 'TELECOMMUTE' |
location.cityName / location.regionName / location.postCode / location.countryCode | jobLocation.address |
pay.minPay / pay.maxPay / pay.currency / pay.tenure | baseSalary |
2. Render the JSON-LD per job page
Example in JavaScript — the same mapping works in any language:
js
const EMPLOYMENT_TYPES = {
'Permanent': 'FULL_TIME',
'Full Time': 'FULL_TIME',
'Part Time': 'PART_TIME',
'Contract': 'CONTRACTOR',
'Temporary': 'TEMPORARY',
'Intern': 'INTERN',
// map every job type label you use in Recruitly
};
function jobPostingJsonLd(job, agency) {
const ld = {
'@context': 'https://schema.org/',
'@type': 'JobPosting',
title: job.title,
description: job.description, // full advert; HTML allowed
datePosted: job.postedOn?.slice(0, 10),
employmentType: EMPLOYMENT_TYPES[job.jobType] || 'FULL_TIME',
identifier: {
'@type': 'PropertyValue',
name: agency.name,
value: job.reference,
},
hiringOrganization: {
'@type': 'Organization',
name: job.companyName || agency.name,
sameAs: agency.website,
},
jobLocation: {
'@type': 'Place',
address: {
'@type': 'PostalAddress',
addressLocality: job.location?.cityName || undefined,
addressRegion: job.location?.regionName || undefined,
postalCode: job.location?.postCode || undefined,
addressCountry: job.location?.countryCode, // 2-letter code
},
},
directApply: false,
};
if (job.estClosingDate) ld.validThrough = job.estClosingDate;
if (job.remoteWorking) ld.jobLocationType = 'TELECOMMUTE';
if (job.pay && (job.pay.minPay > 0 || job.pay.maxPay > 0)) {
ld.baseSalary = {
'@type': 'MonetaryAmount',
currency: job.pay.currency?.code,
value: {
'@type': 'QuantitativeValue',
minValue: job.pay.minPay > 0 ? job.pay.minPay : undefined,
maxValue: job.pay.maxPay > 0 ? job.pay.maxPay : undefined,
unitText: job.pay.tenure?.code || 'YEAR', // HOUR | DAY | WEEK | MONTH | YEAR
},
};
}
// Stop "</script>"-style content in the description closing the tag early:
return JSON.stringify(ld).replace(/<\//g, '<\\/');
}In your job page template:
html
<script type="application/ld+json">${jobPostingJsonLd(job, agency)}</script>3. The rules that get sites rejected
- One job = one crawlable URL — public, in your sitemap, not blocked by
robots.txt, no login wall. - Escape
</in the JSON output (see snippet) so descriptions containing HTML can't break out of the<script>element. - Expire closed jobs — return 404/410 or set
validThroughin the past the moment a job closes. Sync on a schedule (or use webhooks —job_created/job_deletedevents) so your pages track the CRM. - Full descriptions — Google drops thin listings; use
description, notshortDescription. hiringOrganizationis mandatory — use your agency name or "Confidential Employer" for confidential roles.
Next: validate and submit
Head to Testing & Search Console.