Building a High-Performance SPA without Next.js
How LZStock eschews heavy SSR frameworks in favor of a layered MVP React architecture using Webpack, Jotai, and TanStack Router for a lightweight, maintainable dashboard.
- Ditch SSR for Pure SPA Speed: Eschewed heavy Next.js/SSR architectures for a Webpack-powered Client-Side SPA, eliminating Node.js infrastructure costs and hydration mismatches for an authenticated, zero-SEO dashboard.
- Eradicate Spaghetti Code via MVP Pattern: Enforced strict Clean Architecture on the frontend by decoupling "Dumb" UI components (Views) from business logic, utilizing Custom Hooks as orchestrators (Presenters) and TanStack Query as the data source (Model).
- Atomic Re-renders & Route-Level Security: Replaced boilerplate-heavy Redux and render-blocking React Context with Jotai for surgical, atomic state updates, while locking down protected boundaries using TanStack Router's type-safe
beforeLoadinterceptors.
The Objective
In the current front-end ecosystem, SSR frameworks like Next.js are the default. However, LZStock is a financial dashboard locked entirely behind an authentication wall. It requires zero SEO. Deploying a Node.js server to render React components on the server side adds unnecessary infrastructure costs, latency, and complexity (hydration mismatches). The objective was to build a pure Client-Side SPA that is statically hostable (via AWS S3 or Cloudflare Pages), incredibly fast after the initial load, and architecturally strict enough to prevent components from bloating into unmaintainable spaghetti code.
The Mental Model: The React MVP Pattern
Inspired by Clean Architecture on the backend, we implement the MVP (Model-View-Presenter) pattern on the frontend. UI Components (Views) are strictly "dumb." They contain no business logic or API calls. All interactions are delegated to Custom Hooks (Presenters/UseCases), which coordinate with our atomic state management and API clients (The Model).
Core Implementation
This strict separation of concerns allows front-end developers to test business logic independently of the DOM.
The Model (State & API Integration)
We leverage jotai-tanstack-query to blend atomic state management with server-state caching. The "Atom" represents our Model.
// src/models/apisWithAtoms/dashboard.ts
import { atomWithMutation } from 'jotai-tanstack-query'
export const createDashboardAtom = atomWithMutation<ICreateDashboardResDTO, ICreateDashboardReqDTO>((get) => {
const { investor_id } = get(authAtom)
return {
mutationKey: ['createDashboard', investor_id],
mutationFn: async (req: ICreateDashboardReqDTO) => {
// Axios instance with pre-configured interceptors
const { data } = await axios.post<ICreateDashboardResDTO>('/dashboard', req);
return data;
},
// Prevent execution if the user's state is not yet loaded
enabled: !!investor_id,
}
})
The Presenter (UseCases)
The custom hook acts as the orchestrator. It executes the mutation, formats errors securely, and invalidates stale cache data (forcing the UI to automatically refetch the updated dashboard list).
// src/models/useCases/useCreateDashboard.ts
export const useCreateDashboard = () => {
const { mutateAsync, isPending, isError, error } = useAtomValue(createDashboardAtom)
const { investor_id } = useAtomValue(authAtom)
const queryClient = useAtomValue(queryClientAtom)
const createDashboard = async (req: ICreateDashboardReqDTO) => {
try {
const data = await mutateAsync(req)
// Cache Invalidation: Triggers an automatic re-render of the dashboard list
queryClient.invalidateQueries({
queryKey: ['dashboards_meta', investor_id]
})
return data
} catch (error) {
const axiosError = error as AxiosError<IErrorDTO>;
return { error: axiosError?.response?.data || { message: 'Unknown error' } };
}
}
return { createDashboard, isPending, isError, error }
}
The View (Logical Component)
The React component is now drastically simplified. It only consumes the hook and handles rendering.
// src/components/logic/DashboardSidebar.tsx
export function DashboardSidebar() {
// Delegate all logic to the Presenter (UseCase)
const { createDashboard, isPending } = useCreateDashboard();
const handleCreateDashboard = async (templateId: string, dashboardName: string) => {
await createDashboard({
investor_id,
dashboard_name: dashboardName,
period_type: 'PERIOD_TYPE_1Y',
tool_template: templateId,
});
};
return (
<div>
<CreateDashboardDialog
onCreateDashboard={handleCreateDashboard}
isPending={isPending}
/>
</div>
)
}
Route-Level Security (TanStack Router)
To prevent unauthorized users from viewing the dashboard, we intercept navigation at the router level before the component even mounts, utilizing TanStack Router's type-safe beforeLoad hook.
// src/routes/app/_auth.tsx
import { createFileRoute, redirect } from '@tanstack/react-router'
export const Route = createFileRoute('/app/_auth')({
beforeLoad: async ({ context }) => {
// Intercepts routing at the boundaries
if (!context.auth.is_authenticated) {
throw redirect({
to: '/app/signin',
replace: true, // Prevents the user from clicking "Back" to a protected route
});
}
},
component: () => <DashboardLayout />,
})
Edge Cases & Trade-offs
- The SSR vs. SPA Trade-off: By completely abandoning Server-Side Rendering (SSR), our First Contentful Paint (FCP) metric is marginally slower because the browser must download an empty index.html and fetch the JavaScript bundle before rendering anything. However, the trade-off is absolutely worth it for a financial dashboard. Once the bundle is cached, subsequent route transitions via TanStack Router are instantaneous, and our cloud hosting costs remain close to zero (pure static assets).
- Why Jotai instead of Redux or Context? React Context forces re-renders on all consuming components when any part of the value changes. Redux requires massive boilerplate and centralized stores. Jotai provides a bottom-up, atomic approach. A component only subscribes to the specific atom it needs, guaranteeing surgical, highly optimized re-renders without prop-drilling.
- The Overhead of the MVP Pattern: Creating separate files for DTOs, Atoms, UseCases, and Views requires more initial boilerplate than writing everything inside a single .tsx file. However, as the team scales, this strict boundary allows developers to write pure TypeScript unit tests for the useCreateDashboard logic without having to mock the entire React DOM.
The Outcome
By combining Webpack, Jotai, TanStack Query, and a strict MVP architectural pattern, LZStock achieves a highly scalable, type-safe, and lightning-fast frontend application. We maintain maximum control over our bundle size while completely avoiding the operational overhead of Node.js-based server-side rendering frameworks.