react-typescript-2026" class="internal-link">Frontend architecture decisions made today determine whether your codebase is a joy to work with or a liability in 12 months. The ecosystem has settled on clear patterns for 2026. This guide covers the architecture choices that scale, the ones that don't, and how to structure a React/TypeScript codebase that your future self will thank you for.
The 2026 Frontend Stack (Consensus Picks)
| Layer | Recommendation | Why | |-------|----------------|-----| | **Framework** | Next.js 14+ (App Router) or Astro | RSC, streaming, edge, best DX | | **Language** | TypeScript (strict mode) | Catch bugs at compile time | | **Styling** | Tailwind CSS + CSS Variables | Utility-first, design tokens, no runtime | | **State** | TanStack Query (server) + Zustand/Jotai (client) | Separation of concerns, no prop drilling | | **Forms** | React Hook Form + Zod | Type-safe, performant, accessible | | **UI Primitives** | Radix UI / cms-comparison-wordpress-sanity-strapi-2026" class="internal-link">Headless UI / shadcn/ui | Accessible, unstyled, composable | | **Animation** | Framer Motion (complex) / CSS (simple) | Performant, reducer-friendly | | **Validation** | Zod (schema) + Valibot (lighter alt) | Runtime + compile-time types | | **Testing** | Vitest + React Testing Library + Playwright | Fast unit, realistic integration | | **Lint/Format** | ESLint (typescript-eslint) + Prettier + Husky | Consistent, automated | | **Build** | Turbopack (Next.js) / Vite (Astro) | Fast HMR, optimized production | | **Deploy** | Vercel / Netlify / Cloudflare Pages | Edge, preview deploys, zero config |
---
Project Structure: Feature-Folder Architecture
src/
βββ app/ # Next.js App Router pages (route segments)
β βββ (auth)/ # Route groups: layout-only grouping
β β βββ login/
β β βββ register/
β βββ (dashboard)/
β β βββ layout.tsx # Dashboard shell (sidebar, header)
β β βββ projects/
β β βββ settings/
β βββ api/ # Route handlers (server-only)
β βββ globals.css
β βββ layout.tsx # Root layout
β βββ page.tsx # Homepage
βββ components/ # Shared UI components (generic, reusable)
β βββ ui/ # Design system primitives (Button, Input, Dialog)
β β βββ button.tsx
β β βββ input.tsx
β β βββ index.ts # Barrel exports
β βββ forms/ # Form-specific components
β βββ layout/ # Header, Footer, Sidebar, Container
β βββ providers/ # Context providers (Query, Theme, Auth)
βββ features/ # Feature-based modules (business logic)
β βββ projects/
β β βββ components/ # Project-specific components
β β βββ hooks/ # useProjects, useProjectActions
β β βββ queries/ # TanStack Query keys, hooks
β β βββ schema/ # Zod schemas
β β βββ types.ts # TypeScript types
β β βββ utils.ts # Feature-specific utilities
β βββ auth/
β βββ billing/
β βββ notifications/
βββ hooks/ # Truly shared hooks (useMediaQuery, useLocalStorage)
βββ lib/ # Pure utilities, no React deps
β βββ api/ # API client (Ky/Fetch wrapper)
β βββ auth/ # Token management, auth helpers
β βββ date/ # date-fns wrappers
β βββ validation/ # Zod schemas reused across features
β βββ utils.ts # cn(), formatCurrency, etc.
βββ styles/ # Global styles, Tailwind config
β βββ globals.css
β βββ tokens.css # CSS custom properties (design tokens)
βββ types/ # Global types (API responses, env)
β βββ api.ts
β βββ user.ts
β βββ global.d.ts
βββ middleware.ts # Next.js middleware (auth, i18n, logging)**Key Principles:**
- **Colocation**: Feature code lives together (components, hooks, types, queries)
- **Shared only when proven**: `components/ui`, `hooks`, `lib` β not `components/ProjectCard` used once
- **Route groups** `(auth)` for layout grouping without URL segment
- **Server Components by default** β `'use client'` only when needed (interactivity, hooks, browser APIs)
---
Server Components First: The Mental Model
βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
β SERVER COMPONENT β
β - Runs on server (build time or request time) β
β - Direct DB/API access, no bundle size cost β
β - Streams HTML β client β
β - Cannot use: useState, useEffect, browser APIs, listeners β
βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
β
βΌ
βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
β CLIENT COMPONENT β
β - 'use client' directive β
β - Hydrates in browser, interactive β
β - Can use: hooks, state, effects, browser APIs β
β - Bundle size counts β
βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ**Default to Server Components.** Add `'use client'` only at the *boundary* where interactivity starts.
// app/projects/page.tsx β Server Component (default)
import { ProjectList } from '@/features/projects/components/ProjectList';
import { getProjects } from '@/features/projects/queries/getProjects';export default async function ProjectsPage() { // Direct data fetching β no API route needed const projects = await getProjects(); // async/await in Server Component
return ( Projects
// features/projects/components/ProjectList.tsx β Client Component 'use client';
import { useState } from 'react'; import { ProjectCard } from './ProjectCard';
interface ProjectListProps { initialProjects: Project[]; }
export function ProjectList({ initialProjects }: ProjectListProps) { const [projects, setProjects] = useState(initialProjects); const [filter, setFilter] = useState('');
// Client-side filtering, sorting, pagination return (
**Boundary Strategy:** Push `'use client'` down to leaves. Parent stays server, passes data as props.
---
Data Fetching: TanStack Query + Server Components
Server Components: Direct Data Access
// app/projects/[id]/page.tsx
import { ProjectDetail } from '@/features/projects/components/ProjectDetail';
import { getProject, getProjectTasks } from '@/features/projects/queries';export default async function ProjectPage({ params }: { params: { id: string } }) { // Parallel data fetching const [project, tasks] = await Promise.all([ getProject(params.id), getProjectTasks(params.id), ]);
if (!project) notFound();
return
Client Components: TanStack Query for Mutations & Client State
// features/projects/hooks/useProjectMutations.ts
import { useMutation, useQueryClient } from '@tanstack/react-query';
import { api } from '@/lib/api';export function useCreateProject() { const queryClient = useQueryClient();
return useMutation({ mutationFn: (data: CreateProjectInput) => api.post('/projects', data), onSuccess: () => { queryClient.invalidateQueries({ queryKey: ['projects'] }); }, }); }
// features/projects/components/ProjectForm.tsx 'use client';
import { useCreateProject } from '@/features/projects/hooks/useProjectMutations'; import { useForm } from 'react-hook-form'; import { zodResolver } from '@hookform/resolvers/zod'; import { createProjectSchema } from '@/features/projects/schema';
export function ProjectForm() { const createProject = useCreateProject(); const form = useForm({ resolver: zodResolver(createProjectSchema) });
const onSubmit = form.handleSubmit(data => createProject.mutate(data));
return (
); }---
State Management: Separation of Concerns
| State Type | Solution | Example | |------------|----------|---------| | **Server state** (cached, shared, sync) | TanStack Query | Projects, users, settings from API | | **Client UI state** (ephemeral, local) | `useState` / `useReducer` | Modal open, form input, tab selection | | **Global client state** (shared across features) | Zustand / Jotai | Theme, auth user, sidebar collapse | | **URL state** (shareable, bookmarkable) | Search params / Router | Filters, pagination, sort | | **Form state** | React Hook Form | Validation, submission, dirty tracking |
Global Store (Zustand) β Minimal, Typed
// lib/store/uiStore.ts
import { create } from 'zustand';
import { persist } from 'zustand/middleware';interface UIState { theme: 'light' | 'dark' | 'system'; sidebarOpen: boolean; setTheme: (theme: UIState['theme']) => void; toggleSidebar: () => void; }
export const useUIStore = create
---
Type-Safe API Layer
// lib/api/client.ts
import { z } from 'zod';// Base API error export class APIError extends Error { constructor(public status: number, public data: unknown) { super(`API Error: ${status}`); } }
// Typed fetch wrapper async function request
if (!res.ok) { const data = await res.json().catch(() => null); throw new APIError(res.status, data); }
if (res.status === 204) return undefined as T;
const data = await res.json(); return schema ? schema.parse(data) : data; }
// HTTP methods with Zod validation export const api = { get:
// Usage with full type safety const project = await api.get('/projects/123', projectSchema); // ^? Project (fully typed)
---
Design System: Tokens β Primitives β Patterns
1. Design Tokens (CSS Custom Properties)
/* styles/tokens.css */
:root {
/* Color */
--color-primary-50: #eff6ff;
--color-primary-500: #3b82f6;
--color-primary-600: #2563eb;
--color-primary-foreground: #ffffff;--color-neutral-50: #fafafa; --color-neutral-900: #171717;
/* Semantic */ --color-background: var(--color-neutral-50); --color-foreground: var(--color-neutral-900); --color-border: var(--color-neutral-200); --color-ring: var(--color-primary-500);
/* Spacing */ --space-1: 0.25rem; --space-2: 0.5rem; --space-3: 0.75rem; --space-4: 1rem; --space-6: 1.5rem; --space-8: 2rem;
/* Typography */ --font-sans: var(--font-inter), system-ui, sans-serif; --font-mono: var(--font-jetbrains-mono), monospace;
--text-xs: 0.75rem; --text-sm: 0.875rem; --text-base: 1rem; --text-lg: 1.125rem; --text-xl: 1.25rem; --text-2xl: 1.5rem; --text-3xl: 1.875rem; --text-4xl: 2.25rem;
/* Radius */ --radius-sm: 0.25rem; --radius-md: 0.375rem; --radius-lg: 0.5rem; --radius-xl: 0.75rem;
/* Shadows */ --shadow-sm: 0 1px 2px 0 rgb(0 0 0 / 0.05); --shadow-md: 0 4px 6px -1px rgb(0 0 0 / 0.1); --shadow-lg: 0 10px 15px -3px rgb(0 0 0 / 0.1);
/* Transitions */ --transition-fast: 150ms ease; --transition-normal: 200ms ease; }
@media (prefers-color-scheme: dark) { :root { --color-background: var(--color-neutral-950); --color-foreground: var(--color-neutral-50); --color-border: var(--color-neutral-800); } }
2. Primitive Components (shadcn/ui pattern)
// components/ui/button.tsx
'use client';import { Slot } from '@radix-ui/react-slot'; import { cva, type VariantProps } from 'class-variance-authority'; import { cn } from '@/lib/utils';
const buttonVariants = cva( 'inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50', { variants: { variant: { default: 'bg-primary text-primary-foreground hover:bg-primary/90', destructive: 'bg-destructive text-destructive-foreground hover:bg-destructive/90', outline: 'border border-input bg-background hover:bg-accent hover:text-accent-foreground', secondary: 'bg-secondary text-secondary-foreground hover:bg-secondary/80', ghost: 'hover:bg-accent hover:text-accent-foreground', link: 'text-primary underline-offset-4 hover:underline', }, size: { default: 'h-10 px-4 py-2', sm: 'h-9 rounded-md px-3', lg: 'h-11 rounded-md px-8', icon: 'h-10 w-10', }, }, defaultVariants: { variant: 'default', size: 'default' }, } );
export interface ButtonProps extends React.ButtonHTMLAttributes
export const Button = React.forwardRef
3. Pattern Components (Feature-specific compositions)
// features/projects/components/ProjectCard.tsx
'use client';import { Button } from '@/components/ui/button'; import { Badge } from '@/components/ui/badge'; import { Project } from '../types';
interface ProjectCardProps { project: Project; onEdit?: (id: string) => void; }
export function ProjectCard({ project, onEdit }: ProjectCardProps) { return ( {project.description}{project.name}
---
Testing Strategy: Pyramid for Frontend
βββββββββββββββββββ
β E2E (Playwright) β β 10% Critical user flows
β 5-10 tests β
βββββββββΌββββββββββββββββββΌββββββββ
β β Integration (RTL) β β β 20% Component interactions
β β 20-30 tests β β
βββββββββΌββββββββΌββββββββββββββββββΌββββββββΌββββββββ
β β β Unit (Vitest) β β β 70% Pure logic, hooks, utils
β β β 100+ tests β β
βββββββββ΄ββββββββ΄ββββββββββββββββββ΄ββββββββ΄ββββββββUnit: Pure Logic & Hooks
// features/projects/hooks/__tests__/useProjectFilters.test.ts
import { renderHook, act } from '@testing-library/react';
import { useProjectFilters } from '../useProjectFilters';describe('useProjectFilters', () => { it('filters projects by search term', () => { const projects = [ { id: '1', name: 'Alpha Project' }, { id: '2', name: 'Beta App' }, ];
const { result } = renderHook(() => useProjectFilters(projects));
act(() => { result.current.setSearch('alpha'); }); expect(result.current.filteredProjects).toHaveLength(1); expect(result.current.filteredProjects[0].name).toBe('Alpha Project'); }); });
Integration: Component Behavior
// features/projects/components/__tests__/ProjectForm.test.tsx
import { render, screen, fireEvent, waitFor } from '@testing-library/react';
import { ProjectForm } from '../ProjectForm';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { createProjectSchema } from '@/features/projects/schema';const wrapper = ({ children }: { children: React.ReactNode }) => (
it('submits form and shows success', async () => { render(
fireEvent.change(screen.getByLabelText(/name/i), { target: { value: 'New Project' } }); fireEvent.click(screen.getByRole('button', { name: /create/i }));
await waitFor(() => expect(screen.getByText(/created/i)).toBeInTheDocument()); });
E2E: Critical Flows
// e2e/projects.spec.ts
import { test, expect } from '@playwright/test';test('user can create and view a project', async ({ page }) => { await page.goto('/login'); await page.fill('[name="email"]', 'test@example.com'); await page.fill('[name="password"]', 'password123'); await page.click('button:has-text("Sign in")');
await page.goto('/dashboard/projects'); await page.click('button:has-text("New Project")'); await page.fill('[name="name"]', 'Playwright Test Project'); await page.click('button:has-text("Create")');
await expect(page.locator('text=Playwright Test Project')).toBeVisible(); });
---
Performance Checklist (Enforced in CI)
// package.json scripts
{
"scripts": {
"lint": "eslint . --ext .ts,.tsx",
"typecheck": "tsc --noEmit",
"test": "vitest run",
"test:e2e": "playwright test",
"build": "next build",
"analyze": "ANALYZE=true next build",
"ci": "npm run lint && npm run typecheck && npm run test && npm run build"
}
}# .github/workflows/ci.yml
name: CI
on: [push, pull_request]
jobs:
check:
runs-on: ubuntu-latest
steps:with: { node-version: 20, cache: 'npm' }
uses: treosh/lighthouse-ci-action@v11 with: { urls: 'https://preview-url.com', budgetPath: './lighthouse-budget.json' }
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
- run: npm ci
- run: npm run ci
- name: Lighthouse CI
// lighthouse-budget.json
{
"ci": {
"collect": { "numberOfRuns": 3 },
"assert": {
"assertions": {
"categories:performance": ["error", { "minScore": 0.9 }],
"categories:accessibility": ["error", { "minScore": 0.95 }],
"categories:best-practices": ["error", { "minScore": 0.9 }],
"categories:seo": ["error", { "minScore": 0.9 }],
"largest-contentful-paint": ["error", { "maxNumericValue": 2500 }],
"cumulative-layout-shift": ["error", { "maxNumericValue": 0.1 }],
"total-byte-weight": ["error", { "maxNumericValue": 500000 }]
}
}
}
}---
Migration Path: From Legacy to Modern
| Legacy Pattern | Modern Replacement | Effort | |----------------|-------------------|--------| | Class components | Function + hooks | Medium | | Redux + thunks | TanStack Query + Zustand | Medium | | CSS Modules / Styled Components | Tailwind + CSS Variables | Low-Medium | | React Router (SPA) | Next.js App Router (RSC) | High | | Prop drilling | Feature folders + context/selectors | Low | | Custom form handling | React Hook Form + Zod | Low | | Manual testing | Vitest + Playwright | Medium |
**Incremental approach:**
- Add TypeScript + ESLint + Prettier
- Introduce Tailwind + design tokens
- Migrate leaf components to shadcn/ui primitives
- Add TanStack Query for server state
- Migrate routes to Next.js App Router (one at a time)
- Convert remaining class components
- Add test coverage for critical paths
---
The Architecture Decision Record (ADR) Template
For every significant decision, document:
# ADR-004: State Management with TanStack Query + ZustandStatus: Accepted
Context
We need to manage server state (API data) and client state (UI) in our Next.js 14 app. Redux Toolkit was considered but adds boilerplate and doesn't solve server caching.
Decision
- Server state: TanStack Query (caching, deduping, invalidation, mutations)
- Global client state: Zustand (theme, auth, sidebar)
- Local UI state: useState/useReducer
- URL state: Search params
Consequences
+ Separation of concerns, less boilerplate, great DevTools
- Two libraries to learn (but both small APIs)
- Team must understand server vs client state distinction
Alternatives Considered
- Redux Toolkit: Too heavy, server caching requires RTK Query anyway
- Jotai: Great but Zustand simpler for our global needs
- React Context: Fine for theme, bad for high-frequency updates
---
The UI Designer Frontend Standard
Every project we build follows this architecture because:
- **Ships faster** β Feature folders = parallel work, less merge conflicts
- **Scales** β Adding features doesn't increase cognitive load
- **Performs** β Server Components + streaming + edge = fast by default
- **Maintains** β TypeScript + testing + linting = confident refactoring
- **Onboards** β New devs productive in days, not weeks
Want This Architecture for Your Project?
We scaffold this exact structure for every engagement. You get:
- Production-ready Next.js + TypeScript + Tailwind setup
- Design system with accessible primitives
- API layer with Zod validation
- Testing configuration (unit + integration + E2E)
- CI/CD with Lighthouse budgets
- Documentation + ADR templates
Book a free architecture review: ui-designer.in/frontend-architecture
Frequently Asked Questions
Frequently Asked Questions
- Why feature folders instead of flat structure?
- Feature folders (colocation) keep related code together β components, hooks, types, queries for a feature live in one place. This reduces cognitive load, enables parallel team work, and makes deletion/refactoring safer. Flat structures become unmanageable past ~50 components.
Frequently Asked Questions
- When should I use Server Components vs Client Components?
- Default to Server Components. Use Client Components (`'use client'`) only when you need: interactivity (useState, useEffect), browser APIs (localStorage, IntersectionObserver), or third-party libraries that require client execution. Push `'use client'` down to leaf components.
Frequently Asked Questions
- Is Redux dead?
- Not dead, but rarely needed for server state. TanStack Query handles caching, deduping, invalidation, and mutations far better. Zustand or Jotai cover global UI state with less boilerplate. Redux Toolkit + RTK Query is still valid if your team knows it well.
Frequently Asked Questions
- How do I handle authentication with Server Components?
- Use Next.js Middleware for route protection. Read session from cookies in Server Components via `cookies()` (App Router). For client-side auth state, use a lightweight Client Component provider that reads from a `/api/auth/me` endpoint.
Frequently Asked Questions
- What's the best way to share types between frontend and backend?
- Use a shared package in a monorepo (Turborepo) with tRPC for end-to-end type safety. Or generate TypeScript from OpenAPI/Swagger specs. Or use GraphQL Code Generator. Avoid manually duplicating types.
Frequently Asked Questions
- How do I migrate from Redux to TanStack Query?
- Incrementally. Keep Redux for UI state. Replace one API slice at a time with `useQuery`/`useMutation`. Use `queryClient.setQueryData` for optimistic updates. Remove Redux thunks/sagas as you go.
Frequently Asked Questions
- Is Turborepo worth it for small teams?
- Yes, if you have >2 packages (shared UI, shared config, apps). Caching, parallel execution, and independent versioning pay off quickly. For single-app projects, standard Vite/Next.js is simpler.

