Frontend engineering.
One Next.js app serves three audiences from one repo — a public SEO site, an authenticated tenant portal, and a platform admin surface — by leaning on the App Router's route groups and pairing SSR/ISR with strict client-side guards.
Snapshot
frontend/src/servicestypes/api.ts (1,713 lines)Route map
Three route groups under frontend/src/app/, each with its own rendering profile:
contact · terms · privacy"/]:::pub end subgraph AUTH["(auth) · CSR · pre-login"] direction TB LOGIN[/"/login"/]:::auth REG[/"/register · onboarding · complete"/]:::auth VER[/"/verify-email · forgot-password · reset-password/[token]"/]:::auth end subgraph PORTAL["portal/ · 100% CSR · <ProtectedRoute>"] direction TB DASH[/"/dashboard · analytics · activity-log"/]:::portal PROPS[/"/properties · new · [id] · edit · analytics"/]:::portal INQ[/"/inquiries · contacts"/]:::portal BILL[/"/subscription · credits · payment-slips · refunds · boosts"/]:::portal CONTENT[/"/blogs · articles · approvals · categories"/]:::portal ADMIN[/"/users · agencies · verification · support · settings"/]:::portal end
/. portal/ requires authentication; everything else does not.Rendering strategy
| Group | Strategy | Why |
|---|---|---|
(public) | SSR + ISR (revalidate=3600 on dynamic routes) | SEO. generateMetadata + generateStaticParams on listing/blog/agency detail pages produce OG tags, canonicals and JSON-LD per slug. |
(auth) | CSR | Forms, redirects to Keycloak, no SEO surface. |
portal/ | CSR — every file marks "use client" | Highly stateful dashboards; portal/layout.tsx wraps children in <ProtectedRoute>. |
persist writes auth state to
sessionStorage. <ProtectedRoute> waits for
hasHydrated to flip true before rendering children — without this the
portal flashes an unauthenticated state during the first paint.
API client
One Axios instance in frontend/src/lib/api.ts handles all backend traffic:
Behaviour
- Base URL —
API_URL_INTERNAL(SSR, Docker private net) vsNEXT_PUBLIC_API_URL(browser). - Auth interceptor — pulls
tokenfrom Zustand auth store on every request. - Refresh queue — 401 triggers
keycloak.updateToken(-1); in-flight requests are queued until refresh completes (no thundering herd). - Retry — up to 3 attempts with 1 s → 2 s → 3 s backoff for network errors and 5xx.
- Toast — global error toast via
sonneron 4xx/5xx, silenced for/auth/refresh,/auth/me,/auth/one-tap-refresh.
Refresh flow
Service layer
26 service modules wrap the API surface. One file per domain; one function per endpoint. UI components never call api.ts directly — always via a service.
| Service | Wraps |
|---|---|
auth.ts | register, verify, reset, refresh, One Tap token exchange, getMe |
property.ts | search, CRUD, images, slug, refunds |
agency.ts | public search, detail, admin CRUD |
subscription.ts · payment.ts | plan list, checkout, recurring, refunds |
boost.ts | boost packs, apply, options |
blog.ts · article.ts | CRUD, drafts, approval queue |
inquiry.ts | submit, list, thread |
upload.ts | R2 presigned uploads |
notification.ts | list, mark read, preferences |
verification.ts | KYC docs, admin review |
support.ts | tickets, comments |
financial-audit.ts | ledger export |
dashboard.ts · activity-log.ts · favorite.ts · location.ts · lookup.ts · contact-us.ts · system-config.ts | specialised utilities |
State management
Zustand · auth
frontend/src/stores/auth.ts — persisted to sessionStorage.
- State:
user,isAuthenticated,token,refreshToken,keycloak,hasHydrated. - Actions:
login,logout,fetchUser,loginWithOAuth2,setToken. - Helpers:
useHasRole(role),useHasAgencyAccess(agencyId).
React Query · server data
40+ custom hooks under frontend/src/hooks/:
- Pattern: one hook per endpoint (
useProperties,useAgency,useBlog). - Query keys follow
['properties', filters]. - After mutations:
invalidateQuerieson the affected key. - Devtools mounted in dev.
Auth on the client
middleware.ts — route protection is component-based via <ProtectedRoute>. Keeps SSR simple but means client must hydrate before redirect.UI library
- Primitives at
frontend/src/components/ui/— Shadcn primitives wrapping Radix UI:button,card,dialog,form,input,select,toast,tabs, etc. (27 files). - Domain components at
frontend/src/components/— grouped by feature:home/,property/,agency/,blog/,inquiries/,payment/,layouts/,shared/. - Editor — Tiptap for blog/article WYSIWYG.
- Maps — react-leaflet on property detail pages.
- Charts — Highcharts in agency analytics.
- Forms —
react-hook-form+zodviazodResolver.
SEO
Sitemap
app/sitemap.ts generates 1,000+ URLs with ISR revalidate=86400:
- Static hubs (priority 0.9–1.0)
- Property-type filters (priority 0.75)
- Type × transaction × city combinations
- Dynamic property, blog, agency slugs
Metadata & structured data
- Root
layout.tsx— base metadata, OG defaults, localeen_LK. - Detail pages use
generateMetadatafor canonical, OG image, twitter card. - JSON-LD injected via inline
<script>for properties, agencies, articles. robots.tsexcludes/portal/,/api/, auth routes.
Locale & currency
No i18n library — copy is English-only. Currency uses toLocaleString('en-LK')
for LKR formatting. Dates use date-fns. Timezone-sensitive views
assume Asia/Colombo.
Build & deploy
Scripts
cd frontend
pnpm dev # local
pnpm build # next build (standalone)
pnpm lint # eslint
pnpm test # vitest run (unit)
Next.js config
output: 'standalone'— VPS/PM2-compatible.- Image domains: localhost,
*.r2.dev,*.cplk.org,*.r2.cloudflarestorage.com,*.commercialproperty.lk. - Security headers: HSTS, X-Frame-Options SAMEORIGIN, CSP-style headers.
- Redirects:
?type=office_space→/office-space. - Build target prod:
opennextjs-cloudflare buildfor Cloudflare Pages.
Conventions
- UI components never call Axios directly — always via
services/*. - Every public route exports
generateMetadata(or has root metadata). - Portal pages mark
"use client"at the top of the file. - Forms use
react-hook-form+zod; schemas live next to the form component. - Query keys are arrays, prefixed by domain (
['properties', …]); invalidate at the same prefix after mutations. - Errors from the API arrive as
ApiError; rely on the global toast for unsurprising paths.