DEV Community

AJ
AJ

Posted on

Migrating Ekehi from Vanilla JS to a TypeScript Stack

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
Enter fullscreen mode Exit fullscreen mode

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'
Enter fullscreen mode Exit fullscreen mode

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 in lib/utils.ts) merge class names and resolve Tailwind conflicts deterministically.
  • lucide-react provides 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.tsx is the application shell.
  • routes/(layout)/route.tsx wraps 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);
  /* ... */
}
Enter fullscreen mode Exit fullscreen mode

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. See refactor(client): rename color tokens to role-based names (surface/content/line).
  • Deterministic class ordering. prettier-plugin-tailwindcss sorts 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 401 it transparently calls refreshSession() 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)
}
Enter fullscreen mode Exit fullscreen mode

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() })
    },
  })
}
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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.tsx boundary enforces auth before any submission/admin page renders.
  • $id.tsx dynamic 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-msg runs commitlint (@commitlint/config-conventional) on every message — feat(client): ..., refactor(server): ..., chore: ....
  • pre-commit runs lint-staged, auto-fixing staged files with eslint --fix and prettier --write so 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
Enter fullscreen mode Exit fullscreen mode

Domains: auth, admin, opportunities, trainings, guides, templates, profile, meta, health. The composition root is app.tsroutes.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:

  • tsx runs the dev server with watch mode (tsx watch src/server.ts) — no precompile step in development.
  • tsc + tsc-alias produce the production build, rewriting the #/* path alias to real relative paths in the emitted dist/.
  • 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.
  • 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 endpnpm typecheck (client and server) and generated Supabase types catch contract drift before runtime.
  • One quality gatepnpm check runs lint + typecheck + tests + format on both packages; pre-commit blocks unformatted code; commit-msg blocks malformed history.
  • Smaller, lazier bundlesautoCodeSplitting ships one chunk per route instead of one monolithic script.
  • Zero cumulative layout shift on first paint via fontaine fallbacks.
  • 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)