OnlineCharlotte, NC
v2026.05
clt_AIGuy
// FRONT_END.exe

The layer users touch.

HTML, CSS, JavaScript, TypeScript, React, Next.js. The vocabulary you need to read and write production front-end code without faking it.

If you're learning with AI, use this page as your syllabus. Ask Claude or ChatGPT to explain anything you don't recognize, then build something that ships.

// LEGENDREAL-WORLDIMPLEMENTATIONPITFALLWAR_STORY— click to expand any block
// SECTION_01

The big mental model

Front-end is the layer where code meets the human. Everything else in the stack exists to serve what shows up on a screen — and what shows up has to be fast, accessible, and correct under conditions you don't control.

Before any framework, internalize what the browser actually does:

  1. Parses HTML into a tree of nodes (the DOM).
  2. Parses CSS into rules that apply to nodes (the CSSOM).
  3. Combines them into a render tree.
  4. Lays out elements (computes positions and sizes).
  5. Paints pixels.
  6. Composites layers.
  7. Runs JavaScript, which can change any of the above and trigger re-runs.
// BROWSER_RENDERING_PIPELINE HTML parse DOM tree of nodes CSS parse CSSOM rules per node render tree visible nodes only layout size + position paint pixels composite GPU layers JavaScript runtime mutates DOM mutates CSSOM (style) → triggers re-render of one or more pipeline stages CHEAPER → COSTLIER composite-only (transform/opacity) paint-only (color/bg) layout (width/height/margin) tree change (insert/remove nodes)

The browser is a chef working from three separate recipe books — HTML for the structure, CSS for the presentation, JavaScript for the behavior. Each ingredient changes what comes out. Change a CSS color, only the paint step re-runs. Change a layout-affecting property, the layout step re-runs (slow). Change something that affects the DOM tree itself, almost everything re-runs.

"Performant front-end" is mostly the discipline of triggering the cheapest possible re-runs.

The three concerns, separately

  • Structure (HTML) — what content exists and how it's organized semantically.
  • Presentation (CSS) — what it looks like.
  • Behavior (JavaScript) — what happens when users interact.

Modern frameworks blur these boundaries (CSS-in-JS, JSX, server components), but the underlying separation is still how the browser thinks. Knowing it explains why some patterns are fast and others aren't.

What "front-end engineering" actually involves in 2026

The job is broader than HTML/CSS/JS now:

  • UI components and design systems
  • State management (server, client, URL, form)
  • Routing and data fetching
  • Build tooling (bundlers, transpilers)
  • Performance (loading, rendering, interaction)
  • Accessibility
  • Testing (unit, integration, end-to-end, visual)
  • Type safety (TypeScript)
  • Mobile (responsive web, native apps via React Native)
  • Server-side rendering and the seams between server and client

This guide covers each of those, with one canonical modern stack used as the worked example: Next.js + TypeScript + Tailwind + shadcn/ui + TanStack Query. Most concepts transfer to other frameworks; the canonical stack is just to make examples concrete.

If you only remember one thing: the browser is the slowest part of your stack and the user's machine is the most variable. A backend developer optimizes for a known server. A front-end developer optimizes for every phone, laptop, network condition, and accessibility need that exists. This is why front-end has so many techniques and patterns — the constraints are wider than any other layer.

// SECTION_02

HTML and semantics

HTML is the bones. Get the structure right and accessibility, SEO, and styling all get easier. Get it wrong and you spend years working around the consequences.

Semantic HTML — what each tag actually means

Every HTML element has implicit meaning that browsers, search engines, and screen readers use. Using <div> for everything strips that meaning.

TagMeaningWhen to use
<header>Introductory contentTop of page or section
<nav>Navigation linksPrimary site nav, breadcrumbs
<main>Main page contentOne per page
<article>Self-contained contentBlog post, news article, comment
<section>Thematic groupingLogical division within an article or page
<aside>Tangentially relatedSidebar, callout, related links
<footer>Closing contentBottom of page or section
<button>Triggers an actionClick handlers, form submits
<a>Navigates somewhereLinks to URLs

The button vs link rule

The most common semantic mistake. Both look similar when styled, but:

  • If clicking it changes the URL, it's a link (<a>). Browsers know how to right-click, open in new tab, copy, bookmark.
  • If clicking it triggers an action without navigating, it's a button (<button>). Submits forms, opens dialogs, toggles state.

"Click here to read more" is a link. "Save changes" is a button. Confusing them breaks keyboard navigation and screen readers.

PITFALLThe div-with-onClick anti-pattern

Engineers wrap onClick on a <div> because they want custom styling. The result:

  • Not focusable with Tab key (need tabindex="0")
  • Doesn't activate on Enter or Space (need keyboard handlers)
  • Screen readers don't announce it as interactive (need role="button")
  • No focus ring by default
  • Doesn't work with form submission

The fix: use <button> and style it. Reset the default styles with CSS:

button {
  background: none;
  border: none;
  padding: 0;
  cursor: pointer;
  font: inherit;
  color: inherit;
}

Now you have a properly accessible button you can style however you want. Tailwind users typically just apply utility classes directly to the button element.

Forms — the most-used and most-broken element

Native form elements are wildly underrated. They handle keyboard, accessibility, validation, and mobile keyboards correctly out of the box.

<form action="/signup" method="POST">
  <label for="email">Email</label>
  <input type="email" id="email" name="email" required autocomplete="email">

  <label for="password">Password</label>
  <input type="password" id="password" name="password"
         required minlength="8" autocomplete="new-password">

  <button type="submit">Create account</button>
</form>

What this gets you for free:

  • Mobile devices show the right keyboard (type="email" shows the @ key prominently)
  • Browser-native validation (required, minlength, type="email" pattern)
  • Password managers offer to save credentials (autocomplete="new-password")
  • Submit on Enter key works automatically
  • Screen readers announce labels via for / id
  • Form submits even without JavaScript (progressive enhancement)

Input types worth knowing

TypeEffect
emailEmail keyboard on mobile, basic validation
telNumeric keypad on mobile
numberNumeric input with stepper
dateNative date picker
searchSearch keyboard, optional clear button
urlURL keyboard, basic validation
fileFile picker, supports multiple and accept

Headings — the document outline

Headings (<h1> through <h6>) form a hierarchical outline. Screen reader users navigate by headings; search engines use them to understand content structure.

Rules:

  • One <h1> per page (usually the page title).
  • Don't skip levels — <h2> follows <h1>, <h3> follows <h2>.
  • Headings describe content, not size. If you want a small font, use CSS, not <h6>.

Images — the most-bloated element

<img
  src="/photo-800.jpg"
  srcset="/photo-400.jpg 400w, /photo-800.jpg 800w, /photo-1600.jpg 1600w"
  sizes="(max-width: 600px) 400px, 800px"
  alt="Person walking through a forest at sunset"
  width="800"
  height="600"
  loading="lazy"
/>

What each attribute does:

  • srcset — multiple sizes; browser picks the best for device + viewport.
  • sizes — tells the browser how big the image will display, so it can pick the right srcset entry.
  • alt — text alternative for screen readers and broken image fallback. Required.
  • width/height — reserves space before the image loads (prevents layout shift).
  • loading="lazy" — defers loading until the image is near the viewport.
REAL-WORLDWhy <picture> matters for art-direction

You have a hero image that looks great on desktop (wide aspect ratio) but the subject gets cut off on mobile (narrow aspect ratio). You actually want a different cropped version on mobile.

<picture>
  <source media="(max-width: 600px)" srcset="/hero-mobile.jpg">
  <source media="(max-width: 1200px)" srcset="/hero-tablet.jpg">
  <img src="/hero-desktop.jpg" alt="..." >
</picture>

The browser picks the first matching source. Lets you ship completely different images per breakpoint, not just resized versions.

Same element handles modern image formats with fallback:

<picture>
  <source srcset="/photo.avif" type="image/avif">
  <source srcset="/photo.webp" type="image/webp">
  <img src="/photo.jpg" alt="...">
</picture>

Browsers that support AVIF use it (smaller files). Older browsers fall back to JPEG.

HTML is the contract with everyone who isn't your visual designer. Search engines, screen readers, password managers, browser extensions, future maintainers. Semantic HTML is how you tell those audiences what you actually mean.

// SECTION_03

CSS — modern layout and styling

CSS in 2026 is dramatically better than CSS in 2015. Flexbox and Grid replaced floats. Custom properties replaced Sass variables for most uses. Container queries replaced viewport-only media queries. Most "tricky" CSS problems have one-line answers now.

The box model

Every element is a box with four layers, from inside out: content → padding → border → margin.

The default box-sizing (content-box) computes width based on content only, with padding/border added on top. This is almost never what you want. Set box-sizing: border-box globally:

*, *::before, *::after {
  box-sizing: border-box;
}

Now width: 300px means the box is 300px including padding and border. Tailwind, every modern framework, every reset stylesheet does this. It's effectively the new default.

Flexbox — for one-dimensional layout

When items go in a row (or a column), Flexbox is right. Navbars, cards in a row, button groups, anything where you align items along a single axis.

.row {
  display: flex;
  gap: 16px;              /* space between children */
  align-items: center;    /* vertical alignment of items */
  justify-content: space-between;  /* horizontal distribution */
}

Key properties:

  • flex-direction: row (default) or column.
  • justify-content: aligns along the main axis (horizontal for row).
  • align-items: aligns along the cross axis (vertical for row).
  • gap: space between items. Replaces all the margin tricks.
  • flex: 1 on a child: take up remaining space.
  • flex-wrap: wrap: items wrap to new lines when they overflow.

Grid — for two-dimensional layout

When you need rows AND columns at the same time. Page layouts, dashboards, photo galleries.

.gallery {
  display: grid;
  grid-template-columns: repeat(auto-fill, minmax(240px, 1fr));
  gap: 16px;
}

That single line: as many columns as fit, each at least 240px wide, growing to fill the space equally. No media queries needed for responsive grids.

VS / COMPARISONFlexbox vs Grid — when to pick which

The rule of thumb: 1D = Flex, 2D = Grid.

Use Flexbox when...Use Grid when...
Items go in a single lineLayout has explicit rows AND columns
Item sizes are content-drivenItems align in a structured way across rows
You need wrapping behaviorYou need precise track sizes
You don't know how many itemsYou're laying out a known structure (page chrome, dashboard)

They compose. A grid layout where each cell uses Flexbox internally is normal and good.

CSS Custom Properties (variables)

:root {
  --color-primary: #9C3D1F;
  --color-text: #1a1a1a;
  --space-md: 16px;
  --font-body: 'Inter', system-ui, sans-serif;
}

.button {
  background: var(--color-primary);
  padding: var(--space-md);
  font-family: var(--font-body);
}

Unlike Sass variables, CSS custom properties are runtime — they can change with media queries, with class changes, with JavaScript. This is how dark mode works:

:root {
  --bg: white;
  --text: black;
}
[data-theme="dark"] {
  --bg: #0e0f10;
  --text: #0E0E0E;
}
body {
  background: var(--bg);
  color: var(--text);
}

Toggle data-theme="dark" on the root element and every element using these variables updates instantly.

Container queries

The new big thing. Style a component based on its container's size, not the viewport.

.card-container {
  container-type: inline-size;
}

.card {
  display: block;
}

@container (min-width: 400px) {
  .card {
    display: grid;
    grid-template-columns: 1fr 2fr;
  }
}

Now the card layout responds to its parent container's width, not the window's width. A card in a sidebar stays narrow even on a wide screen. A card in a wide main area gets the wide layout. The component is truly self-contained.

Modern color: OKLCH

RGB and HSL are the legacy color spaces. Modern CSS supports OKLCH, a perceptually uniform color space.

color: oklch(70% 0.15 30);  /* lightness, chroma, hue */

Why it matters: in HSL, two colors with the same "lightness" can look very different (yellow looks lighter than blue at the same HSL lightness). In OKLCH, equal lightness values produce equally light-looking colors. This makes generating accessible color palettes mathematical instead of artistic.

The CSS reset

Browsers ship with default styles that vary slightly. A reset normalizes them. Modern minimal reset:

*, *::before, *::after { box-sizing: border-box; }

* { margin: 0; }

html, body { height: 100%; }

body {
  line-height: 1.5;
  -webkit-font-smoothing: antialiased;
}

img, picture, video, canvas, svg {
  display: block;
  max-width: 100%;
}

input, button, textarea, select { font: inherit; }

p, h1, h2, h3, h4, h5, h6 { overflow-wrap: break-word; }

Tailwind ships its own reset (Preflight) which is similar.

The CSS mindset shift: describe the layout intent, not the pixels. grid-template-columns: repeat(auto-fill, minmax(240px, 1fr)) is not a recipe for specific column counts; it's an instruction. CSS is most powerful when you write rules that produce sensible outputs at any size, not pixel-perfect designs at one size.

// SECTION_04

CSS architecture — Tailwind, CSS Modules, CSS-in-JS

How you organize CSS at scale. Three dominant approaches in 2026, each with strong opinions and real trade-offs.

Tailwind CSS — utility-first

Apply classes that map to single CSS properties. flex = display: flex. p-4 = padding: 16px. text-lg = font-size: 1.125rem.

<button class="rounded-md bg-orange-500 px-4 py-2 text-white hover:bg-orange-600">
  Save
</button>

Looks ugly. Reads great. The class name is the CSS, so you never context-switch between HTML and a stylesheet.

Wins:

  • Constraints — no arbitrary values, no "is this color #9C3D1F or #d97743?"
  • No naming things — no BEM, no CSS-in-JS API to learn.
  • Dead-code elimination — utilities not used in your HTML get stripped from the final CSS.
  • Fast iteration — change a class, see the result.
  • Componentize when patterns emerge (extract a React component, not a CSS class).

Costs:

  • HTML gets verbose. Long class lists are intimidating at first.
  • Need to learn the utility shorthand (lots of it).
  • Doesn't enforce design system the way a structured token system does.

CSS Modules

Write regular CSS in a .module.css file. Import it as a JS object. Class names get auto-scoped.

/* Button.module.css */
.primary {
  background: orange;
  padding: 8px 16px;
}

/* Button.tsx */
import styles from './Button.module.css';
<button className={styles.primary}>Save</button>

The compiled output uses unique class names like Button_primary__a8f3b, so there's no global namespace conflict.

Right when: you want regular CSS with scoping, your team is more comfortable with traditional stylesheets than utility classes.

CSS-in-JS (styled-components, Emotion)

Write CSS as JavaScript template literals. Styles can use props/state.

const Button = styled.button`
  background: ${props => props.primary ? 'orange' : 'gray'};
  padding: 8px 16px;
`;

Wins: dynamic styles based on props, co-located with components.

Costs: runtime cost (CSS injected during render), no longer recommended for new projects in 2026 — the React Server Components model breaks most CSS-in-JS libraries' assumptions.

VS / COMPARISONTailwind vs CSS Modules vs CSS-in-JS — the 2026 pick
TailwindCSS ModulesCSS-in-JS
PerformanceBest (zero runtime)Best (zero runtime)Worst (runtime)
RSC compatibilityFullFullLimited
Learning curveMedium (utility shorthand)Low (regular CSS)Medium (library API)
Dynamic stylesOK (variants)OK (composing classes)Best
Co-locationYes (in JSX)Adjacent fileYes (in JS)
Type safetyWith pluginsWith typescript-plugin-css-modulesBuilt-in
Trend in 2026↑ dominant→ stable↓ declining

The pragmatic 2026 answer: Tailwind for new projects. CSS Modules if your team prefers traditional CSS. CSS-in-JS only if you have a runtime requirement that demands it (and accept the RSC limitations).

shadcn/ui — the modern component approach

Not a library you install. A CLI that copies component code into your repo. You own the components, modify them freely, no version-pinning hell.

$ npx shadcn-ui@latest add button
# generates components/ui/button.tsx in your project

Built on Radix UI primitives (accessible, unstyled) + Tailwind for styling. Has become the default starting point for most modern apps.

Design tokens

The color/spacing/typography values that get reused across the system. In Tailwind, they live in tailwind.config.js:

module.exports = {
  theme: {
    extend: {
      colors: {
        brand: {
          50: '#fef3ee',
          500: '#9C3D1F',
          900: '#5a2810',
        },
      },
      spacing: {
        18: '4.5rem',
      },
    },
  },
};

Now bg-brand-500 works as a utility. The token system is enforced by the framework — no rogue hex colors.

The senior framing

The 2026 default stack: Tailwind + shadcn/ui + Radix primitives. Utility-first for speed, owned components for control, headless primitives for accessibility. CSS Modules as the alternative for teams that prefer traditional stylesheets. CSS-in-JS only for legacy or specialized cases.
// SECTION_05

JavaScript — modern language fundamentals

JavaScript in 2026 is a language with two layers: the small consistent core (let, const, arrow functions, async/await, modules) and a vast ecosystem of patterns and gotchas. Knowing the core well covers 80% of what you write.

Variable declarations — let, const, var

  • const — block-scoped, can't be reassigned. Default for almost everything.
  • let — block-scoped, can be reassigned. Use when reassignment is the point (loop counters, mutating values).
  • var — function-scoped, hoisted. Don't use. Legacy.

"Const" doesn't mean "immutable" — const arr = []; arr.push(1); works fine. It means "the binding can't be reassigned." For true immutability, use Object.freeze() or libraries like Immer.

Arrow functions vs function declarations

// function declaration — hoisted, has its own `this`
function greet(name) { return `Hello, ${name}`; }

// arrow function — not hoisted, inherits `this` from enclosing scope
const greet = (name) => `Hello, ${name}`;

Use arrows for callbacks, inline functions, methods that need to capture this from outside. Use function declarations for top-level helpers and recursive functions.

Destructuring

const user = { name: 'Alex', email: 'a@b.com', age: 30 };

// extract into variables
const { name, email } = user;

// rename
const { name: userName } = user;

// defaults
const { phone = 'none' } = user;

// arrays
const [first, second, ...rest] = [1, 2, 3, 4, 5];

// in function parameters
function greet({ name, email }) { ... }

Spread and rest

// spread (expand)
const newUser = { ...user, age: 31 };  // copy with override
const newArr = [...arr1, ...arr2];      // merge arrays

// rest (collect)
function sum(...nums) { return nums.reduce((a, b) => a + b, 0); }

Async/await

The good way to handle promises. Replaces .then().catch() chains for most cases.

async function fetchUser(id) {
  try {
    const res = await fetch(`/api/users/${id}`);
    if (!res.ok) throw new Error(`HTTP ${res.status}`);
    const user = await res.json();
    return user;
  } catch (err) {
    console.error('Failed to fetch user', err);
    throw err;
  }
}

Promise patterns worth knowing

// run multiple in parallel, wait for all
const [user, posts, comments] = await Promise.all([
  fetchUser(id),
  fetchPosts(id),
  fetchComments(id),
]);

// run multiple, get whichever finishes first
const result = await Promise.race([
  fetch(url),
  new Promise((_, reject) => setTimeout(() => reject('timeout'), 5000)),
]);

// run multiple, get all results (success or failure)
const results = await Promise.allSettled([fetchA(), fetchB(), fetchC()]);
results.forEach(r => {
  if (r.status === 'fulfilled') console.log(r.value);
  else console.error(r.reason);
});

Modules (ES modules)

// utils.js
export function add(a, b) { return a + b; }
export const PI = 3.14;
export default function multiply(a, b) { return a * b; }

// app.js
import multiply, { add, PI } from './utils.js';
import * as utils from './utils.js';

Browsers and Node both support ES modules natively now. CommonJS (require/module.exports) is legacy in new code.

PITFALLThe classic 'this' confusion in event handlers
class Counter {
  constructor() {
    this.count = 0;
    document.getElementById('btn').addEventListener('click', this.increment);
  }
  increment() {
    this.count++;  // ❌ `this` is the button, not the Counter
  }
}

The fix — bind this:

// option 1: arrow function as class property
class Counter {
  count = 0;
  increment = () => { this.count++; };  // arrow inherits `this`
}

// option 2: bind in constructor
constructor() {
  this.increment = this.increment.bind(this);
}

// option 3: arrow in addEventListener call
btn.addEventListener('click', () => this.increment());

This is one reason React function components with hooks are easier than class components — no this at all.

Equality — the === rule

== does type coercion ('5' == 5 is true). Confusing and error-prone. Always use ===. The ESLint eqeqeq rule enforces this.

Optional chaining and nullish coalescing

// optional chaining: ?. returns undefined if any step is null/undefined
const city = user?.address?.city;  // no error if address is missing

// nullish coalescing: ?? falls back only on null/undefined
const port = process.env.PORT ?? 3000;
// vs ||, which also falls back on '', 0, false

Map, Set, WeakMap, WeakSet

  • Map — like an object, but keys can be anything (objects, functions). Maintains insertion order. Use when keys aren't strings.
  • Set — collection of unique values. Use for deduplication.
  • WeakMap/WeakSet — keys are objects, values are garbage-collected when the key has no other references. Useful for caches keyed on object identity.
const seen = new Set();
arr.filter(item => {
  if (seen.has(item.id)) return false;
  seen.add(item.id);
  return true;
});  // dedupe by id

Iteration patterns

// forEach — side effects, no return
arr.forEach(item => console.log(item));

// map — transform each, returns new array
const doubled = arr.map(x => x * 2);

// filter — keep matching, returns new array
const evens = arr.filter(x => x % 2 === 0);

// reduce — fold to single value
const sum = arr.reduce((acc, x) => acc + x, 0);

// find — first match, returns single item or undefined
const first = arr.find(x => x.id === 5);

// some / every — boolean checks
const hasAdult = users.some(u => u.age >= 18);
const allAdults = users.every(u => u.age >= 18);

// for...of — works on any iterable, can use await/break
for (const item of arr) { ... }

// Object.entries for iterating objects
for (const [key, value] of Object.entries(obj)) { ... }

Modern JavaScript has converged on a small set of patterns: const by default, arrow functions everywhere, destructuring at boundaries, async/await for asynchrony, array methods over loops. If you're writing var, function for callbacks, or .then() chains, you're writing pre-2017 JavaScript. The patterns aren't faster or shorter for their own sake — they're shorter because they cover the right cases the right way.

// SECTION_06

TypeScript

TypeScript is JavaScript with a static type checker. The runtime behavior is identical; the win is catching errors at compile time and getting autocomplete that knows your data shapes.

Why TypeScript won

The shift happened around 2020-2022. Today, almost every major framework, library, and SDK ships with TypeScript types. Writing JavaScript without types in 2026 is like writing Python without type hints — possible, but you give up tooling.

The basics

// primitives
const name: string = 'Alex';
const age: number = 30;
const active: boolean = true;

// arrays
const ids: number[] = [1, 2, 3];
const names: Array<string> = ['a', 'b'];

// objects (interfaces or types)
interface User {
  id: number;
  name: string;
  email: string;
  age?: number;  // optional
}

type User = {
  id: number;
  name: string;
};
// interface vs type — mostly interchangeable; interface for objects, type for unions/aliases

// functions
function greet(name: string): string {
  return `Hello, ${name}`;
}

const greet = (name: string): string => `Hello, ${name}`;

Union and intersection types

// union — value can be any of these
type Status = 'pending' | 'active' | 'banned';
type ID = string | number;

// intersection — value must satisfy all
type EmployeeUser = User & { employeeId: string };

Type narrowing

TypeScript follows your control flow. Inside an if that checks a type, the variable is narrowed.

function format(value: string | number): string {
  if (typeof value === 'string') {
    return value.toUpperCase();  // TS knows it's a string here
  }
  return value.toFixed(2);  // TS knows it's a number here
}

Generics

Functions and types that work with multiple types while preserving type information.

function first<T>(arr: T[]): T | undefined {
  return arr[0];
}

const num = first([1, 2, 3]);   // typed as number | undefined
const str = first(['a', 'b']);  // typed as string | undefined

Common in API client code:

async function fetchJSON<T>(url: string): Promise<T> {
  const res = await fetch(url);
  return res.json();
}

const user = await fetchJSON<User>('/api/users/1');
// user is typed as User

Utility types

type User = { id: number; name: string; email: string; age?: number; };

// Partial — all fields optional
type UserUpdate = Partial<User>;  // { id?: number; name?: string; ... }

// Required — all fields required
type CompleteUser = Required<User>;

// Pick — select specific fields
type UserSummary = Pick<User, 'id' | 'name'>;

// Omit — exclude specific fields
type UserPublic = Omit<User, 'email'>;

// Record — object with specific keys/values
type UsersById = Record<number, User>;

// ReturnType — extract function's return type
type Fetched = ReturnType<typeof fetchUser>;

Discriminated unions

The pattern that makes TypeScript click. A field tags which variant of a union you have.

type State =
  | { status: 'loading' }
  | { status: 'success'; data: User }
  | { status: 'error'; error: string };

function render(state: State) {
  switch (state.status) {
    case 'loading':
      return <Spinner />;
    case 'success':
      return <UserCard user={state.data} />;  // TS knows data is here
    case 'error':
      return <Error message={state.error} />;
  }
}

Type vs value

TypeScript has two parallel namespaces. User can be a type AND a value (a class or const). They're separate.

// declaring both:
class User {
  constructor(public name: string) {}
}
// `User` is now both a type (the instance shape) and a value (the constructor)

// usually you want just the type:
type User = { name: string };  // type only, no runtime presence

Zod — runtime validation that integrates with types

TypeScript types vanish at runtime. They don't validate API responses or user input. Zod fills that gap:

import { z } from 'zod';

const UserSchema = z.object({
  id: z.number(),
  name: z.string(),
  email: z.string().email(),
  age: z.number().optional(),
});

type User = z.infer<typeof UserSchema>;
// type derived from schema — no duplication

// validate at runtime:
const user = UserSchema.parse(jsonResponse);
// throws if invalid; user is fully typed

// safe parse:
const result = UserSchema.safeParse(input);
if (result.success) {
  console.log(result.data);
} else {
  console.error(result.error);
}

The pattern: define a schema, derive the type, validate at trust boundaries (API responses, form input, env vars).

PITFALLThe 'any' escape hatch — and why it ruins TypeScript

When TypeScript complains, it's tempting to type something as any. The check passes. Problem solved.

But any turns off type checking entirely for that value. Anything you do to it is allowed. The whole call chain becomes untyped.

Better escape hatches:

  • unknown — like any but you have to narrow it before using.
  • Type assertions — value as User — tells TS to trust you. Use sparingly.
  • Generic constraints — solve at the type level instead of giving up.

The ESLint rule @typescript-eslint/no-explicit-any catches this. Most production codebases have it on.

The senior framing

TypeScript discipline: types at boundaries (API responses, function signatures, exported APIs), inferred types inside functions. Zod for runtime validation of anything coming from outside your code (user input, API responses, env). Discriminated unions for state machines. Generics for reusable code. Avoid any — if you can't type it, your design might be wrong.
// SECTION_07

React fundamentals

React is a library for building UI by composing functions that return descriptions of UI. The descriptions become real DOM via React's reconciler.

Components are functions

function Greeting({ name }) {
  return <h1>Hello, {name}</h1>;
}

// usage
<Greeting name="Alex" />

That's the entire model: a function that takes props (input) and returns JSX (output). Composed by writing one component inside another.

JSX is JavaScript

JSX looks like HTML but compiles to function calls. <div className="x">hi</div> becomes React.createElement('div', { className: 'x' }, 'hi'). You can put any JavaScript expression in { }.

function UserList({ users }) {
  return (
    <ul>
      {users.map(user => (
        <li key={user.id}>
          {user.name} ({user.age >= 18 ? 'adult' : 'minor'})
        </li>
      ))}
    </ul>
  );
}

The keys rule

When rendering lists, every item needs a stable, unique key prop. React uses keys to match elements between renders.

  • Use IDs from your datauser.id.
  • Don't use array index if items can reorder/insert/delete (causes bugs).
  • Don't use Math.random() — defeats the purpose; React thinks every render is a different list.

State with useState

import { useState } from 'react';

function Counter() {
  const [count, setCount] = useState(0);

  return (
    <button onClick={() => setCount(count + 1)}>
      Clicked {count} times
    </button>
  );
}

Calling setCount tells React "the state changed, re-run this component." React calls the function again with the new state.

The mental model: your component is a function from state to UI. State changes → function runs again → new UI.

Effects with useEffect

For side effects: data fetching, subscriptions, manual DOM manipulation, anything that interacts with the world outside React.

useEffect(() => {
  // runs after the component renders
  const subscription = subscribe(data => setItems(data));

  return () => {
    // cleanup function — runs before re-running effect or unmount
    subscription.unsubscribe();
  };
}, [/* dependencies */]);

The dependency array controls when the effect re-runs:

  • [] — run once on mount.
  • [someValue] — re-run when someValue changes.
  • No array — run after every render (rarely what you want).
PITFALLuseEffect for data fetching is usually wrong

Classic pattern from 2018-2022:

function UserProfile({ userId }) {
  const [user, setUser] = useState(null);
  const [loading, setLoading] = useState(true);

  useEffect(() => {
    setLoading(true);
    fetch(`/api/users/${userId}`)
      .then(r => r.json())
      .then(data => { setUser(data); setLoading(false); });
  }, [userId]);

  // ...
}

Problems:

  • Race conditions — if userId changes fast, the wrong response can win.
  • No caching — every component re-fetches even if data is fresh.
  • No retry logic, no error handling, no loading consolidation.
  • Manual setState boilerplate.

The 2026 answer: use TanStack Query, SWR, or Server Components. useEffect for data fetching is now considered an anti-pattern.

const { data: user, isLoading } = useQuery({
  queryKey: ['user', userId],
  queryFn: () => fetch(`/api/users/${userId}`).then(r => r.json()),
});

Other essential hooks

  • useRef — mutable value that persists across renders without triggering re-renders. For DOM refs, timers, and instance values.
  • useMemo — caches an expensive computation between renders.
  • useCallback — caches a function reference between renders (rare; usually unnecessary).
  • useContext — read a value from a parent Context provider without prop drilling.
  • useReducer — useState's bigger sibling for complex state logic.

Custom hooks

The composition mechanism. Any function starting with use that calls other hooks is a custom hook. Lets you extract logic for reuse.

function useLocalStorage(key, initialValue) {
  const [value, setValue] = useState(() => {
    const stored = localStorage.getItem(key);
    return stored ? JSON.parse(stored) : initialValue;
  });

  useEffect(() => {
    localStorage.setItem(key, JSON.stringify(value));
  }, [key, value]);

  return [value, setValue];
}

// use it like a regular hook:
const [theme, setTheme] = useLocalStorage('theme', 'light');

Server Components vs Client Components

The biggest React change in years. With React 18+ and frameworks like Next.js App Router:

  • Server Components (default) — run on the server, can read databases directly, return rendered output. Don't ship JS to client. No state, no hooks like useState/useEffect.
  • Client Components — marked with "use client" directive. Run in the browser. Can have state, effects, event handlers.
// server component (default)
async function UserList() {
  const users = await db.query("SELECT * FROM users");
  return <ul>{users.map(u => <li key={u.id}>{u.name}</li>)}</ul>;
}

// client component
'use client';
import { useState } from 'react';
function Counter() {
  const [n, setN] = useState(0);
  return <button onClick={() => setN(n + 1)}>{n}</button>;
}

Mental model: server by default, client when interactivity demands it. The bundle ships only client components and their dependencies.

React's mental model is one rule repeated at every level: UI is a function of state. State changes, function re-runs, UI updates. Effects let you reach outside that pure model when needed. Server Components extend the model to "some functions run on the server, some on the client, the framework figures out the seam."

// SECTION_08

Next.js — the canonical React framework

Next.js is React with batteries: routing, server rendering, data fetching, image optimization, and a build pipeline. In 2026 it's the default starting point for production React apps.

The App Router (Next.js 13+)

File-based routing where folders are URL segments and special filenames have meaning.

app/
  layout.tsx              → wraps all routes
  page.tsx                → /
  about/
    page.tsx              → /about
  blog/
    [slug]/
      page.tsx            → /blog/:slug
  (auth)/                 → grouping, doesn't appear in URL
    login/page.tsx        → /login
    signup/page.tsx       → /signup
  api/
    users/
      route.ts            → /api/users (API endpoint)

Special files:

  • page.tsx — the route's UI.
  • layout.tsx — wraps children, persists across navigation.
  • loading.tsx — shown while the page is loading.
  • error.tsx — error boundary.
  • not-found.tsx — 404 UI.
  • route.ts — API endpoint (replaces pages/api).

Server components by default

// app/users/page.tsx — server component
async function UsersPage() {
  const users = await db.query("SELECT * FROM users");
  return (
    <div>
      <h1>Users</h1>
      <ul>
        {users.map(u => <li key={u.id}>{u.name}</li>)}
      </ul>
    </div>
  );
}
export default UsersPage;

This component:

  • Runs on the server only.
  • Direct database access — no API layer needed.
  • Ships zero JS to the client.
  • Renders to HTML; client gets the rendered output.

Server Actions — the new mutation pattern

Functions marked with "use server" run on the server but can be called from client components.

// app/users/actions.ts
'use server';

import { revalidatePath } from 'next/cache';

export async function createUser(formData: FormData) {
  const name = formData.get('name');
  await db.execute("INSERT INTO users (name) VALUES (?)", [name]);
  revalidatePath('/users');
}

// app/users/new/page.tsx
import { createUser } from '../actions';

export default function NewUserPage() {
  return (
    <form action={createUser}>
      <input name="name" />
      <button type="submit">Create</button>
    </form>
  );
}

The form posts directly to the server function. No API endpoint, no JSON, no manual fetch. Next.js handles serialization, the network call, and cache invalidation.

Data fetching strategies

Next.js supports multiple rendering modes per route:

ModeHowWhen
StaticRender at build timeContent rarely changes (marketing, blog)
SSRRender on every requestPer-user content, fresh data
ISRStatic + revalidate after N secondsMostly static with occasional updates
StreamingStream HTML chunks as components resolveMixed fast/slow data on one page
Client-sideRender on client (escape to client component)Highly interactive UI
// static (default for server components without dynamic data)
export default async function Page() {
  const data = await fetch('https://...').then(r => r.json());
  return ...;
}

// ISR — revalidate every 60 seconds
export const revalidate = 60;

// force dynamic (always SSR)
export const dynamic = 'force-dynamic';

// streaming — wrap slow parts in Suspense
import { Suspense } from 'react';
<Suspense fallback={<Loading />}>
  <SlowComponent />
</Suspense>
REAL-WORLDA real Next.js app structure
app/
  layout.tsx                    # Root: html, body, providers
  page.tsx                      # / (landing)
  (marketing)/                  # group with shared layout
    layout.tsx
    pricing/page.tsx
    about/page.tsx
  (app)/                        # authenticated app
    layout.tsx                  # checks auth, renders shell
    dashboard/
      page.tsx                  # /dashboard
      loading.tsx               # skeleton while loading
    settings/
      page.tsx
      profile/page.tsx
  api/
    webhooks/
      stripe/route.ts           # POST /api/webhooks/stripe
  components/
    ui/                         # shadcn primitives
    nav/                        # nav components
  lib/
    db.ts                       # database client
    auth.ts                     # auth helpers
    actions/                    # server actions

Key patterns:

  • Route groups (marketing), (app) let different sections share layouts without affecting URLs.
  • Server actions live in lib/actions/ for reuse across pages.
  • UI primitives in components/ui/, feature components alongside their pages.
  • The (app) layout checks auth once and redirects unauthenticated users — every nested route inherits this.

Caching and revalidation

Next.js caches aggressively. Understanding the layers:

  • Request memoization — same fetch in one request returns cached result.
  • Data cache — fetch results cached across requests, configurable TTL.
  • Full route cache — rendered route HTML cached.
  • Router cache — client-side cache of visited routes.
// fetch with caching control
fetch(url);                          // cached forever (until revalidated)
fetch(url, { cache: 'no-store' });   // never cache
fetch(url, { next: { revalidate: 60 } });  // cache for 60s

// invalidate by tag
fetch(url, { next: { tags: ['users'] } });
// later:
import { revalidateTag } from 'next/cache';
revalidateTag('users');

// invalidate by path
import { revalidatePath } from 'next/cache';
revalidatePath('/users');

Image and font optimization

import Image from 'next/image';
import { Inter } from 'next/font/google';

const inter = Inter({ subsets: ['latin'] });

export default function Page() {
  return (
    <div className={inter.className}>
      <Image src="/photo.jpg" width={800} height={600} alt="..." />
    </div>
  );
}

next/image automatically: lazy-loads, generates responsive srcsets, serves modern formats (WebP/AVIF), preserves aspect ratio.

next/font downloads fonts at build time, self-hosts them (no CLS, no third-party request), generates fallback metrics.

Next.js gives you the convention. App Router + server components is the new default. You write components; Next.js decides what runs where, what gets cached, and how to ship the smallest possible bundle. The mental model is "describe the page; the framework handles the seam between server and client."

// SECTION_09

State management

State management is the question of where your data lives and who can change it. The 2026 answer is more nuanced than "use Redux" — different state has different homes.

The five kinds of state

KindExampleWhere it lives
Server stateUser profile, posts, settingsServer, cached in TanStack Query
URL stateCurrent page, filters, searchURL itself (?q=...&page=2)
Form stateInput values, validationReact Hook Form / native form
UI state (local)Modal open, dropdown activeComponent useState
UI state (global)Theme, sidebar collapsed, authZustand / Context

Most state belongs in one of the first three categories. Global client state is rare in well-designed apps.

Server state: TanStack Query

The single biggest improvement to React data fetching in years. Covered in detail in the API clients section, but the model:

const { data, isLoading, error } = useQuery({
  queryKey: ['user', userId],
  queryFn: () => fetchUser(userId),
});

const mutation = useMutation({
  mutationFn: updateUser,
  onSuccess: () => {
    queryClient.invalidateQueries({ queryKey: ['user'] });
  },
});

You declare what data you want; the library handles caching, deduplication, retries, refetch-on-focus, optimistic updates.

URL state: nuqs / searchParams

Filters, search queries, current tab, pagination — these belong in the URL. The URL is shareable; the back button works; it survives refresh.

// Next.js App Router
'use client';
import { useSearchParams, useRouter } from 'next/navigation';

function Filters() {
  const params = useSearchParams();
  const router = useRouter();
  const status = params.get('status') ?? 'all';

  return (
    <select
      value={status}
      onChange={(e) => {
        const next = new URLSearchParams(params);
        next.set('status', e.target.value);
        router.push(`?${next}`);
      }}
    >
      <option value="all">All</option>
      ...
    </select>
  );
}

Or use nuqs for typed URL state with a hook-like API.

Form state: React Hook Form + Zod

The 2026 default for forms.

import { useForm } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod';
import { z } from 'zod';

const Schema = z.object({
  email: z.string().email(),
  password: z.string().min(8),
});
type FormData = z.infer<typeof Schema>;

function SignupForm() {
  const { register, handleSubmit, formState: { errors } } = useForm<FormData>({
    resolver: zodResolver(Schema),
  });

  return (
    <form onSubmit={handleSubmit(async (data) => { ... })}>
      <input {...register('email')} />
      {errors.email && <span>{errors.email.message}</span>}

      <input type="password" {...register('password')} />
      {errors.password && <span>{errors.password.message}</span>}

      <button type="submit">Sign up</button>
    </form>
  );
}

The schema is the source of truth — validation, types, and the form structure all derive from it.

Local UI state: useState

If state belongs to one component, useState is the answer. Modal open state, hover state, expanded panel — all useState.

Global client state: Zustand

For state that's truly global (theme, sidebar collapsed, auth user) and not server data. Zustand is the modern choice — small, no boilerplate, no providers.

import { create } from 'zustand';

const useAuthStore = create<AuthState>((set) => ({
  user: null,
  setUser: (user) => set({ user }),
  logout: () => set({ user: null }),
}));

// in any component:
function Header() {
  const { user, logout } = useAuthStore();
  return user ? <UserMenu user={user} onLogout={logout} /> : <LoginButton />;
}
VS / COMPARISONRedux vs Zustand vs Context — when each
Redux ToolkitZustandReact Context
BoilerplateHighestLowestMedium
DevToolsBest (time travel)GoodNone
PerformanceGood (selectors)Good (selectors)Re-renders all consumers
Bundle sizeLarger~1KB0 (built-in)
Right whenComplex async flows, large teamMost appsTheme, locale, rarely-changing values

The 2026 answer: Zustand for client state. Don't reach for Redux unless your app has Redux-shaped problems (complex state machines, undo/redo, time-travel debugging requirement). Context for low-frequency values (theme, locale, current user) — but be aware every component using the context re-renders when the context value changes.

Don't put server data in any of these. Server data goes in TanStack Query.

The wrong-state-in-the-wrong-place anti-pattern

Common mistakes:

  • Server data in Zustand/Redux. You end up reimplementing caching, refetching, and invalidation. Use TanStack Query.
  • URL state in component state. Filters that don't survive refresh, can't be shared. Move to URL.
  • Global state for things that should be local. Modal open state in Redux is a code smell.
  • Form state in component useState. Reinventing validation, dirty-checking, error display. Use React Hook Form.

The state question is "what's the source of truth, and what should happen when it changes?" Server state's source of truth is the server (so cache it, refetch when invalidated). URL state's source of truth is the URL (so the back button works). Form state's source of truth is the form library (so validation just works). Global state is what's left — keep that as small as possible.

// SECTION_10

Forms

Forms are where most apps' real work happens. Get them right and the app feels professional. Get them wrong and users bounce.

The non-negotiable form features

  • Submit on Enter from any input.
  • Tab order follows visual order.
  • Disabled submit button while submitting (prevents double-submit).
  • Error messages tied to the field they describe (with aria-describedby).
  • Loading state while submitting.
  • Success state after submit (toast, redirect, or inline confirmation).
  • Mobile keyboards match input type.
  • Autocomplete attributes for browser autofill.

Validation timing

StrategyWhen errors showUX
onSubmitAfter submitDoesn't pester, less helpful
onBlurWhen leaving the fieldBest default — feels respectful
onChangeEvery keystrokeAnnoying for empty/incomplete inputs
onTouchedonChange after first onBlurBest of both — quiet until they engage, then live

React Hook Form's mode: 'onTouched' implements the last pattern.

Field-level validation rules

const Schema = z.object({
  email: z
    .string()
    .min(1, 'Email is required')
    .email('Must be a valid email'),

  password: z
    .string()
    .min(8, 'Password must be at least 8 characters')
    .regex(/[A-Z]/, 'Must contain an uppercase letter')
    .regex(/[0-9]/, 'Must contain a number'),

  age: z.coerce.number().min(13, 'Must be 13 or older'),

  terms: z.literal(true, { errorMap: () => ({ message: 'You must accept the terms' }) }),
});

Server-side validation is mandatory

Client-side validation is a UX feature. Server-side validation is a security requirement. Always validate on the server too — clients can be bypassed.

// app/users/actions.ts
'use server';

import { z } from 'zod';

const SignupSchema = z.object({
  email: z.string().email(),
  password: z.string().min(8),
});

export async function signup(formData: FormData) {
  const result = SignupSchema.safeParse({
    email: formData.get('email'),
    password: formData.get('password'),
  });

  if (!result.success) {
    return { error: result.error.flatten().fieldErrors };
  }

  // safe to use result.data
  await createUser(result.data);
}

The same Zod schema can be used on client and server — Single source of truth for validation.

The submit pattern

function ContactForm() {
  const { register, handleSubmit, formState: { errors, isSubmitting } } = useForm();
  const [submitted, setSubmitted] = useState(false);

  const onSubmit = async (data) => {
    try {
      await sendContact(data);
      setSubmitted(true);
    } catch (err) {
      toast.error('Something went wrong. Please try again.');
    }
  };

  if (submitted) return <SuccessMessage />;

  return (
    <form onSubmit={handleSubmit(onSubmit)}>
      <input {...register('name')} disabled={isSubmitting} />
      <input {...register('email')} disabled={isSubmitting} />
      <textarea {...register('message')} disabled={isSubmitting} />
      <button type="submit" disabled={isSubmitting}>
        {isSubmitting ? 'Sending...' : 'Send message'}
      </button>
    </form>
  );
}
PITFALLCommon form bugs that ship to production
  • Double-submit — user clicks submit twice fast, two requests go out. Disable the button while submitting.
  • Submit-on-Enter doesn't work — happens when you don't wrap inputs in a <form> element. Always use a real form.
  • Error messages disappear on refocus — frustrating. Errors should persist until the field is fixed and revalidated.
  • Mobile keyboard blocks the submit button — scroll the form so the active input is visible above the keyboard.
  • Browser autofill is broken — happens when input names don't match autocomplete conventions. Use autocomplete="email", "new-password", "current-password", etc.
  • Required fields not announced to screen readers — use aria-required="true" in addition to the visual asterisk.
  • Generic error messages — "An error occurred" tells the user nothing. Be specific: "Email already in use" or "Password must contain a number."

Server actions for forms (Next.js)

The new pattern progressively enhances forms. Forms work without JavaScript and get richer with it.

// no JavaScript needed for the basic case
<form action={signup}>
  <input name="email" />
  <input name="password" type="password" />
  <button type="submit">Sign up</button>
</form>

// add useActionState for client-side feedback
'use client';
import { useActionState } from 'react';
import { signup } from './actions';

function SignupForm() {
  const [state, action, pending] = useActionState(signup, { error: null });

  return (
    <form action={action}>
      <input name="email" />
      {state.error?.email && <span>{state.error.email[0]}</span>}
      ...
      <button disabled={pending}>{pending ? 'Signing up...' : 'Sign up'}</button>
    </form>
  );
}

Form posts to the server action; result comes back; UI updates. Works with JS off (just no inline error display).

A good form is a conversation, not a wall. Validate when the user finishes a field (onBlur or onTouched), not while they type. Be specific about errors. Disable submit when busy. Match the mobile keyboard to the input type. The mechanical correctness is most of the work; the polish is what makes apps feel professional.

// SECTION_11

Data fetching (client side)

How you fetch, cache, and update data from the client. The naive useEffect approach is dead; modern apps use TanStack Query, SWR, or Server Components.

TanStack Query — the canonical client data layer

import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';

function UserProfile({ userId }) {
  const { data: user, isLoading, error } = useQuery({
    queryKey: ['user', userId],
    queryFn: () => fetch(`/api/users/${userId}`).then(r => r.json()),
    staleTime: 5 * 60 * 1000,    // 5 min
    refetchOnWindowFocus: true,   // default
  });

  if (isLoading) return <Skeleton />;
  if (error) return <ErrorState />;
  return <Profile user={user} />;
}

What TanStack Query handles for you

  • Deduplication — two components with the same query key share one network request and cache entry.
  • Caching — data lives in memory, configurable freshness (staleTime).
  • Background refetch — refetch when window regains focus or network reconnects.
  • Retry logic — exponential backoff on errors, configurable.
  • Pagination — built-in helpers for offset and cursor pagination.
  • Infinite scroll — useInfiniteQuery composes pages.
  • Optimistic updates — apply changes immediately, roll back on error.
  • Stale-while-revalidate — show cached data instantly, refresh in background.

Mutations and invalidation

function useCreatePost() {
  const queryClient = useQueryClient();
  return useMutation({
    mutationFn: (post) => fetch('/api/posts', {
      method: 'POST',
      body: JSON.stringify(post),
    }).then(r => r.json()),

    onSuccess: () => {
      // refetch the posts list after creating a new one
      queryClient.invalidateQueries({ queryKey: ['posts'] });
    },
  });
}

// usage
const createPost = useCreatePost();
<button onClick={() => createPost.mutate({ title, body })}>Create</button>

Optimistic updates

const likeMutation = useMutation({
  mutationFn: (postId) => fetch(`/api/posts/${postId}/like`, { method: 'POST' }),

  onMutate: async (postId) => {
    // cancel in-flight refetches
    await queryClient.cancelQueries({ queryKey: ['post', postId] });

    // snapshot previous state for rollback
    const previous = queryClient.getQueryData(['post', postId]);

    // optimistically update
    queryClient.setQueryData(['post', postId], old => ({
      ...old,
      liked: true,
      likeCount: old.likeCount + 1,
    }));

    return { previous };
  },

  onError: (err, postId, ctx) => {
    // rollback on failure
    queryClient.setQueryData(['post', postId], ctx.previous);
  },

  onSettled: (data, err, postId) => {
    // refetch canonical state after success or failure
    queryClient.invalidateQueries({ queryKey: ['post', postId] });
  },
});

The user sees instant feedback. The network call is invisible unless it fails.

Suspense mode

For cleaner code, use the Suspense version:

const { data: user } = useSuspenseQuery({
  queryKey: ['user', userId],
  queryFn: fetchUser,
});
// data is never undefined; component suspends while loading

function UserPage() {
  return (
    <Suspense fallback={<Skeleton />}>
      <UserProfile userId={123} />
    </Suspense>
  );
}
VS / COMPARISONTanStack Query vs SWR vs RTK Query
TanStack QuerySWRRTK Query
Bundle size~13KB~5KB~9KB (with Redux)
API surfaceLargestSmallestMedium
MutationsBuilt-in, richBuilt-in, simpleBuilt-in, generates hooks
Code generationManual hooksManual hooksAuto from OpenAPI
Best forMost appsSimple casesApps already on Redux

The 2026 answer: TanStack Query for most apps. SWR if you want minimal API surface. RTK Query only if you're already committed to Redux and want consistency.

When NOT to use client data fetching

With Server Components, a lot of data fetching moves to the server:

// server component — no useQuery needed
async function UserList() {
  const users = await db.query("SELECT * FROM users");
  return <ul>{users.map(u => <li key={u.id}>{u.name}</li>)}</ul>;
}

The rule: fetch on the server when possible, on the client when interactivity demands it. Initial data loads should be server-rendered. Refetches, mutations, real-time updates happen on the client with TanStack Query.

Data fetching has three steps and three tools: fetching (server components for initial load), caching (TanStack Query for reactive caching on the client), invalidating (mutations triggering query invalidations). The mental model is "data is reactive; treat it like state, not like code that runs once."

// SECTION_12

Framework comparison — React, Vue, Svelte, Solid

React isn't the only answer. Vue, Svelte, Solid, Astro, Qwik all have real adoption and meaningful differences. Knowing what each is good at clarifies why React is dominant — and where it isn't.

React

The dominant framework. JSX-based. Component-as-function model. Re-renders on state changes; reconciler diffs the virtual DOM.

Strengths: ecosystem (every library has React bindings), hiring (most engineers know it), Next.js, React Native for mobile.

Weaknesses: performance requires care (everything re-renders by default), bundle size, the hooks rules learning curve.

Vue

Single-file components (.vue files with template, script, style). Templates use directives like v-if, v-for. Reactive system is fine-grained (only changed parts re-render).

<template>
  <button @click="count++">Count: {{ count }}</button>
</template>
<script setup>
import { ref } from 'vue';
const count = ref(0);
</script>

Strengths: gentler learning curve, fine-grained reactivity, official tooling (Vue Router, Pinia, Nuxt), great docs.

Weaknesses: smaller ecosystem than React, less common in US/EU job market.

Svelte

A compiler, not a runtime framework. Compiles components to vanilla JS. No virtual DOM; updates the actual DOM directly.

<script>
  let count = 0;
</script>
<button on:click={() => count++}>Count: {count}</button>

Strengths: tiny bundles (often 5-10x smaller than React equivalents), beautifully simple syntax, fast.

Weaknesses: smaller ecosystem, fewer libraries, smaller talent pool.

Solid

JSX syntax like React, fine-grained reactivity like Vue/Svelte. Components only run once; reactivity happens at the signal level.

function Counter() {
  const [count, setCount] = createSignal(0);
  return <button onClick={() => setCount(count() + 1)}>
    Count: {count()}
  </button>;
}

Strengths: fastest framework in benchmarks, JSX familiarity, no re-render performance traps.

Weaknesses: small ecosystem, signals require a different mental model, less mature than React.

Astro

Multi-page-app framework with islands of interactivity. Renders mostly static HTML, hydrates only the components that need it.

Right for: content sites, blogs, marketing pages, docs. Anywhere most of the page is static.

Wrong for: heavily interactive apps. Astro's strength is shipping less JS, which conflicts with app-like interactivity.

Qwik

Resumability, not hydration. Apps work immediately on load — JS is lazy-loaded only when interaction needs it.

Right for: sites where time-to-interactive matters more than developer ergonomics. Marketing pages, e-commerce.

Wrong for: apps where the developer experience compromises hurt productivity more than the perf gains help.

VS / COMPARISONWhen to pick what
If you need...Pick
Maximum ecosystem and hireabilityReact + Next.js
Smaller team, faster onboardingVue + Nuxt
Smallest bundle, simplest syntaxSvelte + SvelteKit
Maximum runtime performanceSolid
Mostly static content siteAstro
Mobile app from web codebaseReact Native (or Expo)
SaaS dashboardReact + Next.js (App Router)
Marketing site for an appAstro or Next.js (static mode)

For a new project at most companies, the pragmatic answer is React + Next.js + TypeScript + Tailwind + shadcn. Not because it's the best on every dimension — it's not — but because the ecosystem, hiring, and support around it dominate every alternative. The other frameworks are real and good; you pick them when their specific strengths matter more than ecosystem.

React Native — when mobile comes up

Same React knowledge, native mobile output. <View> instead of <div>, <Text> instead of <p>, native components instead of HTML.

import { View, Text, Pressable } from 'react-native';

function Counter() {
  const [count, setCount] = useState(0);
  return (
    <View>
      <Text>Count: {count}</Text>
      <Pressable onPress={() => setCount(c => c + 1)}>
        <Text>Increment</Text>
      </Pressable>
    </View>
  );
}

Expo is the typical starting point — handles the build pipeline, native modules, OTA updates. Supports the new architecture (Fabric + TurboModules) which closes the perf gap with native code substantially.

Trade-off: code reuse with web is partial, not total. Layout (Flexbox), state management, business logic transfer cleanly. Components, navigation, and styling are platform-specific.

Frameworks differ on three axes: where work happens (compile vs runtime), granularity of reactivity (component-level vs signal-level), and how much HTML they ship (full SPA vs islands vs static). React picks runtime + component + SPA by default. Other frameworks make different trade-offs. The right choice depends on what your site actually is — content-heavy, app-like, or somewhere between.

// SECTION_13

Performance

Front-end performance is about three things: how fast the page first appears, how fast it becomes interactive, and how smooth interactions feel after that. Each has its own techniques.

Core Web Vitals — what Google measures

MetricWhat it measuresGood
LCP Largest Contentful PaintTime to render the biggest visible element< 2.5s
INP Interaction to Next PaintLatency of user interactions< 200ms
CLS Cumulative Layout ShiftHow much things move around< 0.1

These directly affect SEO ranking. They also correlate with conversion and engagement.

Optimizing LCP — make the page appear fast

The LCP element is usually a hero image, a headline, or a large block of text. Make sure it's:

  • Server-rendered — don't block on JS to show critical content.
  • Preloaded<link rel="preload" as="image" href="/hero.jpg"> for hero images.
  • Optimized format — AVIF or WebP, with JPEG fallback.
  • Right-sized — don't ship a 4000px image to a 600px container.
  • Not lazy-loaded — lazy-loading the LCP image delays it. Use loading="eager" for above-the-fold images.

Optimizing INP — make interactions feel instant

INP measures the latency from user input to the next paint. Common causes of bad INP:

  • Long JavaScript tasks blocking the main thread.
  • Large component re-renders on every keystroke.
  • Synchronous work in event handlers (parsing, sorting, filtering big lists).

The fixes:

  • Debounce rapid events (search input).
  • Defer non-critical work with requestIdleCallback or scheduler.yield().
  • Use React's useDeferredValue for expensive renders that depend on fast-changing input.
  • Virtualize long lists (react-window, react-virtuoso) — render only visible items.

Optimizing CLS — keep things from jumping

The killers:

  • Images without dimensions. Always specify width and height, or use aspect-ratio CSS.
  • Late-loading fonts. Causes FOUT (flash of unstyled text). Use font-display: optional or preload fonts.
  • Ads and embeds without reserved space. Reserve space with min-height or aspect-ratio containers.
  • Late-injected banners. Cookie banners that push content down. Reserve their space ahead of time.

Bundle splitting

Don't ship one 5MB JS file. Split by route, lazy-load heavy components, defer third-party scripts.

// route-based splitting (automatic in Next.js)
// each route has its own bundle

// dynamic import for heavy components
import dynamic from 'next/dynamic';
const Chart = dynamic(() => import('./Chart'), {
  loading: () => <Skeleton />,
  ssr: false,  // skip server rendering for this component
});

// React.lazy with Suspense (without Next.js)
const Chart = React.lazy(() => import('./Chart'));
<Suspense fallback={<Skeleton />}>
  <Chart />
</Suspense>

Image optimization

// Next.js
import Image from 'next/image';

<Image
  src="/photo.jpg"
  alt="..."
  width={800}
  height={600}
  priority   // for above-the-fold; sets loading=eager and fetchpriority=high
  placeholder="blur"
  blurDataURL="data:image/jpeg;base64,..."  // tiny preview
/>

next/image handles: format negotiation (WebP/AVIF), responsive srcset, lazy-loading, blur-up placeholders.

Font loading

// next/font — handles everything correctly
import { Inter } from 'next/font/google';
const inter = Inter({
  subsets: ['latin'],
  display: 'swap',  // show fallback then swap when font loads
});

The display options matter:

  • swap — show fallback immediately, swap when font loads. Causes FOUT but text is visible.
  • optional — show fallback; only swap if font loads quickly (otherwise stick with fallback). No layout shift.
  • block — invisible text until font loads. Hurts LCP.
REAL-WORLDA real performance audit

An e-commerce site has LCP of 4.2s, INP of 380ms, CLS of 0.18. All bad.

Investigation:

  • LCP: hero image is 2.5MB JPEG, no responsive sizes, lazy-loaded by default.
  • INP: product filter runs synchronously through 5000 items on every keystroke.
  • CLS: ads load after 2 seconds and push content down. Header banner appears late.

Fixes:

  • Hero image → AVIF + WebP + JPEG fallback, <Image priority> in Next.js with srcset. LCP drops to 1.4s.
  • Product filter → useDeferredValue, debounced 200ms, virtualized with react-window. INP drops to 80ms.
  • Ad slots → min-height: 250px reserved before ads load. Cookie banner moved to bottom-fixed overlay (no layout shift). CLS drops to 0.04.

The wins came from understanding what each metric actually measures and addressing the specific cause, not from generic "optimize bundle size" advice.

The performance budget

Set targets and measure against them:

  • Initial JS: < 100KB compressed
  • Initial CSS: < 50KB compressed
  • Total page weight: < 1MB
  • LCP: < 2.5s on slow 4G
  • INP: < 200ms p75
  • CLS: < 0.1

Tools to measure: Lighthouse (built into Chrome DevTools), PageSpeed Insights, WebPageTest, Vercel Analytics.

Performance is a budget, not an optimization. Pick targets, measure on real devices, fix the specific things hurting the metric. The biggest wins in 2026 are: server-render the LCP, defer everything below the fold, virtualize long lists, reserve space for late-loading content. Most "make it faster" instinct goes into bundle size — but bundle size matters less than where you spend the bytes you have.

// SECTION_14

Accessibility

Accessibility is engineering for people who can't use your default UI. About 15% of the world has some form of disability. The work isn't optional — it's just engineering for everyone.

WCAG — the standard

Web Content Accessibility Guidelines. Three conformance levels: A (basic), AA (target for most apps), AAA (extra strict). Most legal compliance regimes (ADA, EAA, AODA) align with WCAG 2.1 or 2.2 Level AA.

The four principles (POUR)

  • Perceivable — users can perceive the content (alt text, captions, contrast).
  • Operable — users can operate the interface (keyboard, no time traps).
  • Understandable — content and operation are clear (labels, error messages).
  • Robust — works with assistive technologies (screen readers, voice).

The high-leverage rules

1. Use semantic HTML

Buttons, links, headings, lists, forms — use the right elements. They have built-in accessibility behavior. Custom div components with role attributes are a last resort.

2. Every image has alt text

<img src="/diagram.png" alt="Architecture diagram showing user requests flowing through CDN to API to database" />

<!-- decorative images: empty alt, not missing -->
<img src="/decoration.svg" alt="" />

3. Form inputs have labels

<label for="email">Email</label>
<input id="email" name="email" type="email" />

<!-- or wrap -->
<label>
  Email
  <input type="email" />
</label>

Placeholder text is NOT a label substitute. It disappears when the user types.

4. Color contrast meets minimums

  • Normal text: 4.5:1 contrast against background.
  • Large text (18pt+): 3:1.
  • UI components and graphics: 3:1.

Tools: Chrome DevTools has a built-in contrast checker. Figma has plugins. Don't eyeball it.

5. Keyboard navigation works

Test by unplugging your mouse. Can you:

  • Tab through every interactive element in a logical order?
  • Activate buttons with Enter or Space?
  • Activate links with Enter?
  • Close modals with Escape?
  • Navigate menus with arrow keys?
  • See where focus is at all times (visible focus ring)?

6. Focus management

When you open a modal, focus moves into it. When you close it, focus returns to where it was. When content updates dynamically, focus moves to the update or stays put. The user should never lose track of where they are.

// when opening a modal:
const triggerRef = useRef(null);
const closeModal = () => {
  setOpen(false);
  triggerRef.current?.focus();  // restore focus
};

<button ref={triggerRef} onClick={() => setOpen(true)}>Open</button>

ARIA — when semantic HTML isn't enough

ARIA (Accessible Rich Internet Applications) attributes provide info that HTML can't express. Use only when needed; bad ARIA is worse than no ARIA.

Common patterns:

  • aria-label — labels an element when there's no visible label.
  • aria-describedby — points to an element that describes this one (error messages, hints).
  • aria-live — announces dynamic content changes ("polite" or "assertive").
  • aria-expanded — indicates collapsed/expanded state.
  • aria-current — marks the current item in a set (current page in nav).
  • aria-hidden — hides decorative content from screen readers.
<button aria-expanded={open} aria-controls="menu">
  Menu
</button>
<ul id="menu" hidden={!open}>
  <li>...</li>
</ul>

<input aria-describedby="email-error" type="email" />
<span id="email-error" role="alert">Email is required</span>

Screen reader testing

The only way to know if your app works for screen reader users is to use a screen reader.

  • VoiceOver (Mac) — Cmd+F5 to toggle. Built in.
  • NVDA (Windows) — free download, the most-used screen reader.
  • TalkBack (Android), VoiceOver (iOS) — for mobile testing.

Headless component libraries

The 2026 way: use a library that gives you accessible behavior without imposing styling.

  • Radix UI — fully unstyled, fully accessible primitives. shadcn/ui is built on top.
  • React Aria (Adobe) — even more granular, hooks-based. Powers Adobe's apps.
  • Headless UI (Tailwind Labs) — for Tailwind-flavored projects.

Don't roll your own modal/dropdown/tooltip/combobox. The accessibility requirements are vast and these libraries have spent years getting them right.

PITFALLThe accessibility mistakes that ship to production
  • Custom dropdowns built without arrow-key navigation. Use a library.
  • Tooltips that only appear on hover. Touch users and keyboard users can't see them. Use focusable triggers.
  • Toast notifications without role="status" or aria-live. Screen readers don't announce them.
  • Skip links missing. Keyboard users can't skip the nav to get to main content. Add <a href="#main">Skip to content</a>.
  • Color-only state — green/red for valid/invalid with no icon or text. Color-blind users can't tell the difference.
  • Disabled buttons with no explanation. Why can't I submit? Add aria-describedby pointing to the reason.
  • Modal traps where Escape doesn't close, focus escapes, or background scrolls. Use Radix or React Aria.
  • Form errors not associated with their input. Use aria-describedby + role="alert".

Accessibility tooling

  • axe DevTools — browser extension, runs WCAG checks, finds 30-50% of issues automatically.
  • eslint-plugin-jsx-a11y — catches issues at write time.
  • Playwright + axe-core — automated checks in CI.
  • Lighthouse — accessibility score in Chrome DevTools.

Automated tools catch ~30% of issues. The rest requires manual testing — keyboard, screen reader, contrast, real users with disabilities.

Accessibility isn't a feature you add at the end; it's a property of how you build. Use semantic HTML, real form labels, library primitives. Test with keyboard and a screen reader. The work compounds — once your component library is accessible, every feature you build with it inherits the work.

// SECTION_15

Testing

Testing front-end code is testing user behavior, not implementation. The good tests describe what users do; the bad tests describe what components look like.

The testing layers

LayerWhat it testsTool (2026)
UnitPure functions, hooksVitest
ComponentRendered UI behaviorVitest + React Testing Library
IntegrationMultiple components togetherVitest + RTL or Playwright Component Testing
E2EFull user flows in a real browserPlaywright
VisualScreenshot comparisonsChromatic, Percy, Playwright

Vitest — the modern test runner

Replaces Jest for new projects. Faster, better TypeScript support, native ESM, Vite-native.

// math.ts
export function add(a: number, b: number) { return a + b; }

// math.test.ts
import { describe, it, expect } from 'vitest';
import { add } from './math';

describe('add', () => {
  it('sums two numbers', () => {
    expect(add(2, 3)).toBe(5);
  });

  it('handles negatives', () => {
    expect(add(-1, 1)).toBe(0);
  });
});

React Testing Library — testing components like a user

The principle: test what the user sees and does, not internal state. Find elements by their visible text or accessibility role, not by class name or test ID.

import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { LoginForm } from './LoginForm';

it('shows error when submitting empty form', async () => {
  const user = userEvent.setup();
  render(<LoginForm />);

  await user.click(screen.getByRole('button', { name: /sign in/i }));

  expect(screen.getByText(/email is required/i)).toBeInTheDocument();
});

it('submits when filled out correctly', async () => {
  const onSubmit = vi.fn();
  const user = userEvent.setup();
  render(<LoginForm onSubmit={onSubmit} />);

  await user.type(screen.getByLabelText(/email/i), 'a@b.com');
  await user.type(screen.getByLabelText(/password/i), 'password123');
  await user.click(screen.getByRole('button', { name: /sign in/i }));

  expect(onSubmit).toHaveBeenCalledWith({
    email: 'a@b.com',
    password: 'password123',
  });
});

Notice: no class names, no implementation details. Just "find the button labeled Sign In, click it." This makes tests resilient to refactoring — if you change the implementation but the user-visible behavior stays the same, the test still passes.

The query priority

RTL has a recommended priority for finding elements:

  1. getByRole — buttons, links, headings, etc. Mirrors how screen readers find things.
  2. getByLabelText — form inputs by their label.
  3. getByPlaceholderText — inputs by placeholder.
  4. getByText — by visible text.
  5. getByDisplayValue — current input value.
  6. getByAltText — images by alt.
  7. getByTitle — by title attribute.
  8. getByTestId — last resort, when nothing else works.

If you can't find an element by role or label, that's often a sign the element isn't accessible. Tests double as accessibility checks.

Mocking

// mock a module
vi.mock('./api', () => ({
  fetchUser: vi.fn().mockResolvedValue({ id: 1, name: 'Test' }),
}));

// mock fetch globally
global.fetch = vi.fn().mockResolvedValue({
  ok: true,
  json: async () => ({ users: [...] }),
});

// MSW (Mock Service Worker) — better for component tests
import { setupServer } from 'msw/node';
import { http, HttpResponse } from 'msw';

const server = setupServer(
  http.get('/api/users', () => {
    return HttpResponse.json([{ id: 1, name: 'Alex' }]);
  })
);
beforeAll(() => server.listen());
afterAll(() => server.close());

MSW is the modern way — intercepts at the network layer, so your components actually exercise their fetch code.

Playwright — E2E in real browsers

// e2e/login.spec.ts
import { test, expect } from '@playwright/test';

test('user can sign in', async ({ page }) => {
  await page.goto('/login');
  await page.getByLabel('Email').fill('alex@example.com');
  await page.getByLabel('Password').fill('correctpassword');
  await page.getByRole('button', { name: 'Sign in' }).click();

  await expect(page).toHaveURL('/dashboard');
  await expect(page.getByText(/welcome, alex/i)).toBeVisible();
});

Runs against real Chromium, Firefox, and WebKit. Catches issues that component tests miss: real network, real navigation, real focus management, real CSS rendering.

VS / COMPARISONCypress vs Playwright in 2026
PlaywrightCypress
BrowsersChromium, Firefox, WebKit (Safari)Chromium, Firefox, WebKit (paid tier)
SpeedFaster (parallel by default)Slower
Multi-tab/originYesLimited
APIasync/awaitChained commands (idiosyncratic)
DebuggingTrace viewer (great)Time-travel debugger (great)
Component testingYes (newer)Yes (mature)
2026 momentum↑ winning→ stable but losing share

The 2026 answer: Playwright. Better cross-browser support, faster, simpler API. Cypress is still fine if you're already on it.

Visual regression testing

Take screenshots, compare them against approved baselines, flag differences for human review.

  • Chromatic — built on Storybook, tests every component variant.
  • Percy — cross-browser, integrates with E2E.
  • Playwright has built-in screenshot comparison.

Catches CSS regressions that functional tests miss.

What to test

  • Critical user flows (signup, checkout, primary feature).
  • Components with non-trivial logic.
  • Edge cases that broke before.
  • Anything that's been a regression source.

What NOT to test

  • Trivial components (a div with text).
  • Third-party library behavior.
  • Implementation details (state shape, internal callbacks).
  • Things that change every sprint and provide no signal.

Test as much as gives you confidence; no more. Component tests for important UI behavior, E2E tests for the few critical paths, visual tests if regressions matter. The pyramid is the right shape — many fast component tests, few slow E2E tests. Tests written from the user's perspective survive refactoring.

// SECTION_16

Build tooling

Build tools turn modern source code (TypeScript, JSX, modern CSS) into what browsers run. The 2026 landscape is dominated by Vite for development and Turbopack/Webpack for Next.js production builds.

What a bundler actually does

  1. Reads your entry file (e.g., app/page.tsx).
  2. Follows imports to build a dependency graph.
  3. Transpiles each file (TypeScript → JS, JSX → JS, modern syntax → compatible syntax).
  4. Bundles related files together, splits at code-splitting boundaries.
  5. Minifies (removes whitespace, shortens names, eliminates dead code).
  6. Outputs files for the browser, with hashed names for cache-busting.

Vite — the modern dev server

Uses native ES modules during development. No bundling needed for dev — the browser fetches modules directly. Production build uses Rollup.

Why it's fast: in dev, only the files you're actively editing get processed. No full-project rebuild on every save.

# scaffold a new Vite project
npm create vite@latest my-app -- --template react-ts

# config in vite.config.ts
import { defineConfig } from 'vite';
import react from '@vitejs/plugin-react';

export default defineConfig({
  plugins: [react()],
  server: { port: 3000 },
  build: { outDir: 'dist' },
});

Webpack — the legacy default

Most established projects still use Webpack. Slower than Vite for development but extremely flexible. Next.js 14 and earlier shipped with Webpack.

Turbopack — the Next.js future

Rust-based bundler from the Next.js team. Replaces Webpack incrementally in newer Next.js versions. Significantly faster than Webpack, comparable to Vite.

esbuild and SWC — the transpilers

Underneath the bundlers, the actual compilation is done by:

  • esbuild — Go-based, extremely fast. Used by Vite for transpilation.
  • SWC — Rust-based. Used by Next.js for transpilation. Has Babel-compatible plugins.

Both are 10-100x faster than Babel. You usually don't choose them directly; your framework picks one.

VS / COMPARISONVite vs Webpack vs Turbopack vs esbuild
ViteWebpackTurbopackesbuild (alone)
RoleDev server + bundlerBundlerBundlerBundler/transpiler
Dev speedExcellentOKExcellentExcellent
Production buildRollup-basedWebpackNativeesbuild
Plugin ecosystemGrowingLargestLimitedLimited
Best forVite/SvelteKit/SolidStartExisting Webpack appsNext.js (newer)Library bundling

The 2026 answer: if you're using Next.js, use what it ships with (Turbopack/Webpack). Otherwise, Vite. Don't configure Webpack manually unless you have a specific need.

What a typical config looks like

// vite.config.ts
import { defineConfig } from 'vite';
import react from '@vitejs/plugin-react';
import { resolve } from 'path';

export default defineConfig({
  plugins: [react()],
  resolve: {
    alias: {
      '@': resolve(__dirname, './src'),  // import from '@/components/...'
    },
  },
  server: {
    port: 3000,
    proxy: {
      '/api': 'http://localhost:8000',  // proxy API to backend during dev
    },
  },
  build: {
    sourcemap: true,
    rollupOptions: {
      output: {
        manualChunks: {
          // split big libraries into separate chunks
          react: ['react', 'react-dom'],
          ui: ['@radix-ui/react-dialog', '@radix-ui/react-dropdown-menu'],
        },
      },
    },
  },
});

Environment variables

# .env (committed, defaults)
VITE_API_URL=http://localhost:8000

# .env.local (gitignored, secrets)
VITE_API_KEY=secret_key_here

# .env.production (production values)
VITE_API_URL=https://api.example.com

In Vite, only variables prefixed with VITE_ are exposed to client code. In Next.js, the prefix is NEXT_PUBLIC_. Variables without the prefix stay server-only — important for secrets.

// access in code
const apiUrl = import.meta.env.VITE_API_URL;  // Vite
const apiUrl = process.env.NEXT_PUBLIC_API_URL;  // Next.js

TypeScript configuration

// tsconfig.json — modern web app baseline
{
  "compilerOptions": {
    "target": "ES2022",
    "lib": ["ES2022", "DOM", "DOM.Iterable"],
    "jsx": "preserve",
    "module": "ESNext",
    "moduleResolution": "Bundler",
    "strict": true,
    "skipLibCheck": true,
    "esModuleInterop": true,
    "isolatedModules": true,
    "resolveJsonModule": true,
    "allowSyntheticDefaultImports": true,
    "forceConsistentCasingInFileNames": true,
    "noEmit": true,
    "paths": {
      "@/*": ["./src/*"]
    }
  },
  "include": ["src"],
  "exclude": ["node_modules", "dist"]
}

Linting and formatting

  • ESLint for code quality (catching bugs, enforcing patterns).
  • Prettier for formatting (no debate about indentation).
  • Biome — newer, replaces both. Fast (Rust-based). Growing adoption.
// .eslintrc.json
{
  "extends": [
    "next/core-web-vitals",
    "plugin:@typescript-eslint/recommended",
    "plugin:jsx-a11y/recommended",
    "prettier"
  ],
  "rules": {
    "@typescript-eslint/no-unused-vars": "error",
    "@typescript-eslint/no-explicit-any": "warn"
  }
}

The 2026 build experience is "the framework picks." Next.js, Vite, Remix, SvelteKit all bring their own tooling. Configuration mostly happens at the framework level, not the bundler level. The deeper bundler layer (Webpack, Vite, Turbopack) only matters when you have a specific problem to solve.

// SECTION_17

Routing

Routing is how URLs map to UI. The 2026 default is file-based routing — folder structure becomes URL structure — popularized by Next.js, Remix, SvelteKit, and others.

File-based routing (Next.js App Router)

app/
  page.tsx                → /
  about/page.tsx          → /about
  blog/
    page.tsx              → /blog (list)
    [slug]/page.tsx       → /blog/:slug
  shop/
    [...all]/page.tsx     → /shop/* (catch-all)
    [[...slug]]/page.tsx  → /shop and /shop/* (optional catch-all)

The folder is the URL; the page.tsx is what renders.

Dynamic segments

// app/blog/[slug]/page.tsx
export default function BlogPost({ params }: { params: { slug: string } }) {
  return <article>Slug: {params.slug}</article>;
}

Layouts

A layout.tsx wraps all pages in its folder and below. Persists across navigation (doesn't unmount when you navigate to a sibling).

// app/dashboard/layout.tsx
export default function DashboardLayout({ children }: { children: React.ReactNode }) {
  return (
    <div className="dashboard">
      <Sidebar />
      <main>{children}</main>
    </div>
  );
}

The sidebar persists when navigating between dashboard sub-pages. Faster transitions, no flashing.

Loading and error states

// app/dashboard/loading.tsx — shown while page loads
export default function Loading() {
  return <Skeleton />;
}

// app/dashboard/error.tsx — shown if page throws
'use client';
export default function Error({ error, reset }: ...) {
  return (
    <div>
      <p>Something went wrong: {error.message}</p>
      <button onClick={reset}>Try again</button>
    </div>
  );
}

Navigation

import Link from 'next/link';
import { useRouter } from 'next/navigation';

// declarative
<Link href="/blog/my-post">Read more</Link>
<Link href="/blog" prefetch={false}>Blog</Link>

// programmatic
'use client';
const router = useRouter();
router.push('/dashboard');
router.replace('/login');  // doesn't add to history
router.back();
router.refresh();  // refetch server components

Next.js prefetches links in the viewport — navigation feels instant because the next page is often already loaded.

Reading URL state

// in a server component
export default function SearchPage({
  searchParams,
}: {
  searchParams: { q?: string; page?: string };
}) {
  const query = searchParams.q ?? '';
  return <Results query={query} />;
}

// in a client component
'use client';
import { useSearchParams, usePathname } from 'next/navigation';

function Filters() {
  const params = useSearchParams();
  const pathname = usePathname();
  const status = params.get('status');
  // ...
}

Route groups and parallel routes

// route groups — wrap with parens, don't appear in URL
app/
  (marketing)/        # group with shared layout
    layout.tsx
    pricing/page.tsx  → /pricing
    about/page.tsx    → /about
  (app)/              # different group, different layout
    layout.tsx        # checks auth
    dashboard/page.tsx → /dashboard

// parallel routes — render multiple pages in one layout
app/
  layout.tsx          # uses both children and modal
  @modal/
    (..)photos/[id]/page.tsx  # interceptor: shows photo as modal over the list page
  page.tsx
  photos/[id]/page.tsx  # full-page version

Parallel routes enable patterns like Instagram-style modals — same URL renders one way from a list, another way from a direct visit.

Other routing libraries (non-Next.js)

LibraryUsed byStyle
React RouterSPAs without Next.jsCode-based or file-based (v7)
TanStack RouterModern type-safe SPAsCode-based, fully typed routes
RemixRemix apps (now part of React Router v7)File-based with loaders
Vue RouterVue appsCode-based or file-based (Nuxt)
Expo RouterReact NativeFile-based, modeled on Next.js

The mental model

Modern routing isn't just "URL → component." It's:

  • URL → layout(s) → page
  • Page can fetch data on the server
  • Loading/error/not-found states are co-located
  • Navigation is prefetched and feels instant
  • URL is the source of truth for shareable state

File-based routing is convention over configuration: the folder structure tells the framework what to do. The win isn't lines saved — it's that anyone can look at your app/ folder and immediately understand the URL structure. Layouts compose; loading/error states have a home; URL state stays in the URL.

// SECTION_18

Mobile and PWA

Most users are on phones. Building well for mobile means responsive design first, then thinking about Progressive Web Apps and native app options.

Responsive design — mobile first

Default styles are mobile; add desktop styles via media queries that scale UP, not down.

/* mobile by default */
.card {
  padding: 16px;
  font-size: 14px;
}

/* tablet and up */
@media (min-width: 768px) {
  .card {
    padding: 24px;
    font-size: 16px;
  }
}

/* desktop */
@media (min-width: 1024px) {
  .card {
    padding: 32px;
  }
}

In Tailwind: p-4 md:p-6 lg:p-8 text-sm md:text-base. Same idea, utility classes.

Tap targets

Touch targets need to be at least 44×44 CSS pixels (Apple's HIG) or 48×48 (Material Design). Smaller and users miss-tap, especially with thumbs.

button {
  min-width: 44px;
  min-height: 44px;
  padding: 12px 16px;
}

Viewport meta tag

<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">

Without this, mobile browsers pretend to be 980px wide and shrink the page. viewport-fit=cover handles iPhone notch areas.

Safe areas (iOS notch, navigation bars)

.fixed-header {
  padding-top: env(safe-area-inset-top);
}

.fixed-bottom-bar {
  padding-bottom: env(safe-area-inset-bottom);
}

The env() CSS function exposes platform-defined safe areas.

Mobile-specific gotchas

  • 100vh is wrong on mobile — counts the address bar. Use 100dvh (dynamic viewport height) instead.
  • iOS bounces past the page edges — by design. Use overscroll-behavior: contain on scrollable containers to prevent it.
  • Hover states don't work on touch — use @media (hover: hover) to apply hover styles only on devices with real hover.
  • Touch delays — modern browsers eliminated the 300ms tap delay, but touch-action: manipulation on buttons confirms it for older browsers.
  • Pull-to-refresh interferesoverscroll-behavior-y: contain on the body if you have a custom refresh implementation.

Progressive Web Apps

A PWA is a website that can:

  • Be installed to the home screen (acts like a native app).
  • Work offline (via service workers).
  • Send push notifications (with user permission).
  • Access device features (camera, geolocation, etc., via web APIs).

Service workers

JavaScript that runs separately from your page, intercepts network requests, can cache responses for offline use.

// register the service worker
if ('serviceWorker' in navigator) {
  navigator.serviceWorker.register('/sw.js');
}

// sw.js — minimal example
self.addEventListener('install', (e) => {
  e.waitUntil(
    caches.open('v1').then(cache => cache.addAll([
      '/',
      '/styles.css',
      '/app.js',
    ]))
  );
});

self.addEventListener('fetch', (e) => {
  e.respondWith(
    caches.match(e.request).then(cached => cached || fetch(e.request))
  );
});

For most apps, use Workbox (Google's library) or your framework's built-in PWA support rather than writing service workers by hand.

Web app manifest

// public/manifest.json
{
  "name": "My App",
  "short_name": "MyApp",
  "icons": [
    { "src": "/icon-192.png", "sizes": "192x192", "type": "image/png" },
    { "src": "/icon-512.png", "sizes": "512x512", "type": "image/png" }
  ],
  "start_url": "/",
  "display": "standalone",
  "background_color": "#0e0f10",
  "theme_color": "#9C3D1F"
}

Linked from your HTML: <link rel="manifest" href="/manifest.json">. Lets users install the app to their home screen.

React Native — actual native apps

When you need true native: store distribution, native UI, deeper device access. React Native + Expo is the React-native (lowercase) path.

VS / COMPARISONPWA vs React Native vs Native
PWAReact NativeNative (Swift/Kotlin)
Code reuse with web100%~70% (logic shared)0%
App store distributionNo (not on iOS)YesYes
Native UI feelWeb-feelingMostly nativeFully native
PerformanceOKGoodBest
Push notificationsLimited (no iOS Safari)YesYes
Device APIsLimited (web APIs only)Most via librariesAll
Update mechanismInstantOTA (Expo) or app storeApp store
Best forWeb app with offline + installCross-platform mobile appPerformance-critical, platform-specific

The 2026 answer: if you're building primarily a web app and want it installable, PWA. If you need real mobile presence and have time for two platforms, React Native + Expo. Native only when you have specific reasons (gaming, deep platform integration, high-performance computing).

Capacitor — the middle path

Capacitor (from the Ionic team) wraps your web app in a native container. Less code reuse than React Native but easier transition from a web codebase. Used by major brands when they want app store presence without rewriting in native or RN.

Mobile-first isn't a slogan; it's the design constraint. Build for thumbs and small screens, then scale up. PWAs cover most "make my web app installable" needs without a separate mobile codebase. React Native is the right answer when you need true app store presence and can invest in mobile-specific patterns. The lines between web and native keep blurring — what matters is matching the technology to the user expectation, not the other way around.

// BUILDING ALONE IS HARD. THE WEBINAR IS FREE.

SAVE_MY_SEAT.exe