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 dataThis 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 patientError 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:
Add the table to
lib/db/schema.tsand runnpm run db:pushAdd Zod schemas to
lib/validations/Create
lib/actions/{entity}-actions.tswith CRUD functions following the standard patternAdd route at
app/(dashboard)/dashboard/{entity}/page.tsxAdd permission keys to
lib/constants/permissions.tsif neededAdd the nav item to
nav-main.tsxwith 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.