A recruiter-facing Applicant Tracking Dashboard built with Refine + Next.js against a real Appwrite REST backend. Recruiters manage applicants through a hiring pipeline, schedule interviews, and see pipeline stats at a glance.
🔗 Live demo: https://applicant-tracker-eta.vercel.app
Create an account at /register
(pick a role) to try it — or run it locally with the steps below.
Internal recruiter tool — staff sign in with one of three roles (
admin/recruiter/interviewer). There is no jobseeker/applicant login; applicants are records that recruiters manage.
- Applicants — full CRUD: list with server-side search, status filter, sorting and pagination; create / edit with validation; detail page with related interviews; delete with confirmation.
- Interviews — linked to an applicant; schedule from the applicant page (prefilled) or globally; per-applicant list + global list; edit / detail / delete.
- Dashboard — total applicants, applicants per status, recent applicants, and upcoming interviews.
- Access control — lightweight role matrix (
admin/recruiter/interviewer) via Refine'saccessControlProvider; unauthorized actions are hidden and pages are guarded. - States everywhere — loading skeletons, error states, and success/error toast notifications on every mutation.
- Accessible & dark — Linear-inspired dark UI (see
DESIGN.md), keyboard navigation, visible focus rings, labelled inputs, skip link, WCAG-minded.
| Concern | Choice |
|---|---|
| Framework | Next.js 16 (App Router, RSC), React 19, TypeScript strict |
| Data framework | Refine v5 (@refinedev/core, nextjs-router, appwrite) |
| Backend | Appwrite Cloud (REST) via the Refine Appwrite data provider |
| UI | Tailwind CSS v4 + hand-authored shadcn/ui (Radix primitives), lucide |
| Forms | React Hook Form + Zod v4 |
| Notifications | sonner |
| Tests | Vitest + Testing Library + vitest-axe; Playwright + @axe-core/playwright |
- Node.js 20+ (developed on Node 24)
- An Appwrite project (Cloud or self-hosted)
npm installcp .env.local.example .env.local| Variable | Required | Notes |
|---|---|---|
NEXT_PUBLIC_APPWRITE_ENDPOINT |
yes | e.g. https://sgp.cloud.appwrite.io/v1 (your region) |
NEXT_PUBLIC_APPWRITE_PROJECT_ID |
yes | Appwrite project id |
NEXT_PUBLIC_APPWRITE_DATABASE_ID |
yes | Database id holding the collections |
APPWRITE_API_KEY |
provisioning only | Server-only key used by npm run provision (Databases read+write). Never prefix NEXT_PUBLIC_. |
Only the NEXT_PUBLIC_* values reach the browser (safe by Appwrite's design).
Ready-to-use values for this evaluation's backend — paste into .env.local.
The NEXT_PUBLIC_* values are publishable (the project id/endpoint ship to the
browser by design), so they're shared here to run against the existing project:
NEXT_PUBLIC_APPWRITE_ENDPOINT=https://sgp.cloud.appwrite.io/v1
NEXT_PUBLIC_APPWRITE_PROJECT_ID=6a296875000740032c7d
NEXT_PUBLIC_APPWRITE_DATABASE_ID=6a2a346200126595dd4c
# Server-only key (Databases read+write scope) — used only by `npm run provision`.
APPWRITE_API_KEY=standard_64d40d8a53a2f47f4617900ff6eadad14b93783e11e4dd097b1b7c76a3d9a26063c55b35994a030d7b0fc52a91785ec01569bedf00b30ee139dda5f8efaa275fc71f4006f017802fda02c1af0c28394c965d3577d64c09714ebde3b0795b084256f0eae067a03d6719be4d0314520585a6a5c26e18162512e4600f438dbf1c03The app itself (login, CRUD, dashboard) runs with just the three
NEXT_PUBLIC_*values —APPWRITE_API_KEYis only for the provisioning script. To point at your own project instead, swap the project/database ids and add a Web platform forlocalhostin the Appwrite console.The API key above is included for the reviewer's convenience (private repo). If this repo is ever made public, revoke/rotate that key in the Appwrite console — it grants Databases read+write to the project.
Add an API key (Databases read+write scope) to .env.local as
APPWRITE_API_KEY, then:
npm run provisionThis creates the applicants, interviews and profiles collections (with
attributes and indexes) in your database via the REST API. It's idempotent — safe
to re-run. Schema reference: appwrite.json (and docs/appwrite-setup.md).
Also add a Web platform for
localhostin the Appwrite console so the browser SDK is allowed. You can delete the API key after provisioning.
npm run devOpen http://localhost:3000, go to /register, create a user and pick a role.
| Script | Purpose |
|---|---|
npm run dev |
Dev server |
npm run build |
Production build |
npm run lint |
ESLint (incl. jsx-a11y) |
npm run typecheck |
tsc --noEmit |
npm test |
Vitest component + a11y tests |
npm run test:e2e |
Playwright end-to-end (+ axe) |
npm run provision |
Create the Appwrite schema from REST |
src/
app/
(auth)/ login, register — public auth pages
(app)/ dashboard, applicants, interviews
— guarded by <Authenticated>, wrapped in the app shell
components/
ui/ shadcn primitives (single source of truth)
applicants/ interviews/ dashboard/ data-table/ layout/ common/ forms/
lib/
appwrite/ one shared Appwrite client (all SDK access goes here)
auth/ session + role cache
validation/ Zod schemas + form↔payload transforms
constants.ts roles, statuses, resource ids, routes
format.ts date/number formatters
providers/ Refine: data, live, auth, accessControl, notification
types/ Applicant / Interview document shapes
appwrite.json schema-as-code (repo root)
scripts/provision-appwrite.mjs
Separation of concerns
- One Appwrite layer (
src/lib/appwrite) — the only place the SDK is constructed; the data provider and auth provider share that single client. - Data access via Refine hooks —
useTable,useList,useOne,useCreate,useUpdate,useDelete. Server state lives in Refine/React Query; there's no duplicated local copy of server data. - Validation is shared — the same Zod schema validates a form; thin
to…Payload/…ToFormValuestransforms convert between the form's string shape and the Appwrite document shape. - Resources configured once in the Refine provider; routes under
src/appmirror them.
The app talks to Appwrite through the official Refine Appwrite data provider,
which implements Refine's DataProvider contract (getList, getOne, create,
update, deleteOne, …) over Appwrite's REST API. Resource names map 1:1 to
Appwrite collection ids, so the CRUD contract is:
| REST-style operation | Refine call | Appwrite collection |
|---|---|---|
GET /applicants (list) |
useTable/useList |
applicants.listDocuments |
GET /applicants/:id |
useOne |
applicants.getDocument |
POST /applicants |
useCreate |
applicants.createDocument |
PUT /applicants/:id |
useUpdate |
applicants.updateDocument |
DELETE /applicants/:id |
useDelete |
applicants.deleteDocument |
GET /interviews?applicantId=… |
useList + filter |
interviews.listDocuments |
- Pagination / filtering / sorting are server-side — Refine's
currentPage/pageSize,CrudFiltersandCrudSorterstranslate to AppwriteQuery.limit/offset/equal/search/orderAsc|Desc. Search uses fulltext indexes onfullName/email. - Notifications come from the data layer automatically — a
notificationProvidermaps Refine's success/error events onto sonner toasts. - Auth uses an
authProviderover Appwrite Account (email/password sessions); roles live in aprofilescollection and drive theaccessControlProvider.
| Action | admin | recruiter | interviewer |
|---|---|---|---|
| View applicants/interviews | ✓ | ✓ | ✓ |
| Create/edit/delete applicants | ✓ | ✓ | — |
| Create/edit interviews | ✓ | ✓ | ✓ |
| Delete interviews | ✓ | ✓ | — |
Enforced in the UI via <CanAccess> (buttons hidden) and page-level guards. Pick
a role at registration; change it later by editing the user's profiles document.
- Appwrite SDK / provider version drift —
@refinedev/appwrite@8bundles the Appwrite v15 SDK; the standalone SDK had resolved to v26 (incompatible client shape). Fixed by pinningappwrite@^15so one SDK version is shared. - Appwrite 1.9 "TablesDB" migration — the CLI's
push collectionsis deprecated andpush tablesperformed a destructive resync that deleted the database and stripped the local schema. Resolved by provisioning over the REST API with a small idempotent script (npm run provision) instead of the CLI — deterministic and free of the CLI's sync surprises. - Regional Cloud + custom ids — the project lives in the SGP region and the database must use a fixed id; mismatched console-created ids caused failures. The provision script targets the configured endpoint/id directly.
- Form ↔ document shape — inputs are strings while Appwrite wants
numbers/arrays/ISO datetimes. Kept the Zod schema as the input shape and put
conversion in explicit
to…Payload/…ToFormValueshelpers to keep types clean.
DESIGN.md is the source of truth for the UI — a Linear-inspired dark system
(near-black canvas, lavender accent, hairline borders). Tokens are mapped into the
Tailwind theme in src/app/globals.css.