Careers
Careers is two routes over one config module. /careers/ draws the index — hero, benefits, life strip, hiring process and the open-roles table — and /careers/<slug>/ draws an advert. Nine roles ship across four departments, producing 10 pages.
Like the integrations directory, this is typed config rather than a content collection, for the same reason: an advert has no prose body. It is a summary, a facts table, and five named lists that the layout positions.
Two files, one seam
careersData.json.ts holds the index’s chrome and everything both pages share — hero copy, benefits, the hiring process, offices and departments. careers/roles.ts holds the nine adverts.
The split is the lesson the integrations catalog already taught, applied again: the chrome is written once and read by one page, while the roles are nine pages’ worth of body copy that churns every time a role opens or fills. Keeping them apart means opening a role is a diff in one file that touches nothing the layout depends on.
The count that is never written down
Six places on these ten pages quote the number of open roles — the index heading, its lead, the hero card, the about page’s careers teaser, the detail page’s secondary CTA and the closing band’s. The design writes “nine” into every one of them.
The config writes {count}:
title: "Careers at Finly — {count} open roles in Amsterdam and Lisbon",
@js/roleFacts fills it from catalog.length. Opening a tenth role is one entry in roles.ts, not a hunt through two config files for the word “nine” — and a “nine roles” button under a table of eight becomes unrepresentable rather than merely unlikely.
The same treatment covers the department count in the “two offices, four teams” line, which counts only departments that actually have an advert in them.
The department grouping
Four departments, declared in order:
departments: [
{ id: "engineering", label: "Engineering" },
{ id: "finance", label: "Finance & operations", short: "Finance" },
{ id: "customer", label: "Customer" },
{ id: "design", label: "Design" },
],
groupByDepartment(catalog, departments) returns one group per department, in the departments’ own order, each holding its roles in catalog order. Two of its behaviours are deliberate and documented in the function itself:
It throws rather than dropping. A role naming a department that does not exist would otherwise vanish from the table with no error anywhere — the row simply would not be there, while the heading above it still said nine. A build that fails names the entry instead.
It returns empty departments rather than filtering them. Whether a block with no rows should render is a layout decision and belongs at the call site; a helper that silently dropped it would take that decision away and hide a department that had just been emptied.
short exists only where the hero card’s box cannot hold the full label.
An advert
Each entry in roles.ts carries a slug, a datePosted, a title, a department, a published salary band and bandNote, a location, a type, a one-sentence summary, a meta row, a glance table, and five prose lists: about, responsibilities, requirements and niceToHave.
Three conventions in the shipped catalog are worth keeping if you edit it rather than replace it.
The hybrid line is a constant, not nine strings. HYBRID, FULL_TIME and EQUITY are declared once at the top of the module, because they are one policy rather than nine independent facts. When the policy changes it changes in one place.
reportsTo names real people from the about page. Every advert’s “Reports to” row points at someone in aboutData.team, with the same job title, so the two pages cannot contradict each other about who runs what. Renaming someone means renaming them twice — that is the cost of two pages that agree, and it is cheaper than the alternative.
Every advert publishes its band in euro, per year, before you apply. That is a content decision rather than a technical one, but it is load-bearing for the structured data below.
JobPosting structured data, and the two opposite failure modes
Each advert emits a JobPosting node built by getJobPostingSchema, and two helpers feed it — one that fails soft and one that fails hard. The contrast is the clearest example in the theme of when each is right.
salaryRange returns null on anything it cannot parse.
salaryRange(role); // { currency: "EUR", min: 78000, max: 96000, unitText: "YEAR" }
It reads the published band string. A band written in a shape it cannot read degrades to an advert with no baseSalary rather than failing the build — because the whole advert, which is the thing a candidate came for, should survive, while a missing baseSalary costs one optional property on one node. Half-filled structured data is a claim the page never made; absent beats wrong.
officesFor throws.
throw new Error(
`Role "${role.slug}" — location "${role.location}" names none of careersData.offices`,
);
An advert whose location matches no declared office cannot produce a jobLocation, and a JobPosting with no location is not a job posting a search engine will show. There is no partial version of this that is still useful, so it fails the build with the role named.
offices itself is not drawn anywhere. It is the structured half of the prose in each role’s location — “Amsterdam, NL” and “Lisbon, PT” — existing only so the schema has real place data to emit. A role written as “Amsterdam or Lisbon” matches both and gets both.
Adding a role
Add an entry to roles.ts:
{
slug: "staff-engineer-payments",
datePosted: "2026-09-01",
title: "Staff Engineer, Payments",
department: "engineering", // must be a declared department id
band: "€95,000 – €120,000",
bandNote: "EUR per year · Staff · IC5",
location: "Amsterdam", // must contain a declared office city
// …
}
Everything else follows: the route, the table row under the right department heading, all six count-quoting sentences, the JobPosting node, the sitemap entry and the link from the about page’s teaser.
Two mistakes will stop the build rather than shipping quietly — an unknown department id, and a location naming no office. A band in an unparseable shape will not, and that is the deliberate asymmetry described above.
Removing the last role in a department leaves the block rendering empty rather than disappearing, which is the helper handing the decision back to you.