Jobs & applications
List job postings, render a careers page, and let candidates apply — from the apply flow to the private applications board.
- Use it for: Public website · Server integration
- Reads: public token — works in the browser; Bearer for
listApplications - Writes: Bearer (
apiKey) — server-side only - CLI:
uidu jobs,uidu applications - Auth: what each token can do and where it may run — Authentication
A job is a call whose kind is job; an application is a
candidate's answer to it: a contact plus their form response, linked to the job. Together they
cover a careers site: a listing, a detail page with salary and location, an apply form, and the
board of applications received.
Mental model
Call (kind: 'job') ← listJobs / getJob — a filtered view over calls
├─ salaryMin / salaryMax, quantity, expiresAt, primaryAddress
├─ form ← the application form (questions)
└─→ Application ← createApplication / listApplications
├─ contact the candidate
├─ formResponse their answers
├─ status pipeline stage on the board
└─ aasmState 'draft' → 'completed' (completeApplication) → 'accepted' | 'declined'- Jobs are a derived view. There is no
jobsfield in the GraphQL schema:listJobsandgetJobcall the calls queries and keep onlykind: 'job'.Jobis theCalltype, so everything on the Calls page — summary vs full fields, the inlinedform, salaries in whole units — holds here. listJobsfilters after fetching.firstis a page of calls; thekindfilter runs on what came back.listJobs(client, { first: 20 })can return fewer than 20 jobs when the workspace also has other calls.getJobreturnsnullfor a call of another kind, not only for an unknown id, so a careers route can't be used to show a grant call.- Applications are first-class: their own GraphQL type and operations, unlike jobs.
- A new application is a draft.
createApplicationleaves it indraft, and the call's board lists only completed ones: untilcompleteApplicationruns, nobody sees the candidate. - Auth. Reading jobs works with a public token. Applying is a write:
createApplicationandcompleteApplicationrun in a server action with theapiKeyclient, never from the browser.listApplicationsneeds the Bearer too: applications hold private candidate data. See Authentication.
Use cases
The examples import uidu from lib/uidu.ts, a client built with publicToken and apiKey
and imported only from server code, as in Authentication.
Careers page
Goal: list the open positions with their salary range and city.
// app/careers/page.tsx
import { listJobs } from '@uidu/client';
import { formatSalaryRange } from '@uidu/react';
import { uidu } from '@/lib/uidu';
export default async function CareersPage() {
// A page of *calls*, filtered to jobs afterwards: ask for more than you show.
const jobs = await listJobs(uidu, { first: 100 });
return (
<ul>
{jobs.map((job) => {
const salary = formatSalaryRange(job, {
currency: 'EUR',
locale: 'it-IT',
});
return (
<li key={job.id}>
<a href={`/careers/${encodeURIComponent(job.id)}`}>{job.name}</a>
{job.primaryAddress?.city && ` · ${job.primaryAddress.city}`}
{salary && ` · ${salary}`}
</li>
);
})}
</ul>
);
}formatSalaryRange(job, { currency?, locale? }) from @uidu/react is a pure function, safe in
Server and Client Components. Defaults: currency: 'EUR' (ISO 4217), locale: 'en-US'
(BCP 47). It never shows decimals, and returns string | null:
formatSalaryRange({ salaryMin: 30000, salaryMax: 40000 }); // '€30,000–€40,000'
formatSalaryRange({ salaryMin: 30000, salaryMax: 30000 }); // '€30,000'
formatSalaryRange({ salaryMin: 30000, salaryMax: null }); // 'From €30,000'
formatSalaryRange({ salaryMin: null, salaryMax: 40000 }); // 'Up to €40,000'
formatSalaryRange({ salaryMin: null, salaryMax: null }); // null — hide the fieldThe call's own currency isn't in the SDK's selection, so pass currency when it isn't euros.
Job page with an apply form
Goal: one job's page, and an apply form that records an application and submits it, so it reaches the board.
// app/careers/[id]/page.tsx
import { getJob, createApplication, completeApplication } from '@uidu/client';
import {
DynamicForm,
RichText,
formatSalaryRange,
type DynamicFormValues,
} from '@uidu/react';
import { notFound } from 'next/navigation';
import { uidu } from '@/lib/uidu';
export default async function JobPage({
params,
}: {
params: Promise<{ id: string }>;
}) {
const { id } = await params;
const job = await getJob(uidu, { id: decodeURIComponent(id) });
if (!job) notFound(); // unknown id, or a call that isn't a job
const jobId = job.id;
const formId = job.form?.id;
async function apply(values: DynamicFormValues) {
'use server';
const created = await createApplication(uidu, {
input: {
attributes: {
applicableId: jobId,
contactAttributes: values.contact && {
email: values.contact.email,
contactableAttributes: {
kind: 'person',
firstName: values.contact.firstName,
lastName: values.contact.lastName || undefined,
},
},
formResponseAttributes: formId
? {
formId,
fieldValuesAttributes: values.fieldValues.map((fv) => ({
fieldId: fv.fieldId,
content: { value: fv.value }, // always wrapped
})),
}
: undefined,
},
},
});
if (created?.errors?.length)
return { ok: false as const, errors: created.errors };
const applicationId = created?.application?.id;
if (!applicationId)
return {
ok: false as const,
errors: [{ name: 'No application created' }],
};
// Without this the application stays a draft and never reaches the board.
const completed = await completeApplication(uidu, applicationId);
if (completed?.errors?.length)
return { ok: false as const, errors: completed.errors };
return { ok: true as const, meta: { applicationId } };
}
const salary = formatSalaryRange(job, { currency: 'EUR', locale: 'it-IT' });
return (
<article>
<h1>{job.name}</h1>
{salary && <p>{salary}</p>}
<p>{job.primaryAddress?.formattedAddress}</p>
{job.body && <RichText doc={job.body} />}
{job.form && (
<DynamicForm form={job.form} action={apply} submitLabel="Apply" />
)}
</article>
);
}createApplicationlinks the candidate and their answers to the job (applicableId) in one mutation. The candidate iscontactAttributes(a new or matched contact) orcontactId(an existing one); the answers areformResponseAttributes. Its payload is{ errors, application }:applicationhasid,aasmState,summary,completedAt,createdAt,contact { id, humanName, email },applicable { id, name, kind }andformResponse { id }. It doesn't throw on validation errors: checkerrors.completeApplication(client, id)fires thecomplete!event: it stampscompletedAt, marks the form response completed and notifies whoever follows the call, the same call uidu's own public site makes after its last step. It's a no-op on an application already completed. Its payload'sapplicationhasid,aasmState,completedAtandformResponse { id }.- Multi-step flows use
updateApplication(client, { input: { id, attributes, event? } })to save answers as the candidate goes, then complete.attributesis required (pass{}when you only fire an event), andeventsits oninput, not insideattributes;completeApplicationisupdateApplicationwithevent: 'complete!'andattributes: {}. - Attachments (CV, portfolio) go through the file upload flow.
- A transport failure throws a
UiduErrorinstead — see Errors.
Applications board
Goal: an internal page listing the applications received, with the candidate, the job and the pipeline stage.
// app/admin/applications/page.tsx — server only, needs the apiKey client
import { listApplications } from '@uidu/client';
import { uidu } from '@/lib/uidu';
export default async function ApplicationsPage() {
const applications = await listApplications(uidu, { first: 50 });
return (
<table>
<tbody>
{applications.map((a) => (
<tr key={a.id}>
<td>
{a.contact?.humanName} · {a.contact?.email}
</td>
<td>{a.applicable?.__typename === 'Call' && a.applicable.name}</td>
<td>{a.status?.name ?? a.aasmState}</td>
<td>
{a.completedAt &&
new Date(a.completedAt as string).toLocaleDateString()}
</td>
</tr>
))}
</tbody>
</table>
);
}listApplicationscovers the whole workspace, every job. Filter onapplicable.idfor one job's board.aasmStateis the lifecycle (draft,completed,accepted,declined);status(id,name,color) is the stage on the board.- With a public-token client the list comes back empty rather than failing — check that the
client has
apiKey. To read every application, passListApplicationsDocumenttopaginate(Pagination & field values).
API at a glance
Every @uidu/client function in Jobs & applications, generated from the source — the full reference has the rest of the SDK.
| Function | Kind | Auth | CLI | Description |
|---|---|---|---|---|
completeApplication | write | Bearer | — | Submit a draft application, the last step of applying. |
createApplication | write | Bearer | applications create | Submit an application to a job. Pass attributes.applicableId (the job's id) plus contactAttributes and/or formResponseAttributes. Returns the mutation payload as-is (including any errors) rather than throwing. |
getJob | read | public token | jobs get | Fetch a single job posting; returns null if the call is not a job. |
listApplications | read | Bearer | applications list | List applications across the workspace (requires a Bearer token). |
listJobs | read | public token | jobs list | List job postings (calls of kind job). |
updateApplication | write | Bearer | — | Update an application, optionally firing a state event (event: 'complete!'). Returns the mutation payload as-is (including any errors) rather than throwing. |
Types: Application, Job
CLI:
| Entity | list | get | create | update | delete |
|---|---|---|---|---|---|
jobs | ✓ | ✓ | — | — | — |
applications | ✓ | — | ✓ | — | — |