{
  "$type": "site.standard.document",
  "bskyPostRef": {
    "cid": "bafyreiasosytlucqglsw426gxduw2iocdx4ctzamfigdkmsjfhmm7ht5hi",
    "uri": "at://did:plc:25rdn5elo5izoxrmtis34zuk/app.bsky.feed.post/3moocmmycgl42"
  },
  "coverImage": {
    "$type": "blob",
    "ref": {
      "$link": "bafkreifxtuno3iyylkhazom4vhopxv2oyiwbpfzbv7xnrlpyu6w3cdjehy"
    },
    "mimeType": "image/webp",
    "size": 55610
  },
  "path": "/aj1732/migrating-ekehi-from-vanilla-js-to-a-typescript-stack-16nm",
  "publishedAt": "2026-06-19T21:20:09.000Z",
  "site": "https://dev.to",
  "tags": [
    "webdev",
    "typescript",
    "react",
    "tanstack",
    "@theme",
    "@radix-ui",
    "@tailwindcss",
    "@import",
    "@fontsource-variable",
    "@font-face",
    "@commitlint",
    "@vitejs",
    "@tanstack",
    "@testing-library",
    "@supabase"
  ],
  "textContent": "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.\n\n##  0. Where we started and where we landed\n\n**Before.** A static client built from per-page folders (`landing/`,\n`contributors/`, `login/`, `signup/`, `admin/`), each shipping its own\n`index.html`, a shared `styles.css`, and vanilla ES module scripts under\n`client/shared/`. The server was an Express API written in plain JavaScript.\n\n**After.**\n\nLayer | Before | After\n---|---|---\nClient | Static HTML + CSS + vanilla JS | React 19 + Vite 8 + TanStack Router + TypeScript 6\nStyling | One global `styles.css` | Tailwind CSS 4 with `@theme` design tokens\nData |  `fetch` scattered per page | TanStack Query over a typed `lib/api` client\nServer | Express in JavaScript | Express + TypeScript, module-per-domain\nRepo | Two loose folders | pnpm workspace with shared git hooks\nQuality gate | None | ESLint 9, Prettier 3, Husky, commitlint, Vitest\n\nThe migration ran in two phases on separate branches:\n\n  * **Phase 1 — client rewrite.\n  * **Phase 2 — server rewrite.\n\n\n\n##  1. Why this framework?\n\n**Choice: React 19** , rendered as a client-side SPA through **Vite 8** , routed by **TanStack Router**.\n\n**TanStack Router** was chosen rather than React Router because it\ngives fully type-safe routes, first-class search-param typing, built-in code-splitting, and file-based route generation that pairs cleanly with Vite.\n\n##  2. The folder and component structure\n\nThe client uses a **feature-sliced** layout: code is grouped by domain, not by\ntechnical type.\n\n\n\n    client/src/\n    ├── components/\n    │   ├── layout/         navbar.tsx, footer.tsx\n    │   └── ui/             button, input, modal, select, dropdown, ... (design system)\n    ├── config/             env.ts, env-schema.ts, endpoints.ts\n    ├── features/           one folder per domain\n    │   ├── auth/           auth.query.ts, auth.service.ts, auth.types.ts, components/, pages/\n    │   ├── opportunities/  pages/\n    │   ├── resources/      pages/\n    │   ├── submissions/    pages/\n    │   ├── admin/          pages/\n    │   └── site/           pages/ (landing, contributors)\n    ├── lib/\n    │   ├── api/            request.ts, errors.ts, refresh.ts, types.ts  (HTTP client)\n    │   ├── auth/           token-store.ts\n    │   ├── query-client.ts\n    │   └── utils.ts\n    ├── routes/             TanStack file-based route tree\n    ├── router.tsx\n    ├── routeTree.gen.ts    generated, do not edit\n    └── styles.css          Tailwind import + @theme tokens\n\n\nThe 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.\n\nImports use the `#/*` alias (defined in both `package.json#imports` and `tsconfig.json#paths`) so there are no `../../../` chains:\n\n\n\n    import { env } from '#/config/env'\n    import { getAccessToken } from '#/lib/auth/token-store'\n\n\n##  3. Decompose the UI into reusable components\n\nThe 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`.\n\nTechnique and libraries:\n\n  * **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.\n  * **`class-variance-authority` (CVA)** defines component variants (size, intent) as typed config instead of ad-hoc conditional class strings.\n  * **`clsx` + `tailwind-merge`** (wrapped in `lib/utils.ts`) merge class names and resolve Tailwind conflicts deterministically.\n  * **`lucide-react`** provides the icon set as tree-shakeable components.\n\n\n\nLayout components (`navbar`, `footer`) live separately under\n`components/layout/` because they are app chrome, not reusable primitives.\n\n##  4. Migrate static HTML sections into JSX\n\nEach former page folder became a **route + page component** pair. The old\n`landing/index.html` is now `features/site/pages/landing-page.tsx` mounted at\n`routes/(layout)/index.tsx`; `contributors/`, `login/`, `signup/`, the admin screens, and the resource/opportunity pages followed the same move.\n\nShared chrome that used to be copy-pasted into every `index.html` now lives once:\n\n  * `routes/__root.tsx` is the application shell.\n  * `routes/(layout)/route.tsx` wraps every public page in the shared navbar/footer layout, so markup is defined a single time and inherited.\n\n\n\nThe vanilla client folders (`client/shared/`, `client/landing/`,\n`client/contributors/`, etc.) and the root `index.html`/`styles.css` were deleted in the same release (see `CHANGELOG.md [2.0.0] → Removed`).\n\n##  5. Migrate CSS with the framework's recommended approach\n\nStyling moved to **Tailwind CSS 4** , installed as a Vite plugin\n(`@tailwindcss/vite`) rather than a PostCSS pipeline — the v4 recommended path.\n\nThe single global stylesheet was replaced by a **token-driven theme** declared\nwith the new `@theme` directive in `client/src/styles.css`:\n\n\n\n    @import 'tailwindcss';\n\n    @theme {\n      --color-purple-700: #730099;\n      --color-primary: var(--color-purple-700);\n      --color-surface: #ffffff;\n      --color-surface-subtle: var(--color-neutral-50);\n      /* ... */\n    }\n\n\nTwo deliberate techniques:\n\n  * **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. See `refactor(client): rename color tokens to role-based names (surface/content/line)`.\n  * **Deterministic class ordering.** `prettier-plugin-tailwindcss` sorts utility classes on every format pass, so class lists never drift between authors.\n\n\n\nFonts are self-hosted via `@fontsource-variable/inter` and\n`@fontsource-variable/lora`, and `fontaine` generates metric-matched fallback `@font-face` rules to eliminate layout shift on font swap.\n\n##  6. Replace vanilla JS logic with framework equivalents\n\nThe biggest behavioural shift. Imperative `fetch` + DOM updates became **declarative server-state** managed by **TanStack Query** on top of a typed HTTP client.\n\n**The HTTP client (`lib/api/request.ts`).** A `makeRequest` factory builds typed callers and centralises everything that used to be repeated per page:\n\n  * attaches the bearer token from `lib/auth/token-store`,\n  * serialises GET params vs JSON/FormData bodies,\n  * unwraps the server's `{ success, data, meta }` envelope,\n  * and — critically — on a `401` it transparently calls `refreshSession()` and retries the original request once (skipping `/auth/*` routes to avoid loops):\n\n\n\n\n    if (response.status === 401 && !isRetry && !route.startsWith(AUTH_PATH_PREFIX)) {\n      const refreshed = await refreshSession()\n      if (refreshed) return executeRequest(route, method, options, true)\n    }\n\n\n**Server state via TanStack Query.** Features expose typed hooks instead of inline fetch calls. Auth (`features/auth/auth.query.ts`) is representative:\n\n\n\n    export function useLoginMutation() {\n      const queryClient = useQueryClient()\n      return useMutation<LoginResponse, Error, LoginRequest>({\n        mutationFn: (data) => AuthService.login({ data }).then((r) => r.data),\n        onSuccess: (response) => {\n          setTokens({ access_token: response.access_token,\n                      refresh_token: response.refresh_token })\n          queryClient.invalidateQueries({ queryKey: authKeys.me() })\n        },\n      })\n    }\n\n\nQuery 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`.\n\n**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.\n\n**Validation** is shared end to end with **Zod 4** — the same library validates client env, form input, and server request bodies.\n\n##  7. Set up client-side routing\n\nRouting 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:\n\n\n\n    routes/\n    ├── __root.tsx                         app shell\n    ├── (auth)/login.tsx, signup.tsx       auth pages (no app chrome)\n    ├── (layout)/\n    │   ├── route.tsx                      shared navbar/footer layout\n    │   ├── index.tsx                      landing\n    │   ├── opportunities/index.tsx, $id.tsx\n    │   ├── resources/.../$id.tsx          training + guides, list + detail\n    │   └── (protected)/\n    │       ├── route.tsx                  auth guard\n    │       ├── submit.tsx, submissions.tsx, my-submissions.tsx\n    └── admin/index.tsx, queue.tsx, review.tsx\n\n\nThree conventions do the heavy lifting:\n\n  * **`(group)` folders** organise routes without adding URL segments — `(auth)`, `(layout)`, `(protected)` are structural, not path segments.\n  * **Pathless layout routes** (`route.tsx`) inject shared UI and guards. The `(protected)/route.tsx` boundary enforces auth before any submission/admin page renders.\n  * **`$id.tsx`** dynamic segments handle detail pages with type-safe params.\n\n\n\nThe generated `routeTree.gen.ts` is produced by the router plugin (or `pnpm generate-routes`) and must not be hand-edited.\n\n##  8. Push to GitHub with clear, meaningful commits\n\nThe repo enforces **Conventional Commits** repo-wide through git hooks installed at the workspace root:\n\n  * **Husky 9** activates the hooks (`.husky/`).\n  * **`commit-msg`** runs **commitlint** (`@commitlint/config-conventional`) on every message — `feat(client): ...`, `refactor(server): ...`, `chore: ...`.\n  * **`pre-commit`** runs **lint-staged** , auto-fixing staged files with `eslint --fix` and `prettier --write` so nothing unformatted lands.\n\n\n\nBranching model (from `CONTRIBUTING.md`): branches start from and target `development`; `main` advances only via release merge. The migration itself\n\n##  Server migration — JavaScript Express to TypeScript modules\n\nPhase 2 rewrote the API in **TypeScript 6** without changing the\nruntime contract the client consumes.\n\n**Architecture: one module per domain.** Each domain owns four files with a single responsibility each:\n\n\n\n    server/src/modules/<domain>/\n    ├── <domain>.routes.ts       route table\n    ├── <domain>.controller.ts   HTTP in/out only\n    ├── <domain>.service.ts      business logic + Supabase calls\n    └── <domain>.schema.ts       Zod request validation\n\n\nDomains: `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/`\n(`authenticate`, `require-role`, `rate-limit`, `validate-request`, `upload`, `error-handler`) and shared helpers in `lib/` (`response`, `http-error`, `async-handler`, `supabase`, `validation`, `storage`, `logger`).\n\n**Toolchain:**\n\n  * **`tsx`** runs the dev server with watch mode (`tsx watch src/server.ts`) — no precompile step in development.\n  * **`tsc` + `tsc-alias`** produce the production build, rewriting the `#/*` path alias to real relative paths in the emitted `dist/`.\n  * **Typed database access.** `pnpm db:types` generates `src/types/database.ts` from the live Supabase schema, so queries are checked against the real table shapes.\n  * **Zod** validates every request body before it reaches a controller, mirroring the client's use of the same library.\n\n\n\nSecurity and ops middleware carried over and are now typed: `helmet`, `cors`, `morgan`, `express-rate-limit`, and `multer` for uploads.\n\n##  Library reference\n\nEvery library introduced by the migration and the job it does.\n\n###  Client\n\nLibrary | Role\n---|---\n`react`, `react-dom` 19 | UI runtime; `useActionState` for form submission\n`vite` 8 + `@vitejs/plugin-react` | Build tool and dev server, React fast refresh\n`@tanstack/react-router` (+ `router-plugin`) | Type-safe, file-based, code-split routing\n`@tanstack/react-query` | Server-state cache, mutations, invalidation\n`tailwindcss` 4 + `@tailwindcss/vite` | Utility CSS with `@theme` design tokens\n`@tailwindcss/typography` | Prose styling for long-form content\n`@radix-ui/react-dialog`, `-dropdown-menu`, `-slot` | Accessible unstyled UI primitives\n`class-variance-authority` | Typed component variants\n`clsx` + `tailwind-merge` | Class composition and conflict resolution\n`lucide-react` | Icon set\n`zod` 4 | Env, form, and shared validation\n`@fontsource-variable/inter`, `-lora` | Self-hosted variable fonts\n`fontaine` | Metric-matched font fallbacks (zero CLS)\n`typescript` 6 | Static typing\n`vitest` + `@testing-library/react` + `jsdom` | Unit/component tests\n`eslint` 9 (`@tanstack/eslint-config`, `simple-import-sort`, `unicorn`, `unused-imports`) | Linting\n`prettier` 3 + `prettier-plugin-tailwindcss` | Formatting + class sorting\n\n###  Server\n\nLibrary | Role\n---|---\n`express` 4 | HTTP framework\n`typescript` 6 | Static typing\n`tsx` | Dev runtime with watch\n`tsc-alias` | Rewrites path aliases in the build output\n`@supabase/supabase-js` | Database + auth client (service role)\n`zod` 4 | Request validation\n`helmet` | Security headers\n`cors` | Cross-origin policy\n`morgan` | Request logging\n`express-rate-limit` | Rate limiting\n`multer` | Multipart upload handling\n`dotenv` | Env loading\n\n###  Workspace\n\nLibrary | Role\n---|---\n`pnpm` workspace | Single install for client + server, shared hooks\n`husky` 9 | Git hook activation\n`@commitlint/cli` + `config-conventional` | Conventional Commit enforcement\n`lint-staged` | Auto-fix staged files pre-commit\n\n##  What the migration bought, measurably\n\n  * **Type safety end to end** — `pnpm typecheck` (client and server) and generated Supabase types catch contract drift before runtime.\n  * **One quality gate** — `pnpm check` runs lint + typecheck + tests + format on both packages; `pre-commit` blocks unformatted code; `commit-msg` blocks malformed history.\n  * **Smaller, lazier bundles** — `autoCodeSplitting` ships one chunk per route instead of one monolithic script.\n  * **Zero cumulative layout shift** on first paint via `fontaine` fallbacks.\n  * **Reusable UI** — a single design-system layer replaces per-page markup, so a button change lands everywhere at once.\n\n\n\n##  Related docs\n\n  * `CHANGELOG.md` — `[2.0.0]` frontend rewrite entry with full Added/Changed/Removed.\n  * `docs/pnpm-workspace-migration.md` — the workspace consolidation.\n  * `client/docs/tooling-plan.md` — client tooling design notes.\n  * `client/docs/migration-shared-components.md` — component extraction notes.\n  * `server/docs/system-design-case-study.md` — server architecture rationale.\n  * `server/docs/api/endpoints.md` — API reference the client consumes.\n\n",
  "title": "Migrating Ekehi from Vanilla JS to a TypeScript Stack"
}