SEO and structured data
Generate canonical metadata, breadcrumbs, listing markup, and JobPosting JSON-LD.
A
JBuild search artifacts from the same Board API objects you render. This prevents titles, canonical URLs, and structured data from drifting away from the visible job.
Prerequisites
- A public, absolute HTTPS origin.
- Canonical routes built with
@cavuno/board/paths. - Server rendering for metadata and JSON-LD.
- A policy that excludes non-published or expired jobs from active JobPosting markup.
Build a job detail SEO model
createJobPostingJsonLd is the SDK method that creates the Schema.org JobPosting object. It does not decide whether a job is currently eligible for Google for Jobs; make that decision before calling it.
12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152// src/data/job-seo.tsimport { isNotFound } from '@cavuno/board';import { jobDetailPath } from '@cavuno/board/paths';import {buildJobBreadcrumbs,createBreadcrumbJsonLd,createJobPostingJsonLd,} from '@cavuno/board/seo';import { board } from '../lib/board';const origin = 'https://jobs.example.com';export async function loadJobSeo(jobSlug: string) {try {const [job, context] = await Promise.all([board.jobs.retrieve(jobSlug),board.context(),]);if (!job.slug || !job.company?.slug) return null;const path = jobDetailPath(job.company.slug, job.slug);const canonical = `${origin}${path}`;const expiresAt = job.expiresAt ? Date.parse(job.expiresAt) : null;const isActive =job.status === 'published' &&(expiresAt === null || expiresAt > Date.now());const breadcrumbs = buildJobBreadcrumbs(job,context.language,context.labels,);return {title: `${job.title} | ${context.name}`,description: `Apply for ${job.title} at ${job.company?.name ?? context.name}.`,canonical,jobPosting: isActive? createJobPostingJsonLd({ job, board: context, shareUrl: canonical }): null,breadcrumbs: createBreadcrumbJsonLd(breadcrumbs.map(({ name, path }) => ({ label: name, href: path })),{ origin },),};} catch (error) {if (isNotFound(error)) return null;throw error;}}
The helper derives the identifier, dates, employment type, hiring organization, physical or remote location requirements, salary, education, experience, and canonical URL from PublicJob and the board context.
Emit JSON-LD safely
Your framework owns the <script> element. Escape < in serialized JSON so stored text cannot terminate the script element.
123456789101112export function JsonLd({ value }: { value: Record<string, unknown> | null }) {if (!value) return null;const html = JSON.stringify(value).replace(/</g, '\\u003c');return (<scripttype="application/ld+json"dangerouslySetInnerHTML={{ __html: html }}/>);}
Render the JobPosting and breadcrumb objects as separate JSON-LD scripts, or as a framework-supported graph. Keep every structured value consistent with visible page content.
Build listing metadata
1234567891011121314151617import { listingHead, listingJsonLd } from '@cavuno/board/seo';const page = await board.jobs.list({ limit: 20 });const head = listingHead({boardName: context.name,origin,path: '/jobs',heading: 'Jobs',count: page.count,});const jsonLd = listingJsonLd({origin,breadcrumbs: [{ name: 'Home', path: '/' }, { name: 'Jobs' }],jobs: page.data,});
Map the returned meta and links descriptors into your framework’s head API.
Expected result
An active job detail returns one canonical URL, a JobPosting object, and breadcrumb markup derived from the same job. Listings return head descriptors and breadcrumb/ItemList JSON-LD for their visible cards.
Verify the result
- The canonical URL matches the visible route and public origin.
- JobPosting markup appears only on active job detail pages.
- Remote jobs contain the location requirements derived from the job data.
- JSON-LD text cannot close its own script element.
- Breadcrumb and listing URLs are absolute in the rendered output.
Start with Cavuno’s JobPosting schema validator, then confirm search eligibility with Google’s Rich Results Test. Inspect the raw server-rendered HTML, not only the hydrated DOM.
Errors and edge cases
createJobPostingJsonLdcan returnnull; omit the script in that case.- Missing salary, education, or experience data is omitted rather than fabricated.
- Expired jobs can remain useful pages, but must not masquerade as active postings.
board.seo()separately returnscanonicalBase, verification, icons, manifest data,adsTxt, and the IndexNow key.
Production cautions
- Do not add structured fields that users cannot see on the page.
- Keep job expiry handling synchronized between routing, metadata, and sitemaps.
- Validate representative on-site, hybrid, remote, salaried, and salary-less jobs.
- Revalidate SEO output when job or board settings change.
Next, publish every indexable route with Sitemap generation.