How to Build a Job Board on a Headless CMS in a Weekend
You scoped a job board as a "simple" CRUD app, then spent the weekend not on the listing page but on everything around it: a publishing workflow so a recruiter can't push a half-written role live, a way to schedule a posting to expire on…
You scoped a job board as a "simple" CRUD app, then spent the weekend not on the listing page but on everything around it: a publishing workflow so a recruiter can't push a half-written role live, a way to schedule a posting to expire on the closing date, structured salary and location fields that won't rot into free-text mush, and a content model that survives the inevitable "can we add a department filter?" request three days after launch. The hello-world is an afternoon. The operability is the weekend.
The trap is treating the CMS as a database with a form on top. A job board is mostly editorial operations dressed as data, roles get drafted, reviewed, embargoed until a press date, queried by facet, and syndicated to a careers page, a Slack feed, and an email digest from the same source. If your stack can't model that, you ship the demo and inherit the maintenance.
This guide treats the CMS as the operational layer, not the data dump. We'll model jobs as structured documents, query the board with a single round trip, govern who can publish, schedule expirations, and wire a live preview, using Sanity as the worked example because the editor is code you ship, not a fixed form you accept.
Model the job, not the page
The first weekend mistake is modeling the rendered page instead of the role. You end up with a 'job HTML' field and a recruiter pasting formatted bullet lists into a rich-text box, and the moment marketing wants to filter by remote-eligibility or sort by salary band, you're regex-parsing prose. Model the domain instead: a Job document with discrete fields for title, department (a reference, not a string), employmentType (an enum), location with a geo-eligibility flag, a salaryRange object with min/max/currency, applyUrl, and a structured description.
The payoff is queryability and reuse. A department modeled as a reference means renaming 'Eng' to 'Engineering' once, not across 200 postings. A salaryRange as a typed object means you can sort, filter, and validate it, and surface a 'salary undisclosed' state honestly rather than leaving the field blank and ambiguous. Employment type as an enum means the front-end filter is generated from the schema, not hand-maintained.
In Sanity this is a `defineType` schema in `sanity.config.ts`: the schema is portable code, version-controlled alongside the app, not clicked together in a settings UI you can't diff. The job description itself is Portable Text rather than an HTML blob, structured rich text that maps cleanly onto your design system's heading, list, and callout components, and stays readable when the same content has to render in an email digest or a plain-text feed. The schema is the contract; everything downstream, the filter UI, the TypeScript types, the API shape, derives from it instead of drifting away from it.
Query the whole board in one round trip
The listing page is where naive headless stacks fall over. You fetch jobs, then fetch each job's department, then resolve the hiring manager's avatar, three waterfalls and an N+1 problem before you've rendered a single card. Under GraphQL you either over-fetch a fixed type or stitch multiple queries and assemble the shape in client code. Either way the front-end carries logic that belongs in the query.
GROQ collapses this. You ask for exactly the shape the listing needs in a single request: project the fields you'll render, dereference the department with `->`, filter to open roles, and order by posted date, all server-side. A query like `*[_type == 'job' && status == 'open' && expiresAt > now()]{ title, slug, salaryRange, 'department': department->name, location } | order(publishedAt desc)` returns render-ready objects, not raw documents you reshape later. Faceted filtering, by department, employment type, or remote eligibility, is a clause change, not a new endpoint.
Because the query lives in the Content Lake and is schema-aware, you can add a filter the same afternoon a recruiter asks for it. Need a 'remote only' toggle? Add `&& location.remoteEligible == true`. Need full-text on the description? Reach for `match()`. The shape you request is the shape you get, so the front-end stays a thin rendering layer. TypeGen closes the loop: it reads your schema and the query and emits TypeScript types, so the listing component knows that `salaryRange.min` is a number before you ship, the codegen catches the typo the runtime would have caught in production.
Governance: who can publish a role
A job board has real publishing stakes. A role posted with the wrong salary band, or made live before legal sign-off, isn't a cosmetic bug, it's a compliance and brand problem. The weekend version where any logged-in user can flip a job to 'live' doesn't survive contact with an actual talent team, where a recruiter drafts, a hiring manager reviews, and someone owns the final publish.
The operational answer is a draft-and-review workflow with roles, not a single 'published' boolean. Recruiters create and edit drafts; an approver moves a role to published; nobody edits live content by accident. On a headless stack this is where a fixed editor hurts: if the workflow your org needs isn't one the vendor shipped, you wait for the roadmap. The Studio is a React application you customise, Structure Builder lets you split the desk into 'Drafts awaiting review', 'Open roles', and 'Expired', so the approver sees a queue instead of a flat list, and custom input components can enforce that a salaryRange or applyUrl is present before a role is publishable.
Content Releases and scheduling extend this to the time dimension: a batch of roles for a hiring push can be staged and published together rather than trickled out by hand. The point isn't bureaucracy, it's that the same content store that serves the public listing also encodes who is allowed to change what, so the governance lives next to the content instead of in a side document nobody reads.
Scheduling, expiry, and the stale-listing problem
Every job board accumulates ghosts: roles that were filled weeks ago but still rank in search and still collect applications nobody reads. The manual fix, a recruiter remembering to unpublish, fails exactly when the team is busiest. Stale listings are the single most common job-board quality complaint, and they're an operations problem, not a front-end one.
The durable approach is to make expiry a property of the content, not a chore. Give the Job document an `expiresAt` date and let the query exclude anything past it, `expiresAt > now()` in the listing GROQ filter means an expired role drops off the board automatically the moment its date passes, with no cron job watching a database. Pair that with scheduled publishing so a role embargoed until a campaign launch goes live on its own at the right hour.
For the housekeeping that genuinely needs to run, archiving expired roles, pinging Slack when a posting closes, or revalidating a static careers pageFunctions give you serverless handlers that react to content events without you standing up a separate worker service. A document published or expired can trigger a function that revalidates the Next.js or Astro page so the live site reflects the change in seconds. The board curates itself: open roles surface, closed roles fall away, and the recruiter's weekend isn't spent playing janitor over a list of dead links.
Wire the live preview without going un-headless
Recruiters don't think in JSON. Hand a hiring manager a field for `salaryRange.max` and a Portable Text editor and they'll ask, reasonably, 'what does the actual posting look like?' The classic headless tax is that the editor and the rendered site are two disconnected worlds, you edit blind, publish, refresh the careers page, and discover the long job title wrapped onto three lines and broke the card.
The fix is preview that shows the real front-end, not a CMS-flavoured approximation. Sanity's Presentation tool with Visual Editing stitches the Studio to your live Next.js, Astro, or Remix front-end: the editor sees the actual rendered job card and detail page, and clicking an element in the preview jumps to the field that produced it. The Live Content API keeps that preview in sync as fields change, so a recruiter tuning a job summary watches the card update in place.
Crucially this is preview without surrendering headlessness, the same GROQ-queried content still feeds the public API, the Slack feed, and the email digest. You get the WYSIWYG comfort that makes non-technical editors productive without collapsing back into a page-coupled CMS where the content can only ever be a web page. The recruiter edits with confidence; the developer keeps a content model that serves every channel.
Ship it: from starter to live careers page
The reason this is a weekend and not a sprint is that almost none of the work is plumbing. With a framework starter you skip the boilerplate: the Astro, Next.js, or Remix starters wire the Studio, the client, and the deployment together, so by hour one you're editing a schema rather than configuring a build. You define the Job type, point the listing component at a GROQ query, and let TypeGen hand you the types.
A realistic weekend arc: Saturday morning, model the Job and Department schemas and seed a handful of roles. Saturday afternoon, build the listing and detail pages off two GROQ queries and ship the faceted filter as query clauses. Sunday morning, add the draft/review desk structure and the `expiresAt` auto-expiry. Sunday afternoon, wire Presentation-tool preview and a Function that revalidates the careers page on publish. What's left, auth on the apply flow, analytics, is application code, not CMS wrestling.
The deeper point is what you didn't build. You didn't write a custom CMS to get a review workflow, you didn't stand up a search service to get faceted filtering, and you didn't build a scheduler to expire roles. Those came from modeling the job as a structured document in a content store that treats querying, governance, and real-time preview as first-class. The board is the easy part once the operational layer is doing the operational work.
Job-board building blocks across headless platforms
| Feature | Sanity | Contentful |
|---|---|---|
| Editor customisation | Sanity Studio is a React app you ship: custom input components and Structure Builder desk views (Drafts / Open / Expired) defined in sanity.config.ts. | Fixed cloud editor; custom UI via App Framework extensions and field apps within the bounds Contentful exposes. |
| Querying the listing | One GROQ round trip: project render fields, dereference department with ->, filter open + unexpired, order by date, no N+1. | GraphQL or REST; resolve references and shape data, often stitching or over-fetching a fixed type on the client. |
| Auto-expiring stale roles | expiresAt > now() in the GROQ filter drops closed roles instantly; Functions can archive or notify on the event. | Scheduled publish/unpublish available; filtering expired entries is done in query logic you write per front-end. |
| Review / publish workflow | Draft-and-publish built in; desk structure plus custom validation gate publishing, and Content Releases batch a hiring push. | Built-in workflows and scheduled releases on higher tiers; richer approval flows tend to need add-ons. |
| Live front-end preview | Presentation tool + Visual Editing renders the real Next.js/Astro card; Live Content API keeps it in sync, click-to-edit fields. | Live Preview connects to your front-end via the preview SDK; visual click-to-edit is a separate integration to wire. |
| Type safety to the front-end | TypeGen reads schema + GROQ and emits TypeScript types, so salaryRange.min is checked before you ship. | GraphQL schema enables codegen via standard tooling you configure; types track the API contract. |