Ekehi platform has moved from hand-written HTML/CSS/JS pages to a typed, component-driven React 19 client and a module-based TypeScript Express/Node.js API.
0. Where we started and where we landed
Before. A static client built from per-page folders (landing/,
contributors/, login/, signup/, admin/), each shipping its own
index.html, a shared styles.css, and vanilla ES module scripts under
client/shared/. The server was an Express API written in plain JavaScript.
After.
| Layer | Before | After |
|---|---|---|
| Client | Static HTML + CSS + vanilla JS | React 19 + Vite 8 + TanStack Router + TypeScript 6 |
| Styling | One global styles.css
|
Tailwind CSS 4 with @theme design tokens |
| Data |
fetch scattered per page |
TanStack Query over a typed lib/api client |
| Server | Express in JavaScript | Express + TypeScript, module-per-domain |
| Repo | Two loose folders | pnpm workspace with shared git hooks |
| Quality gate | None | ESLint 9, Prettier 3, Husky, commitlint, Vitest |
The migration ran in two phases on separate branches:
- **Phase 1 — client rewrite.
- **Phase 2 — server rewrite.
1. Why this framework?
Choice: React 19, rendered as a client-side SPA through Vite 8, routed by TanStack Router.
TanStack Router was chosen rather than React Router because it
gives fully type-safe routes, first-class search-param typing, built-in code-splitting, and file-based route generation that pairs cleanly with Vite.
2. The folder and component structure
The client uses a feature-sliced layout: code is grouped by domain, not by
technical type.
client/src/
├── components/
│ ├── layout/ navbar.tsx, footer.tsx
│ └── ui/ button, input, modal, select, dropdown, ... (design system)
├── config/ env.ts, env-schema.ts, endpoints.ts
├── features/ one folder per domain
│ ├── auth/ auth.query.ts, auth.service.ts, auth.types.ts, components/, pages/
│ ├── opportunities/ pages/
│ ├── resources/ pages/
│ ├── submissions/ pages/
│ ├── admin/ pages/
│ └── site/ pages/ (landing, contributors)
├── lib/
│ ├── api/ request.ts, errors.ts, refresh.ts, types.ts (HTTP client)
│ ├── auth/ token-store.ts
│ ├── query-client.ts
│ └── utils.ts
├── routes/ TanStack file-based route tree
├── router.tsx
├── routeTree.gen.ts generated, do not edit
└── styles.css Tailwind import + @theme tokens
The principle: a route file in routes/ is thin glue that points at a page component in the matching features/<domain>/pages/ folder. Domain logic (queries, services, types) lives next to the feature that owns it, so a single Claude or dev session can work one feature without touching another.
Imports use the #/* alias (defined in both package.json#imports and tsconfig.json#paths) so there are no ../../../ chains:
import { env } from '#/config/env'
import { getAccessToken } from '#/lib/auth/token-store'
3. Decompose the UI into reusable components
The old per-page markup was factored into a small design-system layer under components/ui/: button, input, password-input, textarea, select, checkbox, label, form-field, dropdown, modal, search-bar, skeleton.
Technique and libraries:
-
Radix UI primitives (
@radix-ui/react-dialog,react-dropdown-menu,react-slot) supply accessible, unstyled behaviour (focus traps, keyboard nav, ARIA). The team styles them rather than reimplementing accessibility. -
class-variance-authority(CVA) defines component variants (size, intent) as typed config instead of ad-hoc conditional class strings. -
clsx+tailwind-merge(wrapped inlib/utils.ts) merge class names and resolve Tailwind conflicts deterministically. -
lucide-reactprovides the icon set as tree-shakeable components.
Layout components (navbar, footer) live separately under
components/layout/ because they are app chrome, not reusable primitives.
4. Migrate static HTML sections into JSX
Each former page folder became a route + page component pair. The old
landing/index.html is now features/site/pages/landing-page.tsx mounted at
routes/(layout)/index.tsx; contributors/, login/, signup/, the admin screens, and the resource/opportunity pages followed the same move.
Shared chrome that used to be copy-pasted into every index.html now lives once:
-
routes/__root.tsxis the application shell. -
routes/(layout)/route.tsxwraps every public page in the shared navbar/footer layout, so markup is defined a single time and inherited.
The vanilla client folders (client/shared/, client/landing/,
client/contributors/, etc.) and the root index.html/styles.css were deleted in the same release (see CHANGELOG.md [2.0.0] → Removed).
5. Migrate CSS with the framework's recommended approach
Styling moved to Tailwind CSS 4, installed as a Vite plugin
(@tailwindcss/vite) rather than a PostCSS pipeline — the v4 recommended path.
The single global stylesheet was replaced by a token-driven theme declared
with the new @theme directive in client/src/styles.css:
@import 'tailwindcss';
@theme {
--color-purple-700: #730099;
--color-primary: var(--color-purple-700);
--color-surface: #ffffff;
--color-surface-subtle: var(--color-neutral-50);
/* ... */
}
Two deliberate techniques:
-
Role-based token names. Raw scales (
purple-700,neutral-50) are mapped to semantic roles (primary,surface,content,line) so components reference intent, not hex values. Seerefactor(client): rename color tokens to role-based names (surface/content/line). -
Deterministic class ordering.
prettier-plugin-tailwindcsssorts utility classes on every format pass, so class lists never drift between authors.
Fonts are self-hosted via @fontsource-variable/inter and
@fontsource-variable/lora, and fontaine generates metric-matched fallback @font-face rules to eliminate layout shift on font swap.
6. Replace vanilla JS logic with framework equivalents
The biggest behavioural shift. Imperative fetch + DOM updates became declarative server-state managed by TanStack Query on top of a typed HTTP client.
The HTTP client (lib/api/request.ts). A makeRequest factory builds typed callers and centralises everything that used to be repeated per page:
- attaches the bearer token from
lib/auth/token-store, - serialises GET params vs JSON/FormData bodies,
- unwraps the server's
{ success, data, meta }envelope, - and — critically — on a
401it transparently callsrefreshSession()and retries the original request once (skipping/auth/*routes to avoid loops):
if (response.status === 401 && !isRetry && !route.startsWith(AUTH_PATH_PREFIX)) {
const refreshed = await refreshSession()
if (refreshed) return executeRequest(route, method, options, true)
}
Server state via TanStack Query. Features expose typed hooks instead of inline fetch calls. Auth (features/auth/auth.query.ts) is representative:
export function useLoginMutation() {
const queryClient = useQueryClient()
return useMutation<LoginResponse, Error, LoginRequest>({
mutationFn: (data) => AuthService.login({ data }).then((r) => r.data),
onSuccess: (response) => {
setTokens({ access_token: response.access_token,
refresh_token: response.refresh_token })
queryClient.invalidateQueries({ queryKey: authKeys.me() })
},
})
}
Query keys are namespaced (authKeys), caching/invalidation is handled by the library, and useLogoutMutation clears the cache on settle. The shared client config lives in lib/query-client.ts.
Forms use React 19's useActionState. Login and signup wire the form action straight to the mutation, getting pending/error state from the framework with no manual loading flags.
Validation is shared end to end with Zod 4 — the same library validates client env, form input, and server request bodies.
7. Set up client-side routing
Routing is file-based through TanStack Router with autoCodeSplitting enabled in vite.config.ts, so every route is its own lazy chunk. The route tree under client/src/routes/ uses TanStack's grouping conventions:
routes/
├── __root.tsx app shell
├── (auth)/login.tsx, signup.tsx auth pages (no app chrome)
├── (layout)/
│ ├── route.tsx shared navbar/footer layout
│ ├── index.tsx landing
│ ├── opportunities/index.tsx, $id.tsx
│ ├── resources/.../$id.tsx training + guides, list + detail
│ └── (protected)/
│ ├── route.tsx auth guard
│ ├── submit.tsx, submissions.tsx, my-submissions.tsx
└── admin/index.tsx, queue.tsx, review.tsx
Three conventions do the heavy lifting:
-
(group)folders organise routes without adding URL segments —(auth),(layout),(protected)are structural, not path segments. -
Pathless layout routes (
route.tsx) inject shared UI and guards. The(protected)/route.tsxboundary enforces auth before any submission/admin page renders. -
$id.tsxdynamic segments handle detail pages with type-safe params.
The generated routeTree.gen.ts is produced by the router plugin (or pnpm generate-routes) and must not be hand-edited.
8. Push to GitHub with clear, meaningful commits
The repo enforces Conventional Commits repo-wide through git hooks installed at the workspace root:
-
Husky 9 activates the hooks (
.husky/). -
commit-msgruns commitlint (@commitlint/config-conventional) on every message —feat(client): ...,refactor(server): ...,chore: .... -
pre-commitruns lint-staged, auto-fixing staged files witheslint --fixandprettier --writeso nothing unformatted lands.
Branching model (from CONTRIBUTING.md): branches start from and target development; main advances only via release merge. The migration itself
Server migration — JavaScript Express to TypeScript modules
Phase 2 rewrote the API in TypeScript 6 without changing the
runtime contract the client consumes.
Architecture: one module per domain. Each domain owns four files with a single responsibility each:
server/src/modules/<domain>/
├── <domain>.routes.ts route table
├── <domain>.controller.ts HTTP in/out only
├── <domain>.service.ts business logic + Supabase calls
└── <domain>.schema.ts Zod request validation
Domains: auth, admin, opportunities, trainings, guides, templates, profile, meta, health. The composition root is app.ts → routes.ts → each module's routes. Cross-cutting concerns live in middleware/
(authenticate, require-role, rate-limit, validate-request, upload, error-handler) and shared helpers in lib/ (response, http-error, async-handler, supabase, validation, storage, logger).
Toolchain:
-
tsxruns the dev server with watch mode (tsx watch src/server.ts) — no precompile step in development. -
tsc+tsc-aliasproduce the production build, rewriting the#/*path alias to real relative paths in the emitteddist/. -
Typed database access.
pnpm db:typesgeneratessrc/types/database.tsfrom the live Supabase schema, so queries are checked against the real table shapes. - Zod validates every request body before it reaches a controller, mirroring the client's use of the same library.
Security and ops middleware carried over and are now typed: helmet, cors, morgan, express-rate-limit, and multer for uploads.
Library reference
Every library introduced by the migration and the job it does.
Client
| Library | Role |
|---|---|
react, react-dom 19 |
UI runtime; useActionState for form submission |
vite 8 + @vitejs/plugin-react
|
Build tool and dev server, React fast refresh |
@tanstack/react-router (+ router-plugin) |
Type-safe, file-based, code-split routing |
@tanstack/react-query |
Server-state cache, mutations, invalidation |
tailwindcss 4 + @tailwindcss/vite
|
Utility CSS with @theme design tokens |
@tailwindcss/typography |
Prose styling for long-form content |
@radix-ui/react-dialog, -dropdown-menu, -slot
|
Accessible unstyled UI primitives |
class-variance-authority |
Typed component variants |
clsx + tailwind-merge
|
Class composition and conflict resolution |
lucide-react |
Icon set |
zod 4 |
Env, form, and shared validation |
@fontsource-variable/inter, -lora
|
Self-hosted variable fonts |
fontaine |
Metric-matched font fallbacks (zero CLS) |
typescript 6 |
Static typing |
vitest + @testing-library/react + jsdom
|
Unit/component tests |
eslint 9 (@tanstack/eslint-config, simple-import-sort, unicorn, unused-imports) |
Linting |
prettier 3 + prettier-plugin-tailwindcss
|
Formatting + class sorting |
Server
| Library | Role |
|---|---|
express 4 |
HTTP framework |
typescript 6 |
Static typing |
tsx |
Dev runtime with watch |
tsc-alias |
Rewrites path aliases in the build output |
@supabase/supabase-js |
Database + auth client (service role) |
zod 4 |
Request validation |
helmet |
Security headers |
cors |
Cross-origin policy |
morgan |
Request logging |
express-rate-limit |
Rate limiting |
multer |
Multipart upload handling |
dotenv |
Env loading |
Workspace
| Library | Role |
|---|---|
pnpm workspace |
Single install for client + server, shared hooks |
husky 9 |
Git hook activation |
@commitlint/cli + config-conventional
|
Conventional Commit enforcement |
lint-staged |
Auto-fix staged files pre-commit |
What the migration bought, measurably
-
Type safety end to end —
pnpm typecheck(client and server) and generated Supabase types catch contract drift before runtime. -
One quality gate —
pnpm checkruns lint + typecheck + tests + format on both packages;pre-commitblocks unformatted code;commit-msgblocks malformed history. -
Smaller, lazier bundles —
autoCodeSplittingships one chunk per route instead of one monolithic script. -
Zero cumulative layout shift on first paint via
fontainefallbacks. - Reusable UI — a single design-system layer replaces per-page markup, so a button change lands everywhere at once.
Related docs
-
CHANGELOG.md—[2.0.0]frontend rewrite entry with full Added/Changed/Removed. -
docs/pnpm-workspace-migration.md— the workspace consolidation. -
client/docs/tooling-plan.md— client tooling design notes. -
client/docs/migration-shared-components.md— component extraction notes. -
server/docs/system-design-case-study.md— server architecture rationale. -
server/docs/api/endpoints.md— API reference the client consumes.
Top comments (1)
The transparent 401 then refresh then retry-once is a nice touch. That's usually the bit people bolt on later and get subtly wrong. The case I'd check is concurrent requests. If five queries fire at once and all hit a 401 because the token just expired, does refreshSession() get called five times, or do you dedupe so the first refresh blocks the rest and they retry with the new token? That thundering-herd thing is the classic way this pattern bites once you're past one request at a time. Either way, feature-sliced plus Zod on both ends is a clean place to land.