e State Domain Separation**
Server state and client state serve fundamentally different purposes. Server state represents data that lives on a backend, requires synchronization, and benefits from caching. Client state represents UI interactions, form inputs, and ephemeral session data. Mixing them creates race conditions, unnecessary re-renders, and cache corruption.
Implement a typed client wrapper that isolates server communication and handles retries, timeouts, and error boundaries explicitly:
// api/dataClient.ts
import type { ApiConfig, Fetcher } from './types';
const defaultConfig: ApiConfig = {
baseUrl: process.env.NEXT_PUBLIC_API_URL ?? '',
timeout: 8000,
retryAttempts: 2,
retryDelay: 300,
};
export function createDataClient(config: Partial<ApiConfig> = {}): Fetcher {
const merged = { ...defaultConfig, ...config };
return async function request<T>(endpoint: string, init?: RequestInit): Promise<T> {
const url = `${merged.baseUrl}${endpoint}`;
const controller = new AbortController();
const timeoutId = setTimeout(() => controller.abort(), merged.timeout);
try {
const response = await fetch(url, {
...init,
signal: controller.signal,
headers: { 'Content-Type': 'application/json', ...init?.headers },
});
clearTimeout(timeoutId);
if (!response.ok) throw new Error(`API Error: ${response.status}`);
return response.json() as Promise<T>;
} catch (error) {
if (error instanceof DOMException && error.name === 'AbortError') {
throw new Error('Request timed out');
}
throw error;
}
};
}
export const api = createDataClient();
Pair this with a lightweight client store strictly for UI state. Avoid storing API responses here:
// store/navigationStore.ts
import { create } from 'zustand';
interface NavigationState {
panelExpanded: boolean;
activeRoute: string;
togglePanel: () => void;
navigate: (path: string) => void;
}
export const useNavigationStore = create<NavigationState>((set) => ({
panelExpanded: false,
activeRoute: '/',
togglePanel: () => set((s) => ({ panelExpanded: !s.panelExpanded })),
navigate: (path) => set({ activeRoute: path }),
}));
Step 2: Align Rendering Strategy with Traffic Patterns
Rendering choices must map to actual requirements, not defaults. Static generation suits marketing pages and documentation. Server-side rendering fits personalized dashboards and SEO-critical content. Client-side rendering remains appropriate for highly interactive, authenticated tools where TTFB is less critical than interactivity.
Configure route-level rendering directives explicitly in your framework configuration:
// next.config.ts
import type { NextConfig } from 'next';
const nextConfig: NextConfig = {
experimental: {
optimizePackageImports: ['@design-system/core'],
},
images: {
formats: ['image/avif', 'image/webp'],
minimumCacheTTL: 60,
},
async rewrites() {
return [
{ source: '/docs/:path*', destination: '/static-docs/:path*' },
{ source: '/dashboard/:path*', destination: '/server-rendered/:path*' },
];
},
};
export default nextConfig;
Step 3: Implement Contract-First API Development
Frontend-backend drift is a primary cause of integration failures. Enforce typed contracts using OpenAPI or GraphQL schemas. Generate client types automatically to prevent manual synchronization errors.
// contracts/tenant.ts
export interface TenantProfile {
id: string;
name: string;
subscriptionTier: 'free' | 'pro' | 'enterprise';
lastActive: string;
features: string[];
}
export type ProfileResponse = {
data: TenantProfile;
meta: { requestId: string; cached: boolean; ttl: number };
};
Step 4: Establish CI-Gated Testing Protocols
Testing must cover unit logic, component integration, and end-to-end user flows. CI pipelines should block merges when coverage drops below thresholds or when E2E smoke tests fail.
Architecture decisions here prioritize predictability over flexibility. State separation prevents cache invalidation bugs. Rendering alignment optimizes resource allocation. Contract generation eliminates type drift. CI gates enforce quality before code reaches production.
Pitfall Guide
-
State Conflation
Explanation: Developers store API responses alongside UI toggles in a single global store. This causes unnecessary re-renders, breaks cache invalidation logic, and makes debugging data flow nearly impossible.
Fix: Route server data through a dedicated query client with built-in caching and background refetching. Reserve global stores strictly for ephemeral UI state like modals, tooltips, and navigation flags.
-
Rendering Defaulting
Explanation: Teams apply SSR to every route or default to client-only rendering out of habit. This wastes server resources, increases cold-start latency, or degrades SEO and TTFB.
Fix: Map each route to a rendering strategy based on data freshness requirements, personalization needs, and SEO priority. Document the rationale in the architecture guide and enforce it via route configuration.
-
Contract Drift
Explanation: Frontend and backend teams manually update types, leading to mismatched payloads, missing fields, and runtime errors that only surface in staging.
Fix: Generate TypeScript interfaces directly from OpenAPI or GraphQL schemas. Run type validation in CI to block merges when contracts diverge. Treat the schema as the single source of truth.
-
Testing Theater
Explanation: Projects boast high unit test coverage but lack integration or E2E tests. Components pass in isolation but fail when wired to real APIs, routing, or state synchronization.
Fix: Implement React Testing Library for component integration, Playwright or Cypress for critical user journeys, and enforce coverage gates in the pipeline. Test data flow, not just rendering.
-
Black-Box Handoff
Explanation: Vendors restrict repository access until launch, leaving internal teams unable to audit dependencies, review architecture, or prepare for maintenance.
Fix: Require day-one repository access, documented architecture diagrams, and a pre-launch build audit. Treat the handoff as a continuous process with shared ownership of CI/CD pipelines.
-
Multi-Tenancy Guesswork
Explanation: Teams select a data isolation strategy without analyzing tenant count, compliance requirements, or operational overhead. This leads to either security vulnerabilities or unmanageable infrastructure costs.
Fix: Evaluate row-level, schema-per-tenant, and database-per-tenant models against data residency laws, expected scale, and DevOps capacity before implementation. Document the trade-offs explicitly.
Production Bundle
Action Checklist
Decision Matrix
| Scenario | Recommended Approach | Why | Cost Impact |
|---|
| High-traffic marketing site | Static generation + CDN | Maximizes TTFB, minimizes server load, scales predictably | Low infrastructure cost |
| Personalized SaaS dashboard | Server-side rendering + streaming | Balances SEO, personalization, and interactivity without blocking hydration | Moderate compute cost |
| Internal admin tool | Client-side rendering + cached API | Reduces server overhead, prioritizes interactivity and real-time updates | Low compute, higher bandwidth |
| Multi-tenant compliance app | Schema-per-tenant or DB-per-tenant | Meets strict data isolation requirements and audit trails | High operational overhead |
| Rapid MVP validation | Row-level multi-tenancy + shared schema | Fastest implementation, lowest initial cost, easy to refactor later | Low initial, scales with refactoring |
Configuration Template
// tsconfig.json
{
"compilerOptions": {
"target": "ES2022",
"lib": ["dom", "dom.iterable", "esnext"],
"module": "esnext",
"moduleResolution": "bundler",
"strict": true,
"noUncheckedIndexedAccess": true,
"exactOptionalPropertyTypes": true,
"baseUrl": ".",
"paths": {
"@api/*": ["src/api/*"],
"@store/*": ["src/store/*"],
"@contracts/*": ["src/contracts/*"]
}
},
"include": ["next-env.d.ts", "**/*.ts", "**/*.tsx"],
"exclude": ["node_modules"]
}
# .github/workflows/ci.yml
name: Architecture Validation
on: [pull_request]
jobs:
validate:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with: { node-version: 20 }
- run: npm ci
- run: npm run typecheck
- run: npm run lint
- run: npm run test:unit -- --coverage
- run: npm run test:e2e -- --project=chromium
- run: npm run contract:validate
Quick Start Guide
- Initialize a monorepo with strict TypeScript configuration and path aliases for API, store, and contract layers.
- Generate API client types from your OpenAPI or GraphQL schema and commit them to a dedicated contracts directory.
- Configure a lightweight UI store for ephemeral state and a query client for server data, ensuring zero overlap.
- Set up CI pipelines with type checking, linting, unit coverage thresholds, and E2E smoke tests.
- Document rendering strategies per route and align them with traffic patterns before writing components.