| name | frontend-architecture |
| description | Frontend architecture patterns — invoked when choosing rendering strategies, structuring large applications, designing API layers, state architecture, build tooling, or testing strategy. |
Frontend Architecture Patterns
Rendering Strategies
Decision tree:
Content changes how often?
├─ Never / rarely (docs, marketing)
│ → SSG (Astro, Next.js static export)
│
├─ Periodically (news, product catalog)
│ → ISR (Next.js revalidate, Astro on-demand)
│
├─ Per-request (user dashboard, personalized)
│ Is fast initial HTML critical?
│ ├─ YES → SSR + Streaming (Next.js App Router)
│ └─ NO → CSR (Vite SPA)
│
└─ Real-time (collaboration, trading)
→ CSR + WebSockets/SSE
Comparison
Strategy │ TTFB │ FCP │ SEO │ Personalized │ Infra cost
──────────┼──────────┼──────────┼──────┼──────────────┼───────────
CSR │ Fast │ Slow* │ Poor │ Yes │ Low (CDN)
SSR │ Variable │ Fast │ Good │ Yes │ Medium
SSG │ Fastest │ Fastest │ Good │ No │ Lowest
ISR │ Fast │ Fast │ Good │ Partial │ Low
Streaming │ Fast │ Fast │ Good │ Yes │ Medium
* CSR FCP is slow because JS must download, parse, execute before render
CSR (Client-Side Rendering)
import { createRoot } from 'react-dom/client';
import { BrowserRouter } from 'react-router-dom';
import { QueryClientProvider, QueryClient } from '@tanstack/react-query';
import { App } from './App';
const queryClient = new QueryClient();
createRoot(document.getElementById('root')!).render(
<QueryClientProvider client={queryClient}>
<BrowserRouter>
<App />
</BrowserRouter>
</QueryClientProvider>
);
SSG (Static Site Generation)
export async function generateStaticParams() {
const posts = await fetchAllPosts();
return posts.map(post => ({ slug: post.slug }));
}
export default async function BlogPostPage({
params,
}: {
params: { slug: string };
}) {
const post = await fetchPost(params.slug);
return <Article post={post} />;
}
ISR (Incremental Static Regeneration)
export const revalidate = 1800;
export default async function ProductPage({ params }: { params: { id: string } }) {
const product = await fetchProduct(params.id);
return <ProductDetail product={product} />;
}
import { revalidatePath, revalidateTag } from 'next/cache';
export async function POST(req: Request) {
const { secret, type, id } = await req.json();
if (secret !== process.env.REVALIDATE_SECRET) {
return Response.json({ error: 'Invalid secret' }, { status: 401 });
}
revalidateTag(`product-${id}`);
return Response.json({ revalidated: true, timestamp: Date.now() });
}
Streaming SSR
import { Suspense } from 'react';
export default function DashboardPage() {
return (
<main>
{/* Renders immediately — shell HTML streamed first */}
<DashboardShell />
{/* These stream in as their async Server Components resolve */}
<Suspense fallback={<KPICardsSkeleton />}>
<KPICards /> {/* fetches metrics */}
</Suspense>
<Suspense fallback={<ChartSkeleton />}>
<RevenueChart /> {/* fetches chart data */}
</Suspense>
<Suspense fallback={<TableSkeleton />}>
<RecentOrdersTable /> {/* fetches orders */}
</Suspense>
</main>
);
}
async function KPICards() {
const metrics = await db.metrics.findMany({ where: { period: 'current' } });
return (
<div className="kpi-grid">
{metrics.map(m => <KPICard key={m.id} metric={m} />)}
</div>
);
}
Application Structure
Feature-Based Folder Structure
src/
├── features/ ← domain slices
│ ├── auth/
│ │ ├── components/
│ │ │ ├── LoginForm.tsx
│ │ │ └── AuthGuard.tsx
│ │ ├── hooks/
│ │ │ └── useAuth.ts
│ │ ├── api/
│ │ │ └── auth.api.ts
│ │ ├── store/
│ │ │ └── auth.store.ts
│ │ └── types.ts
│ │
│ ├── orders/
│ │ ├── components/
│ │ ├── hooks/
│ │ ├── api/
│ │ └── types.ts
│ │
│ └── products/
│ ├── components/
│ ├── hooks/
│ ├── api/
│ └── types.ts
│
├── shared/ ← cross-feature utilities
│ ├── components/ ← generic UI (Button, Input, Modal)
│ ├── hooks/ ← useDebounce, useLocalStorage
│ ├── utils/ ← formatCurrency, parseDate
│ └── types.ts ← shared TypeScript types
│
├── app/ ← app shell, routing, providers
│ ├── providers.tsx
│ ├── router.tsx
│ └── layout.tsx
│
└── pages/ (or app/ for Next.js)
├── dashboard/
├── orders/
└── settings/
Why feature-based over type-based:
Type-based (harder to work with): Feature-based (co-located):
src/components/OrderTable.tsx src/features/orders/components/OrderTable.tsx
src/hooks/useOrderFilters.ts src/features/orders/hooks/useOrderFilters.ts
src/api/orders.ts src/features/orders/api/orders.api.ts
src/types/order.ts src/features/orders/types.ts
To work on Orders, you touch 4 dirs. Everything in one place.
To delete Orders, you hunt everywhere. Delete one folder.
Module Federation (Micro-Frontends at Runtime)
new ModuleFederationPlugin({
name: 'shell',
remotes: {
checkout: 'checkout@https://checkout.example.com/remoteEntry.js',
analytics: 'analytics@https://analytics.example.com/remoteEntry.js',
},
shared: { react: { singleton: true }, 'react-dom': { singleton: true } },
})
new ModuleFederationPlugin({
name: 'checkout',
filename: 'remoteEntry.js',
exposes: {
'./CheckoutFlow': './src/CheckoutFlow',
'./CartSummary': './src/CartSummary',
},
shared: { react: { singleton: true } },
})
const CheckoutFlow = lazy(() => import('checkout/CheckoutFlow'));
State Architecture
Three categories of state — each needs a different tool:
┌─────────────────────────────────────────────────────────────┐
│ SERVER STATE │
│ Data from API; lives on server; cached on client │
│ Tool: TanStack Query / SWR │
│ Examples: user profile, product list, order history │
└─────────────────────────────────────────────────────────────┘
┌─────────────────────────────────────────────────────────────┐
│ CLIENT UI STATE │
│ Local to UI; doesn't need persistence or sharing │
│ Tool: useState (local) / Zustand (shared) │
│ Examples: modal open, selected tab, form dirty state │
└─────────────────────────────────────────────────────────────┘
┌─────────────────────────────────────────────────────────────┐
│ URL STATE │
│ Lives in URL; shareable, bookmarkable, browser-history safe│
│ Tool: nuqs / useSearchParams │
│ Examples: search query, filters, pagination, sort order │
└─────────────────────────────────────────────────────────────┘
URL State with nuqs
import { useQueryState, parseAsInteger, parseAsString, parseAsArrayOf } from 'nuqs';
function ProductFilters() {
const [page, setPage] = useQueryState('page', parseAsInteger.withDefault(1));
const [search, setSearch] = useQueryState('q', parseAsString.withDefault(''));
const [sort, setSort] = useQueryState('sort', parseAsString.withDefault('relevance'));
const [categories, setCategories] = useQueryState('cat', parseAsArrayOf(parseAsString));
return (
<div>
<SearchInput value={search} onChange={setSearch} />
<CategoryFilter selected={categories} onChange={setCategories} />
<SortSelect value={sort} onChange={setSort} />
<Pagination page={page} onPageChange={setPage} />
</div>
);
}
State Colocation Principle
State should live as close to where it's used as possible.
Only lift state when two components genuinely need to share it.
Local → Sibling → Parent → Feature store → Global store → URL
↑ ↑ ↑
(lift when (Zustand slice (rare; truly
siblings share) when feature app-wide state)
components share)
API Layer
Never call fetch directly in components
function ProductList() {
const [products, setProducts] = useState([]);
useEffect(() => {
fetch('/api/products').then(r => r.json()).then(setProducts);
}, []);
}
import { api } from '@/shared/api/client';
import type { Product, ProductFilters } from '../types';
export const productsApi = {
list: (filters: ProductFilters) =>
api.get<Product[]>('/products', { params: filters }),
getById: (id: string) =>
api.get<Product>(`/products/${id}`),
create: (data: Omit<Product, 'id'>) =>
api.post<Product>('/products', data),
update: (id: string, data: Partial<Product>) =>
api.patch<Product>(`/products/${id}`, data),
};
import ky from 'ky';
export const api = ky.create({
prefixUrl: process.env.NEXT_PUBLIC_API_URL,
headers: { 'Content-Type': 'application/json' },
hooks: {
beforeRequest: [
req => {
const token = getAuthToken();
if (token) req.headers.set('Authorization', `Bearer ${token}`);
},
],
afterResponse: [
async (_req, _opts, res) => {
if (res.status === 401) redirectToLogin();
},
],
},
}).extend({ parseJson: r => r.json() });
OpenAPI Code Generation
npx openapi-typescript https://api.example.com/openapi.json -o src/shared/api/schema.ts
import createClient from 'openapi-fetch';
import type { paths } from './schema';
export const client = createClient<paths>({
baseUrl: process.env.NEXT_PUBLIC_API_URL,
});
const { data, error } = await client.GET('/products/{id}', {
params: { path: { id: '123' } },
});
tRPC (End-to-End Type Safety)
import { z } from 'zod';
import { router, publicProcedure, protectedProcedure } from '../trpc';
export const productsRouter = router({
list: publicProcedure
.input(z.object({ category: z.string().optional(), page: z.number().default(1) }))
.query(async ({ input, ctx }) => {
return ctx.db.products.findMany({
where: input.category ? { category: input.category } : undefined,
skip: (input.page - 1) * 20,
take: 20,
});
}),
create: protectedProcedure
.input(z.object({ name: z.string().min(1), price: z.number().positive() }))
.mutation(async ({ input, ctx }) => {
return ctx.db.products.create({ data: input });
}),
});
import { trpc } from '@/utils/trpc';
function ProductList() {
const { data } = trpc.products.list.useQuery({ category: 'shoes', page: 1 });
}
Build and Tooling
Vite Configuration
import { defineConfig } from 'vite';
import react from '@vitejs/plugin-react';
import { visualizer } from 'rollup-plugin-visualizer';
export default defineConfig({
plugins: [
react(),
visualizer({ open: true }),
],
resolve: {
alias: {
'@': '/src',
'@features': '/src/features',
'@shared': '/src/shared',
},
},
build: {
rollupOptions: {
output: {
manualChunks: {
'vendor-react': ['react', 'react-dom'],
'vendor-query': ['@tanstack/react-query'],
'vendor-router': ['react-router-dom'],
},
},
},
sourcemap: true,
},
server: {
proxy: {
'/api': 'http://localhost:8080',
},
},
});
TypeScript Strict Config
{
"compilerOptions": {
"target": "ES2022",
"lib": ["ES2022", "DOM", "DOM.Iterable"],
"module": "ESNext",
"moduleResolution": "Bundler",
"jsx": "react-jsx",
"strict": true,
"noUncheckedIndexedAccess": true,
"exactOptionalPropertyTypes": true,
"noImplicitReturns": true,
"noFallthroughCasesInSwitch": true,
"baseUrl": ".",
"paths": {
"@/*": ["src/*"],
"@features/*": ["src/features/*"],
"@shared/*": ["src/shared/*"]
},
"skipLibCheck": true
}
}
Testing Strategy
Testing pyramid:
/\
/ \
/ E2E \ Playwright — few, critical paths
/────────\
/ Integration\ Testing Library + MSW — feature flows
/──────────────\
/ Unit Tests \ Vitest — many, fast, component behavior
/──────────────────\
Unit / Component Tests (Vitest + Testing Library)
import { render, screen, userEvent } from '@testing-library/react';
import { vi } from 'vitest';
import { LoginForm } from './LoginForm';
const setup = () => {
const onSubmit = vi.fn();
render(<LoginForm onSubmit={onSubmit} />);
return { onSubmit };
};
test('submits credentials when form is filled', async () => {
const { onSubmit } = setup();
const user = userEvent.setup();
await user.type(screen.getByLabelText(/email/i), 'alice@example.com');
await user.type(screen.getByLabelText(/password/i), 'secret123');
await user.click(screen.getByRole('button', { name: /sign in/i }));
expect(onSubmit).toHaveBeenCalledWith({
email: 'alice@example.com',
password: 'secret123',
});
});
test('shows error when email is invalid', async () => {
setup();
const user = userEvent.setup();
await user.type(screen.getByLabelText(/email/i), 'not-an-email');
await user.click(screen.getByRole('button', { name: /sign in/i }));
expect(screen.getByRole('alert')).toHaveTextContent(/valid email/i);
});
Integration Tests with MSW
import { http, HttpResponse } from 'msw';
export const handlers = [
http.get('/api/products', () =>
HttpResponse.json([
{ id: '1', name: 'Widget', price: 29.99 },
{ id: '2', name: 'Gadget', price: 49.99 },
])
),
http.post('/api/products', async ({ request }) => {
const body = await request.json();
return HttpResponse.json({ id: '3', ...body }, { status: 201 });
}),
];
import { setupServer } from 'msw/node';
const server = setupServer(...handlers);
beforeAll(() => server.listen());
afterEach(() => server.resetHandlers());
afterAll(() => server.close());
test('product list loads and displays items', async () => {
render(
<QueryClientProvider client={new QueryClient()}>
<ProductList />
</QueryClientProvider>
);
expect(await screen.findByText('Widget')).toBeInTheDocument();
expect(screen.getByText('$29.99')).toBeInTheDocument();
});
E2E Tests with Playwright
import { test, expect } from '@playwright/test';
test.describe('Checkout flow', () => {
test.beforeEach(async ({ page }) => {
await page.request.post('/test/seed', { data: { scenario: 'checkout' } });
await page.goto('/products');
});
test('user can complete purchase', async ({ page }) => {
await page.getByRole('button', { name: /add to cart/i }).first().click();
await expect(page.getByRole('status', { name: /cart/i })).toContainText('1');
await page.getByRole('link', { name: /checkout/i }).click();
await expect(page).toHaveURL('/checkout');
await page.getByLabel(/first name/i).fill('Alice');
await page.getByLabel(/email/i).fill('alice@test.com');
await page.getByRole('button', { name: /place order/i }).click();
await expect(page.getByRole('heading', { name: /order confirmed/i })).toBeVisible();
});
});
export default defineConfig({
testDir: './tests',
fullyParallel: true,
forbidOnly: !!process.env.CI,
retries: process.env.CI ? 2 : 0,
workers: process.env.CI ? 1 : undefined,
reporter: 'html',
use: {
baseURL: 'http://localhost:5173',
trace: 'on-first-retry',
},
projects: [
{ name: 'chromium', use: { ...devices['Desktop Chrome'] } },
{ name: 'Mobile Safari', use: { ...devices['iPhone 14'] } },
],
webServer: {
command: 'npm run dev',
url: 'http://localhost:5173',
reuseExistingServer: !process.env.CI,
},
});
Environment Variables
VITE_API_URL=https://api.example.com
API_SECRET=secret
NEXT_PUBLIC_API_URL=https://api.example.com
DATABASE_URL=postgres://...
Anti-Patterns
- Type-based folder structure:
src/components/, src/hooks/ — causes high coupling between features; use feature-based structure
- Fetching directly in components with useEffect: race conditions, no caching; use TanStack Query
- Storing server state in Redux/Zustand: doubles the state; TanStack Query IS the store for server data
- No API abstraction layer:
fetch('/api/...') in components — not typesafe, not testable, repeated error handling
- CSR for SEO-important pages: crawlers may not execute JS; use SSG or SSR
- SSR for highly dynamic, auth-gated apps: overcomplicated for no SEO benefit; CSR is fine
- Not code-splitting: single 2MB JS bundle; split at routes and heavy features
any in TypeScript: defeats the purpose; use unknown + type guards or generated types
- E2E tests for unit-level concerns: slow; use unit tests for logic; E2E for critical paths only
Quick Reference Checklist
Architecture:
Build:
Testing:
Code quality: