Skip to content

Commit 2393b72

Browse files
authored
feat(careers): careers page backed by the Ashby job board (#5316)
* feat(careers): careers page backed by the Ashby job board * fix(careers): harden Ashby parsing and filter edge cases from review - validate jobUrl as http(s) only; drop postings with unsafe URLs - validate postings individually so one bad row can't empty the board - namespace the all-filter sentinel to avoid colliding with real values - dedupe the job metadata line (fixes duplicate React keys / Remote·Remote) - parse filters server-side so deep-linked views don't flash unfiltered * fix(careers): filter-aware empty state; drop inline comments - JobGroups owns its empty copy via a filtersActive flag, so the server fallback and client board render identical, correct empty messaging (no-open-roles vs no-matching-filters) - convert remaining inline comments to TSDoc
1 parent af53eda commit 2393b72

9 files changed

Lines changed: 554 additions & 1 deletion

File tree

Lines changed: 93 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,93 @@
1+
import { Suspense } from 'react'
2+
import type { SearchParams } from 'nuqs/server'
3+
import { getAshbyJobs } from '@/lib/ashby/jobs'
4+
import {
5+
filterPostings,
6+
groupByDepartment,
7+
hasActiveFilters,
8+
JobBoard,
9+
JobGroups,
10+
} from '@/app/(landing)/careers/components/job-board'
11+
import { careersSearchParamsCache } from '@/app/(landing)/careers/search-params'
12+
import { TrustedBy } from '@/app/(landing)/components/trusted-by'
13+
14+
interface CareersProps {
15+
searchParams: Promise<SearchParams>
16+
}
17+
18+
/**
19+
* The careers page — a mission-led hero above the live open-roles board. Roles
20+
* are pulled from Sim's public Ashby job board at build/revalidate time
21+
* ({@link getAshbyJobs}) and server-rendered in full, so every posting is in the
22+
* crawlable HTML; the interactive {@link JobBoard} hydrates on top to add
23+
* Team/Location filtering.
24+
*
25+
* Both sections share the landing gutter — capped and centered at `max-w-[1446px]`
26+
* with the navbar-aligned `px-12 max-lg:px-8 max-sm:px-5` so the headline starts on
27+
* the same vertical line as the wordmark. The hero carries the single `<h1>`
28+
* (containing "Sim" and "AI workspace") plus an sr-only product summary for AI
29+
* citation (landing CLAUDE.md → GEO); the roles section owns its own `<h2>`.
30+
*
31+
* Because {@link JobBoard} reads the URL via nuqs (`useSearchParams`), it sits under
32+
* a `<Suspense>` boundary. The page parses the same `?team=`/`?location=` query on
33+
* the server ({@link careersSearchParamsCache}) and pre-filters the fallback to
34+
* match, so a deep-linked filter renders the correct roles server-side — the list
35+
* never flashes unfiltered before the client board hydrates.
36+
*/
37+
export default async function Careers({ searchParams }: CareersProps) {
38+
const { team, location } = await careersSearchParamsCache.parse(searchParams)
39+
const postings = await getAshbyJobs()
40+
const fallbackGroups = groupByDepartment(filterPostings(postings, team, location))
41+
42+
return (
43+
<main id='main-content'>
44+
<section
45+
id='careers-hero'
46+
aria-labelledby='careers-heading'
47+
className='mx-auto flex w-full max-w-[1446px] flex-col gap-5 px-12 pt-20 pb-10 max-sm:px-5 max-sm:pt-16 max-lg:px-8'
48+
>
49+
<p className='sr-only'>
50+
Careers at Sim, the open-source AI workspace where teams build, deploy, and manage AI
51+
agents. Sim is hiring engineers, designers, and go-to-market builders to help teams
52+
automate real work across 1,000+ integrations and every major LLM — visually,
53+
conversationally, or with code.
54+
</p>
55+
56+
<h1
57+
id='careers-heading'
58+
className='max-w-[24ch] text-balance text-[48px] text-[var(--text-primary)] leading-[1.1] max-sm:text-[32px] max-xl:text-[40px]'
59+
>
60+
Help build Sim, the AI workspace for teams.
61+
</h1>
62+
<p className='max-w-[60ch] text-pretty text-[var(--text-body)] text-lg leading-[1.5] max-sm:text-base'>
63+
Sim is the open-source AI workspace where teams build, deploy, and manage AI agents. We're
64+
a small, high-agency team shipping fast to thousands of builders. If you want to own real
65+
work and shape the workspace teams live in, we'd love to meet you.
66+
</p>
67+
</section>
68+
69+
<section
70+
id='open-roles'
71+
aria-labelledby='open-roles-heading'
72+
className='mx-auto flex w-full max-w-[1446px] flex-col gap-10 px-12 pt-6 pb-24 max-sm:px-5 max-sm:pb-16 max-lg:px-8'
73+
>
74+
<h2
75+
id='open-roles-heading'
76+
className='text-[24px] text-[var(--text-primary)] leading-[110%] tracking-[-0.02em]'
77+
>
78+
Open roles
79+
</h2>
80+
81+
<Suspense
82+
fallback={
83+
<JobGroups groups={fallbackGroups} filtersActive={hasActiveFilters(team, location)} />
84+
}
85+
>
86+
<JobBoard postings={postings} />
87+
</Suspense>
88+
89+
<TrustedBy className='pt-6' />
90+
</section>
91+
</main>
92+
)
93+
}
Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
export { JobBoard } from './job-board'
2+
export { filterPostings, groupByDepartment, hasActiveFilters, JobGroups } from './job-groups'
Lines changed: 74 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,74 @@
1+
'use client'
2+
3+
import { ChipSelect, type ChipSelectOption } from '@sim/emcn'
4+
import { useQueryStates } from 'nuqs'
5+
import type { CareerPosting } from '@/lib/ashby/jobs'
6+
import {
7+
filterPostings,
8+
groupByDepartment,
9+
hasActiveFilters,
10+
JobGroups,
11+
} from '@/app/(landing)/careers/components/job-board/job-groups'
12+
import {
13+
ALL_FILTER_VALUE,
14+
careersParsers,
15+
careersUrlKeys,
16+
} from '@/app/(landing)/careers/search-params'
17+
18+
interface JobBoardProps {
19+
postings: CareerPosting[]
20+
}
21+
22+
/** Builds `{ label, value }` options for a filter, with an "All" row at the top. */
23+
function toFilterOptions(values: string[], allLabel: string): ChipSelectOption[] {
24+
return [
25+
{ label: allLabel, value: ALL_FILTER_VALUE },
26+
...values.map((value) => ({ label: value, value })),
27+
]
28+
}
29+
30+
/** Distinct, alphabetically sorted values from a list. */
31+
function uniqueSorted(values: string[]): string[] {
32+
return Array.from(new Set(values)).sort((a, b) => a.localeCompare(b))
33+
}
34+
35+
/**
36+
* The interactive open-roles board — the single `'use client'` leaf on the
37+
* careers page. Every posting is server-rendered into the HTML (via the static
38+
* {@link JobGroups} Suspense fallback in `careers.tsx`), so all roles stay
39+
* crawlable; this leaf hydrates on top to add Team/Location filtering. Filter
40+
* state lives in the URL via nuqs (`?team=`/`?location=`) so a filtered view is
41+
* shareable and survives reload/back-forward. The filter set is small and
42+
* static, so filtering reads the instant URL value directly (no debounce).
43+
*/
44+
export function JobBoard({ postings }: JobBoardProps) {
45+
const [{ team, location }, setFilters] = useQueryStates(careersParsers, careersUrlKeys)
46+
47+
const teamOptions = toFilterOptions(uniqueSorted(postings.map((p) => p.department)), 'All teams')
48+
const locationOptions = toFilterOptions(
49+
uniqueSorted(postings.map((p) => p.location).filter(Boolean)),
50+
'All locations'
51+
)
52+
const groups = groupByDepartment(filterPostings(postings, team, location))
53+
54+
return (
55+
<div className='flex flex-col gap-10'>
56+
<div className='flex flex-wrap items-center gap-3'>
57+
<ChipSelect
58+
options={teamOptions}
59+
value={team}
60+
onChange={(value) => setFilters({ team: value })}
61+
aria-label='Filter roles by team'
62+
/>
63+
<ChipSelect
64+
options={locationOptions}
65+
value={location}
66+
onChange={(value) => setFilters({ location: value })}
67+
aria-label='Filter roles by location'
68+
/>
69+
</div>
70+
71+
<JobGroups groups={groups} filtersActive={hasActiveFilters(team, location)} />
72+
</div>
73+
)
74+
}
Lines changed: 156 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,156 @@
1+
import { cn } from '@sim/emcn'
2+
import { ArrowRight } from '@sim/emcn/icons'
3+
import type { CareerPosting } from '@/lib/ashby/jobs'
4+
import { ALL_FILTER_VALUE } from '@/app/(landing)/careers/search-params'
5+
6+
export interface DepartmentGroup {
7+
department: string
8+
postings: CareerPosting[]
9+
}
10+
11+
/**
12+
* Narrows postings to a selected Team and Location, treating {@link ALL_FILTER_VALUE}
13+
* as "any". Shared by the server-rendered fallback and the client board so a
14+
* deep-linked filter resolves to the exact same set on both sides.
15+
*/
16+
export function filterPostings(
17+
postings: CareerPosting[],
18+
team: string,
19+
location: string
20+
): CareerPosting[] {
21+
return postings.filter(
22+
(posting) =>
23+
(team === ALL_FILTER_VALUE || posting.department === team) &&
24+
(location === ALL_FILTER_VALUE || posting.location === location)
25+
)
26+
}
27+
28+
/** Whether either the Team or Location filter is narrowing the board. */
29+
export function hasActiveFilters(team: string, location: string): boolean {
30+
return team !== ALL_FILTER_VALUE || location !== ALL_FILTER_VALUE
31+
}
32+
33+
/** Empty-state copy: distinguishes a truly empty board from a filtered-to-zero view. */
34+
const NO_OPEN_ROLES_MESSAGE = 'No open roles right now — check back soon.'
35+
const NO_MATCHING_ROLES_MESSAGE =
36+
'No roles match these filters right now. Try clearing them, or check back soon.'
37+
38+
/**
39+
* Buckets postings by department, preserving their incoming order (the fetcher
40+
* pre-sorts by department then title). Shared by the interactive board and its
41+
* static Suspense fallback so the two can never render a different grouping.
42+
*/
43+
export function groupByDepartment(postings: CareerPosting[]): DepartmentGroup[] {
44+
const byDepartment = new Map<string, CareerPosting[]>()
45+
for (const posting of postings) {
46+
const bucket = byDepartment.get(posting.department)
47+
if (bucket) bucket.push(posting)
48+
else byDepartment.set(posting.department, [posting])
49+
}
50+
return Array.from(byDepartment, ([department, items]) => ({ department, postings: items }))
51+
}
52+
53+
interface JobGroupsProps {
54+
groups: DepartmentGroup[]
55+
/**
56+
* Whether a Team/Location filter is active. Selects the empty-state copy so an
57+
* unfiltered empty board ("no open roles") never reads as a filtered miss ("no
58+
* matches") — and the server fallback and client board always agree.
59+
*/
60+
filtersActive?: boolean
61+
}
62+
63+
/**
64+
* The presentational open-roles list: one labeled section per department, each a
65+
* list of {@link JobRow}s. Server-safe (no client hooks) so it renders both as
66+
* the static Suspense fallback and inside the client {@link JobBoard}.
67+
*/
68+
export function JobGroups({ groups, filtersActive = false }: JobGroupsProps) {
69+
if (groups.length === 0) {
70+
return (
71+
<p className='py-10 text-[var(--text-muted)] text-base'>
72+
{filtersActive ? NO_MATCHING_ROLES_MESSAGE : NO_OPEN_ROLES_MESSAGE}
73+
</p>
74+
)
75+
}
76+
77+
return (
78+
<div className='flex flex-col gap-12'>
79+
{groups.map((group) => (
80+
<section
81+
key={group.department}
82+
aria-label={`${group.department} roles`}
83+
className='flex flex-col'
84+
>
85+
<h3 className='pb-2 font-medium text-[var(--text-muted)] text-sm'>{group.department}</h3>
86+
<ul className='flex flex-col'>
87+
{group.postings.map((posting) => (
88+
<li key={posting.id}>
89+
<JobRow posting={posting} />
90+
</li>
91+
))}
92+
</ul>
93+
</section>
94+
))}
95+
</div>
96+
)
97+
}
98+
99+
interface JobRowProps {
100+
posting: CareerPosting
101+
}
102+
103+
/**
104+
* A single role row: title over a metadata line, with an "Apply" affordance that
105+
* links out to the posting on Ashby. The whole row is the link target; hovering
106+
* tints the row and advances the arrow. The metadata values are de-duplicated
107+
* because a remote posting normalizes both `location` and `workplaceType` to
108+
* "Remote", which would otherwise render "Remote · Remote" and collide as keys.
109+
*/
110+
function JobRow({ posting }: JobRowProps) {
111+
const meta = Array.from(
112+
new Set(
113+
[
114+
posting.location,
115+
posting.employmentType,
116+
posting.workplaceType,
117+
posting.compensationSummary,
118+
].filter((value): value is string => Boolean(value))
119+
)
120+
)
121+
122+
return (
123+
<a
124+
href={posting.jobUrl}
125+
target='_blank'
126+
rel='noopener noreferrer'
127+
className={cn(
128+
'group flex items-center justify-between gap-6 border-[var(--border)] border-t py-5',
129+
'transition-colors hover:bg-[var(--surface-hover)]'
130+
)}
131+
>
132+
<div className='flex min-w-0 flex-col gap-1.5'>
133+
<h4 className='truncate font-medium text-[var(--text-primary)] text-base'>
134+
{posting.title}
135+
</h4>
136+
<div className='flex flex-wrap items-center gap-x-2 gap-y-1 text-[var(--text-muted)] text-sm'>
137+
{meta.map((item, index) => (
138+
<span key={item} className='flex items-center gap-2'>
139+
{index > 0 && (
140+
<span aria-hidden className='text-[var(--text-muted)]'>
141+
·
142+
</span>
143+
)}
144+
{item}
145+
</span>
146+
))}
147+
</div>
148+
</div>
149+
150+
<span className='flex shrink-0 items-center gap-1.5 font-medium text-[var(--text-body)] text-sm'>
151+
Apply
152+
<ArrowRight className='size-[14px] text-[var(--text-icon)] transition-transform group-hover:translate-x-0.5' />
153+
</span>
154+
</a>
155+
)
156+
}
Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
import type { SearchParams } from 'nuqs/server'
2+
import { buildLandingMetadata } from '@/lib/landing/seo'
3+
import Careers from '@/app/(landing)/careers/careers'
4+
5+
export const revalidate = 3600
6+
7+
export const metadata = buildLandingMetadata({
8+
title: 'Careers at Sim — Build the AI workspace for teams',
9+
description:
10+
'Join Sim, the open-source AI workspace where teams build, deploy, and manage AI agents. See open engineering, design, and go-to-market roles.',
11+
path: '/careers',
12+
keywords: 'Sim careers, Sim jobs, AI workspace jobs, AI agent engineering jobs, open source jobs',
13+
})
14+
15+
export default function Page({ searchParams }: { searchParams: Promise<SearchParams> }) {
16+
return <Careers searchParams={searchParams} />
17+
}
Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
import { createSearchParamsCache, parseAsString } from 'nuqs/server'
2+
3+
/**
4+
* Sentinel value for an inactive filter — matches every posting. Namespaced with
5+
* underscores so it can never collide with a real Ashby department or location
6+
* value (e.g. a team literally named "all").
7+
*/
8+
export const ALL_FILTER_VALUE = '__all__'
9+
10+
/**
11+
* Co-located, typed URL query params for the careers job board's Team and
12+
* Location filters. Shareable, deep-linkable view-state over an already-rendered
13+
* list, so it lives in the URL (nuqs) — never in a store. The values are dynamic
14+
* (departments/locations come from the live board), so plain string parsers with
15+
* an `all` sentinel default rather than a fixed literal set.
16+
*/
17+
export const careersParsers = {
18+
team: parseAsString.withDefault(ALL_FILTER_VALUE),
19+
location: parseAsString.withDefault(ALL_FILTER_VALUE),
20+
} as const
21+
22+
/** Clean URLs, no back-stack churn — the filters are a passive view switch. */
23+
export const careersUrlKeys = {
24+
history: 'replace',
25+
shallow: true,
26+
clearOnDefault: true,
27+
} as const
28+
29+
/**
30+
* Server-side reader for the same parser map. The page parses the request's
31+
* query with this so the statically-rendered fallback is filtered to match a
32+
* deep-linked `?team=`/`?location=` URL — the roles never flash unfiltered before
33+
* the client board hydrates.
34+
*/
35+
export const careersSearchParamsCache = createSearchParamsCache(careersParsers)

apps/sim/app/(landing)/components/footer/footer.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -41,7 +41,7 @@ const RESOURCES_LINKS: FooterItem[] = [
4141
{ label: 'Blog', href: '/blog' },
4242
{ label: 'Docs', href: 'https://docs.sim.ai', external: true },
4343
{ label: 'Partners', href: '/partners' },
44-
{ label: 'Careers', href: 'https://jobs.ashbyhq.com/sim', external: true },
44+
{ label: 'Careers', href: '/careers' },
4545
{ label: 'Changelog', href: '/changelog' },
4646
{ label: 'Contact', href: '/contact' },
4747
]

0 commit comments

Comments
 (0)