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/fafor FaLinkedin;react-icons/sifor SiGithub and other brand icons) - Docs URL:
VITE_DOCS_URL— baked into the bundle at build time (defaults tohttps://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 tohttps://admin.sven-relijveld.comin prod,https://admin.dev.sven-relijveld.comin 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(usesVITE_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, exposescvGeneration,portfolioContentApi,heroRoleCycler, andprojectPageV2flags via context),SkillBar(proficiency bar component inclient/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 theCmsProjectPageshape to the localProjectV2Datatype viacmsToProjectV2Data, uses the matching staticprojectV2Dataentry 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¶
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} />
{/* /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 path="/content" component={ContentPage} />
<Route path="/content/:slug" component={ContentDetailPage} />
<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#sectionfragment 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:
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:
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 fromGET /api/projects; uses the staticprojectCardsfromdata/projects.tsasinitialDataso the page renders immediately before the request completes.useProjectDetail— fetches a single project by slug fromGET /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 fromGET /cms/api/content-posts?sort=publish_date:desc&populate=*; usesplaceholderData(notinitialData) 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 unreachableuseContentPost(slug)— fetches a single post by slug fromGET /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:
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¶
Production¶
Docker Build¶
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-05-19 | #454, #456 | Add SkillBar component and role cycler to component list; add uigen-feature-flags.spec.ts to E2E table; update experience.ts data module note; update hero-section and navigation component descriptions |
| 2026-06-02 | #484, #485, #490, #511 | Add project-page-v2 route (feature-flagged), useProjectPageCMS hook, uigen-crud E2E entries, projectPageV2 feature flag |
| 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 VITE_UIGEN_URL default (empty not localhost:4400); update Docker snippet to match actual Dockerfile.frontend |