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
- Feature Module Organization
- Centralized API Client
- Forms — react-hook-form + Zod
- Centralized Error Handling
- Loading State Management
- TypeScript Discipline — Co-located Types
- Environment Variables — Validated at Boot
- State Management — Explicit Layering
- Constants — Namespaced and Typed
- 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.tsxProblem: 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_URLaccessed 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
useStateper 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?.errorstring 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.isSubmittingreplaces the manual loading boolean.- Per-field error messages come from Zod automatically.
LoginFormValuestype 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/resolverszod 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.statusCodeis 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 hereActionResult<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: stringinstead 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 alwaysstring | 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:
| Layer | Tool | What belongs here |
|---|---|---|
| Server state | Async server components + revalidatePath | Data that comes from the database or API — activities, leads, tenants |
| URL state | useSearchParams + router.push | Filters, pagination, selected tab — anything that should survive a refresh or be shareable as a link |
| Local UI state | useState / useReducer | Transient 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 itTarget 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:
src/lib/env.ts— Fast, no risk, immediate safety benefit. Do this first.src/lib/api/client.ts— Central. Unblocks consistent error handling in all subsequent work.src/lib/action-handler.ts+withAction— Wrap existing actions without changing their logic.- Feature modules — Migrate one feature at a time. Start with Action Items (most complex, good test case).
- Forms — Install react-hook-form + Zod, migrate one form per sprint.
- Types — Co-locate types as you migrate each feature.
- Constants — Consolidate as you touch each file.
- URL state for filters — Last, as it requires coordinated changes to pages and client components.