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 (0)