SEO and structured data

Generate canonical metadata, breadcrumbs, listing markup, and JobPosting JSON-LD.

Build 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.

ts
// src/data/job-seo.ts
import { 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.

tsx
export function JsonLd({ value }: { value: Record<string, unknown> | null }) {
if (!value) return null;
const html = JSON.stringify(value).replace(/</g, '\\u003c');
return (
<script
type="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

ts
import { 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

  • createJobPostingJsonLd can return null; 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 returns canonicalBase, 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.