Skip to content

Frontend Architecture

Overview

The frontend is a modern React application built with TypeScript, Vite, and Tailwind CSS.

Technology Stack

  • Framework: React 19
  • Language: TypeScript
  • Build Tool: Vite
  • Styling: Tailwind CSS
  • UI Components: shadcn/ui (Radix UI primitives)
  • Routing: Wouter
  • State Management: TanStack Query
  • Icons: Lucide React, react-icons (react-icons/fa for FaLinkedin; react-icons/si for SiGithub and other brand icons)
  • Docs URL: VITE_DOCS_URL — baked into the bundle at build time (defaults to https://docs.sven-relijveld.com; overridden by docker-compose / CI)
  • UIGen URL: VITE_UIGEN_URL — URL of the UIGen API explorer shim (defaults to empty string in Dockerfile, hiding the Admin button; set to https://admin.sven-relijveld.com in prod, https://admin.dev.sven-relijveld.com in dev by CI)
  • CMS URL: VITE_CMS_URL — base URL for CMS media (defaults to /cms; used to resolve Strapi media /uploads/ paths in content pages)

Project Structure

client/
├── src/
│   ├── components/       # Reusable UI components
│   │   ├── ui/          # shadcn/ui components
│   │   ├── hero-section.tsx
│   │   ├── navigation.tsx
│   │   └── ...
│   ├── pages/           # Page components
│   │   ├── home.tsx
│   │   ├── about.tsx
│   │   ├── projects.tsx
│   │   ├── contact.tsx
│   │   ├── content.tsx
│   │   └── content-detail.tsx
│   ├── data/            # Typed data modules (awards, certifications, experience, projects, skills)
│   ├── hooks/           # Custom React hooks (e.g. useContactForm)
│   ├── lib/             # Utility functions
│   ├── assets/          # Images and static files
│   ├── App.tsx          # Main app component
│   └── main.tsx         # Entry point
├── public/              # Static assets
└── index.html           # HTML template
e2e/                     # Playwright E2E tests (repo root)
├── contact.spec.ts      # Contact form happy path + validation
├── navigation.spec.ts   # Page load, title, console errors
└── responsive.spec.ts   # Mobile viewport: no overflow, form visible
playwright.config.ts     # Playwright config (chromium + Pixel 5 mobile)

Key Features

Component Architecture

  • Atomic Design: Components organized by complexity
  • Composition: Reusable, composable components
  • Type Safety: Full TypeScript coverage
  • Key components: Navigation (anchor-scroll on home, <Link> on sub-pages; sliding pill indicator; includes Admin button), HeroSection (type-in role cycler behind feature flag, OneDNA logo), HowItsMadeSection (uses VITE_DOCS_URL), ContactForm (useContactForm hook), CVDownloadButton (EN/NL selector, calls /api/download-cv), AdminModal (opens UIGen via /api/uigen-token + VITE_UIGEN_URL/auth?token=), FeatureFlagsProvider (wraps app; reads /api/feature-flags, exposes cvGeneration, portfolioContentApi, heroRoleCycler, and projectPageV2 flags via context), SkillBar (proficiency bar component in client/src/components/ui/skill-bar.tsx), ContentModal (modal for presentations — YouTube/Vimeo embed or PDF iframe, ESC/click-outside to close), ContentPreviewSection (home teaser section; hides when CMS returns no posts)
  • useProjectPageCMS (client/src/hooks/useProjectPageCMS.ts): fetches a single project-page entry from the Strapi CMS REST API (/api/project-pages?filters[slug][$eq]={slug}&populate=*), maps the CmsProjectPage shape to the local ProjectV2Data type via cmsToProjectV2Data, uses the matching static projectV2Data entry as placeholder while in flight, falls back to static data on error or no-match. Results are cached for 5 minutes with no retries.

Routing

typescript
import { Route, Switch } from "wouter";

<Switch>
  <Route path="/" component={Home} />
  <Route path="/about" component={About} />
  <Route path="/projects" component={Projects} />
  <Route path="/contact" component={Contact} />
  <Route path="/experience" component={Experience} />
  <Route path="/content" component={ContentPage} />
  <Route path="/content/:slug" component={ContentDetailPage} />
  {/* /project/:projectId renders ProjectRoute, which selects ProjectPageV2
      when the projectPageV2 flag is on AND the slug has v2 data,
      otherwise falls back to ProjectDetail */}
  <Route path="/project/:projectId" component={ProjectRoute} />
  <Route component={NotFound} />
</Switch>

Navigation behaviour (navigation.tsx):

  • On the home page (/): nav items use anchor-scroll (<a href="#section">) so clicking a link smoothly scrolls to the section without a full route change.
  • On sub-pages (/about, /projects, etc.): nav items use wouter <Link> to navigate back to / (which renders the home sections). Using anchor-scroll on a sub-page would produce a broken #section fragment with no matching element.

API Request Routing

All API calls from the frontend use relative paths (e.g., /api/contact). There is no hardcoded backend host in the client code.

Environment How /api/* is resolved
Development Vite dev server proxies /api/http://localhost:8000 (configured in vite.config.ts)
Production nginx proxies /api/ → the backend container (configured in nginx.default.conf.template); uses public DNS resolvers 8.8.8.8/1.1.1.1 — Azure internal DNS (168.63.129.16) is not reachable from Container Apps in the Consumption tier

This means the frontend container image is environment-agnostic — the same build artifact works in dev and prod without rebuilding.

E2E Testing

Playwright is used for end-to-end tests against the deployed dev environment.

File Coverage
e2e/contact.spec.ts Contact form happy path (mocked API); validation errors; API 500 error toast
e2e/navigation.spec.ts Home page loads with correct <title>; no console errors on load
e2e/responsive.spec.ts Mobile (Pixel 5): no horizontal overflow; contact form inputs visible
e2e/cv-download.spec.ts CV download button (EN/NL); feature flag disables generate button
e2e/projects.spec.ts Projects list renders; project detail page loads
e2e/admin-uigen.spec.ts Admin modal opens; guest flow opens UIGen /auth popup; admin token flow; error state; UIGen shim smoke tests (remote)
e2e/uigen-feature-flags.spec.ts Feature-flags override panel in UIGen shim: read, toggle, and verify flag state
e2e/uigen-crud.spec.ts UIGen CRUD flows (unauthenticated); local dev only — skipped in CI via env check
e2e/uigen-crud-authenticated.spec.ts UIGen CRUD flows (authenticated); local dev only — skipped in CI via env check

Config (playwright.config.ts):

  • baseURL: process.env.BASE_URL ?? 'https://dev.sven-relijveld.com'
  • Projects: chromium (Desktop Chrome) + mobile (Pixel 5)
  • Retries: 1; timeout: 30 s

Running locally:

bash
npm run test:e2e
# or against a specific URL:
BASE_URL=https://www.sven-relijveld.com npm run test:e2e

CI: two complementary triggers — see Pipeline Overview (workflows 5 and 3). Type checking and unit tests run in test.yml.

Data Modules

Static content lives in client/src/data/ as typed TypeScript modules. Components import from these modules rather than defining data inline.

Module Exports Source
awards.ts awards: Award[], AwardIconName Static
certifications.ts certifications: Certification[] Static
experience.ts experiences: Experience[] (4 entries incl. W+B DevOps role; each entry may have relatedProjects?: ProjectLink[]) Static (synced with seed.json via migrations 0008+0009)
projects.ts projectCards: ProjectCard[], projectDetails: Record<string, ProjectDetail>, projectV2Data: ProjectV2Data[] (static fallback for project-page-v2 pages) initialData fallback (see below); projectV2Data used by useProjectPageCMS
skills.ts skillCategories: SkillCategory[] Static

Icon JSX (<Trophy />, <Medal />, etc.) stays in the component as a Record<IconName, React.ElementType> lookup — only the string icon name is stored in the data module.

API-Backed Data (useProjects)

client/src/hooks/useProjects.ts wraps TanStack Query for project data:

typescript
export function useProjects() {
  return useQuery<ProjectCard[]>({
    queryKey: ["/api/projects"],
    initialData: staticProjectCards as ProjectCard[], // static data as fallback
  });
}

export function useProjectDetail(projectId: string | undefined) {
  return useQuery<ProjectDetail>({
    queryKey: ["/api/projects", projectId],
    enabled: !!projectId,
  });
}
  • useProjects — fetches from GET /api/projects; uses the static projectCards from data/projects.ts as initialData so the page renders immediately before the request completes.
  • useProjectDetail — fetches a single project by slug from GET /api/projects/:id (detail data is not pre-seeded statically).

Both hooks consume types generated from the OpenAPI schema (@shared/api.types).

CMS-Backed Content (useContentPosts)

client/src/hooks/useContentPosts.ts fetches published content posts from Strapi:

  • useContentPosts() — fetches from GET /cms/api/content-posts?sort=publish_date:desc&populate=*; uses placeholderData (not initialData) so the CMS request always fires even when static fallback is shown; 5-minute stale time; retry: 0; falls back to 2 static OneDNA article entries when CMS is unreachable
  • useContentPost(slug) — fetches a single post by slug from GET /cms/api/content-posts?filters[slug][$eq]=<slug>&populate=*; same fallback and cache strategy

CMS media URLs from Strapi (/uploads/...) are resolved by prepending VITE_CMS_URL (defaults to /cms). nginx proxies /cms/uploads/ to Strapi with a ^~ prefix location that takes priority over the regex static-file block — required so .jpg/.png Strapi files are not intercepted before reaching the proxy.

End-to-End Type Safety

TypeScript types for all API responses are generated directly from FastAPI's OpenAPI schema:

bash
npm run types:generate
# runs: openapi-typescript http://localhost:8000/openapi.json -o shared/api.types.ts

shared/api.types.ts is committed to the repo and imported as components["schemas"]["..."] wherever API response types are needed. This ensures the frontend types stay in sync with the Pydantic models on the backend — a breaking backend model change will surface as a TypeScript compile error.

Contact Form Hook

client/src/hooks/useContactForm.ts centralises all contact form logic:

  • State: formData, handleInputChange, handleSubmit, isPending
  • Client-side validation (runs before the API call): email regex, name ≥ 2 chars, message 10–5000 chars
  • Error mapping: HTTP 400/422 → "check your input", 429 → "too many requests", 5xx → "server error"
  • Success: resets form and shows a toast

Both contact-section.tsx and pages/contact.tsx use this hook — no duplicated mutation/state logic.

State Management

  • TanStack Query: Server state management
  • React Context: Theme and global state
  • Local State: Component-specific state with hooks

Styling

  • Tailwind CSS: Utility-first CSS
  • CSS Variables: Theme customization
  • Dark Mode: Full dark/light theme support
  • OneDNA Branding: Pink accent colors

Build Process

Development

bash
npm run dev
# Vite dev server on port 5173

Production

bash
npm run build
# Outputs to dist/

Docker Build

dockerfile
FROM node:20-alpine AS builder
WORKDIR /app
COPY package*.json ./
RUN npm install --legacy-peer-deps

# Build-time args — baked into the bundle at build time
ARG VITE_DOCS_URL=https://docs.sven-relijveld.com
ENV VITE_DOCS_URL=$VITE_DOCS_URL
ARG VITE_UIGEN_URL=
ENV VITE_UIGEN_URL=$VITE_UIGEN_URL
# Note: VITE_CMS_URL defaults to "/cms" in source code (import.meta.env fallback);
# it is not a Dockerfile build arg — pass it via docker build --build-arg if needed.

COPY client/ ./client/
COPY shared/ ./shared/
COPY vite.config.ts tsconfig.json postcss.config.js components.json ./
RUN npx vite build

FROM nginx:alpine
COPY nginx.conf /etc/nginx/nginx.conf
COPY nginx.default.conf.template /etc/nginx/nginx.default.conf.template
COPY --from=builder /app/dist/public /usr/share/nginx/html

Performance Optimization

  • Code Splitting: Lazy loading for routes
  • Tree Shaking: Unused code elimination
  • Asset Optimization: Image compression
  • Bundle Analysis: Regular bundle size monitoring

See Also

Page history

Field Value
Last updated 2026-06-29

Changelog

Date PRs Summary
2026-03-02 #43, #44 Add API Request Routing section (relative URLs via Vite/nginx proxy); document navigation conditional rendering on sub-pages
2026-03-03 #55 Add E2E Testing section (Playwright); update nginx Production row to note public DNS resolvers; add e2e/ to project structure
2026-03-04 #56, #58, #59, #60 Add data/ modules to project structure; add Data Modules section; add react-icons to tech stack; document useContactForm hook with client-side validation
2026-04-12 #177, #178 React 18 → React 19; update react-icons note (both fa and si packages used); add useProjects hook and API-backed data section; update Data Modules table to reflect projects now use initialData fallback Incorrect claim that react-icons/si is the only icon package used
2026-04-17 Add VITE_DOCS_URL to technology stack; add CVDownloadButton to component architecture; update Docker Build snippet with ARG VITE_DOCS_URL; update CI reference from test-api.yml to test.yml
2026-04-21 #206, #207, #247, #255 Add VITE_UIGEN_URL build arg; add AdminModal and FeatureFlagsProvider to component list; add admin-uigen.spec.ts, cv-download.spec.ts, projects.spec.ts to E2E table; update Docker Build snippet with VITE_UIGEN_URL; add UIGen smoke tests note
2026-06-29 #560, #562 Add /content and /content/:slug routes; document useContentPosts and useContentPost hooks; add ContentModal, ContentPreviewSection to component list; add VITE_CMS_URL build arg; document nginx /cms/uploads/ proxy rule; update project structure; fix routing block (add /experience, /project/:projectId with ProjectRoute); fix FeatureFlagsProvider to list all 4 flags; add SkillBar, useProjectPageCMS, uigen E2E specs; fix VITE_UIGEN_URL default; fix Docker snippet to match actual Dockerfile.frontend