How to build a job directory and search page

Combine context, filters, numbered or cursor pagination, job cards, and canonical URLs.

A listing page should be driven by URL state so search engines and users can share the same result set.

Parse public URL state

Use the SDK’s filter vocabulary instead of accepting arbitrary values. Invalid public query parameters are dropped rather than throwing.

ts
// src/lib/load-jobs.ts
import { parseListingFilters } from '@cavuno/board/filters';
import { board } from './cavuno';
export async function loadJobs(search: Record<string, unknown>) {
const filters = parseListingFilters(search);
const common = {
remoteOption: filters.remoteOption ? [filters.remoteOption] : undefined,
employmentType: filters.employmentType
? [filters.employmentType]
: undefined,
seniority: filters.seniority,
};
if (filters.q) {
return board.jobs.search({
query: filters.q,
sort: filters.sort,
filters: common,
limit: 20,
});
}
return board.jobs.list({
...common,
sort: filters.sort,
limit: 20,
});
}

Browse uses GET board.jobs.list; free-text search uses POST board.jobs.search. Both return job cards with data, hasMore, and nextCursor. Catalog responses can also include count, limit, offset, and gatedCount.

Format cards in the board language

Load board.context() once for the language and configured labels. Pass that language into every label-producing helper.

ts
import {
cardLocationLabel,
formatPublishedRelativeDate,
formatSalaryRange,
} from '@cavuno/board/format';
const context = await board.context();
const page = await loadJobs(searchParams);
const cards = page.data.map((job) => ({
...job,
location: cardLocationLabel(context.language, job),
published: formatPublishedRelativeDate(
context.language,
job.publishedAt,
),
salary: formatSalaryRange(
context.language,
job.salaryMin,
job.salaryMax,
job.salaryTimeframe,
job.salaryCurrency,
),
}));

Build the page head from the same result

Do not calculate metadata from different filters or a separate result set. The visible heading, result count, canonical path, and structured data should describe the same page.

ts
import { listingHead, listingJsonLd } from '@cavuno/board/seo';
const origin = 'https://jobs.example.com';
const path = pageNumber > 1 ? `/jobs?page=${pageNumber}` : '/jobs';
const heading = 'Remote jobs';
const head = listingHead({
boardName: context.name,
origin,
path,
heading,
count: page.count,
});
const jsonLd = listingJsonLd({
origin,
breadcrumbs: [
{ name: 'Home', path: '/' },
{ name: heading },
],
jobs: page.data,
});

Map head.meta and head.links into the framework’s metadata API. Serialize each JSON-LD object safely into an application/ld+json script.

Use numbered pages for direct navigation

Most public directories should expose stable page URLs:

ts
const pageNumber = Math.max(1, Number(searchParams.page ?? '1'));
const limit = 20;
const page = await board.jobs.list({
...filters,
limit,
offset: (pageNumber - 1) * limit,
});

Preserve every active filter when generating ?page=2. Reset the page to 1 when the visitor changes a filter. Use the response’s hasMore, count, and gatedCount when present; do not fabricate a total.

Give each numbered page its own self-referencing canonical: /jobs for page 1 and /jobs?page=2 for page 2. Do not canonicalize the whole sequence to page 1. Render previous and next navigation as real <a href> links so crawlers and keyboard users can follow the sequence; a button-only “Load more” control is not a crawl path. See Google’s pagination guidance.

Treat arbitrary search, sort, and filter combinations as discovery states. They should normally use noindex,follow, while the unfiltered directory and each numbered page remain indexable. Index a filtered route only when you have deliberately created a stable landing page with a canonical path, unique useful content, internal links, and enough matching jobs.

Use cursors for load-more and traversal

Echo nextCursor into the next request while preserving the filters. If the first request uses offset, drop it before following a cursor—paginate() does this automatically.

ts
import { paginate } from '@cavuno/board';
for await (const job of paginate(board.jobs.list, { limit: 100 })) {
console.log(job.links.public);
}

For load-more interfaces, echo nextCursor unchanged. For a background walk, use paginate. Do not translate a cursor into a page number or combine it with a non-zero offset.

Offer weekly alerts without requiring an account

When job alerts are enabled in board.context().features, let a visitor subscribe from the current search state. Anonymous subscriptions require affirmative consent and use double opt-in.

ts
await board.jobAlerts.subscribe({
email,
consent: true,
frequency: 'weekly',
filters: {
jobFunctions: selectedJobFunctions,
placeSlugs: selectedPlaceSlugs,
remoteOptions: selectedRemoteOptions,
},
context: { source: 'job-search' },
});

The confirmation link supplies the token for board.jobAlerts.confirm({ token }). Do not imply that the alert is active before confirmation. Digest links should lead to the token-authenticated manage experience where the subscriber can update, unsubscribe, or delete the preference. Weekly is the only supported frequency; do not expose a daily option.

Verify the page

  1. Copy a filtered URL into a new browser and confirm it renders the same selection.
  2. Test no results, invalid filters, a gated count, and a failed search dependency.
  3. Open page 1 and page 2 directly, then follow a cursor from the same filtered result; confirm neither path repeats an item because an old offset remained.
  4. Inspect the canonical and ItemList URLs against the visible cards.
  5. Navigate the pagination using only a keyboard and confirm a crawler can discover page 2 from an anchor href in the initial HTML.
  6. Subscribe anonymously, confirm through the emailed token, and verify that manage and unsubscribe actions work without creating an account.

Production notes

  • Show gated counts honestly when a paywall limits results.
  • Abort superseded interactive searches.
  • Keep filters within the Board API’s supported vocabulary.
  • Use an explicit sort when stable iteration matters; the default relevance ranking can change as jobs or featured status change.
  • Give the result count or loading region a polite status announcement, preserve focus when filters update, and keep every filter paired with a visible label.

See the Jobs reference for the complete query and response contract.