Jobs and search

Build paginated job discovery, faceted search, canonical details, and related jobs.

Build the core public experience: a filterable jobs page, a canonical job detail page, and a related-jobs rail. The API returns slim PublicJobCard objects for lists and a full PublicJob for details.

Prerequisites

  • A shared public board client.
  • A framework route for /jobs.
  • A detail route matching /companies/:companySlug/jobs/:jobSlug.

These methods are public. Cache them for a short, explicit period if your framework supports it; do not reuse that policy for board.me methods.

Load a jobs page

Convert untrusted URL parameters with the SDK filter parser, then pass supported values to board.jobs.list.

ts
// src/data/jobs.ts
import { isNotFound } from '@cavuno/board';
import { parseListingFilters } from '@cavuno/board/filters';
import { jobDetailPath } from '@cavuno/board/paths';
import { board } from '../lib/board';
export async function loadJobsPage(search: Record<string, unknown>) {
const filters = parseListingFilters(search);
return board.jobs.list(
{
limit: 20,
remoteOption: filters.remoteOption
? [filters.remoteOption]
: undefined,
employmentType: filters.employmentType
? [filters.employmentType]
: undefined,
seniority: filters.seniority,
sort: filters.sort,
},
{ cache: 'force-cache' },
);
}
export async function searchJobs(query: string) {
return board.jobs.search(
{ query, limit: 20 },
undefined,
{ cache: 'no-store' },
);
}
export async function loadJob(jobSlug: string) {
try {
const job = await board.jobs.retrieve(jobSlug);
const related = await board.jobs.similar(jobSlug, { limit: 5 });
const canonicalPath =
job.company?.slug && job.slug
? jobDetailPath(job.company.slug, job.slug)
: null;
return { job, related: related.data, canonicalPath };
} catch (error) {
if (isNotFound(error)) return null;
throw error;
}
}

list and search return an envelope with data, hasMore, and nextCursor. Job catalog responses can also include count, limit, offset, and gatedCount. Render the server values instead of calculating a total from the current page.

Use nextCursor to request the next page:

ts
const first = await board.jobs.list({ limit: 20 });
const second = first.nextCursor
? await board.jobs.list({ limit: 20, cursor: first.nextCursor })
: null;

Expected result

The listing loader returns a page of PublicJobCard objects and cursor state. The detail loader returns one full PublicJob, up to five related cards, and either a canonical path or null when the resource is missing.

Verify the result

Open /jobs and confirm that:

  • Every card uses job.slug and job.company?.slug to construct its detail URL.
  • Changing a supported filter changes the API request and the visible results.
  • The next-page control disappears when hasMore is false.
  • An unknown job slug produces your 404 page, not an empty job template.

Errors and edge cases

  • limit must be between 1 and 100. Radius searches accept 10–250 km.
  • Category and skill listing slugs can return 404 when they cannot be resolved.
  • fields: '+description' is the only supported sparse-field addition for cards.
  • A job without both a job slug and company slug has no canonical detail path. Do not invent one.
  • Search is a POST; avoid placing it in a long-lived shared cache unless the cache key includes the entire body.

Production checklist

  • Keep one canonical detail route: /companies/:companySlug/jobs/:jobSlug.
  • Abort requests tied to navigation or rapidly changing search input.
  • Log BoardApiError.requestId for unexpected failures.
  • Add metadata and JobPosting JSON-LD with SEO and structured data.

Next, connect each job to its employer with Companies.