Skip to Content
IssuesFrontendFrontend Refactoring Guide

Frontend Refactoring Guide

This document identifies the concrete weaknesses in the current Next.js frontend apps (apps/dashboard, apps/manage, apps/dm) and prescribes the target patterns to adopt. Every section follows the same structure: current pattern → problem → target pattern → example.


Table of Contents

  1. Feature Module Organization
  2. Centralized API Client
  3. Forms — react-hook-form + Zod
  4. Centralized Error Handling
  5. Loading State Management
  6. TypeScript Discipline — Co-located Types
  7. Environment Variables — Validated at Boot
  8. State Management — Explicit Layering
  9. Constants — Namespaced and Typed
  10. What to Keep from the Current Apps

1. Feature Module Organization

Current pattern

Business logic is scattered across app/, services/, lib/, and components/. A feature like “Action Items” has its route in app/(dashboard)/action-items/page.tsx, its service in services/activities.service.ts, its types defined inline, and its UI components in components/action-items/. These are structurally unrelated folders with no enforced boundary.

src/ app/(dashboard)/action-items/ page.tsx ← data fetch + render actions.ts ← mutations ActionItemsClient.tsx services/ activities.service.ts ← some logic here components/ action-items/ ActionItemCard.tsx

Problem: There is no single place to look for everything related to a feature. When a feature is deleted, renamed, or moved, you have to hunt across four separate directories. New developers cannot find the service for a given page without searching the codebase.

Target pattern

Every feature owns a self-contained folder. Routes in app/ remain thin — they import from the feature module and do nothing else.

src/ features/ action-items/ index.ts ← barrel export (public API of the feature) ActionItemsLayout.tsx ← top-level client component for the feature actionItemsService.ts ← all API/DB calls for this feature actionItemsTypes.ts ← all TypeScript types for this feature actionItemsSchema.ts ← all Zod schemas for this feature components/ ActionItemCard.tsx ActionItemFilters.tsx ActionItemStatusBadge.tsx app/(dashboard)/action-items/ page.tsx ← one-liner: renders <ActionItemsLayout /> actions.ts ← server actions (thin wrappers, call feature service)

Example

src/features/action-items/actionItemsTypes.ts

export type ActionItemStatus = "pending" | "in_progress" | "completed" | "failed"; export type ActionItem = { id: string; title: string; status: ActionItemStatus; tenantId: string; dueDate: string | null; createdAt: string; }; export type ActionItemsPage = { items: ActionItem[]; pagination: { page: number; pageSize: number; total: number; }; };

src/features/action-items/actionItemsService.ts

import { API } from "@/lib/api"; import type { ActionItem, ActionItemsPage } from "./actionItemsTypes"; export const actionItemsService = { async list(params: { page?: number; status?: string }): Promise<ActionItemsPage> { return API.get<ActionItemsPage>({ url: "/action-items", params }); }, async execute(id: string): Promise<ActionItem> { return API.post<ActionItem>({ url: `/action-items/${id}/execute` }); }, };

src/app/(dashboard)/action-items/page.tsx

import { ActionItemsLayout } from "@/features/action-items"; export default function ActionItemsPage() { return <ActionItemsLayout />; }

Rule: Nothing inside app/ imports directly from another feature’s internals. Features import from shared lib/, components/, and @leadmetrics/ui only.


2. Centralized API Client

Current pattern

Every server action and route handler constructs its own fetch() call, manually adds the Authorization header, manually handles errors, and has no retry logic.

// apps/dm/src/app/(dm)/action-items/actions.ts "use server"; export async function approveActionItems(tenantId: string) { const { token } = await requireAuth(); const res = await fetch(`${process.env.API_URL}/dm/v1/action-items/approve`, { method: "POST", headers: { "Content-Type": "application/json", Authorization: `Bearer ${token}`, }, body: JSON.stringify({ tenantId }), }); if (!res.ok) throw new Error("Failed"); return res.json(); }

Problems:

  • Auth header construction is duplicated in every file.
  • No retry on transient 5xx failures.
  • Error shape parsed differently in each file.
  • process.env.API_URL accessed raw — no type safety or validation.
  • Changing the base URL or auth scheme requires editing dozens of files.

Target pattern

A typed API factory built once in src/lib/api/. All HTTP calls go through it.

src/lib/api/client.ts

import { requireSession } from "@/lib/server-auth"; import { env } from "@/lib/env"; type RequestOptions<TBody = unknown> = { url: string; params?: Record<string, string | number | boolean | undefined>; data?: TBody; headers?: Record<string, string>; }; async function buildHeaders(extraHeaders?: Record<string, string>) { const { token } = await requireSession(); return { "Content-Type": "application/json", Authorization: `Bearer ${token}`, ...extraHeaders, }; } function buildUrl(base: string, path: string, params?: Record<string, unknown>) { const url = new URL(path, base); if (params) { Object.entries(params).forEach(([k, v]) => { if (v !== undefined && v !== null) url.searchParams.set(k, String(v)); }); } return url.toString(); } async function request<TResponse>( method: string, { url, params, data, headers }: RequestOptions, retries = 3 ): Promise<TResponse> { const fullUrl = buildUrl(env.API_URL, url, params); const init: RequestInit = { method, headers: await buildHeaders(headers), ...(data ? { body: JSON.stringify(data) } : {}), }; for (let attempt = 1; attempt <= retries; attempt++) { const res = await fetch(fullUrl, init); if (res.ok) return res.json() as Promise<TResponse>; // Only retry on transient server errors if (res.status >= 500 && attempt < retries) { await new Promise((r) => setTimeout(r, attempt * 500)); continue; } const body = await res.json().catch(() => ({})); const message = body?.message ?? body?.error ?? `HTTP ${res.status}`; throw new ApiError(message, res.status, body); } throw new ApiError("Max retries exceeded", 503); } export class ApiError extends Error { constructor( message: string, public statusCode: number, public body?: unknown ) { super(message); this.name = "ApiError"; } } export const API = { get: <T>(opts: Omit<RequestOptions, "data">) => request<T>("GET", opts), post: <T>(opts: RequestOptions) => request<T>("POST", opts), put: <T>(opts: RequestOptions) => request<T>("PUT", opts), patch: <T>(opts: RequestOptions) => request<T>("PATCH", opts), delete: <T>(opts: Omit<RequestOptions, "data">) => request<T>("DELETE", opts), };

Usage in a server action:

"use server"; import { API } from "@/lib/api/client"; import type { ActionItem } from "@/features/action-items/actionItemsTypes"; export async function executeActionItem(id: string) { const item = await API.post<ActionItem>({ url: `/action-items/${id}/execute` }); revalidatePath("/action-items"); return item; }

Every call automatically gets the auth header, retry logic, and standardized error throwing. No duplication.


3. Forms — react-hook-form + Zod

Current pattern

Forms use one useState per field. Validation is imperative. Types are inferred from whatever string happens to be in state.

// Current — manual form state "use client"; export function LoginForm() { const [email, setEmail] = useState(""); const [password, setPassword] = useState(""); const [error, setError] = useState(""); const handleSubmit = async (e: React.FormEvent) => { e.preventDefault(); if (!email) { setError("Email required"); return; } if (!password) { setError("Password required"); return; } const result = await loginAction(email, password); if (result?.error) setError(result.error); }; return ( <form onSubmit={handleSubmit}> <input value={email} onChange={(e) => setEmail(e.target.value)} /> <input value={password} onChange={(e) => setPassword(e.target.value)} /> {error && <p>{error}</p>} </form> ); }

Problems:

  • One useState per field — 10-field forms need 20 variables.
  • Validation logic is imperative and scattered inline.
  • No per-field error messages.
  • No type safety on form values.
  • result?.error string check is fragile.
  • Re-implements what react-hook-form already does.

Target pattern

Schema-first forms with react-hook-form + Zod. The schema is the single source of truth for both runtime validation and compile-time types.

src/features/login/loginSchema.ts

import { z } from "zod"; export const loginSchema = z.object({ email: z.string().email("Enter a valid email address"), password: z.string().min(1, "Password is required"), }); export type LoginFormValues = z.infer<typeof loginSchema>;

src/features/login/components/LoginForm.tsx

"use client"; import { useForm } from "react-hook-form"; import { zodResolver } from "@hookform/resolvers/zod"; import { Form, FormField, FormItem, FormLabel, FormControl, FormMessage, } from "@leadmetrics/ui"; import { loginSchema, type LoginFormValues } from "../loginSchema"; import { loginAction } from "../loginAction"; export function LoginForm() { const form = useForm<LoginFormValues>({ resolver: zodResolver(loginSchema), defaultValues: { email: "", password: "" }, }); const onSubmit = async (values: LoginFormValues) => { const result = await loginAction(values); if (result.error) { form.setError("root", { message: result.error }); } }; return ( <Form {...form}> <form onSubmit={form.handleSubmit(onSubmit)} className="space-y-4"> <FormField control={form.control} name="email" render={({ field }) => ( <FormItem> <FormLabel>Email</FormLabel> <FormControl> <Input type="email" {...field} /> </FormControl> <FormMessage /> </FormItem> )} /> <FormField control={form.control} name="password" render={({ field }) => ( <FormItem> <FormLabel>Password</FormLabel> <FormControl> <Input type="password" {...field} /> </FormControl> <FormMessage /> </FormItem> )} /> {form.formState.errors.root && ( <p className="text-sm text-destructive"> {form.formState.errors.root.message} </p> )} <Button type="submit" disabled={form.formState.isSubmitting}> {form.formState.isSubmitting ? "Signing in…" : "Sign in"} </Button> </form> </Form> ); }

Benefits:

  • form.formState.isSubmitting replaces the manual loading boolean.
  • Per-field error messages come from Zod automatically.
  • LoginFormValues type is derived from the schema — no separate interface to maintain.
  • Adding a field means adding one z.string() line and one <FormField> block.

Packages to install (once per app):

pnpm add react-hook-form zod @hookform/resolvers

zod is already used in route handlers — this extends it to forms.


4. Centralized Error Handling

Current pattern

Each server action wraps its own try/catch and formats its own error message.

// Repeated across dozens of actions export async function doSomething(id: string) { try { const result = await API.post(...) return { ok: true, data: result }; } catch (e) { console.error(e); return { ok: false, error: "Something went wrong" }; } }

Problems:

  • Error messages are generic and inconsistent.
  • Logging happens at the action layer, not at the API layer.
  • ApiError.statusCode is swallowed — a 404 looks the same as a 500.
  • The calling component has to check result.ok, result.error, result.data — no standard contract.

Target pattern

The API client throws a typed ApiError. A single withAction wrapper in src/lib/action-handler.ts handles all server actions uniformly.

src/lib/action-handler.ts

import { ApiError } from "@/lib/api/client"; export type ActionResult<T> = | { ok: true; data: T } | { ok: false; error: string; statusCode?: number }; export async function withAction<T>( fn: () => Promise<T> ): Promise<ActionResult<T>> { try { const data = await fn(); return { ok: true, data }; } catch (err) { if (err instanceof ApiError) { return { ok: false, error: err.message, statusCode: err.statusCode }; } // Log unexpected errors server-side, return generic message to client console.error("[action]", err); return { ok: false, error: "An unexpected error occurred" }; } }

Usage:

"use server"; import { withAction } from "@/lib/action-handler"; import { API } from "@/lib/api/client"; export async function executeActionItem(id: string) { return withAction(async () => { const item = await API.post({ url: `/action-items/${id}/execute` }); revalidatePath("/action-items"); return item; }); }

In the component:

const result = await executeActionItem(id); if (!result.ok) { toast.error(result.error); return; } // result.data is typed and safe here

ActionResult<T> is a discriminated union — TypeScript narrows the type based on result.ok, so result.data is only accessible in the ok: true branch.


5. Loading State Management

Current pattern

Every client component that triggers an async operation manually manages a loading boolean.

"use client"; export function ActionItemCard({ item }: Props) { const [isExecuting, setIsExecuting] = useState(false); const handleExecute = async () => { setIsExecuting(true); try { await executeActionItem(item.id); } finally { setIsExecuting(false); } }; return ( <Button onClick={handleExecute} disabled={isExecuting}> {isExecuting ? "Running…" : "Execute"} </Button> ); }

Problem: This is 8 lines of state boilerplate per async button. Across dozens of components it becomes a significant source of bugs (forgetting finally, forgetting to reset state on error, etc.).

Target pattern

Use React 19’s useTransition for actions and useOptimistic for immediate feedback. For repeated patterns, extract a useAsyncAction hook.

src/hooks/useAsyncAction.ts

import { useState, useCallback } from "react"; import { toast } from "@leadmetrics/ui"; import type { ActionResult } from "@/lib/action-handler"; export function useAsyncAction<TArgs extends unknown[], TData>( action: (...args: TArgs) => Promise<ActionResult<TData>> ) { const [isPending, setIsPending] = useState(false); const execute = useCallback( async (...args: TArgs): Promise<TData | null> => { setIsPending(true); try { const result = await action(...args); if (!result.ok) { toast.error(result.error); return null; } return result.data; } finally { setIsPending(false); } }, [action] ); return { execute, isPending }; }

Usage:

"use client"; import { useAsyncAction } from "@/hooks/useAsyncAction"; import { executeActionItem } from "../actions"; export function ActionItemCard({ item }: Props) { const { execute, isPending } = useAsyncAction(executeActionItem); return ( <Button onClick={() => execute(item.id)} disabled={isPending}> {isPending ? "Running…" : "Execute"} </Button> ); }

The loading boilerplate collapses to one line. Toast errors are handled automatically by the hook.


6. TypeScript Discipline — Co-located Types

Current pattern

Types are defined inline in components or in ad-hoc interfaces scattered across files. The same shape is often re-typed in multiple places.

// Defined in page.tsx type ActivityItem = { id: string; title: string; status: string; // ← not an enum }; // Redefined differently in ActivityCard.tsx type Activity = { id: string; name: string; // ← different key name status: "active" | "done"; };

Problems:

  • Same domain concept typed differently in different files → runtime mismatches.
  • status: string instead of a union type loses all type narrowing.
  • No single file to update when the backend changes a field.

Target pattern

Each feature has one *Types.ts file. All type definitions for that feature live there. No type is defined inline in a component file.

src/features/activities/activitiesTypes.ts

// Enums as const objects — usable as both type and value export const ACTIVITY_STATUS = { Pending: "pending", InProgress: "in_progress", Completed: "completed", Failed: "failed", } as const; export type ActivityStatus = (typeof ACTIVITY_STATUS)[keyof typeof ACTIVITY_STATUS]; export type Activity = { id: string; title: string; status: ActivityStatus; type: string; tenantId: string; dueDate: string | null; completedAt: string | null; createdAt: string; }; export type ActivitiesPage = { data: Activity[]; pagination: { page: number; pageSize: number; total: number; totalPages: number; }; }; export type ActivitiesFilters = { status?: ActivityStatus; type?: string; page?: number; pageSize?: number; };

Rule for schemas: Zod schemas that are used for both validation and type inference also live in the feature folder (activitiesSchema.ts), separate from pure type definitions.

// activitiesSchema.ts import { z } from "zod"; import { ACTIVITY_STATUS } from "./activitiesTypes"; export const activityStatusSchema = z.enum( Object.values(ACTIVITY_STATUS) as [string, ...string[]] ); export const activitiesFiltersSchema = z.object({ status: activityStatusSchema.optional(), type: z.string().optional(), page: z.coerce.number().int().positive().default(1), pageSize: z.coerce.number().int().positive().max(100).default(20), });

7. Environment Variables — Validated at Boot

Current pattern

Environment variables are accessed raw via process.env.NEXT_PUBLIC_* throughout the codebase.

// Scattered across multiple files const apiUrl = process.env.API_URL; const socketUrl = process.env.NEXT_PUBLIC_API_SOCKET_URL; // Fails silently if the variable is missing — apiUrl is undefined fetch(`${apiUrl}/action-items`);

Problems:

  • Missing env variables cause runtime errors with no clear error message.
  • No TypeScript types — process.env.* is always string | undefined.
  • No single file documents what variables are required.

Target pattern

Validate env variables at startup using Zod. Export a typed env object. The app fails immediately on boot with a clear message if any required variable is missing.

src/lib/env.ts

import { z } from "zod"; const envSchema = z.object({ // Server-only API_URL: z.string().url(), JWT_SECRET: z.string().min(32), DATABASE_URL: z.string().url().optional(), // if needed // Public (available client-side) NEXT_PUBLIC_API_SOCKET_URL: z.string().url(), NEXT_PUBLIC_APP_NAME: z.string().default("Leadmetrics"), }); const parsed = envSchema.safeParse(process.env); if (!parsed.success) { console.error("❌ Missing or invalid environment variables:"); console.error(parsed.error.flatten().fieldErrors); process.exit(1); } export const env = parsed.data;

Usage everywhere else:

import { env } from "@/lib/env"; // env.API_URL is string (not string | undefined) const res = await fetch(`${env.API_URL}/action-items`);

8. State Management — Explicit Layering

Current pattern

State is managed inconsistently: some data is fetched in server components and passed as props, some is fetched inside client components via server actions, some is duplicated between URL params and useState. There is no explicit rule for which layer owns which state.

Target pattern

Three explicit layers, each with a defined responsibility:

LayerToolWhat belongs here
Server stateAsync server components + revalidatePathData that comes from the database or API — activities, leads, tenants
URL stateuseSearchParams + router.pushFilters, pagination, selected tab — anything that should survive a refresh or be shareable as a link
Local UI stateuseState / useReducerTransient UI — open/closed modal, hover state, in-progress animation

Example — filters belong in URL state, not useState:

// ❌ Current: filters in useState — lost on refresh, not shareable const [status, setStatus] = useState(""); const [page, setPage] = useState(1); // ✅ Target: filters in URL params "use client"; import { useRouter, useSearchParams } from "next/navigation"; export function ActivitiesFilters() { const router = useRouter(); const params = useSearchParams(); const setFilter = (key: string, value: string) => { const next = new URLSearchParams(params.toString()); value ? next.set(key, value) : next.delete(key); next.set("page", "1"); // reset to page 1 on filter change router.push(`?${next.toString()}`); }; return ( <Select value={params.get("status") ?? ""} onValueChange={(v) => setFilter("status", v)} > ... </Select> ); }
// page.tsx — reads filters from URL, passes to server query export default async function ActivitiesPage({ searchParams, }: { searchParams: Promise<Record<string, string>>; }) { const filters = activitiesFiltersSchema.parse(await searchParams); const { data, pagination } = await activitiesService.list(filters); return <ActivitiesClient items={data} pagination={pagination} filters={filters} />; }

This makes the back button work correctly, makes filters shareable as links, and removes the need for useEffect to re-fetch when filters change.


9. Constants — Namespaced and Typed

Current pattern

Magic strings and numbers appear inline throughout the codebase.

// Duplicated in multiple files const res = await fetch(`${process.env.API_URL}/dm/v1/action-items/approve`); // ... document.cookie = `dm_active_tenant=; Max-Age=0`; // ... const PAGE_SIZE = 20; // defined in every file that needs it

Target pattern

A src/lib/constants.ts file per app exports all stable constants, namespaced by concern.

src/lib/constants.ts

export const AUTH = { ACCESS_TOKEN_COOKIE: "dashboard_access_token", REFRESH_TOKEN_COOKIE: "dashboard_refresh_token", TOKEN_EXPIRY_DAYS: 30, } as const; export const PAGINATION = { DEFAULT_PAGE_SIZE: 20, MAX_PAGE_SIZE: 100, } as const; export const ROUTES = { LOGIN: "/login", DASHBOARD: "/", ACTIVITIES: "/activities", ACTION_ITEMS: "/action-items", } as const; export const SOCKET = { RECONNECT_ATTEMPTS: 5, RECONNECT_DELAY_MS: 2000, } as const;

Using as const means TypeScript infers literal types — AUTH.ACCESS_TOKEN_COOKIE is "dashboard_access_token", not string, which makes switch statements and comparisons exhaustive.


10. What to Keep from the Current Apps

The following patterns in the current apps are correct and should not be changed:

Edge middleware auth

The @leadmetrics/middleware JWT verification that runs before any page loads is architecturally stronger than client-side token checks. Keep it exactly as-is.

Server Components for data fetching

Async page.tsx components that fetch data server-side and pass it as props to client components is the right model. The pattern should be preserved and extended:

// ✅ Keep this export default async function Page() { const data = await service.list(); // runs on server, no loading state return <ClientComponent data={data} />; }

Route groups for layout isolation

(auth)/, (dashboard)/, (manage)/ grouped routes that share a layout without adding a URL segment are idiomatic and correct.

Server Actions for mutations

Server actions ("use server") are the right primitive for mutations. They provide type safety, automatic CSRF protection, and no JSON serialization overhead. The pattern is correct — the issue is only the inconsistent error handling and missing withAction wrapper around them.

revalidatePath for cache invalidation

Calling revalidatePath("/action-items") inside server actions after a mutation is the correct Next.js 15 cache invalidation approach. Keep it.

Socket.io with short-lived JWT

The pattern of issuing a short-lived JWT specifically for WebSocket auth via /api/socket-token is correct. Keep it.


Refactoring Priority Order

Given the scope of changes above, the recommended order:

  1. src/lib/env.ts — Fast, no risk, immediate safety benefit. Do this first.
  2. src/lib/api/client.ts — Central. Unblocks consistent error handling in all subsequent work.
  3. src/lib/action-handler.ts + withAction — Wrap existing actions without changing their logic.
  4. Feature modules — Migrate one feature at a time. Start with Action Items (most complex, good test case).
  5. Forms — Install react-hook-form + Zod, migrate one form per sprint.
  6. Types — Co-locate types as you migrate each feature.
  7. Constants — Consolidate as you touch each file.
  8. URL state for filters — Last, as it requires coordinated changes to pages and client components.

© 2026 Leadmetrics — Internal use only