How to build an SEO-friendly job board

Define, connect, and verify the complete indexable route system for a custom job board.

An SEO-complete job board is not one job page with JSON-LD. It is a route system where visible content, canonicals, redirects, metadata, structured data, internal links, status codes, and sitemap membership all describe the same public corpus.

Build each page from the Board API response users receive. Never publish a search artifact for content the current board configuration does not expose.

Define the indexable route inventory

SurfaceCanonical routeIndexing ruleStructured data
Job directory/jobsIndex the canonical browse state; do not canonicalize filters as separate pages by defaultItemList only when it matches visible cards
Job detail/companies/:companySlug/jobs/:jobSlugIndex active public jobs; expired or missing jobs return the correct removal stateJobPosting and breadcrumbs
Company directory/companiesIndex when the directory is enabled and usefulItemList optional
Company profile/companies/:companySlugIndex public profiles with meaningful contentOrganization/breadcrumb data when supported by your policy
Blog index/blogIndex when blog is enabledCollection metadata
Blog post/blog/:postSlugIndex published postsArticle and breadcrumbs when emitted by your app
Tag archive/blog/tags/:tagSlugIndex canonical, useful archives; noindex thin or empty archivesBreadcrumbs
Author archive/blog/authors/:authorSlugIndex canonical author archives with published postsPerson/breadcrumb data when supported
Salary pagesBoard path helpersIndex only routes exposed by the salary feature and public corpusDataset/FAQ only when the visible page supports it
Taxonomy and market pagesBoard path helpersIndex canonical slugs that meet the board’s public-content thresholdBreadcrumbs
Talent directory/profileBoard path helpersIndex only when visibility is public; employer-only talent is privateProfile metadata only for returned public fields

Candidate account, saved jobs, applications, alerts, conversations, employer dashboards, company claims, applicant pipelines, billing, checkout, and customer-portal routes are private application pages. Exclude them from sitemaps, send private/no-store cache controls, and use noindex where the framework can render an authenticated shell to crawlers.

Use one canonical path vocabulary

ts
import { boardUrl, jobDetailPath } from '@cavuno/board/paths';
const origin = 'https://jobs.example.com';
const path = jobDetailPath(job.company!.slug, job.slug!);
const canonical = boardUrl(origin, path);

Use the path helpers in navigation, metadata, JSON-LD, email destinations, redirects, and sitemaps. If a request uses an alias or stale company slug, redirect to the helper-derived path instead of publishing a second canonical.

Build every public route from its canonical record

  • Jobs: board.jobs.list, search, retrieve, and similar.
  • Companies: board.companies.list, search, retrieve, listJobs, markets, and salaries.
  • Editorial: blog post, tag, author, adjacent, similar, and search methods.
  • Talent: board.talent.list and retrieve, only under the returned visibility policy.
  • Taxonomy and redirects: use the dedicated namespaces rather than reconstructing slugs.

Search and arbitrary filter combinations are user states, not automatically new landing pages. Give the search page one canonical URL unless you deliberately create a stable, internally linked landing-page strategy with unique content.

Emit job structured data from the same response

ts
import {
buildJobBreadcrumbs,
createBreadcrumbJsonLd,
createJobPostingJsonLd,
} from '@cavuno/board/seo';
const breadcrumbs = buildJobBreadcrumbs(job, context.language);
const breadcrumbJsonLd = createBreadcrumbJsonLd(
breadcrumbs.map((crumb) => ({
label: crumb.name,
href: crumb.path,
})),
{ origin },
);
const jobPostingJsonLd = createJobPostingJsonLd({
job,
board: context,
shareUrl: canonical,
});

Render non-null objects as safely serialized application/ld+json scripts in the initial server HTML. The visible title, description, dates, location, salary, and application behavior must agree with the structured data. Never emit active JobPosting data for an expired, private, or missing job.

Make status codes and redirects truthful

ts
const resolution = await board.redirects.resolve(requestedPath);
if (resolution.target) {
return Response.redirect(new URL(resolution.target, origin), 308);
}
return new Response('Not found', { status: 404 });

Resolve board-relative targets against the known public origin. Framework-native 404 paths should produce a real 404 status and must not be replaced with a 200 empty state. When a board intentionally gates a route, return the signed-in/private experience without leaking its content to public HTML or caches.

Generate all enabled sitemap buckets

ts
import {
buildBucketUrls,
bucketFilename,
listedBuckets,
renderSitemapIndex,
renderUrlset,
} from '@cavuno/board/sitemap';
const buckets = await listedBuckets(board);
const indexXml = renderSitemapIndex(
buckets.map((bucket) => `${origin}/sitemap/${bucketFilename(bucket)}`),
);
const jobUrls = await buildBucketUrls(board, origin, 'jobs-details');
const jobsXml = renderUrlset(jobUrls);

Expose the index at /sitemap.xml and each child at the filename declared in the index. Generate every bucket returned by listedBuckets; it already applies feature gates and the public-content threshold. Split oversized buckets with SITEMAP_CHUNK_SIZE and bucketFilename(bucket, chunkIndex).

The built-in walker covers the eight buckets exported by SITEMAP_BUCKETS; it is not a promise that every route in the inventory above is enumerated. It does not enumerate talent profile URLs, job place-by-category or place-by-skill combinations, or cross-axis salary pages such as title-by-location and company-by-category. Those pages need crawlable internal links or a custom sitemap data source until the Board API exposes bulk enumeration for them.

Jobs and companies use the API’s offset window, which currently stops at 10,000 records. When a board exceeds that ceiling, the walker warns and the tail is not included. Do not publish the generated sitemap as complete for a larger corpus: provide a cursor-safe or bulk enumeration source first.

buildBucketUrls currently returns URL strings without update timestamps. Google recommends accurate lastmod values for job URLs. If your application has a trustworthy content-update time, build SitemapUrlEntry objects and pass them to renderUrlset; never stamp every URL with the deployment time or the current time.

Do not hand-add private candidate or employer URLs. Do not use a sitemap to compensate for pages with no internal links. Public jobs should link to companies, companies to jobs, posts to authors and tags, and directory pages to their detail pages.

Publish board-level discovery files

board.seo() supplies the configured values for ads.txt, IndexNow, site verification, and the canonical base. Render the corresponding public routes from that response. Keep these values separate from the pure metadata and JSON-LD builders in @cavuno/board/seo.

Sitemaps provide corpus discovery; they are not the fastest job lifecycle signal. From a trusted backend, use the Google Indexing API to send an update when a job becomes public and a removal when it expires, closes, or is deleted. Keep the sitemap correct as the coverage fallback, and never place Indexing API credentials in frontend code.

Validate with both Cavuno and Google

Test a job first with Cavuno’s JobPosting schema validator, then confirm eligibility with Google’s Rich Results Test. Inspect the raw server-rendered HTML as well as the hydrated page; crawler-visible artifacts cannot depend on a client effect.

Verify the complete public corpus

  1. Enumerate every public route type above and record whether it is indexable, canonicalized, redirected, private, or disabled for this board.
  2. Inspect raw HTML for a live job, company, post, tag, author, salary/taxonomy page, and public talent profile.
  3. Fetch /sitemap.xml, every child sitemap, and a sample URL from each bucket without authentication.
  4. Confirm every sitemap URL returns 200, self-canonicalizes to the same production origin, and is linked from a public page.
  5. Expire a job, unpublish a post, disable a feature, and change talent visibility; verify metadata, JSON-LD, links, and sitemap membership change together after revalidation.
  6. Request candidate and employer routes anonymously and verify no private entity appears in HTML, RSC payloads, sitemaps, or shared cache responses.
  7. Run npx @cavuno/board doctor --frontend https://jobs.example.com with PUBLIC_CAVUNO_BOARD set and account for every failed or skipped probe.
  8. Compare the intended route inventory with the walker’s documented omissions and the 10,000-record ceiling; verify that custom enumeration or internal links cover every deliberate indexable gap.

Use SEO and structured data for serialization details and Sitemap generation for bucket and chunk behavior.