Frontend Development
This guide covers the architecture, UI standards, naming conventions, error handling, and environment setup for the CTMS Web frontend. It is entirely generic — no entity-specific code lives here. Two worked examples demonstrate these patterns in practice:
| Example | Pattern | When to Use |
|---|---|---|
| Basic CRUD | Type → Page → Table → direct fetch | Simple single-DocType screens (5 files) |
| CRUD with Validation | Adds Zod schemas + Backend-for-Frontend (BFF) API routes | Entities needing validation or external API access (8 files) |
Read the Basic CRUD example first — it covers the minimal file set. Then read the Advanced example to understand when and why to add Zod schemas and API routes.
High-Level Architecture
- Client components fetch data through the KrakenD API Gateway (handles auth automatically via
NEXT_PUBLIC_API_BASE_URL) - BFF API routes (optional) provide a server-side proxy with Zod validation — used when external consumers need a public API
- Frappe stores all domain data as DocTypes in MariaDB
Naming Conventions — DocType to Page
Every Frappe DocType maps to a set of frontend files following a strict naming convention. The DocType name drives all downstream names.
DocType → Slug Derivation
| Step | Rule | Example |
|---|---|---|
| Frappe DocType | As defined in Frappe (Title Case, may contain spaces) | Adverse Event |
| Slug (singular) | Lowercase, kebab-case, drop generic prefixes | adverse-event |
| Slug (plural) | Pluralised slug | adverse-events |
| Type name | Api + PascalCase singular | ApiAdverseEvent |
File Naming Map
| Layer | Path | Convention |
|---|---|---|
| Type | src/lib/types/{singular}.ts | Api{PascalSingular} export |
| Schema | src/lib/schemas/{singular}-schema.ts | {singular}Schema, create{Singular}Schema, update{Singular}Schema |
| Page | src/app/{plural}/page.tsx | Server Component with <Suspense> |
| Client | src/app/{plural}/{plural}-client.tsx | "use client" — state, fetch, CRUD |
| Table | src/components/{plural}/{plural}-table.tsx | TanStack Table definition |
| Form | src/components/{plural}/{singular}-form.tsx | Slide-over form |
| API Route (list) | src/app/api/v1/{plural}/route.ts | GET (list) + POST (create) |
| API Route (single) | src/app/api/v1/{plural}/[id]/route.ts | GET + PUT + DELETE |
The Frappe DocType name (with spaces) is passed as-is to fetchFrappeDoctypePaginated() and URL-encoded for direct API calls. Only the file/folder names use kebab-case.
UI Standards
Every entity screen follows the same visual structure. This ensures a consistent user experience across the application.
Standard Page Layout
┌──────────────────────────────────────────────────────────────────────────────┐
│ [Search...] [Columns ▾] [+ Add Entity] │ ← Header (fixed)
├──────────────────────────────────────────────────────────────────────────────┤
│ Column 1 │ Column 2 │ Description │ Status │ Modified │ Actions │ ← Table Header (sticky)
├──────────────────────────────────────────────────────────────────────────────┤
│ │
│ [Scrollable Table Body — Dynamic Height: calc(100vh - 180px)] │
│ │
├──────────────────────────────────────────────────────────────────────────────┤
│ 99,999 records found Rows per page [10 ▾] Page 1 of 24 [◀] [▶] │ ← Footer (fixed)
└──────────────────────────────────────────────────────────────────────────────┘
Layout Rules
| Concern | Standard |
|---|---|
| Container | h-[calc(100vh-180px)] min-h-[500px] flex column |
| Table header | sticky top-0 bg-background z-10 |
| Row height | Fixed h-12 for visual consistency |
| Pagination footer | Fixed at bottom, outside scrollable area (flex-shrink-0) |
| Default sort | modified desc (most recent first) |
| Default page size | 10 rows, options: [10, 20, 30, 40, 50] |
Action Bar
The top row always contains:
- Left: Search input with debounced filtering
- Right:
[Delete Selected](conditional) ·[Columns ▾]dropdown ·[+ Add Entity]primary button - All action buttons are wrapped in
<ActionGuard>for RBAC
Slide-Over Form (Side Sheet)
All create / edit / view forms open as a right-side slide-over panel:
┌──────────────────────────────────────────┬──────────────────────────┐
│ │ ╔══════════════════════╗│
│ (Backdrop overlay) │ ║ Entity Name ✕ ║│
│ │ ╠══════════════════════╣│
│ │ ║ ║│
│ │ ║ [Scrollable form ║│
│ │ ║ fields here] ║│
│ │ ║ ║│
│ │ ╠══════════════════════╣│
│ │ ║ [Cancel] [Save] ║│
│ │ ╚══════════════════════╝│
└──────────────────────────────────────────┴──────────────────────────┘
| Concern | Standard |
|---|---|
| Panel width | max-w-md (fixed-width) |
| Header | Primary background, title + close button |
| Body | Scrollable, consistent spacing (px-4 sm:px-6, space-y-6) |
| Footer | Fixed at bottom with Cancel / Reset / Save buttons (always visible) |
| Modes | create (empty form), edit (pre-populated), readOnly (disabled fields) |
| Animation | animate-in slide-in-from-right duration-500 |
Delete Confirmation
Destructive actions always show an AlertDialog before execution:
- Title: "Are you sure?"
- Description explains the action is irreversible
- Cancel button + red Delete button
- Bulk delete shows count: "Delete 3 selected items?"
Notifications (Toasts)
All user-facing messages use sonner — imported as import { toast } from "sonner".
| Operation | Toast |
|---|---|
| Create success | toast.success("Entity created successfully") |
| Update success | toast.success("Entity updated successfully") |
| Delete success | toast.success("Successfully deleted N entity(ies)") |
| Error | toast.error(errorMessage) — with extracted message from Frappe |
| Partial bulk delete | toast.error("N deleted, but M failed: reason") |
Do not use the shadcn useToast() hook — use sonner exclusively for consistency.
Error Handling
Client-Side CRUD Pattern
Every CRUD operation in a client component follows this structure:
try {
const response = await fetch(url, { method, headers, body })
const responseData = await response.json()
// ⚠️ KrakenD may return HTTP 200 wrapping a Frappe error
if (responseData.error_backend_alias || !response.ok) {
throw new Error(extractErrorMessage(responseData))
}
toast.success("Entity created successfully")
fetchEntities() // re-fetch the list (preserves pagination)
} catch (error) {
console.error("Error:", error)
toast.error(error instanceof Error ? error.message : "An unexpected error occurred")
} finally {
setIsLoading(false)
}
extractErrorMessage() Helper
Each client component includes an error parser for the nested KrakenD → Frappe error structure:
- Parse
error_backend_alias.http_body._server_messages(JSON array of JSON strings) - Strip HTML tags from extracted message
- Fallback to
responseData.exceptionfield - Fallback to
responseData.error_backend_aliasstring - Final fallback:
"An unexpected error occurred"
Key Error Scenarios
| HTTP Status | Frappe Error | Cause |
|---|---|---|
200 with error_backend_alias | Various | KrakenD wraps the Frappe error inside a 200 response |
417 | LinkExistsError | Delete blocked — linked child records exist |
409 | DuplicateEntryError | Duplicate name or unique-field violation |
403 | PermissionError | User role cannot perform this action |
Error UI State
When a page-level fetch fails, the table area shows a red error box with the error message and a "Try Again" button instead of an empty table.
Server-Side Logging (loggedFetch)
BFF API routes (Next.js Route Handlers) use loggedFetch() instead of plain fetch():
| Feature | Detail |
|---|---|
| File | lib/utils/api-logger.ts |
| Timing | Logs request duration in ms |
| Redaction | Auth headers are replaced with ***REDACTED*** |
| Console output | Coloured method + status + URL for dev readability |
| Ring buffer | Last 1,000 entries kept in memory (singleton ApiLogger) |
| Scope | Server-side only — client components use plain fetch() |
Loading & Skeleton States
| Context | Standard |
|---|---|
| Table loading | 5 animated skeleton rows: <div className="h-4 bg-muted animate-pulse rounded" /> |
| Button saving | <Loader2 className="mr-2 h-4 w-4 animate-spin" /> + "Saving…" text |
| Form overlay | Full-screen overlay with spinner + "Saving…" (for long operations) |
| Page suspense | <Suspense fallback={<div>Loading...</div>}> in the server component |
Shared Helpers & Hooks
These are reused across all entity pages:
| Helper | File | Purpose |
|---|---|---|
fetchFrappeDoctypePaginated() | lib/api/frappe-doctype.ts | Paginated fetch for any DocType (count + data in two calls) |
extractErrorMessage() | Inlined in each client | Parse nested KrakenD/Frappe error structure |
usePageHeader() | hooks/use-page-header.ts | Set page title, subtitle, icon |
useActionPermissions() | hooks/useActionPermissions.ts | RBAC — canCreate(), canUpdate(), canDelete() |
useAuth() | contexts/auth-context.tsx | Current user profile and roles |
useApiBaseUrl() | providers/runtime-config-provider.tsx | KrakenD gateway base URL |
DataTablePagination | components/ui/data-table-pagination.tsx | Server-side pagination control |
ActionGuard | components/guards/ActionGuard.tsx | Conditionally render actions based on permissions |
loggedFetch() | lib/utils/api-logger.ts | Fetch wrapper with logging (BFF routes only) |
UI Component Library
All UI primitives come from shadcn/ui in src/components/ui/:
Button · Input · Table · Badge · Sheet · Dialog · AlertDialog · DropdownMenu · Checkbox · Avatar · Tabs · Select · Skeleton · Card · Separator · Tooltip
Data tables use TanStack Table with manualPagination: true for server-side pagination.
Icons come from lucide-react — e.g. Plus, Pencil, Trash2, Loader2, Search, ChevronLeft, ChevronRight.
Key Project Structure
ctms-web/src/
├── app/
│ ├── {plural}/ ← Page routes
│ │ ├── page.tsx ← Server Component (Suspense wrapper)
│ │ └── {plural}-client.tsx ← Client Component (state + CRUD)
│ ├── api/v1/{plural}/ ← BFF API routes (optional)
│ │ ├── route.ts ← GET + POST
│ │ └── [id]/route.ts ← GET + PUT + DELETE
│ ├── error.tsx ← Global error boundary
│ └── not-found.tsx ← 404 page
├── components/
│ ├── {plural}/ ← Feature components
│ │ ├── {plural}-table.tsx ← TanStack Table (columns, actions)
│ │ └── {singular}-form.tsx ← Slide-over form
│ ├── ui/ ← shadcn/ui primitives
│ ├── guards/ ← ActionGuard, RoleGuard
│ └── dropdown-selects/ ← Reusable entity selectors
├── lib/
│ ├── types/{singular}.ts ← TypeScript types (Api{Entity})
│ ├── schemas/{singular}-schema.ts ← Zod schemas (optional)
│ ├── services/{singular}-service.ts ← Service class (optional)
│ ├── api/frappe-doctype.ts ← Paginated fetch helper
│ └── utils/api-logger.ts ← loggedFetch for BFF routes
├── hooks/ ← usePageHeader, useActionPermissions
├── contexts/ ← Auth, Theme contexts
└── providers/ ← RuntimeConfig (apiBaseUrl)
Environment Variables
The following variables must be set for the frontend to work. Copy sample.env to .env.local and fill in values for your environment.
Backend Connection
| Variable | Example | Description |
|---|---|---|
FRAPPE_BASE_URL | http://<host>:8080 | Frappe backend URL (server-side only) |
FRAPPE_API_TOKEN | <api_key>:<api_secret> | API key:secret for server→Frappe auth. See how to get the token. |
NEXT_PUBLIC_API_BASE_URL | http://<host>:9080/api/v1 | KrakenD gateway URL used by client-side fetch |
NEXT_PUBLIC_FRAPPE_URL | http://<host>:8080 | Public Frappe URL for client-side permission checks |
Authentication (Supabase)
| Variable | Example | Description |
|---|---|---|
SUPABASE_URL | http://<host>:8000 | Supabase project URL |
SUPABASE_ANON_KEY | eyJhbGci... | Supabase anonymous key for client auth |
Application Settings
| Variable | Example | Description |
|---|---|---|
NEXT_PUBLIC_USER_ROLES | Patient,Study Designer,... | Comma-separated list of available roles |
NEXT_PUBLIC_DEFAULT_PRACTITIONER_ID | HLC-PRAC-2026-00001 | Default practitioner for encounter creation |
NEXT_PUBLIC_BRAND_NAME | (empty for default) | Whitelabel brand name override |
NEXT_PUBLIC_LOGO_PREFIX | /i/zynomi/logo | Logo file path prefix (expects -dark, -mark variants) |
NEXT_PUBLIC_LOGO_EXT | svg | Logo file extension |
NEXT_PUBLIC_SITE_URL | http://localhost:3000 | Site URL for SEO / OpenGraph |
Adding a New Entity (Checklist)
Minimum (Basic CRUD)
- Type —
src/lib/types/{singular}.ts— defineApi{Entity}type - Page —
src/app/{plural}/page.tsx— Server Component with<Suspense> - Client —
src/app/{plural}/{plural}-client.tsx— state, fetch, CRUD, toasts - Table —
src/components/{plural}/{plural}-table.tsx— TanStack Table with sticky header, pagination footer - Form —
src/components/{plural}/{singular}-form.tsx— slide-over panel with view/edit/create modes - Sidebar — Register route in app sidebar config
- RBAC — Add resource name to permissions system
Additional (With Validation)
- Schema —
src/lib/schemas/{singular}-schema.ts— Zod create/update schemas - API Routes —
src/app/api/v1/{plural}/route.ts+[id]/route.ts
Optional (Complex Flows)
- Service —
src/lib/services/{singular}-service.ts— for multi-step or cross-DocType flows
fetchFrappeDoctypePaginated() in lib/api/frappe-doctype.ts works with any Frappe DocType. Pass the doctype name (with spaces, as in Frappe) and a field list — pagination, count, and search are handled automatically.