Architecture Overview

How CareNova is structured — the full request lifecycle, data flow pattern, server vs client boundary, caching strategy, and middleware layer.

Written By Dev010

Last updated 19 days ago

CareNova is built on Next.js 14 App Router with a clear, repeatable architecture across every module. Understanding the core data flow pattern makes it straightforward to extend the platform, add new modules, or debug unexpected behavior.

The Core Pattern

Every module in CareNova follows the same end-to-end flow:

Server Component
      ↓
  fetches data directly via Drizzle
      ↓
  renders UI with that data
      ↓
User triggers mutation (form submit, button)
      ↓
  Server Action ("use server")
      ↓
  Zod validates input
      ↓
  requireRole / requirePermission check
      ↓
  Drizzle executes DB mutation
      ↓
  revalidatePath invalidates cache
      ↓
UI re-renders with fresh data

This pattern is consistent across patients, appointments, invoices, inventory, staff, and every other module. Once you understand it in one place, you understand it everywhere.

Layer Responsibilities

Server Components handle data fetching and initial render. Most dashboard pages are Server Components — they fetch data directly using Drizzle queries and render HTML on the server. No useEffect, no loading spinners for initial data, no client-side fetch calls. The page receives data as props and renders immediately.

// app/(dashboard)/dashboard/patients/page.tsx
export default async function PatientsPage() {
  const user = await getCachedCurrentUser();
  const patients = await getPatients();
  
  return <PatientList patients={patients} />;
}

Server Actions handle all mutations — create, update, delete. They are async functions marked with "use server" that run exclusively on the server. Client components call them directly like regular functions — no fetch, no API route, no manual HTTP method definition required.

// lib/actions/patient-actions.ts
"use server";

export async function createPatient(input: unknown) {
  const user = await getCachedCurrentUser();
  requireRole(user.role, ["admin", "doctor", "receptionist"]);
  
  const data = createPatientSchema.parse(input);
  
  await db.insert(patients).values(data);
  revalidatePath("/dashboard/patients");
  
  return { success: true };
}

Zod schemas validate all input — both on the client before submission and again on the server inside the action. Schemas live in lib/validations/ and are shared between the client form and the server action. The server never trusts client-provided data — schema.parse(input) runs on every mutation before touching the database.

Drizzle ORM executes all database queries. It provides type-safe query building with full TypeScript inference — no raw SQL strings, no any types, no runtime surprises. The schema defined in lib/db/schema.ts is the single source of truth for table structure and types.

// Type-safe query — TypeScript knows 
// the exact shape of the result
const patient = await db
  .select()
  .from(patients)
  .where(eq(patients.id, id))
  .limit(1);

revalidatePath invalidates the Next.js cache for a given route after a mutation completes. The Server Component for that route re-runs on the next request and fetches fresh data. This is how the UI stays in sync without manual state management or client-side refetch logic.

Server vs Client Components

The boundary is intentional and consistent across the codebase.

Server Components (no "use client"):

  • All dashboard page files (page.tsx)

  • Layout files (layout.tsx)

  • Data-heavy list components that receive data as props and render tables

  • Static UI that has no interactivity

Client Components ("use client"):

  • Forms (require React Hook Form and controlled state)

  • The sidebar (requires open/close state)

  • The calendar (requires drag-and-drop interaction via @dnd-kit)

  • The theme switcher

  • Charts (Recharts requires client rendering)

  • Sheets and dialogs with internal state

  • Any component that uses useState, useEffect, or browser APIs

The result is minimal client-side JavaScript. Most of the application renders on the server — only interactive components ship JS to the browser.

Caching Strategy

CareNova uses three levels of caching to avoid redundant database calls.

React cache (per-request deduplication):getCachedCurrentUser() and getCachedEnsureAppUser() use React's cache() function. Within a single server request, no matter how many components call getCachedCurrentUser(), the database is only queried once. The result is shared across the entire component tree for that request.

// lib/cache/index.ts
export const getCachedCurrentUser = cache(
  async () => getCurrentUser()
);

Module-level cache (cross-request TTL):getCachedClinic() uses a module-level variable with a 5-minute TTL. The clinic record rarely changes, so re-fetching it on every request is wasteful. The cache is invalidated explicitly via invalidateClinicCache() when clinic settings are updated.

Next.js revalidatePath (mutation-triggered): After any mutation, revalidatePath marks the relevant route as stale. The next request to that route fetches fresh data. This keeps lists and detail pages accurate after creates, updates, and deletes.

Parallel Data Fetching

Dashboard pages often need data from multiple tables simultaneously. CareNova uses Promise.all to fetch in parallel rather than sequentially — reducing total page load time significantly.

// Instead of sequential (slow):
const patients = await getPatients();
const appointments = await getAppointments();
const revenue = await getMonthlyRevenue();

// CareNova uses parallel (fast):
const [patients, appointments, revenue] = 
  await Promise.all([
    getPatients(),
    getAppointments(),
    getMonthlyRevenue(),
  ]);

The admin dashboard fetches 9+ queries in two parallel batches of ~5 queries each rather than all at once — staying within Supabase's connection pool limits while keeping load times fast.

Middleware Layer

middleware.ts at the project root runs on every request before any page or action is executed. It handles four responsibilities:

Session validation: Calls updateSession(request) from lib/supabase/middleware.ts which refreshes the Supabase auth token if it is close to expiry and writes the updated session back to the cookie. If no valid session exists on a /dashboard route, the request is redirected to /login.

Route protection: Dashboard routes require authentication. Auth routes (/login, /signup) redirect to /dashboard if the user is already logged in. The setup route (/setup) redirects to /dashboard if the license is already verified.

License check: Requests to /dashboard and /setup verify the Envato license. Unlicensed instances are redirected to /setup to complete registration.

Clinic demo cookie: Requests to landing routes (/, /blog, /appointment, /policies) check for a ?clinic= query parameter. If present and valid, it is persisted as the landing_clinic_demo cookie for 7 days and used to render the matching clinic type landing page.

The middleware matcher excludes static files, _next internals, and image assets — it only runs on navigable routes.

Form Submission Lifecycle

Walking through a complete example — creating a new patient:

1. User fills PatientForm (Client Component)
   → React Hook Form manages field state
   → Zod schema validates on blur/submit

2. User clicks Save
   → handleSubmit fires
   → Client-side Zod parse passes
   → createPatient(formData) Server Action called

3. Server Action runs on server:
   → getCachedCurrentUser() — confirms auth
   → requireRole(user.role, [...]) — confirms permission
   → createPatientSchema.parse(input) — re-validates
   → db.insert(patients).values(data) — writes to DB
   → revalidatePath("/dashboard/patients") — clears cache

4. Server Action returns { success: true }
   → Client shows success toast via sonner
   → Sheet/dialog closes
   → Patient list re-renders with new patient

Error Handling

Server Actions return a consistent shape — either success or an error string the client can display:

// Success
return { success: true };

// Validation error
return { success: false, error: "Invalid input" };

// Permission error (thrown, not returned)
throw new PermissionDeniedError();

Client components catch these responses and show toast notifications via sonner. Unhandled server errors bubble up to the nearest error boundary.

Adding a New Module

Because every module follows the same pattern, adding a new entity to CareNova is mechanical:

  1. Add the table to lib/db/schema.ts and run npm run db:push

  2. Add Zod schemas to lib/validations/

  3. Create lib/actions/{entity}-actions.ts with CRUD functions following the standard pattern

  4. Add route at app/(dashboard)/dashboard/{entity}/page.tsx

  5. Add permission keys to lib/constants/permissions.ts if needed

  6. Add the nav item to nav-main.tsx with appropriate role and permission filters

The architecture enforces consistency — a new module written by any developer will look identical in structure to every existing module.

Technology Versions

Technology

Version

Role

Next.js

14.2.x

Framework — App Router, Server Components, Server Actions

TypeScript

5.6.x

Type safety across the entire codebase

Drizzle ORM

0.36.x

Type-safe database queries and migrations

Supabase

PostgreSQL database, Auth, Storage

Zod

3.23.x

Input validation — client and server

React Hook Form

7.53.x

Form state management

Tailwind CSS

3.4.x

Utility-first styling

shadcn/ui

Accessible UI component primitives

Framer Motion

12.34.x

Animations

next-intl

4.8.x

i18n — en, fr, es, ar

Recharts

3.7.x

Charts and analytics widgets

@dnd-kit

6.3.x

Drag-and-drop calendar

sonner

2.0.x

Toast notifications