Front end for the Contacts API — browse, search, sort, page through, create, edit, and delete contacts.
Next.js 16 (App Router) · TypeScript · Tailwind CSS · Zod · Jest + Testing Library
- MSW · Playwright.
npm install
npx playwright install # once, for e2e browsers
cp .env.local.example .env.local # then set API_BASE_URL
npm run dev # http://localhost:3000 -> /contactsThe backend must be running (default http://127.0.0.1:8000). If it is not, the
list page says so rather than blowing up, and the header badge shows
api unreachable.
Use these two screenshots as the smoke test. If http://localhost:3000 looks
like this and the badge reads api ok, the frontend, the API, and the database
are all wired up correctly and you can start building.
The landing route (/ redirects here). What to check, top to bottom:
- Header —
SFContactswordmark,Contacts/New contactnav with the current route highlighted, and the theme toggle on the right (dark is the default; the sun icon switches to light). 3 contacts+ the badge — the count comes from the API'stotal, and the green-dottedapi ok · sqlitepill is liveGET /healthoutput naming the backend's database. A redapi unreachablehere means the backend is down orAPI_BASE_URLis wrong — everything below it will be empty.- Toolbar — search across name, email, company, and phone, plus a per-page selector. Both write to the URL, so the state survives a reload and is shareable.
- Table — sortable
NameandEmailheaders (the arrow shows the active column and direction), an initials avatar per row,Job title at Companyas the subtitle, and per-row pencil (edit) and trash (delete) actions. - Footer row —
Showing 1–3 of 3with Previous/Next, both disabled on a single page. - Version stamp —
web v0.1.0 (build 2 · 8ce2dc0)at the bottom of every page, so you always know which build you are looking at.
The seed data above (Grace Hopper, Ada Lovelace, Alan Turing) is whatever your backend was seeded with — your names and IDs will differ, and an empty table just means an empty database, not a broken app.
Click a row to get here. It confirms the detail read path works end to end:
< All contactsback link to the list.- Header — avatar, name, and
Job title at Company, with Edit (/contacts/[id]/edit) and a destructive Delete that asks before it acts. - Field table — email and phone rendered as
mailto:/tel:links, then company, job title, address, and notes. Empty optional fields show—rather than collapsing, so the shape of the record stays readable. - Metadata table —
ID,Created, andLast updatedin UTC, monospaced.
Hand-editing the URL to an ID that does not exist gives you the styled 404 page
(src/app/not-found.tsx), not a stack trace — that is also worth a quick try.
| Script | What it does |
|---|---|
npm run dev |
Dev server with fast refresh |
npm run build |
Production build |
npm start |
Serve the production build |
npm run lint |
ESLint (flat config, eslint-config-next) |
npm run typecheck |
tsc --noEmit |
npm test |
Jest unit/component tests |
npm run test:watch |
Jest in watch mode |
npm run test:coverage |
Jest with coverage (thresholds in jest.config.ts) |
npm run test:e2e |
Playwright — starts the dev server itself |
npm run test:e2e:ui |
Playwright UI mode |
npm run test:e2e:report |
Open the last HTML report |
| Route | What it does |
|---|---|
/ |
308 to /contacts (a redirects() rule, not a page) |
/contacts |
List: search, sort, paginate — all held in the URL |
/contacts/new |
Create form |
/contacts/[id] |
Detail view with edit/delete |
/contacts/[id]/edit |
Edit form (PUT, i.e. a full replacement) |
src/app/contacts/(list)/ List page + its loading skeleton
src/app/contacts/ Detail, edit, create routes and the server actions
src/components/contacts/ Feature components (table, toolbar, form, avatar…)
src/components/ui/ Button and Field primitives
src/lib/contacts/ Types, Zod schema, API access, URL query helpers
src/lib/apiClient.ts fetch wrapper: base URL, ApiError, ApiUnreachableError
src/__tests__/ Jest tests + MSW handlers, mirroring the src/ tree
e2e/ Playwright specs (run against the real API)
@/* maps to src/* in both TypeScript and Jest.
- Server-side only. Reads happen in server components, writes in server
actions (
src/app/contacts/actions.ts).API_BASE_URLnever reaches the browser, there is no CORS surface, and no loading waterfall on first paint. That means the app needs a Node runtime —output: "export"is not supported. src/lib/contacts/api.tsis the only module that knows the endpoint shapes. It mirrors/openapi.json:GET /api/v1/contacts(search, limit, offset, sort_by, order),POST,GET|PUT|PATCH|DELETE /api/v1/contacts/{id}, andGET /health.- Errors are typed, not swallowed.
404becomesnull(→ the 404 page),409becomes a field error on email,422is unpacked from FastAPI'sHTTPValidationErrorinto per-field messages, and an unreachable backend becomesApiUnreachableErrorwith a panel that names the URL it tried. - List state lives in the URL (
?q=&sort=&order=&page=&perPage=), parsed and sanitised bysrc/lib/contacts/query.ts. Sorting is validated against the API's allow-list, so a hand-edited URL can never produce a 422.
- Forms — one source of truth:
CONTACT_FIELD_GROUPSinsrc/lib/contacts/schema.tsdrives both the rendered fields and the Zod rules, which mirror the API's own limits. Submitting is a real formaction, so it works before hydration;useActionStatesurfaces what comes back. - Styling — Tailwind against semantic CSS variables (
bg-background,text-muted-foreground,border-hairline, …) defined insrc/app/globals.css. Dark is the default; light lives under[data-theme="light"]. Add colours as tokens there plus an entry intailwind.config.tsrather than hard-coding hex values in components, so both themes stay in sync. - Fonts — Inter / Space Grotesk / JetBrains Mono are self-hosted under
src/app/fonts/vianext/font/local, so builds never fetch Google Fonts. - Version stamp —
next.config.tsinjectsNEXT_PUBLIC_APP_VERSION,NEXT_PUBLIC_BUILD_NUMBER(CIBUILD_NUMBER, else git commit count), andNEXT_PUBLIC_GIT_SHA.VersionFooterrenders them, so any deployed page shows exactly which build it is. - Suspense boundaries change HTTP status. The list skeleton sits in the
(list)route group on purpose: aloading.tsxdirectly undercontacts/would also wrap[id], flush the shell early, and turn itsnotFound()404 into a 200. - Tests — HTTP is stubbed with MSW (
src/__tests__/mocks/), never by mockingfetchdirectly. Query by role/label over test IDs. Three bits ofjest.config.ts/jest.setup.tsexist purely to make this stack work under Jest and should not be removed casually: thejest-fixed-jsdomenvironment (keeps Node'sfetch/Request/stream globals, which plain jsdom strips), thetransformIgnorePatternsoverride (MSW's dependency tree is ESM-only), theserver-onlymodule mapping, and theFormDatashim (undici'sFormDatacannot be built from a<form>, which is what React 19 does on submit).
e2e/ runs against a real backend: each test creates its own contact with a
unique email and deletes it again. Playwright's default is three browsers in
parallel with up to 8 workers; if your backend is a single-worker uvicorn on
in-memory SQLite, that concurrency can wedge it — run npm run test:e2e -- --workers=2 (or --project=chromium) against a dev backend you don't mind
restarting.
Standard Node server build: npm run build && npm start. Set API_BASE_URL in
the server environment to wherever the Contacts API lives.

