Engineering · Next.js 16 · React 19

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

App Router files
86
page · layout · route handlers
Service modules
26
under frontend/src/services
TS DTOs
137+
hand-maintained in types/api.ts (1,713 lines)
Shadcn primitives
27
Next.js
16.2
React 19 · output: standalone
State stack
Zustand + React Query

Route map

Three route groups under frontend/src/app/, each with its own rendering profile:

flowchart TB classDef pub fill:#ecfeff,stroke:#0891b2,color:#0f172a classDef auth fill:#fff7ed,stroke:#f97316,color:#0f172a classDef portal fill:#eef2ff,stroke:#1e3a5f,color:#0f172a subgraph PUBLIC["(public) · SSR + ISR"] direction TB H[/"/"/]:::pub P_LIST[/"/properties"/]:::pub P_SLUG[/"/properties/[slug]"/]:::pub P_CITY[/"/properties/city/[city]"/]:::pub B_HUB[/"/blog"/]:::pub B_SLUG[/"/blog/[slug]"/]:::pub A_LIST[/"/agencies"/]:::pub A_SLUG[/"/agencies/[slug]"/]:::pub STATIC[/"pricing · help · about
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
Route groups in the App Router don't show up in the URL, so the public site and portal share /. portal/ requires authentication; everything else does not.

Rendering strategy

GroupStrategyWhy
(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)CSRForms, redirects to Keycloak, no SEO surface.
portal/CSR — every file marks "use client"Highly stateful dashboards; portal/layout.tsx wraps children in <ProtectedRoute>.
Hydration guard. Zustand's 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 URLAPI_URL_INTERNAL (SSR, Docker private net) vs NEXT_PUBLIC_API_URL (browser).
  • Auth interceptor — pulls token from 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 sonner on 4xx/5xx, silenced for /auth/refresh, /auth/me, /auth/one-tap-refresh.

Refresh flow

sequenceDiagram participant Q as Caller participant AX as Axios participant API as Spring API participant KC as Keycloak.js Q->>AX: GET /properties (Bearer T) AX->>API: request API-->>AX: 401 Unauthorized AX->>KC: keycloak.updateToken(-1) KC-->>AX: new token T' AX->>API: retry GET /properties (Bearer T') API-->>AX: 200 AX-->>Q: data

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.

ServiceWraps
auth.tsregister, verify, reset, refresh, One Tap token exchange, getMe
property.tssearch, CRUD, images, slug, refunds
agency.tspublic search, detail, admin CRUD
subscription.ts · payment.tsplan list, checkout, recurring, refunds
boost.tsboost packs, apply, options
blog.ts · article.tsCRUD, drafts, approval queue
inquiry.tssubmit, list, thread
upload.tsR2 presigned uploads
notification.tslist, mark read, preferences
verification.tsKYC docs, admin review
support.tstickets, comments
financial-audit.tsledger export
dashboard.ts · activity-log.ts · favorite.ts · location.ts · lookup.ts · contact-us.ts · system-config.tsspecialised 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: invalidateQueries on the affected key.
  • Devtools mounted in dev.

Auth on the client

sequenceDiagram participant U as User participant RT as <ProtectedRoute> participant ST as Zustand auth participant KC as Keycloak.js participant KS as Keycloak server U->>RT: navigate /portal/* RT->>ST: read isAuthenticated & hasHydrated alt not authenticated RT->>ST: loginWithOAuth2() ST->>KC: keycloak.login() KC->>KS: OIDC redirect KS-->>KC: code → token KC-->>ST: tokens (in-memory) else authenticated & hydrated RT-->>U: render children end
No 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.
  • Formsreact-hook-form + zod via zodResolver.

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, locale en_LK.
  • Detail pages use generateMetadata for canonical, OG image, twitter card.
  • JSON-LD injected via inline <script> for properties, agencies, articles.
  • robots.ts excludes /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 build for 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.