From Zero to a Good Web Developer

A complete, opinionated learning path. Open this if you've never written a line of code; finish it as someone who ships production web apps. Four parts: Part I takes you from nothing to deployed full-stack projects; Part II teaches the current (2026) stack; Part III teaches the timeless engineering skills the stack hides; Part IV teaches you how to keep learning.

Introduction

The honest truth about becoming a good web developer: it's not one skill, it's maybe twelve overlapping skills, and the order matters. Most beginners thrash because they try to learn React before they've written 100 lines of JavaScript, or copy Tailwind classes without understanding the box model underneath, or build a Next.js app before they've ever deployed a single HTML file. The order in this guide is the order that actually compounds.

Software changes fast, but not as fast as Twitter would have you believe. About 80% of what you'll write in 2026 looks like what you wrote in 2024: HTML, CSS, JavaScript, a backend talking to a database. The remaining 20% — the part Part II focuses on — is where the leverage is. Picking it up is the difference between "I can build websites" and "I can ship production systems that scale, stay reliable, and integrate AI."

This guide does not try to replace MDN, react.dev, or freeCodeCamp — those are already free and excellent. What's missing in the world is a curated path that tells you what to learn, in what order, with the why at each step and a concrete project to cement it. That's what this is.

How to use this guide

If you're a complete beginner, here's the playbook:

  1. Read Part I top-to-bottom, one stage at a time. Don't skip. Each stage builds on the previous.
  2. Build the project at the end of each stage before moving on. The teal "PROJECT" callouts. Reading without building is how you forget everything in a week.
  3. Use the linked canonical resources for depth. Each stage links out to free, gold-standard learning material (MDN, react.dev, etc.). This guide gives you the path and the orientation; those resources give you the hours of practice you need.
  4. Don't rush. Stage 0 through Stage 9 is 4–9 months of part-time work depending on your background and how much you build. There's no shortcut.
  5. When stuck, read Part IV. "How to actually learn" and "Escaping the tutorial trap" exist because being stuck is part of the process — having strategies for it is the skill.

If you already write JavaScript and have shipped a small project: skip to Part II. Use Part I's later stages (TypeScript, React, Next.js) as a checklist of "do I actually know this?"

If you're already a working developer who knows the modern stack and wants to level up: Part III — Beyond the stack is where you live. Computer-science fundamentals, engineering judgment, systems thinking, security. None of which change with framework cycles.

A note on AI-assisted learning: ChatGPT, Claude, and Cursor are real learning accelerators when used well — and a way to fake competence when used badly. The right way to use them is covered in Part IV. The short version: use AI to explain things, not to do things, until you can confidently do them yourself.

Glossary & terminology

A flat reference of words this guide uses repeatedly. Skim it now; come back any time a later section uses one of these terms and you've forgotten what it means. Part I's stages teach these concepts in context with code — this glossary is just a quick lookup, not a curriculum.

Client vs server

The client is the program running on the user's device — usually a web browser like Chrome. The server is a program running on a computer somewhere else (a data centre, a cloud provider). The client sends a request; the server sends back a response. The whole web is built on this pattern.

HTTP, HTTPS, and APIs

HTTP (HyperText Transfer Protocol) is the language clients and servers use to talk. HTTPS is the same thing, encrypted. An API (Application Programming Interface) is just a set of HTTP endpoints — URLs — that a server promises to respond to in a structured way, usually returning JSON (JavaScript Object Notation, a text format for nested data).

Frontend, backend, full-stack

Frontend = code that runs in the user's browser, what they see and click. Backend = code that runs on the server, where data lives. Full-stack = you write both.

Framework vs library

A library is code you call. A framework is code that calls you — it provides the structure of your app and you fill in the slots. React is technically a library, Next.js is a framework. Most people use "framework" loosely.

Runtime

The runtime is what actually executes your code. Browsers have a JavaScript runtime built in (each browser ships its own — V8 in Chrome, JavaScriptCore in Safari). On the server, Node.js is the most common JavaScript runtime; alternatives are Bun and Deno. Python has its own runtime (CPython). When someone says "this code runs on the Edge runtime," they mean a stripped-down JS runtime that runs near the user.

Package manager

A program that downloads other people's code (called packages or libraries) and tracks which versions you depend on. In the JavaScript world that's npm (built into Node), or alternatives like pnpm and yarn. In Python it's pip. The list of your project's dependencies lives in package.json (JS) or requirements.txt / pyproject.toml (Python).

Database

A program that stores data and lets you query it. Two big families: relational (SQL — tables with rows and columns, like Postgres or MySQL or SQLite) and NoSQL (everything else — document stores like MongoDB, key-value stores like Redis). Postgres is the default in 2026 for almost anything that isn't a cache.

SQL and queries

SQL (Structured Query Language) is the language you use to talk to a relational database. A query is a SQL command, e.g. SELECT * FROM users WHERE id = 5. You write SQL by hand or use an ORM (see below) to generate it for you.

ORM

Object-Relational Mapper. A library that lets you query the database using regular code (JavaScript objects, method calls) instead of writing raw SQL strings. Trade-off: easier and safer, slightly less control. Drizzle and Prisma are the modern TypeScript ORMs.

Schema and migrations

A schema is the shape of your data — which tables exist, which columns each has, what types those columns hold. A migration is a small file describing a change to that schema (e.g. "add an email column to users"). Tools apply migrations in order so your team's databases all match.

Rendering: SSR, CSR, SSG, RSC

How HTML gets to the browser:

  • SSR (Server-Side Rendering): server builds the HTML for each request. Old-school PHP, modern Next.js.
  • CSR (Client-Side Rendering): server sends a near-empty HTML shell + a big JavaScript bundle. The browser builds the page. Old single-page apps.
  • SSG (Static Site Generation): HTML is built once at deploy time, served as files. Astro, classic Jekyll/Hugo. Fast and cheap.
  • RSC (React Server Components): a newer hybrid — some components run on the server, some on the client, in the same component tree. Covered in detail below.

Hydration

When a server sends HTML to the browser and then JavaScript "wakes it up" so the page becomes interactive (buttons start working, etc.), that wake-up step is called hydration. It's a performance bottleneck — modern frameworks try to do less of it.

Build step / bundler

Modern frontend code is split across hundreds of files and uses features your browser doesn't natively understand (TypeScript, JSX, modern syntax). A bundler (Vite, Webpack, esbuild, Turbopack) compiles all that into a small number of files the browser can load. The "build step" is running that bundler.

Type safety

A "type" is the kind of value something is — a number, a string, a list of users. JavaScript doesn't check types: 5 + "hello" happily returns "5hello". TypeScript is JavaScript with types added — a separate program (the type checker) verifies that types match before your code ever runs. "Type-safe" means "errors caught before runtime." Almost every project in this guide uses TypeScript.

Compile-time vs runtime

Compile-time means "while your code is being prepared, before it runs." Runtime means "while your code is actually executing." A compile-time error is found by a tool reading your code; a runtime error happens to a real user.

CI/CD

Continuous Integration / Continuous Deployment. A robot service (GitHub Actions, Vercel, Netlify) watches your code repository. Every time you push a change, it runs your tests, builds your project, and deploys it. You've already got this on most of your projects via push-to-deploy.

LLM, token, context window

An LLM (Large Language Model) is the kind of AI behind ChatGPT, Claude, Gemini. It takes text in, produces text out. A token is roughly a syllable — LLMs read and write in tokens, not letters or words. The context window is the maximum number of tokens the model can "see" at once (the whole prompt + its reply). Modern models have context windows of 128k–2M tokens.

Embedding

A way to turn text into a list of numbers (a "vector") that captures its meaning. Two sentences with similar meanings get similar vectors, even if the words differ. Used for semantic search — you'll see this in the RAG section.

Vector database

A database optimised for storing embeddings and finding "the K most similar vectors to this one" quickly. Pinecone, Weaviate, Chroma, or just Postgres with the pgvector extension.

Part I — From Zero

Twelve stages. Each one ends with a project — build it before moving on, or none of this sticks. The whole arc is 4–9 months of part-time effort. If a stage feels easy, skim it and do the project to verify. If a stage feels hard, slow down — there is no shortcut around understanding.

Stage 0 — Get set up STAGE ~1 day

Before you can write code, you need the tools to write it, run it, and save it. This stage is short but everyone gets stuck somewhere — usually on PATH issues on Windows or "permission denied" on Mac. Don't move on until node --version and git --version both print something.

1. Install Node.js (the JavaScript runtime)

Node lets you run JavaScript outside a browser. You'll use it to run every script in this guide, install libraries, and later run your servers. Install the LTS version (Long Term Support — the stable one) from nodejs.org.

Verify it worked by opening your terminal and running:

node --version
# should print something like v22.11.0

npm --version
# should print something like 10.9.0 — npm comes bundled with Node

2. Install VS Code (your editor)

Download VS Code. It's free, by Microsoft, and the default editor for ~90% of professional web developers. Other options exist (Cursor, Zed, WebStorm) but start here — every tutorial assumes it.

Three extensions to install immediately (open the Extensions panel, search by name):

  • Prettier — formats your code automatically on save. End of arguments about indentation forever.
  • ESLint — catches common JavaScript mistakes as you type.
  • GitLens — shows you who wrote each line and when. Indispensable once you read other people's code.

3. Install Git

Git tracks changes to your code. You'll learn it properly in Stage 4 — for now, just install it from git-scm.com so it's there when you need it.

4. Terminal basics

The terminal (or "command line" or "shell") is a text interface for telling your computer what to do. On Mac/Linux, open Terminal. On Windows, use PowerShell (built in) or install Windows Terminal for a nicer experience. The six commands you'll use every day:

pwd              # print working directory — "where am I?"
ls               # list files in the current directory (Windows: dir)
cd folder-name   # change directory into folder-name
cd ..            # go up one level
mkdir new-folder # make a new folder
code .           # open VS Code in the current directory

If code . doesn't work, open VS Code, press Ctrl/Cmd+Shift+P, search for "Shell Command: Install 'code' command in PATH", and run it.

5. Your first program

Create a folder, open it in VS Code, make a file called hello.js, paste this in, save:

console.log("Hello from JavaScript.");
const name = "world";
console.log(`Hello, ${name}!`);

In the VS Code terminal (Ctrl/Cmd+`), run:

node hello.js

You should see two lines print. Congratulations — you're a programmer now.

Common pitfalls

  • Windows: "node is not recognized" — Node didn't get added to your PATH. Restart your terminal; if still broken, reinstall and tick "Add to PATH" during setup.
  • Mac: "command not found: code" — see the Cmd+Shift+P trick above.
  • "Permission denied" on Mac/Linux — never use sudo with npm; it causes permission chaos later. Fix by installing Node via nvm instead.
  • Don't fight your OS. If something obscure breaks, search the exact error message — someone has hit it before, the answer is on Stack Overflow.
Where to go deeper
Project

Write three small scripts in a stage-0/ folder: (a) greet.js that prints a greeting using a name in a variable; (b) add.js that adds two numbers and prints the result; (c) date.js that prints today's date. Run all three with node filename.js. Trivial, but proves your environment works end-to-end.

Stage 1 — JavaScript basics STAGE ~3–6 weeks

The single most important stage in this guide. Everything that comes later (React, Next.js, backend code) is JavaScript with extra rules. If your JavaScript is shaky, every later stage will feel ten times harder than it should. Take your time here.

Eight concepts to actually internalise, not just read about.

1. Variables: let, const, and types

A variable is a named box you put a value in. JavaScript has two ways to declare one: let (the value can be reassigned later) and const (it can't). Always prefer const — use let only when you actually need to reassign. There's also var, an older form — never use it.

const age = 25;
const name = "Tony";
const isStudent = true;
const nothing = null;

let score = 0;
score = score + 10; // allowed because score is `let`

// age = 26;  // ❌ TypeError: Assignment to constant variable

The seven primitive types you'll meet: number, string, boolean, null, undefined, bigint (huge integers, rare), symbol (advanced, ignore for now). Plus two non-primitives: object and array (which is technically an object).

2. Strings, template literals, and the operators that bite

const first = "Tony";
const last = "Yu";

// concatenation with +
const full = first + " " + last;

// template literal (backticks + ${ }) — almost always cleaner
const greeting = `Hello, ${first} ${last}, you are ${age} years old.`;

// === vs ==  — ALWAYS use ===
const a = 5;
const b = "5";
console.log(a == b);   // true — type coercion, surprising
console.log(a === b);  // false — strict equality, what you actually want

Rule: only ever write === and !==. The double-equals form does sneaky type conversion that has produced more bugs than any other JS feature.

3. Conditionals and "truthy" / "falsy"

const score = 73;

if (score >= 90) {
  console.log("A");
} else if (score >= 70) {
  console.log("B");
} else {
  console.log("work harder");
}

// ternary — a compact if/else expression
const label = score >= 70 ? "pass" : "fail";

JavaScript treats certain values as "falsy" when used in a condition: false, 0, "" (empty string), null, undefined, NaN. Everything else is truthy. This is why if (user.name) works as a "did they fill it in" check — but also why if (count) incorrectly skips count === 0.

4. Functions: declarations vs arrows

// function declaration
function add(a, b) {
  return a + b;
}

// arrow function — same thing, terser, used everywhere in modern JS
const add = (a, b) => a + b;

// arrow with a body
const greet = (name) => {
  const hour = new Date().getHours();
  return hour < 12 ? `Morning, ${name}` : `Hi, ${name}`;
};

console.log(add(2, 3));      // 5
console.log(greet("Tony"));   // "Morning, Tony" or "Hi, Tony"

5. Arrays and the three methods you'll use every day

const nums = [1, 2, 3, 4, 5];

nums.length;                       // 5
nums[0];                           // 1 (zero-indexed)
nums.push(6);                      // mutates: [1,2,3,4,5,6]

// .map — transform every item, return a new array
const doubled = nums.map(n => n * 2);  // [2,4,6,8,10]

// .filter — keep items that pass a test
const evens = nums.filter(n => n % 2 === 0);  // [2,4]

// .reduce — fold the array into a single value
const sum = nums.reduce((acc, n) => acc + n, 0);  // 15

// for...of — iterate without indices
for (const n of nums) {
  console.log(n);
}

.map, .filter, .reduce, and for...of cover 95% of array work in modern JS. You'll almost never write a traditional for (let i = 0; ...) loop again.

6. Objects: the most-used data structure in JS

const user = {
  name: "Tony",
  age: 25,
  isStudent: true,
};

user.name;            // "Tony" — dot notation
user["age"];         // 25 — bracket notation, equivalent
user.email = "t@x.com";  // add a field

// destructuring — pull fields into variables in one line
const { name, age } = user;
console.log(name, age);  // "Tony" 25

// spread — copy fields into a new object
const updated = { ...user, age: 26 };  // new object; user is unchanged

7. Reference vs value: the one that trips everyone

Primitives (number, string, boolean) are passed by value — a copy. Objects and arrays are passed by reference — a pointer to the same thing in memory. This causes mysterious bugs.

// primitives — independent copies
let a = 5;
let b = a;
b = 10;
console.log(a); // 5 — unchanged

// objects — same thing through two names
const x = { count: 1 };
const y = x;
y.count = 99;
console.log(x.count); // 99 — x and y point to the SAME object

// to get an independent copy, spread:
const z = { ...x };
z.count = 0;
console.log(x.count); // 99 — x is untouched this time

This becomes critical in React, where mutating state objects directly (instead of creating new ones) silently fails to trigger re-renders.

8. Modules: import / export

Modern JS code is split across many files that import from each other.

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

// app.js
import multiply, { add, PI } from "./math.js";

console.log(add(2, 3));        // 5
console.log(multiply(4, PI));  // 12.56636

One file = one module. export what you want other files to use; import what you need. Default exports are imported without braces; named exports need them.

What to skip for now

You will see these and panic — don't. They're for later, not now:

  • Classes — they exist; modern web code rarely uses them. Skim, don't study.
  • this — it's a confusing rabbit hole tied mostly to classes. Skip until you need it.
  • Prototypes — under-the-hood inheritance mechanism. Important eventually, irrelevant now.
  • Callbacks in the old (err, result) => ... style — superseded by promises/async-await (Stage 3).
Where to go deeper
  • javascript.info — the single best free JavaScript textbook. Work through Part 1 ("The JavaScript language"). Do the exercises. Don't just read.
  • MDN JavaScript Guide — the authoritative reference. Bookmark it; you'll be back daily.
  • The Odin Project — a full free curriculum if you want a structured course with cohort-style progression.
Project — Console-only command-line games

In a stage-1/ folder, build three things, no UI, all printed to the terminal: (1) a number guessing game — the program picks a random number 1–100, the user guesses via readline, the program says "higher" or "lower"; (2) a todo list stored in a JavaScript array, with functions to add, complete, and list todos; (3) a word counter that reads a text file and prints the 10 most-used words. Each one uses different bits of what you just learned. If you can build all three without copy-pasting, you've internalised Stage 1.

Stage 2 — HTML & CSS STAGE ~2–4 weeks

HTML is the structure of every web page. CSS is how it looks. JavaScript (Stage 3) is how it behaves. You need all three, but HTML and CSS are quieter than people make them sound — there's a small set of patterns that cover 90% of real-world UI.

1. The shape of an HTML document

<!DOCTYPE html>
<html lang="en">
<head>
  <meta charset="UTF-8" />
  <meta name="viewport" content="width=device-width, initial-scale=1" />
  <title>My page</title>
  <link rel="stylesheet" href="styles.css" />
</head>
<body>
  <header><h1>Welcome</h1></header>
  <main>
    <p>A paragraph of text.</p>
    <a href="https://example.com">A link</a>
  </main>
  <footer>© 2026</footer>
  <script src="app.js"></script>
</body>
</html>

The <head> holds metadata (the page title, links to CSS, etc.) — the user never sees it directly. The <body> is everything visible. Use semantic tags<header>, <main>, <nav>, <article>, <section>, <footer> — instead of generic <div> where one fits. They help screen readers, search engines, and your future self reading the markup.

2. The tags worth knowing cold

  • Text: <h1><h6> (headings), <p> (paragraph), <strong> (bold for emphasis), <em> (italic for emphasis), <span> (inline neutral wrapper).
  • Layout: <div> (block neutral wrapper), <ul> + <li> (unordered list), <ol> (ordered list).
  • Media: <img src="..." alt="..."> (always include alt for screen readers), <video>, <audio>.
  • Links: <a href="...">. External links typically get target="_blank" rel="noopener" to open in a new tab safely.
  • Forms: <form>, <input> (types: text, email, password, number, checkbox, radio, date), <textarea>, <button>, <label>.

3. Forms done right (the accessibility version)

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

  <label for="msg">Message</label>
  <textarea id="msg" name="msg" required></textarea>

  <button type="submit">Send</button>
</form>

Two non-negotiable rules: every input gets a <label> wired to it via matching for / id (screen readers depend on this), and use the right type (mobile keyboards change for email, number, tel; browsers validate for free).

4. CSS: selectors, the cascade, and the rules of engagement

/* element selector */
p { color: #333; line-height: 1.6; }

/* class selector — your main tool */
.button { padding: 8px 16px; border-radius: 6px; }

/* id selector — high specificity, use sparingly */
#main-nav { background: white; }

/* descendant selector — applies inside .card */
.card p { margin: 0; }

/* state selector */
.button:hover { background: #eee; }

When two rules target the same element, the more specific one wins (id beats class beats element). When two rules have the same specificity, the one that appears later in the CSS wins. This is "the cascade." 90% of CSS confusion is two rules fighting and the wrong one winning.

5. The box model

flowchart TB subgraph M["margin (space OUTSIDE the box)"] subgraph B["border"] subgraph P["padding (space INSIDE the box, before content)"] C["content"] end end end
Every element is content surrounded by padding, then a border, then margin. Width = content + padding + border (with box-sizing: border-box — set this on everything).
* { box-sizing: border-box; } /* universal reset, do this on every project */

.card {
  width: 300px;
  padding: 16px;
  border: 1px solid #ccc;
  margin: 12px;
}

6. Flexbox — for laying things out in a row or column

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

Flexbox is the workhorse of modern layout. Six properties cover 90% of cases: display: flex, flex-direction, justify-content, align-items, gap, flex-wrap. Centering a div — the meme that defined a decade — is now display: flex; justify-content: center; align-items: center;.

7. Grid — for two-dimensional layouts

.dashboard {
  display: grid;
  grid-template-columns: 200px 1fr;  /* sidebar + main */
  grid-template-rows: auto 1fr auto;  /* header, content, footer */
  gap: 16px;
  min-height: 100vh;
}

Use Grid when you have rows AND columns; Flexbox when you have one direction. 1fr means "one fraction of remaining space" — the modern way to say "fill the rest."

8. Responsive design — mobile-first

/* default: mobile styles (small screens) */
.container { padding: 12px; }

/* at 768px and wider: tablet/desktop */
@media (min-width: 768px) {
  .container { padding: 32px; max-width: 1200px; margin: 0 auto; }
}

Mobile-first means: write styles for the small screen first, then add media queries to override for bigger screens. The opposite (desktop-first) tends to produce worse mobile experiences. Two breakpoints (768px tablet, 1024px desktop) is enough for most projects.

What to skip

  • Float layouts — pre-2015 technique for column layouts. Replaced by Flexbox and Grid. If a tutorial uses floats for layout, find a newer tutorial.
  • Internet Explorer hacks, vendor prefixes — modern browsers don't need them. Tools like Autoprefixer handle the rare exception automatically.
  • CSS preprocessors (Sass, Less) — still used but Tailwind (Stage 7) makes them mostly unnecessary for new projects.
  • Animations beyond transition — fancy keyframe animations are fun but rarely the bottleneck on a project. Learn them when you need them.
Where to go deeper
Project — Static personal page, no JS

In a stage-2/ folder, build a single-page personal site by hand: a hero section with your name and a tagline, an "about me" section with 2 paragraphs, a "projects" section showing 3 cards in a grid, a contact form (no submit handler yet — Stage 3 will wire it up), a footer. Make it responsive: looks good on mobile AND a 1440px monitor. Use semantic tags. No frameworks, no libraries — just index.html and styles.css. Open it by double-clicking the HTML file. Iterate until you're proud of it; you'll deploy this version of yourself again in Stage 9 with Next.js.

Stage 3 — JavaScript in the browser STAGE ~2–3 weeks

Stage 1 taught you JavaScript as a language. This stage teaches it as a way to make web pages interactive. The bridge between your JS code and your HTML is the DOM (Document Object Model) — a tree representation of the page that your JS can read and change.

1. Loading JavaScript into a page

<!-- in index.html, right before </body> -->
<script src="app.js"></script>

<!-- modern: a module, can use import/export -->
<script type="module" src="app.js"></script>

Put <script> tags at the end of <body> so the HTML is parsed before your JS runs. Or use type="module" (or defer) and put it in the head — they wait for the DOM automatically.

2. Selecting elements: querySelector

// pass any CSS selector — same syntax as your CSS
const button = document.querySelector("#submit");
const firstP = document.querySelector("p");
const allCards = document.querySelectorAll(".card");  // NodeList of all matches

3. Listening for events

button.addEventListener("click", () => {
  console.log("clicked!");
});

// event object — access the keyboard event, the target, etc.
input.addEventListener("keydown", (event) => {
  if (event.key === "Enter") submit();
});

// form submission — preventDefault stops the page reload
form.addEventListener("submit", (event) => {
  event.preventDefault();
  const formData = new FormData(form);
  console.log(formData.get("email"));
});

Common event names: click, submit, input (fires on every keystroke), change (fires when the value is committed), keydown, mouseenter, scroll.

4. Changing the page

title.textContent = "New title";             // safe text only
container.innerHTML = `<p>Hello</p>`;        // ⚠ injects HTML — XSS risk if user data
element.classList.add("is-active");
element.classList.toggle("is-open");
element.setAttribute("disabled", "");
element.style.color = "red";                // inline style; usually toggle a class instead

// creating new elements
const li = document.createElement("li");
li.textContent = "New todo";
list.appendChild(li);

Prefer textContent over innerHTML when you're inserting user-supplied data — it can't be tricked into running scripts.

5. Asynchrony: promises and async/await

Things that take time — network requests, timers, file reads — return promises: an object representing "a value that will arrive later." You wait for them with await inside an async function.

async function loadUser() {
  try {
    const response = await fetch("https://api.github.com/users/octocat");
    if (!response.ok) throw new Error(`HTTP ${response.status}`);
    const user = await response.json();
    console.log(user.name);
  } catch (err) {
    console.error("failed:", err);
  }
}

loadUser();

The mental model: await "pauses" the function until the promise settles. Other code keeps running in the meantime — the page doesn't freeze. fetch is the built-in way to make HTTP requests from the browser; .json() parses the response body as JSON.

6. localStorage — saving data on the user's device

localStorage.setItem("theme", "dark");
const theme = localStorage.getItem("theme"); // "dark" or null

// to store objects, JSON-serialise them
localStorage.setItem("todos", JSON.stringify(todos));
const todos = JSON.parse(localStorage.getItem("todos") || "[]");

Persists across page reloads but lives only in this browser on this device. Don't store secrets here — the user (and any browser extension) can read it.

7. DevTools — your most-used tool

Press F12 (or right-click → Inspect). Tabs to learn:

  • Elements — inspect the DOM live, edit HTML/CSS in-place to experiment.
  • Console — see console.log output, type ad-hoc JS to inspect variables.
  • Network — see every request the page made, the response, the timing.
  • Sources — set breakpoints, step through code with a real debugger.
  • Application → Local Storage — view what your code is saving.

Senior devs live in DevTools. Learn it now, save a thousand hours later.

Where to go deeper
Project — Vanilla-JS todo list

In a stage-3/ folder, build a todo list as a single HTML file + a single JS file. Features: input + button to add a todo, click a todo to mark it done (struck-through), a delete button per todo, count of remaining items at the bottom, persistence via localStorage so reloading keeps your list. No frameworks. Then add a second feature: a button that calls the GitHub API (https://api.github.com/users/yourname) and shows your avatar and bio at the top of the page. By the end you'll have wired up DOM manipulation, events, async/await, fetch, JSON, and persistence — every browser primitive you'll ever use in React.

Stage 4 — Git & GitHub STAGE ~1–2 weeks

Git is version control — every change to your code becomes a saved snapshot you can return to. GitHub is the most popular site for hosting Git repositories online. Together they're how every team in the world ships software. Learn them now even though you're working solo: future-you will thank you when "I broke something three days ago" stops meaning "I lost three days of work."

1. The mental model

A Git repository ("repo") is a folder where every file is tracked. You make changes; you stage the ones you want to record; you commit them, creating a snapshot with a message. Later you can see every snapshot, jump back to any of them, or branch off to try something risky without disturbing the main line.

flowchart LR WD["Working directory
(your files on disk)"] -->|git add| ST["Staging area
(what's going in the next commit)"] ST -->|git commit| RP["Local repo
(all your snapshots)"] RP -->|git push| GH["Remote repo
(GitHub)"] GH -->|git pull| RP
Three locations every Git command moves files between.

2. The first-time setup

git config --global user.name "Tony Yu"
git config --global user.email "you@example.com"
git config --global init.defaultBranch main

Then create a free GitHub account and set up SSH keys following GitHub's guide — it's a 5-minute setup that saves you typing passwords forever.

3. The 8 commands you'll use 95% of the time

git init                          # turn the current folder into a Git repo
git status                        # what's changed since the last commit?
git add file.js                   # stage one file for the next commit
git add .                         # stage everything changed
git commit -m "add login form"     # take a snapshot with a message
git log                           # list all commits (q to exit)
git diff                          # show unstaged changes line-by-line
git push                          # send commits to GitHub

4. Branches: working on a feature without breaking main

git branch                        # list branches; * marks the current one
git checkout -b add-dark-mode     # create AND switch to a new branch
# ...make changes, commit them...
git checkout main                 # switch back
git merge add-dark-mode           # bring the branch's commits into main
git branch -d add-dark-mode       # delete the branch when done

The pattern: main is always working code. Any new feature or fix happens on a branch. When the branch works, merge it into main. This is how teams work, and how you should work even solo — it forces you to think in self-contained changes.

5. Connecting to GitHub

# after creating an empty repo on GitHub, it shows you these:
git remote add origin git@github.com:yourname/my-project.git
git push -u origin main           # -u remembers this branch for future `git push`s

6. The .gitignore file

Some files shouldn't be tracked: node_modules/ (huge, regenerated by npm install), .env (secrets!), .DS_Store (Mac noise). Create a .gitignore at the repo root listing them:

node_modules/
.env
.env.local
.DS_Store
*.log
dist/
build/

For a Node project, the canonical Node .gitignore from GitHub is a great copy-paste start.

7. When things break

git restore file.js               # discard unstaged changes to file.js
git restore --staged file.js      # unstage a file (un-`git add`)
git reset --soft HEAD~1           # undo the last commit but keep changes
git stash                         # shelve changes temporarily
git stash pop                     # bring them back
git log --oneline                 # compact history

A working philosophy: commit often, with small focused changes. The more granular your history, the easier it is to find the commit that broke something with git bisect later (Part III).

8. Writing commit messages

The convention most teams follow: a short imperative subject line (≤60 chars), a blank line, then optional details.

# bad
fix

# good
fix(form): prevent double-submit on slow networks

The submit button was disabled on click but re-enabled by React's
re-render before the fetch resolved. Move the disabled state into
useTransition so it tracks the actual pending state.

Pull requests (PRs)

When you push a branch to GitHub, you can open a pull request — a UI for "please review and merge these changes." Solo, you'll often PR your own branches into main just for the diff view and a place to write context. On a team, this is where code review happens.

Where to go deeper
  • Pro Git book (free online) — chapters 1–3 cover everything in this stage in proper depth.
  • Learn Git Branching — interactive visualisation of branching/merging/rebasing. The fastest way to build a mental model.
  • ohshitgit.com — "I just did X, how do I undo it?" reference. Bookmark.
Project — Put your previous work on GitHub

Create a free GitHub account if you haven't. Make a new repo per Stage 1–3 project (or one repo with three folders — your call). Practice the full loop: git init, write code, git add, git commit, git push. Then make a change on a branch (git checkout -b polish-styles), commit it, push it, open a PR on GitHub, merge it. Do this until the commands are muscle memory. Bonus: add a README.md to each project explaining what it does, how to run it, and what you learned — your future portfolio.

Stage 5 — TypeScript STAGE ~2–3 weeks

TypeScript is JavaScript with a type system bolted on. You write code that looks ~95% the same as your JS; the TS compiler reads it before your code runs and yells at you if something doesn't add up — "you're passing a string to a function that wants a number," "this object might be undefined." The result: a huge class of bugs is caught at write-time instead of at-runtime-in-production.

In 2026, TypeScript is the default for serious projects. Every framework's documentation assumes you'll use it. Learning it now is non-negotiable.

1. Setup

npm init -y                       # create a package.json
npm install -D typescript tsx     # tsx = run .ts files directly
npx tsc --init                    # create tsconfig.json

The tsconfig.json tells TypeScript how to behave. The defaults are sensible; the one setting worth knowing: "strict": true should already be on. Don't turn it off — it's the option that catches the most bugs.

2. Basic types

const age: number = 25;
const name: string = "Tony";
const isStudent: boolean = true;
const tags: string[] = ["web", "ai"];
const scores: number[] = [88, 92, 76];

// in most cases you don't need to write the type — TS infers it
const doubled = scores.map(n => n * 2);  // inferred as number[]

The rule: let TS infer types where it can. Only annotate where it can't — function parameters, things returned from APIs, exported function signatures.

3. Functions

function add(a: number, b: number): number {
  return a + b;
}

// arrow form
const add = (a: number, b: number): number => a + b;

// optional parameter (?)
function greet(name: string, greeting?: string): string {
  return `${greeting ?? "Hello"}, ${name}`;
}

4. Object shapes: interface and type

interface User {
  id: number;
  email: string;
  age?: number;          // optional field
  isAdmin: boolean;
}

function sendWelcome(user: User) {
  console.log(`Welcome ${user.email}`);
}

// `type` is equivalent for object shapes — both are fine
type Point = { x: number; y: number };

Use interface for object shapes by default; use type when you need unions or computed types (next).

5. Union types and literal types

// a string OR a number
type Id = string | number;

// one of three specific strings — incredibly useful
type Status = "pending" | "shipped" | "delivered";

function nextStatus(s: Status): Status {
  if (s === "pending") return "shipped";
  if (s === "shipped") return "delivered";
  return s;
}

// nextStatus("paid"); ❌ Argument of type '"paid"' is not assignable to 'Status'

6. Generics: the function that works on "any T"

// without generics — loses type info
function first(arr: any[]): any { return arr[0]; }

// with generics — preserves the element type
function first<T>(arr: T[]): T | undefined {
  return arr[0];
}

const n = first([1, 2, 3]);          // n is number | undefined
const s = first(["a", "b"]);          // s is string | undefined

You'll see <T> everywhere — in React's hooks, in API client libraries, in utility functions. The intuition: "this function works for any type; I'll call that type T; whatever you pass in determines what T is for this call."

7. The error you'll see first: "X is possibly undefined"

function findUser(id: number): User | undefined {
  return users.find(u => u.id === id);
}

const user = findUser(5);
console.log(user.email);  // ❌ 'user' is possibly 'undefined'

// fixes:
if (user) console.log(user.email);   // narrowing
console.log(user?.email);             // optional chaining — undefined if user is
console.log(user!.email);             // ! = "trust me bro" — avoid; usually wrong

This is TypeScript saving you from real crashes. Don't reach for ! — handle the undefined case properly.

8. The unknown vs any distinction

any means "turn off type checking for this." It's a fire escape, not a tool. unknown means "I don't know yet; the compiler will force me to check before using it." Prefer unknown.

What you don't need to learn yet

  • Decorators — niche, used mostly in NestJS/Angular which you're skipping.
  • Advanced type gymnastics (mapped types, conditional types, infer) — fascinating but irrelevant until you're writing libraries.
  • Namespaces, triple-slash references — legacy. ES module syntax (Stage 1) replaces them.
Where to go deeper
Project — Port your Stage 3 todo to TypeScript

Take your vanilla-JS todo from Stage 3. Rename app.js to app.ts. Run npx tsc --init to create a config. Fix every type error the compiler reports — there will be many at first. Define an interface Todo { id: string; text: string; done: boolean }. Type every function's parameters and return value. The goal is zero any in the final code. Bonus: type the GitHub user response from your Stage 3 fetch call — you'll have to look at the actual API response and write the interface yourself.

Stage 6 — React fundamentals STAGE ~3–5 weeks

React is a library for building user interfaces out of components. Instead of imperatively writing "find this DOM element, change its text" (Stage 3), you declaratively describe what the UI should look like for a given state, and React figures out the DOM changes for you. Once it clicks, you'll never want to go back to vanilla DOM manipulation.

1. Create your first app

npm create vite@latest my-app -- --template react-ts
cd my-app
npm install
npm run dev

Vite is a build tool — it bundles your code and runs a dev server. Visit the printed URL (usually http://localhost:5173). Hot-reload: edit a file, see the page update without reloading.

2. Components and JSX

A component is a function that returns a tree of UI. JSX is HTML-looking syntax inside JS — the build tool compiles it to regular function calls.

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

function App() {
  return (
    <div>
      <Greeting />
      <p>A sub-paragraph.</p>
    </div>
  );
}

Rules: components start with a Capital Letter. JSX can render only one root element (wrap multiple in <>...</>, a "fragment"). Use className not class and htmlFor not for — the only two HTML attributes that get renamed.

3. Props: passing data into components

interface CardProps {
  title: string;
  body: string;
  onClick?: () => void;
}

function Card({ title, body, onClick }: CardProps) {
  return (
    <div className="card" onClick={onClick}>
      <h3>{title}</h3>
      <p>{body}</p>
    </div>
  );
}

// usage
<Card title="Hello" body="World" onClick={() => alert("!")} />

Props flow one-way: parent → child. A child cannot directly change a parent's data — it can only ask via a callback prop (the onClick in the example).

4. State: useState

import { useState } from "react";

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

  return (
    <div>
      <p>Count: {count}</p>
      <button onClick={() => setCount(count + 1)}>+1</button>
      <button onClick={() => setCount(0)}>reset</button>
    </div>
  );
}

The mental model: useState gives you a value and a setter. Calling the setter tells React "re-run my component function and produce new JSX with this new value." React diffs the new JSX against the old and updates only what changed in the DOM.

5. The rules of state that bite newcomers

// ❌ Don't mutate state directly
const [todos, setTodos] = useState([]);
todos.push(newTodo);           // React doesn't notice — no re-render

// ✅ Create a new array/object
setTodos([...todos, newTodo]);

// ✅ For updates that depend on previous state, use the function form
setCount(prev => prev + 1);    // safe against race conditions

This is the reference vs value distinction from Stage 1 in action. React compares state by reference: if you mutate the same array, the reference is unchanged, no re-render.

6. Rendering lists

function TodoList({ todos }: { todos: Todo[] }) {
  return (
    <ul>
      {todos.map(t => (
        <li key={t.id}>{t.text}</li>
      ))}
    </ul>
  );
}

key is non-optional — it's how React tracks which item is which across re-renders. Use a stable unique id (the database id, a UUID). Never use the array index as the key in lists that can reorder or have items inserted/removed — it causes mysterious bugs.

7. Forms (controlled inputs)

function SignupForm() {
  const [email, setEmail] = useState("");

  function handleSubmit(e: React.FormEvent) {
    e.preventDefault();
    console.log("submitting:", email);
  }

  return (
    <form onSubmit={handleSubmit}>
      <input
        type="email"
        value={email}
        onChange={e => setEmail(e.target.value)}
      />
      <button type="submit">Sign up</button>
    </form>
  );
}

A "controlled" input is one where React holds the value, not the DOM. The input reflects the state, and every keystroke updates the state. It feels like extra work for a single input but pays off the moment you need validation, derived state, or a programmatic reset.

8. useEffect: side effects (and why you should avoid it)

import { useEffect, useState } from "react";

function UserProfile({ userId }: { userId: number }) {
  const [user, setUser] = useState<User | null>(null);

  useEffect(() => {
    fetch(`/api/users/${userId}`)
      .then(r => r.json())
      .then(setUser);
  }, [userId]);  // re-run when userId changes

  if (!user) return <p>loading...</p>;
  return <h2>{user.name}</h2>;
}

useEffect runs after a render. It's how you sync React state to "the outside world" — fetching data, subscribing to events, setting timers. The official advice from the React team is now "you probably don't need an effect": server-side data fetching (Stage 8 / RSC) replaces most data-loading effects. Use useEffect only for genuine side effects — subscribing to a WebSocket, integrating with a non-React library.

9. The mental model that makes React click

Stop thinking "what should I do when X changes?" Start thinking "what should the UI look like for this state?" React re-runs your component every time state changes. Your job is to write a function from state to UI. React handles the "do" part.

What to skip for now

  • Class components — pre-2019 React. Skim, ignore, never write.
  • Redux / Zustand / Jotai (global state libraries) — covered in Tier 3. useState + props gets you very far.
  • useReducer, useContext, useMemo, useCallback — exist for specific problems you don't have yet. Learn when you hit the problem they solve.
  • Higher-order components, render props — legacy patterns superseded by hooks.
Where to go deeper
  • react.dev Learn — the official tutorial, rewritten in 2023, genuinely excellent. Work through "Describing the UI," "Adding Interactivity," and "Managing State." Skip the rest until you need it.
  • react.dev Reference — every hook and API, with examples.
  • Dan Abramov's blog — the React team's longtime lead. "A Complete Guide to useEffect" alone is worth the time.
Project — Todo list in React + TypeScript

Use npm create vite@latest with the react-ts template. Rebuild your Stage 3 todo list in React: add, complete, delete todos, persist via localStorage, show the remaining count. Compose it from at least three components (App, TodoInput, TodoItem). Pass data down via props, send actions up via callback props. No external libraries. The goal isn't features — it's experiencing how much cleaner this is than the Stage 3 vanilla version.

Stage 7 — Tailwind CSS STAGE ~1 week

Tailwind is a CSS framework, but not the kind you're used to. Instead of giving you pre-built components like Bootstrap, it gives you a vocabulary of single-purpose utility classes (px-4, text-lg, flex, rounded-lg) that you compose directly in your HTML/JSX. It feels weird for 20 minutes and natural forever after.

1. Why utility-first wins for solo development

  • You stop naming things. No more .card__header--featured vs .card-header.featured debates.
  • You stop context-switching between HTML and CSS files.
  • Unused styles get tree-shaken out — your production CSS is tiny.
  • Consistency is automatic: spacing comes from a scale (p-1, p-2, p-4, p-8), colors come from a palette, font sizes come from a scale.

2. Setup (in your Vite + React project)

npm install tailwindcss @tailwindcss/vite

Then add the Vite plugin and one CSS import. The Tailwind docs cover the current setup steps with copy-paste accuracy.

3. The 20 utility groups you'll use 95% of the time

<div className="flex items-center justify-between gap-4 p-6 bg-white rounded-lg shadow">
  <h2 className="text-xl font-semibold text-gray-900">Title</h2>
  <button className="px-4 py-2 bg-blue-600 text-white rounded hover:bg-blue-700">
    Action
  </button>
</div>

Categories worth memorising:

  • Spacing: p-4 padding, m-2 margin, gap-4 grid/flex gap (scale: 0, 1, 2, 4, 6, 8, 12, 16…).
  • Layout: flex, grid, grid-cols-3, items-center, justify-between.
  • Size: w-full, w-1/2, h-screen, max-w-4xl.
  • Text: text-sm, text-xl, font-semibold, text-gray-700.
  • Color: bg-blue-500, text-red-600, border-gray-200.
  • Borders / rounding: rounded, rounded-lg, border, border-2.
  • Effects: shadow, shadow-lg, opacity-50, transition.

4. Responsive prefixes

<div className="px-4 md:px-8 lg:px-16">
  {/* px-4 on mobile, px-8 from 768px up, px-16 from 1024px up */}
</div>

Same mobile-first model as raw CSS (Stage 2): the unprefixed class is the default, prefixes (sm:, md:, lg:, xl:) override at progressively wider breakpoints. Min-width, always.

5. State variants

<button className="bg-blue-600 hover:bg-blue-700 active:bg-blue-800 disabled:opacity-50">
  Click
</button>

<input className="border focus:border-blue-500 focus:ring-2 focus:ring-blue-200" />

Variants compose: md:hover:bg-blue-700 = on tablet+, on hover, blue-700.

6. Dark mode

<div className="bg-white text-gray-900 dark:bg-gray-900 dark:text-gray-100">
  Content
</div>

One dark: prefix per styling property. Configure how dark mode activates (system preference vs class toggle) in your Tailwind config.

7. The objection you'll hear (and the answer)

"Tailwind in JSX looks like a wall of classes" — yes, until you stop reading individual classes and start reading shapes. flex items-center justify-between is one shape ("horizontal bar"); px-4 py-2 rounded bg-blue-600 text-white is another ("button"). With a Prettier plugin (prettier-plugin-tailwindcss) classes auto-sort into a consistent order. After a week it reads faster than custom CSS classes ever did.

When NOT to use Tailwind

  • One-off design systems with very custom theming — vanilla CSS with custom properties can be cleaner.
  • Email templates — most email clients ignore most of it. Use inline styles or specialised tools (Resend's react-email, Stage 2 of the Tier 2 list).
  • If you're already proficient at hand-written CSS and a project has a strong existing convention — don't force-migrate for novelty.
Where to go deeper
  • Tailwind docs — searchable, accurate, the best documentation of any frontend tool. Bookmark, keep a tab open.
  • Tailwind Play — browser playground for trying utilities live.
  • Tailwind UI (paid) and shadcn/ui (free) — galleries of pre-built components you can copy. The best way to learn idiomatic Tailwind is to read good Tailwind.
Project — Re-style your Stage 6 todo with Tailwind

Add Tailwind to your Stage 6 React todo project. Delete your existing CSS file entirely. Restyle the app using only Tailwind utilities — at minimum: a centered max-width container, a styled input + button row, list items with hover states, a "completed" struck-through style, dark-mode support via the dark: prefix. Make it responsive — single-column on mobile, two-column "active / done" layout from md: up. By the end of this project, the Tailwind class vocabulary will feel like a language, not a list.

Stage 8 — Next.js STAGE ~3–4 weeks

Next.js is a framework built on React. Where React is just the UI library, Next.js bundles in routing, server-side rendering, image and font optimisation, API endpoints, and a production-ready build/deploy pipeline. It's the default React stack for 2026 — Vercel (the company that makes it) and the React core team are deeply aligned on its direction.

1. Create your app

npx create-next-app@latest my-site
# answer the prompts: TypeScript yes, Tailwind yes, App Router yes, ESLint yes, src/ no, alias yes
cd my-site
npm run dev

Open http://localhost:3000. You're running a Next.js app. The first thing to notice: a file in app/page.tsx became the page at /. That's file-based routing.

2. File-based routing (the App Router)

app/
├── page.tsx                # → /
├── about/
│   └── page.tsx            # → /about
├── blog/
│   ├── page.tsx            # → /blog
│   └── [slug]/
│       └── page.tsx        # → /blog/anything ([slug] is a parameter)
└── layout.tsx              # wraps every page in the app

Folders are URL segments. page.tsx is the actual page component. Square-brackets denote dynamic segments. layout.tsx wraps every child page — perfect for nav and footer.

3. Server vs Client Components — the headline feature

This is what makes Next.js (and modern React) different from anything before. Every component is a server component by default. Server components run on the server only — they can be async, talk directly to a database, read files, hold secrets — and ship zero JavaScript to the browser. They produce HTML.

// app/blog/page.tsx — server component (the default)
async function BlogIndex() {
  // fetch directly from your database; no useEffect, no loading state
  const posts = await db.select().from(postsTable);
  return (
    <ul>
      {posts.map(p => <li key={p.id}>{p.title}</li>)}
    </ul>
  );
}
export default BlogIndex;

When you need interactivity — state, effects, event handlers — you mark a component as client with "use client" at the top of the file:

// app/components/LikeButton.tsx
"use client";
import { useState } from "react";

export function LikeButton() {
  const [liked, setLiked] = useState(false);
  return <button onClick={() => setLiked(true)}>{liked ? "♥" : "♡"}</button>;
}

The pattern: server components for everything that just shows data; client components for the small islands that need interactivity. Most of your tree is server; client is the exception.

4. Server Actions: handling form submissions without API routes

// app/contact/actions.ts
"use server";
import { db } from "@/lib/db";

export async function sendMessage(formData: FormData) {
  const email = formData.get("email") as string;
  const body = formData.get("body") as string;
  await db.insert(messages).values({ email, body });
}
// app/contact/page.tsx
import { sendMessage } from "./actions";

export default function ContactPage() {
  return (
    <form action={sendMessage}>
      <input name="email" type="email" required />
      <textarea name="body" required />
      <button type="submit">Send</button>
    </form>
  );
}

No fetch call, no API route, no JSON serialisation by you. Next.js secretly turns the action call into an HTTP POST. This is where Next.js feels like a different kind of framework.

5. Navigation: the Link component

import Link from "next/link";

<Link href="/about">About</Link>

Use Link instead of <a> for internal navigation. It prefetches the destination, transitions without a full page reload, and feels instant.

6. Image and font optimisation

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

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

// in your layout:
<body className={inter.className}> ... </body>

// in a page:
<Image src="/me.jpg" alt="Tony" width={400} height={400} />

next/image automatically resizes, compresses, and serves modern formats (WebP/AVIF). next/font loads Google Fonts at build time with zero layout shift.

7. Deploying to Vercel

Push your repo to GitHub. Go to vercel.com, click "Import Project," select your repo. The defaults are correct. Click Deploy. You're live on a your-app.vercel.app URL within a minute. Every push to main after that auto-deploys; every push to a branch gets its own preview URL. This is genuinely free for personal projects.

Where to go deeper
  • Next.js Learn — the official interactive tutorial. Builds a dashboard app step by step. The best single resource for this stage.
  • Next.js docs — reference. The "App Router" sections are the only ones that matter for new projects.
  • Lee Robinson on YouTube — VP of Product at Vercel. Tutorial videos that match Next.js's current direction.
Project — A multi-page Next.js site

Use npx create-next-app@latest. Build a site with at least: a home page (hero + 3 feature cards), an "about" page, a "blog" section that lists posts from a hardcoded array, and a dynamic /blog/[slug] page that renders one post. Use the Link component for internal navigation. Use next/image for at least one image. Use a server action for a contact form. Deploy it to Vercel and share the link. You now have a real, public, full-stack site.

Stage 9 — Ship a real portfolio STAGE ~1–2 weeks

By now you've shipped a Next.js site (Stage 8). This stage is about turning that capability into a polished, public portfolio you'd actually want to share — the kind that gets you interviews. There's no new technology here; just deliberate effort applied to what you already know.

1. What goes on a portfolio

  • Who you are — one paragraph. Name, what you do, what you're interested in. No life story.
  • What you've built — your best 3–5 projects, with for each one: a screenshot, 1–3 sentences of what it does and what was hard, a link to live demo and source.
  • How to reach you — email, GitHub, LinkedIn. A working contact form is a flex.
  • Optional: a short blog, a "what I'm currently learning" section, a CV download.

What not to put on a portfolio: tutorials you followed, every tiny exercise, "I know React" badges without projects. Show, don't tell.

2. The bar to aim for

  • Loads in under 1 second on a 4G connection.
  • Looks correct on a 360px-wide phone and a 1440px monitor.
  • Lighthouse score (Chrome DevTools → Lighthouse tab) ≥ 95 in all four categories.
  • No console errors. No 404s.
  • Works without JavaScript (Next.js gives you this for free with server components).
  • Accessible: every image has alt, every input has a label, colour contrast passes WCAG AA.

3. Polish moves that punch above their weight

  • A custom domain. Buy yourname.dev or .com from Namecheap or Cloudflare for ~$10/year. Point it at Vercel (their docs walk you through it). Looking professional without one is hard.
  • Decent typography. One sans-serif (Inter is a safe default), good line-height (1.5–1.7), generous spacing. Most amateur sites look amateur because the typography is cramped, not because of anything code-related.
  • One detail that surprises. A subtle hover animation, a dark/light toggle, a console easter-egg. Doesn't need to be impressive — just shows you cared.
  • Real screenshots, not placeholders. Use shots.so or just a clean Chrome screenshot for project thumbnails.

4. SEO and metadata

// app/layout.tsx
import { Metadata } from "next";

export const metadata: Metadata = {
  title: "Tony Yu — Web Developer",
  description: "I build web apps that ship. Recent work in AI-driven interviewing and full-stack TypeScript.",
  openGraph: {
    title: "Tony Yu",
    description: "Web developer building AI-driven tools.",
    images: [{ url: "/og.png", width: 1200, height: 630 }],
  },
};

The OpenGraph image is what shows up when your link is shared on Twitter, LinkedIn, Discord. Worth 30 minutes in Figma to make a nice one.

5. Track who visits (optional)

Add Plausible or PostHog for basic analytics. Knowing whether anyone visits your site is morale. Both have generous free tiers.

Where to look for inspiration
Project — Your portfolio, deployed at a custom domain

Build it with Next.js + Tailwind. Include at least three projects you've built in earlier stages. Buy a domain. Deploy to Vercel. Run Lighthouse — fix every issue until you have a 95+ in every category. Add basic analytics. Share the link with at least one person who'll give honest feedback, and iterate based on what they say.

Stage 10 — Backend basics STAGE ~3–4 weeks

Up until now you've built frontends. The browser sends a request; somewhere a server responds. This stage is about being that server: writing code that listens for requests, reads from a database, and sends responses. Once you can do this, you can build any web app that exists.

Many backend tutorials still teach Express (the dominant Node framework for a decade). We'll use Hono instead — it's smaller, faster, modern, fully TypeScript-native, and runs on Node, Bun, Cloudflare Workers, and Vercel without code changes. Smaller surface area to learn, more places it can run.

1. What a "server" actually is

A server is a program that listens on a network port for incoming HTTP requests, decides what to do with each one (often: look something up in a database, do some logic, build a response), and sends a response back. That's it. There's no magic.

// the simplest possible server, with built-in Node — no framework
import http from "node:http";

http.createServer((req, res) => {
  res.writeHead(200, { "Content-Type": "text/plain" });
  res.end("Hello from a server");
}).listen(3000, () => console.log("on http://localhost:3000"));

Run it (node server.ts with tsx); visit http://localhost:3000 in a browser. That's a server. Frameworks add ergonomics on top of this — they don't change the fundamental shape.

2. The same thing in Hono

npm install hono @hono/node-server
npm install -D tsx
// server.ts
import { Hono } from "hono";
import { serve } from "@hono/node-server";

const app = new Hono();

app.get("/", (c) => c.text("Hello from Hono"));
app.get("/hello/:name", (c) => c.text(`Hello, ${c.req.param("name")}`));
app.post("/echo", async (c) => c.json(await c.req.json()));

serve({ fetch: app.fetch, port: 3000 });

Run with npx tsx server.ts. Test from your browser (GET) or with curl (any method). The shape: a route is "an HTTP method + a path + a function." The function gets a context object c with the request and response helpers.

3. REST: the convention every API follows

REST isn't a framework — it's a style of organising API endpoints around resources using HTTP verbs. The convention for a blog:

MethodPathMeaning
GET/postsList all posts
POST/postsCreate a new post
GET/posts/:idRead one post
PUT / PATCH/posts/:idUpdate a post
DELETE/posts/:idDelete a post

You don't have to follow REST religiously — Server Actions (Stage 8) skip it entirely. But every API you'll integrate with does, so understand the shape.

4. Reading and validating request data

import { z } from "zod";

const CreatePost = z.object({
  title: z.string().min(1).max(200),
  body: z.string().min(1),
});

app.post("/posts", async (c) => {
  const body = await c.req.json();
  const parsed = CreatePost.safeParse(body);
  if (!parsed.success) return c.json({ error: parsed.error.issues }, 400);

  // parsed.data is typed: { title: string; body: string }
  const post = await db.insert(posts).values(parsed.data).returning();
  return c.json(post, 201);
});

Lesson reinforced: every byte from the outside is suspect. Zod (or any validator) at the boundary, always.

5. Talking to a database — SQLite + Drizzle for your first time

You don't need to set up Postgres just to learn. SQLite is a database that lives in a single file on disk — no server to install, no config. Combined with Drizzle, the experience is identical to "real" databases and the code transfers directly later.

npm install drizzle-orm better-sqlite3
npm install -D drizzle-kit @types/better-sqlite3
// db/schema.ts
import { sqliteTable, integer, text } from "drizzle-orm/sqlite-core";

export const posts = sqliteTable("posts", {
  id: integer("id").primaryKey({ autoIncrement: true }),
  title: text("title").notNull(),
  body: text("body").notNull(),
  createdAt: integer("created_at", { mode: "timestamp" }).defaultNow(),
});
// db/index.ts
import { drizzle } from "drizzle-orm/better-sqlite3";
import Database from "better-sqlite3";

const sqlite = new Database("data.db");
export const db = drizzle(sqlite);
// using it in your routes
import { db } from "./db";
import { posts } from "./db/schema";
import { eq, desc } from "drizzle-orm";

app.get("/posts", async (c) => {
  const all = await db.select().from(posts).orderBy(desc(posts.createdAt));
  return c.json(all);
});

app.get("/posts/:id", async (c) => {
  const id = Number(c.req.param("id"));
  const [post] = await db.select().from(posts).where(eq(posts.id, id));
  if (!post) return c.json({ error: "not found" }, 404);
  return c.json(post);
});

6. Migrations: how the schema changes over time

You define the schema in TypeScript. Drizzle generates SQL migration files for you. You commit those files to git so the next developer (or your future self) can recreate the database.

npx drizzle-kit generate   # reads schema.ts, writes migration .sql
npx drizzle-kit migrate    # applies pending migrations to the DB

7. CORS: letting your frontend talk to your backend

When your frontend (running on localhost:3000) calls your backend (running on localhost:8787), the browser blocks the request by default — different origins. The backend has to opt in by sending a CORS header. Hono has middleware for this:

import { cors } from "hono/cors";
app.use("/*", cors({ origin: "http://localhost:3000" }));

8. Deployment

For your first backend, deploy to Render (free tier, simple) or Railway (free trial, slightly nicer DX). Both: push to GitHub, connect the repo, set the build/start command, deploy. Migrating to Cloudflare Workers (Tier 2) is later.

What to skip for now

  • Express — works fine, but Hono is the better starting point in 2026.
  • Microservices — your first backend is a monolith. The end.
  • GraphQL — niche; REST + Server Actions handle 95% of needs.
  • Docker — useful eventually, but Render/Railway/Vercel abstract it away for now.
Where to go deeper
  • Hono docs — short, well-written, covers everything.
  • Drizzle docs — read "Get Started" and "Queries."
  • SQLite Tutorial — learn enough raw SQL to read your migrations. ~3 hours.
  • Node.js docs — for understanding what's actually happening under Hono.
Project — A working REST API with persistence

Build a Hono API for a "links" app: POST /links creates a new link (validates with Zod: url required, title optional), GET /links lists all links newest first, GET /links/:id returns one, DELETE /links/:id removes one. Store everything in SQLite via Drizzle. Test with curl or Postman. Deploy to Render. By the end you'll have your first public-facing API.

Stage 11 — Your first full-stack project STAGE ~2–4 weeks

This is the capstone of Part I. You've built frontends (Stages 6–9) and backends (Stage 10) separately. Now you'll connect them, ship a single project end-to-end, and experience the full loop a working developer lives in: schema → API → UI → deploy → iterate.

1. Pick a project that's just hard enough

The right first full-stack project has: more than just "list and create" (so you exercise UPDATE and DELETE), at least one piece of state that lives on the server, and a use case you'd actually want to use. Three good options to choose from, ranked by difficulty:

  • Guestbook (easy): visitors leave messages on your portfolio. POST a message, GET the list, optional DELETE for messages you posted. One table.
  • Bookmark manager (medium): save URLs with tags, search/filter by tag, edit and delete. Two tables (bookmarks + tags) with a many-to-many join.
  • Habit tracker (medium): create habits, check them off daily, see a streak count. Two tables (habits + completions), date arithmetic.

Don't pick something more ambitious. You will be tempted. Resist. Shipping a smaller project teaches more than abandoning a bigger one.

2. The architecture (the simplest one that works)

flowchart LR U[User] --> FE[Next.js frontend
Vercel] FE -->|fetch / Server Action| BE[Hono backend
Render] BE -->|Drizzle| DB[(SQLite or Postgres)]
Two deploys (frontend + backend), one database. The standard split.

Or — and this is increasingly common — skip the separate backend entirely. Put your backend logic in Next.js server actions and your database queries in a lib/db.ts file. One deploy, one repo, no CORS to wrangle. Use this approach unless you have a specific reason not to.

3. The build sequence (the order that prevents thrashing)

  1. Sketch the schema first. What tables, what columns, what relationships. On paper or in a comment block — not in code yet. Get this wrong and you'll rewrite the rest twice.
  2. Write the schema and migrate. Drizzle schema in TypeScript, generate the migration, apply it. Verify with a SQLite GUI like DB Browser for SQLite.
  3. Build the "happy path" UI with hardcoded data. The page renders, but reads from a fake array. Get the layout right before plumbing.
  4. Connect one read. Replace one hardcoded array with a server-side fetch from your database. Confirm it works.
  5. Connect one write. A form that creates a row. Use a Server Action.
  6. Add the rest of CRUD. Update and delete, one at a time.
  7. Polish. Empty states, loading states, error states. The 80% of the work that happens after the feature "works."
  8. Deploy. Push to GitHub, deploy on Vercel. Confirm it works on the live URL.

4. The things that will trip you up (in order of likelihood)

  • Database connection in development vs production. SQLite file paths and Postgres connection strings differ. Use environment variables (.env.local for dev, Vercel's env vars for prod).
  • Forgetting to validate user input. Always Zod. Always.
  • State that should live on the server but you put on the client. If reloading the page loses important data, it's in the wrong place.
  • The dreaded "hydration mismatch" error. Means your server-rendered HTML disagrees with your client-rendered version (usually: timestamps, random IDs, or typeof window checks). Fix by computing the variable on one side only.
  • CORS errors when calling a separate backend. See Stage 10. Or skip the separate backend entirely (recommended).

5. Bonus moves that make the project portfolio-worthy

  • Add basic auth with Clerk (Tier 2) — 30 minutes, makes the project per-user.
  • Add optimistic UI — the list updates before the server confirms, with rollback on failure. Feels instant.
  • Write a short README with a screenshot, "what it does," "what's hard about it," and a deployed link. This is what you point recruiters at.
  • Add one test (Tier 1, Vitest + Playwright) — the homepage loads without console errors. Tiny but signals "I think about quality."
Project — Ship the thing you picked

Build one of the three options (or invent your own at similar complexity). Use Next.js + Tailwind + Drizzle + SQLite or Postgres. Deploy it. Add it to your portfolio. The completion of this project is the line between "learning to code" and "I can build software." You've crossed it.

Stage 12 — Going professional STAGE ~3–6 weeks intensive, then ongoing

Stage 11 ended with "I can build software." This stage covers the gap between that and "I work professionally as a web developer." The difference isn't technical depth — it's the practices teams use to ship reliable software together. Skipping this stage is the most common reason self-taught developers stall at junior level: they can build, but they can't work on a team.

Seven practices. None of them are about new frontend frameworks. All of them are universal across web dev jobs.

1. Docker — containerise everything

A container is your app plus everything it needs to run (the right Node version, system libraries, environment variables, dependencies) packaged into a single image that runs identically on any machine. Docker is the tool everyone uses to build and run them.

Why every team uses Docker:

  • "Works on my machine" eliminated. If it runs in the container locally, it runs in the container in production. The environment is the same.
  • Onboarding goes from days to minutes. A new developer clones the repo, runs docker compose up, and has a working dev environment.
  • Production deploys are predictable. The image you tested in CI is the exact same image that runs in production.
# Dockerfile — a recipe for building your app's image
FROM node:22-alpine
WORKDIR /app
COPY package*.json ./
RUN npm ci
COPY . .
RUN npm run build
EXPOSE 3000
CMD ["npm", "start"]
# docker-compose.yml — multiple containers wired together for dev
services:
  app:
    build: .
    ports: ["3000:3000"]
    env_file: .env
    depends_on: [db]
  db:
    image: postgres:16
    environment:
      POSTGRES_PASSWORD: dev
    ports: ["5432:5432"]
    volumes: [postgres_data:/var/lib/postgresql/data]
volumes:
  postgres_data:

The five Docker commands you'll use 95% of the time:

docker build -t myapp .          # build an image from the Dockerfile
docker run -p 3000:3000 myapp    # run a container from the image
docker ps                        # list running containers
docker logs <container>          # view logs
docker compose up                # start everything in compose.yml
Where to go deeper

2. CI/CD with GitHub Actions

Continuous Integration: every push to the repo triggers automated checks (tests pass? code formatted? type-checks clean?). Continuous Deployment: every merge to main triggers an automated deploy. Together they replace the "ship Fridays at 5pm, hope nothing breaks" era with "ship 50 times a day, confident every time."

# .github/workflows/ci.yml — runs on every push and PR
name: CI

on: [push, pull_request]

jobs:
  test:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-node@v4
        with:
          node-version: "22"
          cache: "npm"
      - run: npm ci
      - run: npm run lint
      - run: npm run type-check
      - run: npm test

That file is checked into your repo at .github/workflows/ci.yml. GitHub picks it up automatically. Every PR now gets a green/red status check; broken changes can't be merged.

The mental model: CI is a robot that does on every change what you would do manually if you remembered. The only way to "forget" tests now is to write code so unhinged the test never existed.

Where to go deeper

3. PR workflow and code review

Solo work: you write, you commit, you push. Team work: you write on a branch, open a Pull Request, a colleague reviews it, you address feedback, it gets merged. This loop happens dozens of times a day on a real team.

Writing a good PR:

  • One purpose per PR. Don't bundle "fix bug + refactor + add feature." Reviewers can't reason about three things at once.
  • Title that describes the change. "Fix duplicate-submit on contact form" — not "fix bug."
  • Description with context. What problem this solves, what approach you took, what the reviewer should look at carefully, and how you tested it. Three paragraphs max.
  • Small. Under 400 lines of diff when possible. Reviewer fatigue is real — a 2000-line PR will get rubber-stamped or ignored, not actually reviewed.
  • Self-review first. Before requesting review, look at your own diff in the GitHub UI. You'll catch obvious issues; reviewers won't waste time on the easy stuff.

Reviewing someone else's PR — the four things to look for, in order:

  1. Does it do what the PR description says it does? (Correctness)
  2. Will future-me be able to understand this in 6 months? (Clarity)
  3. Are there security, performance, or reliability concerns? (Risk)
  4. Is there a simpler way? (Design — but be careful here; "simpler" is subjective and bikeshedding burns trust)

Receiving review without taking it personally: code review is about the code, not you. "This could be clearer" is not "you're a bad developer." The fastest path through review is to engage every comment — either change the code, or reply with why you disagree and discuss. Ghosting comments wastes everyone's time.

4. Environments: dev, staging, production

Three copies of your app run in parallel:

EnvironmentWhat it isWho touches it
Development (dev)Your laptop. Hot reload, fake data, no consequences.Just you
StagingA production-like copy on the internet. Real deploy pipeline, real DB (separate from prod), fake or anonymised data.Whole team, plus maybe QA / product
Production (prod)What real users hit. Real data. Outages cost money.Read-only for most of the team; writes only via deploys

The standard release flow: PR opens a preview deploy (a one-off URL just for that branch); merge to main deploys to staging automatically; promotion to production is a deliberate human action (a button, a tag, a Slack command). Vercel does the first two for you out of the box. The third is policy more than tech — every team designs it slightly differently.

What changes per environment:

  • Environment variables. Different API keys, different DB URLs. Never hardcode; always process.env.
  • The database. Each env has its own. Restoring a backup from prod into staging is fine; the other direction is how you get fired.
  • Feature flags (Part II Tier 2): the same code branches differently per env so half-built features can land in main without shipping to users.

5. Reading and contributing to large codebases

Your first day on a real codebase will be humbling. 200,000 lines of code. 50 services. Conventions you don't recognise. Files you can't find. This is normal. The skill is having a method.

  1. Don't try to understand everything. You won't, ever. Successful senior devs at large companies routinely don't know 90% of the codebase. They know how to navigate it.
  2. Pair on something. First week: shadow a colleague through a feature. Watching someone move through the codebase teaches the conventions faster than reading them.
  3. Use the search. VS Code's "find in files" + GitHub code search are your best friends. Search for the user-facing string ("Sign In") to find the component; from there walk outward.
  4. Trust the types. In a TypeScript codebase, click-through to definitions is faster than reading. The types form a map.
  5. Match the existing style. When you add code, copy the patterns in the surrounding files. "Improvements" that fight the codebase's conventions look like noise in code review.
  6. Ask questions in writing. A Slack thread "I'm trying to understand why X is structured as Y — is it because of Z?" gets answers, creates a searchable record, and signals "I'm thinking."

6. Communication and estimation

You will spend less time writing code than you think and more time writing in English. Slack messages, GitHub comments, design docs, PR descriptions, standup updates. The engineers who get promoted are not the ones who write the most code — they're the ones who make the team produce more.

  • Async-first. Default to written, async communication (Slack thread, GitHub comment) over synchronous (meeting, DM). Async lets people respond when they're not deep in something else, creates a record, and scales across time zones. Reserve synchronous for "we've been going back and forth for an hour, let's just hop on a call."
  • Status updates that don't waste time. Three lines: "Yesterday I did X. Today I'm doing Y. Blocked on Z." If nothing's blocked, say so. If something is, name it specifically — vague blockers don't get unblocked.
  • Estimating: be honest, be specific. "Should be done by Friday" is useless if it's actually "next Wednesday." Senior engineers under-promise: they pad estimates by 50–100% because real work always includes unknown debugging. If a task is bigger than a few days, break it into smaller shippable pieces — the act of breaking it down exposes hidden complexity.
  • When to ask for help. Rule of thumb: 30 minutes of genuine struggle, then ask. Less than that and you're not learning. More than that and you're burning team time. Always ask with what you've already tried (Part IV — Asking Good Questions).

7. Cloud literacy (the right amount for a web dev)

Most web dev jobs touch AWS, GCP, or Cloudflare. You don't need to be a cloud architect — that's a different role. You need literacy: enough to deploy your app, read your logs, and know which service does what. Two hours of orientation per cloud is the right budget.

  • Compute: where your app runs. AWS: ECS / Lambda / EC2. GCP: Cloud Run / Cloud Functions / GCE. Cloudflare: Workers. For most web apps, serverless containers (ECS Fargate, Cloud Run) or edge functions (Workers, Vercel) are the sweet spot — no servers to manage, scales automatically.
  • Storage: where files go. AWS S3, GCS, Cloudflare R2. All three are object storage with the same shape: upload a file, get a URL. R2 is the cheapest by a wide margin (no egress fees).
  • Databases: managed Postgres. AWS RDS, Google Cloud SQL, Neon, Supabase. Pick the one your team uses. The query language is the same; the dashboard is different.
  • Logs: AWS CloudWatch, GCP Cloud Logging. Bring up a log stream, filter by your service, see what's happening. You'll do this every time something breaks.
  • IAM (Identity and Access Management): who can do what. Read at the level of "this role can access this S3 bucket but not that one." Don't try to learn AWS IAM deeply — it's a swamp.

8. Kubernetes — the literacy version

Kubernetes (k8s) is the container orchestration system. It runs containers across a fleet of machines, restarts them when they crash, scales them up under load, routes traffic between them. Deep k8s — writing operators, tuning the scheduler, debugging networking, capacity planning — is what platform engineers and SREs do. As a web dev, the right depth is much shallower:

  • Read a Deployment manifest. A YAML file describing "I want N copies of this container image, with these env vars, exposed on this port." You'll see them; understand them.
  • Use kubectl at the basic level. kubectl get pods, kubectl logs <pod>, kubectl describe pod <pod>, kubectl exec -it <pod> -- sh. That's 80% of what application devs use it for.
  • Know what a Helm chart is. Templated k8s manifests; how teams deploy real apps to clusters. You'll modify values files; you usually won't write charts.
  • Understand the words. Pod, deployment, service, ingress, namespace, configmap, secret. These are vocab, not concepts; learn what they mean in 30 minutes.

That's a three-day weekend of effort and it covers what most backend / full-stack roles need. If you join a team that runs k8s, this is enough to deploy your app, read your logs, and debug crashes. Deeper k8s is a career path of its own — pursue it only if platform engineering is what you want to do.

Where to go deeper
  • Kubernetes Basics (official, interactive) — exactly the right level for web devs.
  • "Kubernetes in 100 Seconds" by Fireship for orientation; then the official tutorial for hands-on.
  • learnk8s.io — articles and free guides; deeper than you need at first, but excellent reference later.
Project — Take your Stage 11 project to "production-grade"

Add the following to the full-stack project you built in Stage 11: (1) a Dockerfile for the app and a docker-compose.yml that brings up the app + a Postgres database with one command; (2) a .github/workflows/ci.yml that runs lint, type-check, and tests on every PR; (3) a staging environment separate from production (Vercel preview deployments count); (4) a README explaining how a new developer would clone, install, and run the project locally — then have a friend actually do it and watch where they get stuck. Bonus: open a PR to one open-source repo you've used (a tiny doc fix or typo counts) just to experience the OSS PR loop end-to-end.

Part II — Once you can ship

This is the 2026 stack — what to learn after Part I, in the order it'll pay back. Each tier is "is the cost of learning this worth the leverage right now?" Tier 1 = yes, almost always. Tier 2 = when a specific project asks for it. Tier 3 = no, not yet.

Server-first React TREND

React was originally a client-side library — you shipped JavaScript to the browser, the browser built the page. That model dominated from 2014–2022. The cost: huge JS bundles, slow first paints, code that ran twice (once on server for SEO, once on client for interactivity).

React Server Components (RSC) flip this. Components run on the server by default; they can talk directly to your database, fetch from APIs without spinning up a fetch in the browser, and ship zero JavaScript to the client. You opt specific components in to the client (with the "use client" directive) when you actually need interactivity (a button, a form, animation state).

The other half is Server Actions — functions you declare on the server but call from the client like normal functions. No need to set up a REST endpoint, no need to write fetch code. The framework wires it up. You're already using Next.js 15/16, where this is the default. Most code in 2026 Next.js tutorials assumes it.

Prerequisite knowledge: What HTTP is, what React components are, what "props" are (data passed into a component), what a function call is. Helpful: knowing what fetch is, what an API endpoint is, what hydration is.

AI as a feature layer TREND

For 30 years, an app was: UI ↔ business logic ↔ database. The new layer is: UI ↔ business logic ↔ LLM ↔ database. The model isn't the product — it's a function call inside your normal app. Four patterns matter:

  1. Plain completion: send text, get text back. ChatGPT, but in your app.
  2. Tool calling: the model can decide to call functions you provide ("search the database," "send an email," "run this code"). You write the function; the model picks when to use it.
  3. RAG (Retrieval-Augmented Generation): before asking the LLM, you fetch relevant snippets from your own data (using embeddings + a vector DB) and stuff them into the prompt. Lets the model "know" things outside its training data without retraining.
  4. Agents: a loop where the model takes an action, observes the result, decides what to do next, repeats. Tool calling + state + a goal. Most "AI agents" are this.

MCP (Model Context Protocol) is a new standard for how models talk to tools — a USB-C for AI tools. If a service exposes MCP, any compatible AI client can use it without custom integration code.

Prerequisite knowledge: What an API is, what JSON is, what a function signature is (the name of a function plus the kinds of arguments it takes). Helpful: basic understanding of how ChatGPT-style models work — text in, text out.

Type-safe everywhere TREND

TypeScript has been mainstream for years. The 2026 trend is pushing types through the boundaries of your system — past the front-of-the-server, into the database, across network calls, all the way to user input.

Prerequisite knowledge: What TypeScript is, what a "type" is in TS, the difference between compile-time and runtime errors.

Edge runtimes TREND

Traditional backend code runs on one server (or a few) in a single data centre — say, Virginia. A user in Tokyo pays 200ms of round-trip time just to reach it. Edge runtimes run your code in 200+ locations around the world; the user's request hits the nearest one, ~10ms away.

The trade-off: edge runtimes are stripped-down JavaScript environments. They don't have the full Node API. You can't run heavy native libraries, you can't open a long-lived TCP connection. Within those limits — perfect for APIs, auth, redirects, and small fast endpoints.

Three you'll hear about: Cloudflare Workers (the biggest network, V8 isolates, generous free tier), Vercel Edge Functions (tightly integrated with Next.js deploys), Bun (a Node-compatible runtime that's faster but not strictly "edge").

flowchart LR subgraph Central["CENTRAL (server in Virginia)"] U1[User
Tokyo] -.->|~200 ms| S1[Server] U2[User
London] -.->|~80 ms| S1 U3[User
NYC] -.->|~15 ms| S1 end subgraph Edge["EDGE (Workers, ~300 PoPs)"] V1[User
Tokyo] -.->|~10 ms| E1[Worker
Tokyo PoP] V2[User
London] -.->|~10 ms| E2[Worker
London PoP] V3[User
NYC] -.->|~10 ms| E3[Worker
NYC PoP] end
Same code, different deploy model. Central = one location, far for most users. Edge = many locations, near everyone.
Prerequisite knowledge: What a server is, what Node.js is, what latency is (the delay between sending a request and getting a response), what a CDN is (content delivery network — same idea as edge, but for static files).

Local-first / sync engines TREND

A standard web app stores data on the server and the client just reads/writes via API calls. If the network's slow, the UI is slow. If you're offline, the app's dead.

A local-first app keeps a copy of the data on the client (in the browser's storage) and syncs it with the server in the background. The UI is instant — every action writes to the local copy first, then the sync engine propagates changes to other clients and the server.

You're not building this from scratch. Frameworks like Convex, Zero (by Rocicorp, makers of Replicache), Triplit, and InstantDB bundle the database, the sync protocol, the offline storage, and the conflict resolution into one tool. You write data-shape definitions; they handle the rest.

flowchart LR subgraph CA["Client A (browser)"] UI1[UI] <--> LDB1[(IndexedDB
local copy)] end subgraph CB["Client B (browser)"] UI2[UI] <--> LDB2[(IndexedDB
local copy)] end LDB1 <-->|sync over WS| SE[Sync engine
Convex / Zero] LDB2 <-->|sync over WS| SE SE <--> SDB[(Server DB)]
UI reads/writes the local copy instantly. Sync engine propagates changes to peers and the server in the background.
Prerequisite knowledge: What REST APIs are, what WebSockets are (persistent two-way connections, used for live updates), what optimistic UI is (updating the screen before the server confirms — assumes success).

shadcn/ui TREND

For years, building a styled component (a button, a dialog, a dropdown) meant installing a UI library (Material UI, Chakra, Ant Design) — you'd import their <Button>, accept their styles, and live with their bugs. Customising was painful.

shadcn/ui inverts this. It's not a library. It's a registry of well-built React components that you copy into your own project. You own the source. Want to change the spinner colour? Edit the file. Want to add a prop? Add it. No upgrades to fight with, no version mismatches.

The components are built on Radix UI primitives (which handle accessibility, keyboard nav, focus management) and styled with Tailwind CSS. The CLI tool reads your components.json config and pastes new components in when you ask.

Prerequisite knowledge: What a React component is, what npm is, what Tailwind CSS is (utility-first CSS framework — you compose styles by stringing classes).

Drizzle ORM + Zod TIER 1

What it is

Drizzle is a TypeScript-first ORM (Object-Relational Mapper — see Foundations). You describe your database tables in TypeScript, and Drizzle gives you a typed query builder. It generates SQL under the hood, but you never write raw SQL strings.

Zod is a runtime validator. You describe a "shape" — what fields an object should have, what types they should be — and Zod gives you back a checker function plus a TypeScript type, both derived from the same definition.

Together they cover the two type-safety gaps in most apps: data coming out of the database (Drizzle) and data coming in from users or external APIs (Zod).

Why it matters for you

Your all-in-one-URL backend uses psycopg2-binary — raw SQL strings interpolated into Python code. That works, but it's the same pattern that produces SQL-injection bugs, typo-driven runtime errors, and "I renamed a column and don't know what broke." Drizzle would make every query a TypeScript expression the compiler verifies.

Prerequisites

  • Comfortable with TypeScript basics (types, interfaces, generics at a surface level).
  • You know what a database table is and what a row, column, and primary key are.
  • You've written at least one SQL SELECT by hand at some point — you don't need to be a SQL wizard, but reading the generated queries helps.
  • You understand the difference between compile-time and runtime.

How it looks

// schema.ts — describe your tables in code
import { pgTable, serial, text, timestamp } from "drizzle-orm/pg-core";

export const users = pgTable("users", {
  id: serial("id").primaryKey(),
  email: text("email").notNull().unique(),
  createdAt: timestamp("created_at").defaultNow(),
});

// query.ts — every query is a typed expression
const user = await db.select().from(users).where(eq(users.email, input));
// user is typed as { id: number; email: string; createdAt: Date }[]

And Zod validating the input first:

import { z } from "zod";

const CreateUser = z.object({
  email: z.string().email(),
  age: z.number().int().min(13),
});

// at the boundary
const parsed = CreateUser.parse(req.body); // throws if invalid
// parsed is typed as { email: string; age: number }

First step

Rewrite one endpoint of all-in-one-URL in TypeScript using Drizzle + Zod. Start with the /health or a tiny read endpoint. Keep the rest of the Python backend running side by side.

shadcn/ui (formal adoption) TIER 1

What it is

A CLI tool + a registry of unstyled, accessible React components. Run npx shadcn@latest add button and a button.tsx file appears in your project's components/ui/ folder. You own it. You change it like any other file.

Internally each component wraps a Radix UI primitive (Radix handles the hard parts: keyboard navigation, focus traps, ARIA attributes, animation lifecycles) and applies Tailwind classes for styling. Variants are managed by class-variance-authority (CVA) — a small library that lets you define a base style + a set of named modifiers.

Why it matters for you

Your all-in-one-URL/ui already uses the building blocks — @radix-ui/react-slot, class-variance-authority, clsx, tailwind-merge. You're hand-rolling what shadcn standardises. Adopting shadcn formally means: (a) a CLI manages new components, (b) you get the well-tested Radix-based defaults instead of writing your own, (c) reusing the same components in personal-site and solomock is trivial.

Prerequisites

  • You know what a React component is.
  • You've used Tailwind classes (className="px-4 py-2 bg-blue-500").
  • You've run an npx command before (npx runs a one-off npm package without permanently installing it).
  • You understand what "accessibility" (a11y) is at a basic level — making apps usable with a keyboard, with a screen reader, by people who don't see colour the same way.

How it looks

# 1. one-time setup in any Next.js / Vite + React project
npx shadcn@latest init

# 2. add components as you need them
npx shadcn@latest add button
npx shadcn@latest add dialog
npx shadcn@latest add dropdown-menu

# 3. import like any local component
import { Button } from "@/components/ui/button";

Every file landed in your repo. To customise the button's hover colour, you open components/ui/button.tsx and edit it. There's no "library version" to upgrade.

First step

Run npx shadcn@latest init on personal-site. Add button, card, and dialog. Swap one existing button for the shadcn one and confirm nothing breaks.

React Server Components + Server Actions TIER 1

What it is

A way of writing React that splits components into two flavours:

  • Server components (the default in Next.js App Router) run on the server only. They can be async, can talk to the database directly, can read files, can keep secrets. They produce HTML and a serialised description of what to render. They cannot use hooks like useState or event handlers like onClick.
  • Client components (marked with "use client" at the top of the file) run in both places: on the server to produce initial HTML, then in the browser where they become interactive. These can use state, effects, event handlers.

Server Actions are functions tagged with "use server". You can call them from a client component as if they were local functions; Next.js secretly turns the call into an HTTP request and runs the function on the server. They're typically used to handle form submissions and mutations.

Why it matters for you

Your solomock project has app/api/session/route.ts — a manual REST endpoint you fetch from the client. With Server Actions, you'd just define a function in a server module and call it from the client component directly. Less boilerplate, fewer files, types flow end-to-end.

Your personal-site already uses RSC for the static parts and ssr: false dynamic import for the ContactForm. That's the right pattern — but going deeper means understanding why some things must be client-only (browser extensions, hydration mismatches) and most things shouldn't be.

Prerequisites

  • You know what a React component is.
  • You've used useState at least once.
  • You know what "fetching data" means — calling an API to get data, usually with fetch or axios.
  • You understand "hydration" — see Foundations.
  • Helpful: you've written a form with an onSubmit handler.

How it looks

flowchart TB A["page.tsx
(Server, default)"] --> B["Nav
(Server)"] A --> C["PostsList
(Server, async)
queries DB directly"] A --> D["Footer
(Server)"] C --> E["Post
(Server)"] E --> F["LikeButton
'use client'
has onClick"] E --> G["CommentForm
'use client'
has useState"] classDef server stroke:#9ece6a,stroke-width:2px,fill:#161b22,color:#d6dde6 classDef client stroke:#7aa2f7,stroke-width:2px,fill:#1a1f2a,color:#d6dde6 class A,B,C,D,E server class F,G client
Green = server-only (zero JS shipped). Blue = client (interactive, ships JS). Default is server; you opt in to client.
// app/posts/page.tsx — a server component, the default
import { db } from "@/lib/db";

export default async function PostsPage() {
  const posts = await db.select().from(postsTable); // direct DB access
  return (
    <ul>
      {posts.map(p => <li key={p.id}>{p.title}</li>)}
    </ul>
  );
}
// app/posts/actions.ts — a server action
"use server";
import { db } from "@/lib/db";

export async function createPost(formData: FormData) {
  const title = formData.get("title") as string;
  await db.insert(postsTable).values({ title });
}
// app/posts/NewPostForm.tsx — a client component using the action
"use client";
import { createPost } from "./actions";

export function NewPostForm() {
  return (
    <form action={createPost}>
      <input name="title" />
      <button>Create</button>
    </form>
  );
}

First step

In solomock, convert app/api/session/route.ts into a server action. Keep the same logic — minting an ephemeral OpenAI client_secret — but expose it via a "use server" function and call it from the React component directly.

AI: tool calling + RAG TIER 1

What it is

Tool calling (also called "function calling"): you give the LLM a JSON description of functions it could call, with their names, parameters, and what they do. The model can either reply with normal text, OR it can reply with a structured request like "please call searchDatabase('two sum')." Your code runs that function and feeds the result back into the model, which then continues.

RAG (Retrieval-Augmented Generation): a pipeline that injects relevant snippets from your own data into the LLM's prompt at query time. Steps:

  1. Pre-process: split your documents into chunks (~500 tokens each).
  2. For each chunk, compute an embedding (a list of ~1500 numbers) using an embedding model (e.g. OpenAI's text-embedding-3-small). Store the chunk + embedding in a vector database.
  3. At query time: embed the user's question, find the K most similar chunks in the vector DB, paste them into the prompt as context.
  4. Ask the LLM to answer using the provided context.

RAG lets the model "know" data outside its training set, without retraining the model — which would cost millions.

Why it matters for you

Your solomock only does voice + screen-watching. Two clear upgrades:

  • Tool calling: give the interviewer LLM a run_code(language, source) tool. It can actually execute the user's solution against test cases mid-interview, then react to the output. Much more like a real interviewer.
  • RAG: index your web-dev-tutorial docs. Build a small chatbot that lets readers ask "how do I deploy to Render?" and answers using actual snippets from your guide.

Prerequisites

  • You know what an HTTP request is and how to call an API with fetch or a client SDK.
  • You know what JSON is.
  • You've made at least one call to an LLM API (OpenAI, Anthropic, etc.) and got a text response.
  • For RAG: comfortable with the concept of "this list of numbers represents the meaning of this text" (embeddings — see Foundations).
  • Helpful: basic familiarity with cosine similarity or "distance between vectors" — but you don't need the math, the libraries handle it.

How tool calling looks

sequenceDiagram autonumber participant U as User participant A as Your App participant L as LLM participant T as Tool fn
(e.g. runCode) U->>A: "test my Two Sum solution" A->>L: messages + tool descriptions L-->>A: tool_call: runCode(lang, src) A->>T: execute(lang, src) T-->>A: stdout / stderr A->>L: messages + tool result L-->>A: "fails on input [3,2,4] —
your loop skips duplicates" A-->>U: response
The LLM doesn't run your code. It asks your app to run it, then reasons over the result.
// 1. you define tools the model can call
const tools = [{
  type: "function",
  function: {
    name: "run_code",
    description: "Execute a code snippet and return stdout/stderr",
    parameters: {
      type: "object",
      properties: {
        language: { type: "string", enum: ["python", "javascript"] },
        source: { type: "string" },
      },
      required: ["language", "source"],
    },
  },
}];

// 2. you call the model with these tools attached
const response = await openai.chat.completions.create({
  model: "gpt-4o",
  messages: [...history],
  tools,
});

// 3. if the model picked a tool, response includes a tool_calls array
if (response.choices[0].message.tool_calls) {
  for (const call of response.choices[0].message.tool_calls) {
    const args = JSON.parse(call.function.arguments);
    const result = await runCode(args.language, args.source);
    // feed result back into the conversation
  }
}

How RAG looks (conceptually)

flowchart LR subgraph IDX["INDEXING (once, when docs change)"] D[Your docs] --> S[Split into
~500-token chunks] S --> E[Embed each chunk
text-embedding-3-small] E --> V[(Vector DB
pgvector / Pinecone)] end subgraph QRY["QUERY (per user question)"] QN[User question] --> QE[Embed question] QE --> SR[Search vector DB
top-K similar chunks] SR --> P[Build prompt
system + context + question] P --> L[LLM] L --> A[Answer with citations] end V -.->|read| SR
RAG = look stuff up first, then ask the LLM. The model "knows" your data without ever being retrained.
// indexing pass — run once when content changes
for (const chunk of chunks) {
  const embedding = await openai.embeddings.create({
    model: "text-embedding-3-small",
    input: chunk.text,
  });
  await vectorDB.insert({ text: chunk.text, vector: embedding.data[0].embedding });
}

// query pass — run per user question
const qEmbed = await openai.embeddings.create({ model, input: question });
const hits = await vectorDB.search(qEmbed.data[0].embedding, { k: 5 });
const context = hits.map(h => h.text).join("\n---\n");

const answer = await openai.chat.completions.create({
  model: "gpt-4o",
  messages: [
    { role: "system", content: `Answer using this context:\n${context}` },
    { role: "user", content: question },
  ],
});

First step

Pick one. Either: add a run_code tool to solomock (use a sandboxing service like e2b or Modal for execution) — or: build a 50-line RAG over your web-dev-tutorial markdown using pgvector (Postgres extension) and the OpenAI embedding API. The RAG one is simpler to start with.

Vitest + Playwright TIER 1

What it is

Three layers of testing:

  • Unit tests: test one function in isolation. "Given input X, this function should return Y." Fast (milliseconds), narrow scope.
  • Integration tests: test that several pieces work together. "When I call this API route, it should write a row to the DB."
  • End-to-end (E2E) tests: drive a real browser and click through your app. "User loads homepage, clicks Sign Up, fills the form, sees the dashboard." Slow (seconds-to-minutes), broad scope.

Vitest is the modern test runner for JavaScript/TypeScript projects — drop-in compatible with the Jest API (the older standard) but much faster because it uses Vite under the hood. Good for unit + integration.

Playwright is a browser automation library by Microsoft. It launches a real Chromium/Firefox/WebKit, navigates pages, clicks elements, takes screenshots. Good for E2E.

Why it matters for you

None of your six projects has tests. That's fine while you're learning — but for anything you'd put on a resume or ship to users, a smoke test (does the homepage load and not crash?) is the difference between catching a bug pre-deploy vs. learning about it from a user. Adding even one test per project would massively improve your shipping confidence.

Prerequisites

  • You can write a function.
  • You understand what an assertion is — a statement that says "this should be true; if it isn't, fail the test." Like expect(2 + 2).toBe(4).
  • You understand what a CI is (Continuous Integration — see Foundations). Tests are most useful when they run automatically on every code change.
  • For Playwright: you've used your browser's DevTools (inspect element, console) before.

How a Vitest unit test looks

// problems.test.ts
import { describe, it, expect } from "vitest";
import { findProblem } from "./problems";

describe("findProblem", () => {
  it("returns the problem when slug exists", () => {
    const p = findProblem("two-sum");
    expect(p?.title).toBe("Two Sum");
  });

  it("returns undefined when slug is missing", () => {
    expect(findProblem("does-not-exist")).toBeUndefined();
  });
});

How a Playwright E2E test looks

// e2e/homepage.spec.ts
import { test, expect } from "@playwright/test";

test("homepage loads and shows hero", async ({ page }) => {
  await page.goto("http://localhost:3000");
  await expect(page.getByRole("heading", { level: 1 })).toBeVisible();
  await expect(page).toHaveTitle(/To Yin Yu/);
});

test("contact form submits", async ({ page }) => {
  await page.goto("http://localhost:3000#contact");
  await page.getByLabel("Name").fill("Test User");
  await page.getByLabel("Email").fill("a@b.com");
  await page.getByLabel("Message").fill("hello");
  await page.getByRole("button", { name: "Send" }).click();
  await expect(page.getByText("Thanks")).toBeVisible();
});

First step

Add one Playwright test to personal-site: "homepage loads, hero renders, no console errors." Wire it into a GitHub Action so it runs on every push. That's the smallest setup that gives real value.

Sentry + PostHog TIER 1

What it is

Two categories of observability tools — software that tells you what's happening in your live app.

  • Sentry: error tracking. When your app crashes — anywhere, on any user's browser, on your server — Sentry captures the error, the stack trace, the browser/OS, the user's actions leading up to it, and emails or Slacks you. Without it, you only learn about bugs from angry users.
  • PostHog: product analytics + session replay + feature flags. It tells you which pages users visit, what they click, where they drop off, and lets you watch a recording of any user's session.

A "source map" is a file that maps your minified, bundled production JavaScript back to your original source code. Sentry uses source maps so error stack traces show src/components/Hero.tsx:42 instead of a.b.c at 7,2389.

Why it matters for you

Your personal-site, roofing-site, and all-in-one-URL are public and have zero error tracking or usage analytics. You don't know if someone's contact form ever errors, or whether the URL shortener handles the URL formats people actually paste. Sentry + PostHog free tiers are generous; both are 10-minute integrations.

Prerequisites

  • You know what an "error" is in code — an exception, a crash.
  • You know what a stack trace is — the chain of function calls leading to an error.
  • You've used console.log for debugging.
  • For PostHog: you understand what a "page view" is and what "user funnel" means (a sequence of steps you hope users complete).

How it looks

// install — for a Next.js project
npx @sentry/wizard@latest -i nextjs

// after the wizard, your code is auto-instrumented.
// to track a specific issue manually:
import * as Sentry from "@sentry/nextjs";

try {
  await doSomethingRisky();
} catch (err) {
  Sentry.captureException(err, { extra: { userId, action: "submit" } });
}
// PostHog — track a custom event
import posthog from "posthog-js";

posthog.capture("contact_form_submitted", { source: "homepage" });

First step

Drop Sentry into all-in-one-URL/ui via the wizard. Force an error (throw inside a button handler), confirm it shows up in your Sentry dashboard with a useful stack trace.

Cloudflare Workers + Hono TIER 2

What it is

Cloudflare Workers is a serverless platform — you write a function, deploy it, and Cloudflare runs it for you across 300+ data centres. There's no "server" you provision; it spins up on demand near each user. Built on V8 isolates (the same engine as Chrome's JS) rather than full Node.js, so the runtime is smaller and faster to start than AWS Lambda.

Hono is a tiny web framework (think: Express, but modern and 14kb) designed to run on Workers, Bun, Node, Deno — anywhere. It gives you routing, middleware, and request/response helpers without the bulk of Express or Next.js.

Why it matters for you

Your all-in-one-URL backend currently sits on Render's free tier, which spins down after 15 minutes idle (first request after idle takes 30+ seconds). Workers don't have cold starts in that sense — they're warm everywhere. For a small API like yours, Workers + Hono + a Cloudflare D1 database (their SQLite) would be cheaper, faster, and run everywhere on Earth.

Prerequisites

  • You've built one REST API before.
  • You understand "serverless" at a high level — code that runs on demand, no server to manage, scales automatically.
  • You know what middleware is — a function that runs between the request arriving and your handler (e.g. auth checks, logging).

How it looks

// src/index.ts
import { Hono } from "hono";

const app = new Hono();

app.get("/", (c) => c.text("Hello from the edge"));

app.post("/shorten", async (c) => {
  const { url } = await c.req.json();
  const slug = Math.random().toString(36).slice(2, 8);
  await c.env.DB.prepare("INSERT INTO links VALUES (?, ?)").bind(slug, url).run();
  return c.json({ slug });
});

export default app;

Deploy with npx wrangler deploy. Live globally in seconds.

First step

Re-implement the URL-shortening endpoint of all-in-one-URL as a Worker + Hono service backed by D1. Keep the Python backend running. Compare cold-start times yourself.

Convex / Zero (local-first databases) TIER 2

What it is

A backend-as-a-service built around real-time sync. You define a schema and write "queries" (read functions) and "mutations" (write functions) in TypeScript. The framework hosts your data, runs your functions, and pushes updates to all connected clients automatically — no WebSocket setup, no React Query cache invalidation, no "did the other tab see this update yet" wrangling.

Convex is the mature player — closer to Firebase's developer experience, but with relational data and TypeScript-first APIs. Zero (by Rocicorp) is newer and takes the local-first idea further — the database lives in IndexedDB on the client, the server is just a sync coordinator.

Why it matters for you

Anything multiplayer, anything collaborative, anything that needs "live updates" — Convex or Zero saves you from writing a Postgres + Redis + WebSocket + sync layer by hand. For solo projects it's a 10× speed-up to a working app.

Prerequisites

  • You know what a database is.
  • You've used useState in React.
  • You understand the difference between "fetching data once" and "subscribing to updates."

How it looks

// convex/messages.ts — your backend function
import { query, mutation } from "./_generated/server";

export const list = query(async ({ db }) => {
  return await db.query("messages").order("desc").take(50);
});

export const send = mutation(async ({ db }, { text }) => {
  await db.insert("messages", { text, at: Date.now() });
});
// in your React component
const messages = useQuery(api.messages.list);   // auto-subscribes to updates
const send = useMutation(api.messages.send);

// when send() is called, every other tab updates within ~50ms.

First step

Build a tiny multiplayer toy — a shared todo list, a chat room — on Convex. ~1 hour from zero to deployed. Don't overthink it.

Stripe + Resend TIER 2

What it is

Stripe is the payments platform: you call their API, they handle credit cards, taxes, subscriptions, fraud, and refunds. Stripe Checkout is their hosted pay page — you redirect users to a Stripe-hosted URL, they pay, you get a webhook back. Zero PCI-compliance burden.

Resend is the modern equivalent for transactional email (welcome emails, password resets, receipts). React-based email templates, a clean API, great deliverability.

Why it matters for you

The moment anything you build crosses from "personal portfolio" to "people are paying for this," Stripe is the unambiguous answer. Resend is the unambiguous answer for "the app needs to send email." Both are well under an hour to integrate.

Prerequisites

  • You've made HTTP API calls before.
  • You understand what a webhook is — an HTTP endpoint on your server that an external service calls to notify you of an event ("payment succeeded," "subscription cancelled").
  • You know what an API key is and how to keep it secret (never commit it to git).

How it looks

// create a Stripe Checkout session, redirect the user
const session = await stripe.checkout.sessions.create({
  mode: "payment",
  line_items: [{ price: "price_abc123", quantity: 1 }],
  success_url: "https://your-site.com/thanks",
  cancel_url: "https://your-site.com/cancelled",
});
return Response.redirect(session.url);
// send an email with Resend
await resend.emails.send({
  from: "hello@yourdomain.com",
  to: user.email,
  subject: "Welcome",
  react: <WelcomeEmail name={user.name} />,
});

First step

Don't learn these in the abstract. Wait until a project needs payment or email. Then you'll do it in an evening.

Authentication (Better Auth / Clerk / Lucia) TIER 2

What it is

Authentication = proving who someone is (login). Authorisation = checking what they're allowed to do. The first is a solved problem — don't write it yourself. Three good options:

  • Clerk: hosted auth-as-a-service. Drop-in UI, social logins, multi-factor, user management dashboard. Costs money past free tier. Fastest to ship.
  • Better Auth: open-source, framework-agnostic, you self-host (the data lives in your own DB). Modern, type-safe, growing fast in 2026.
  • Lucia: minimalist, you write more of the glue but you understand every line. Best for learning how auth actually works.

Concepts: a session is a record that you're logged in (usually a row in a sessions table, identified by a long random string stored in a cookie). A JWT is a self-contained signed token (the session info is inside the cookie, no DB lookup needed) — modern advice is "prefer DB sessions; only use JWTs if you have a reason." OAuth is the standard for "log in with Google/GitHub/etc."

Why it matters for you

Your solomock uses an email allowlist — fine for a closed beta. The moment you want anyone to be able to sign up and have their own session history, you need real auth. Don't build it yourself. SQL-injection-grade bugs hide in hand-rolled auth.

Prerequisites

  • You know what a cookie is (a small key/value pair the server tells the browser to remember and send back on every request).
  • You know what HTTPS is.
  • You know what a database is.
  • For Lucia: you've done a Drizzle-style schema definition.

How Clerk looks

// app/layout.tsx
import { ClerkProvider, SignedIn, SignedOut, SignInButton, UserButton } from "@clerk/nextjs";

export default function Layout({ children }) {
  return (
    <ClerkProvider>
      <html><body>
        <SignedOut><SignInButton /></SignedOut>
        <SignedIn><UserButton /></SignedIn>
        {children}
      </body></html>
    </ClerkProvider>
  );
}

First step

When solomock needs real users, drop Clerk in for a weekend trial. If you hit Clerk's free limits, swap to Better Auth — by then you'll know what auth flows you actually need.

Turborepo / pnpm workspaces TIER 2

What it is

A monorepo is a single git repository containing multiple projects (apps, libraries, shared code). The opposite is a polyrepo — one repo per project.

pnpm workspaces is a feature of pnpm (a faster, disk-efficient alternative to npm) that links projects within the same repo together — they can import from each other as if they were published packages, but it's all local.

Turborepo sits on top of pnpm workspaces and adds smart task running: it remembers which files each task touches, so if you change file X, only the projects affected by X get rebuilt or retested. Huge speed-ups in big repos.

Why it matters for you

Your all-in-one-URL is already two projects in one folder (services/ + ui/) without monorepo tooling. You probably duplicate types between them. With pnpm workspaces + a shared packages/types/ folder, the URL response shape is defined once and consumed in both.

Prerequisites

  • You've used npm or pnpm before.
  • You know what package.json is.
  • You know what a git repository is.

How it's organised

my-monorepo/
├── package.json            # root, declares workspaces
├── pnpm-workspace.yaml
├── turbo.json              # Turborepo task config
├── apps/
│   ├── web/                # your Next.js app
│   └── api/                # your backend
└── packages/
    ├── types/              # shared TS types
    └── ui/                 # shared shadcn components

First step

Don't pre-migrate. The next time you start a project that obviously has two halves (a marketing site + an app, or a CLI + a library), reach for pnpm create turbo instead of plain npm.

tRPC / typed server actions TIER 2

What it is

A REST API is a contract you write twice: once on the server (the route handler) and once on the client (the fetch call + the type of the response). If you change the server, nothing forces you to update the client; you find out at runtime.

tRPC ("typed RPC") removes the contract duplication. You define a function on the server. On the client, you import it and call it. The types flow through automatically. Under the hood it's still HTTP, but you never write fetch code or hand-type response shapes.

Server Actions in Next.js (covered in Tier 1) achieve something similar within Next.js specifically. tRPC is the framework-agnostic version — works with React Native, Astro, vanilla React, anything.

Why it matters for you

Your all-in-one-URL/ui calls your FastAPI backend via fetch — you maintain TypeScript types of the response shape by hand. If the Python side changes, the UI breaks at runtime. tRPC eliminates that whole class of bug, but requires the backend to be in TypeScript too. So this is most useful if you're already migrating off Python (which is implied by Drizzle + Workers above).

Prerequisites

  • Comfortable TypeScript (generics, inference).
  • You've built one client-server app with fetch calls.
  • You understand TanStack Query — tRPC pairs with it.

First step

Skip until you have a TS-only stack. Then read the tRPC docs and convert one endpoint.

Bun TIER 2

What it is

A new JavaScript runtime — a replacement for Node.js. Also a package manager (faster than npm/pnpm), a bundler, and a test runner, all in one binary written in Zig. APIs are mostly Node-compatible, so most code works unchanged.

Why it matters for you

Mostly a speed win. bun install is 5–25× faster than npm install. bun test is several times faster than Vitest. For solo work where you run install often, that adds up. Production-readiness for serving traffic is improving but still trails Node.

Prerequisites

  • You've used Node and npm.

First step

Install Bun. Use it as your package manager (bun install instead of npm install) on a project where you do many install cycles, like solomock. Keep Node as the actual runtime for now.

Vercel AI SDK TIER 2

What it is

A TypeScript library that abstracts over LLM providers (OpenAI, Anthropic, Google, local models) with a single API. Handles streaming, tool calling, structured output, multi-turn chat — without writing the boilerplate per provider. Includes React hooks (useChat, useCompletion) for plumbing a streaming chat UI in ~20 lines.

Why it matters for you

solomock uses the OpenAI Realtime API directly (which is fine — it's voice-specific). But the moment you add any non-Realtime LLM feature (e.g. "summarise the interview transcript"), the AI SDK is faster to use than the OpenAI SDK directly, and trivially lets you swap models or providers.

Prerequisites

  • You've made one call to an LLM API.
  • You know what "streaming" means — receiving the response token-by-token as it's generated, rather than waiting for the whole thing.

How it looks

// app/api/chat/route.ts
import { streamText } from "ai";
import { openai } from "@ai-sdk/openai";

export async function POST(req: Request) {
  const { messages } = await req.json();
  const result = await streamText({
    model: openai("gpt-4o"),
    messages,
  });
  return result.toDataStreamResponse();
}

First step

Add a "summarise this interview" button to solomock using generateText from the AI SDK. ~30 minutes of work.

Astro Server Islands TIER 2

What it is

Astro's core model is "everything is static HTML; interactive bits are tiny islands of JavaScript." Originally, those islands hydrated on the client. Server Islands (Astro 4.x+) let you mark a component as "render this on the server, on demand, after the static page loads." So a mostly-static page can still have a dynamic section (current user's name, a personalised quote) without going full SSR.

Why it matters for you

Your roofing-site is pure static. If you wanted to add "show this morning's available booking slots" dynamically without redeploying every hour, Server Islands give you that without ejecting to Next.js.

First step

On the roofing site, replace one hard-coded number (e.g. "5-star reviews this month") with a Server Island that fetches it live. See the Astro docs — pattern is a single attribute change.

OpenTelemetry basics TIER 2

What it is

A vendor-neutral standard for instrumenting code — recording traces (a tree of operations: "this request triggered these DB calls and that external API"), metrics (counts, durations, sizes over time), and logs. Sentry, Datadog, Honeycomb, Grafana — they all speak OTel.

The point: you instrument once. If you change observability vendors later, you don't rewrite instrumentation.

Why it matters for you

For a solo dev with one backend, Sentry alone is fine. OpenTelemetry becomes worth it when you have multiple services calling each other and you need to follow a single user request across them.

First step

Defer until you have ≥2 services. Then read the Honeycomb or Datadog OTel quickstart.

Skip or defer TIER 3

This is the list of things you'll see hyped that aren't worth your time right now.

Rust or Go for your backend

Both are great languages. Neither solves a problem you have. Your bottleneck is "I haven't shipped this yet" or "tests don't exist" — not "Python is too slow." Pick these up only if you specifically want to do systems programming or work somewhere that requires them.

Deep Kubernetes operations, service meshes, custom controllers

Docker itself is essential — covered in Stage 12. Kubernetes literacy (reading manifests, running kubectl logs, deploying to an existing cluster) is also covered there and worth learning for any backend or full-stack role. What you should not do is dive into the deep end: writing custom operators and CRDs, tuning the scheduler, configuring Istio or Linkerd service meshes, managing your own etcd cluster, or building a "Kubernetes platform." That work is what platform engineers and SREs do full-time, and it's a different career track from web dev. Pursue it only if you specifically want to be a platform engineer; otherwise the literacy from Stage 12 is the right ceiling, and your cloud provider's managed Kubernetes (EKS, GKE, AKS) handles the rest.

GraphQL

REST + TanStack Query covers your needs. tRPC or Server Actions give better type safety with less code. GraphQL shines for huge teams with many heterogeneous clients (web, iOS, Android, partners), not solo builds.

Redux, Zustand, Jotai (global state libraries)

TanStack Query already manages server state. URL + local component state covers most "global" needs in your projects. Only reach for one of these if you have a genuinely cross-cutting client state need (a complex editor, multi-step wizard sharing state across routes, etc.).

Microservices, event-driven architecture, CQRS

Patterns designed to solve organisational problems (many teams, independent deploys, regulated separation) at huge scale. At your scale, a monolith is faster to build, faster to deploy, and easier to reason about. Splitting later is doable; splitting too early is the most common cause of doomed greenfield projects.

Web3 / blockchain

No current career signal. If you're personally interested, fine — but don't put it on a learning roadmap.

NestJS, Angular

Both are coherent, well-engineered ecosystems with shrinking 2026 market share against Next.js + plain React. Your React + Next investment compounds.

Server-Sent Events / WebSockets from scratch

Useful primitives but easy to misuse. If you need realtime, use Convex / Zero / Pusher / Liveblocks rather than writing the protocol yourself.

Part III — Beyond the stack

Parts I and II teach you a stack. This part teaches you the skills the stack hides — the timeless engineering layer that decides whether you're a good developer or a fast tutorial-follower. None of these depend on which framework you're using. None of them go obsolete.

Fundamentals beyond the stack FOUNDATION

Everything above is what to learn next on top of the modern web stack. None of it makes you a "good engineer" on its own. The difference between someone who can use Drizzle and someone who can debug a deadlock in production is a different axis — and most of that axis doesn't change with framework cycles.

Four categories. None is a weekend learning sprint. These are things you build up over years by going one layer deeper every time you touch something. The reason this section exists: the rest of this guide teaches you what to use; this section is about why things work and when they break.

Computer-science fundamentals FOUNDATION

The layer under every framework. You don't need a CS degree, but you do need to actively go looking — frameworks are built to hide this from you. Six concepts that pay back every year you do this job.

1. Big-O: why a nested loop hurts later

Big-O notation describes how an algorithm's work grows as input grows. O(n) = double the input, double the work. O(n²) = double the input, quadruple the work. O(1) = same work no matter the size. The trap: code with bad complexity feels fine on small inputs and quietly breaks at scale.

// O(n²) — for every user, scan every user, looking for matches
function findDuplicateEmails(users: User[]) {
  const dupes: string[] = [];
  for (const a of users) {
    for (const b of users) {
      if (a.id !== b.id && a.email === b.email) dupes.push(a.email);
    }
  }
  return dupes;
}
// 1,000 users → 1,000,000 comparisons. 10,000 users → 100,000,000. Frozen UI.

// O(n) — single pass with a Set (hash-based, O(1) lookup)
function findDuplicateEmails(users: User[]) {
  const seen = new Set<string>();
  const dupes: string[] = [];
  for (const u of users) {
    if (seen.has(u.email)) dupes.push(u.email);
    seen.add(u.email);
  }
  return dupes;
}
// 10,000 users → 10,000 lookups. Instant.

The killer move: any time you see a loop inside a loop, ask whether a Set, Map, or a single sorted pass would replace it.

2. The N+1 query: the database version of the same trap

Same shape as O(n²), but the inner "operation" is a network round trip to the database — so the cost is much worse than CPU instructions.

// BAD — 1 query for posts, then N queries for authors
const posts = await db.select().from(postsTable);
for (const p of posts) {
  p.author = await db.select().from(users).where(eq(users.id, p.authorId));
}
// 100 posts → 101 round trips → ~1 second of latency from network alone

// GOOD — one query, one round trip
const postsWithAuthors = await db
  .select()
  .from(postsTable)
  .leftJoin(users, eq(postsTable.authorId, users.id));

ORMs love to hide this. The signature is: a loop over query results that calls .find(), .select(), or .where() inside the loop. Always reach for a JOIN or an IN (?) batch query instead.

3. The HTTP lifecycle no tutorial shows you

When fetch("https://api.example.com/x") runs, here's what actually happens before your code sees a byte of response:

sequenceDiagram participant C as Client participant D as DNS participant S as api.example.com C->>D: what's the IP of api.example.com? D-->>C: 203.0.113.42 (~30ms first time, cached after) C->>S: SYN (TCP) S-->>C: SYN-ACK C->>S: ACK — TCP connection open (~1 round trip) C->>S: ClientHello (TLS) S-->>C: ServerHello + cert C->>S: key exchange — TLS established (~1-2 round trips) C->>S: GET /x HTTP/1.1 S-->>C: 200 OK + body
Round trips are the unit of cost. A user 200ms away pays ~600ms before your handler even runs.

This is why CDNs exist (cache the response near the user), why HTTP/2 multiplexing matters (reuse one connection for many requests), and why edge runtimes are fast (the round trip is short).

Status codes worth knowing cold:

CodeMeaningWhen you see it
301 / 302Permanent / temporary redirectSearch engines treat them differently — pick deliberately
400Bad request (client's fault)Malformed JSON, invalid params
401 / 403Not authenticated / not authorised401 = "log in"; 403 = "logged in, but not allowed"
404Not foundThe resource doesn't exist or you don't have permission to know it exists
409ConflictDuplicate insert, optimistic-lock failure
500Your server crashedUnhandled exception in your code
502 / 504Bad gateway / upstream timeoutYour reverse proxy can't reach the upstream, or it didn't reply in time — often a sign of a slow DB query or a crashed upstream
503Service unavailableServer is up but refusing — usually rate limiting or a deploy in progress

4. Concurrency: the race condition you'll write in JavaScript

"JavaScript is single-threaded" means one CPU instruction at a time. It does not mean you can't have race conditions. Every await is a suspension point where other code runs.

let balance = 100;

async function withdraw(amount: number) {
  const current = balance;          // 1. read
  await checkFraud(amount);          // 2. ⚠ suspend — other withdraw() calls run here
  balance = current - amount;        // 3. write using the STALE current
}

await Promise.all([withdraw(50), withdraw(50), withdraw(50)]);
// balance === 50, not -50. Two withdrawals silently disappeared.

Anything you read before an await and use after may be stale. Fixes: do the operation atomically in the database (UPDATE balance = balance - ? WHERE id = ? is one atomic statement), use a lock, or restructure so the read+write don't span an await.

5. Closures and stale captures

A closure is a function that remembers the variables from where it was created. The bug is what it remembers — and what it doesn't.

// Classic — `var` is function-scoped, all three closures share the same `i`
for (var i = 0; i < 3; i++) {
  setTimeout(() => console.log(i), 100);
}
// logs 3, 3, 3

// `let` is block-scoped, each iteration gets a fresh `i`
for (let i = 0; i < 3; i++) {
  setTimeout(() => console.log(i), 100);
}
// logs 0, 1, 2

This is the same mechanism behind React's "stale closure" bug: a useEffect captures the value of a state variable when it ran; if the component re-renders and the effect doesn't re-run, the closure still holds the old value. Fix: include the variable in the dependency array, or use a ref.

6. Databases: indexes, transactions, isolation

An index is a sorted side-structure (usually a B-tree) that lets the DB find a row in O(log n) instead of scanning the table. Cost: every insert/update also updates the index. Rule of thumb: index columns you filter on (WHERE), join on, or sort by; don't index columns you only ever read.

A transaction groups statements so they all succeed or all roll back. Without one: "transfer $100 from A to B" can debit A and then crash before crediting B. With one: both happen, or neither does.

// Without a transaction — partial state if the second statement throws
await db.update(accounts).set({ balance: a.balance - 100 }).where(eq(accounts.id, a.id));
// 💥 process crashes here — money vanished
await db.update(accounts).set({ balance: b.balance + 100 }).where(eq(accounts.id, b.id));

// With a transaction — atomic
await db.transaction(async (tx) => {
  await tx.update(accounts).set({ balance: a.balance - 100 }).where(eq(accounts.id, a.id));
  await tx.update(accounts).set({ balance: b.balance + 100 }).where(eq(accounts.id, b.id));
});

Isolation levels decide what concurrent transactions can see of each other. Postgres defaults to read committed: you see other transactions' writes the moment they commit. Serialisable behaves as if transactions ran one at a time. Most production bugs are someone assuming serialisable behaviour while running on read committed (e.g., "I read the balance, then wrote a new balance — but someone else wrote between my read and write").

The single most useful skill for backend work: read EXPLAIN ANALYZE output. It shows which indexes Postgres used, which it scanned sequentially, and where the time went. Five minutes with EXPLAIN finds bugs ten hours of staring at code wouldn't.

Why this matters for you

Drizzle hides SQL. Next.js hides HTTP. React hides the DOM. When something breaks at a layer the framework hides, you need to know the layer exists. Every one of the six concepts above will eventually surface in your projects: SoloMock's transcript saving (transactions), the URL shortener (indexes on the slug column), the contact form retry behaviour (idempotency, next section), an interview LLM call that pauses mid-async (concurrency).

How to practice

One book per year in this category. Designing Data-Intensive Applications (Kleppmann) is the canonical backend one — wait until one of your backends has felt the pain before tackling it. Before then: CS50 (Harvard's free intro), Stanford's Algorithms course on Coursera, or the Postgres docs on indexes and EXPLAIN. High Performance Browser Networking by Ilya Grigorik (free online) for the HTTP/TCP/TLS layer.

First step

Next time you write a Drizzle query, log the actual SQL (Drizzle has a query logger built in) and run EXPLAIN ANALYZE on the result against your real data. Do this ten times across your projects. The abstraction stops being opaque, and you'll spot two or three missing indexes immediately.

Engineering judgment FOUNDATION

The meta-skills that decide whether the code you write is good, regardless of stack. None of these are taught in tutorials — they're how senior engineers actually think.

1. Hypothesis-driven debugging

The fastest engineers aren't the smartest. They're the most systematic. Bad debugging looks like: scroll through code, change something, hope it works, change something else. Good debugging is a binary search through hypotheses.

The loop:

  1. State the bug precisely. Not "it's broken" — "when user clicks Save twice within 300ms, two rows appear in the DB." If you can't state it, you can't fix it.
  2. Form a hypothesis. "The handler isn't debounced and both clicks pass through." A specific claim about the mechanism.
  3. Design the cheapest test that would disprove it. Add one console.log at the handler entry, click twice, check the count. 30 seconds of work.
  4. Run it. If the test rules out the hypothesis, the hypothesis dies — form another. If it survives, narrow further.

Most stuck debugging sessions are because step 2 was skipped — you changed code without a specific claim about what should happen. Every change should be designed to answer a question. If you can't say what a change is meant to prove, don't make it.

Two underused tools: git bisect (binary search through commits to find which one introduced a bug — works on any repo with passing tests at some past point) and a real step debugger (Chrome DevTools sources tab, VS Code's debugger). console.log is fine but reaches its limit fast; a debugger lets you pause execution and inspect every variable in scope.

2. The rule of three: don't abstract too early

Junior engineers add code. Senior engineers delete it. The most common over-engineering mistake is abstracting after seeing two similar things. Wait for the third. Two examples don't tell you what's actually shared and what's incidental; three do.

// Two similar functions — DO NOT abstract yet
function emailWelcomeUser(user: User) {
  return resend.send({ to: user.email, subject: "Welcome", react: <Welcome name={user.name} /> });
}
function emailWelcomeAdmin(admin: Admin) {
  return resend.send({ to: admin.email, subject: "Admin access", react: <AdminWelcome name={admin.name} /> });
}

// THREE later — now you can see what's genuinely shared
// (the .email, the .name; NOT the subject or template)
function sendWelcome({ to, name, kind }: { to: string; name: string; kind: "user" | "admin" | "guest" }) { ... }

A premature abstraction is almost always wrong, because the second case constrained the shape to match the first — but the third case is the one that reveals what's incidental.

3. Trade-off thinking

There is no "best" technology, only "best for these constraints." Every non-trivial decision trades latency for cost, simplicity for flexibility, dev speed for ops complexity. Junior engineers pick the technically coolest option; senior engineers ask "what does this team, this product, this stage actually need?"

A worked example — "should I add Redis to cache user sessions?":

OptionLatencyCostOps complexityFailure mode
DB sessions~5ms / hitFree (existing DB)Zero new piecesDB load grows with traffic
Redis cache~0.5ms / hit+$10/mo + setupOne more service to monitorCache wrong/stale; Redis down → cascading
JWT (no lookup)~0ms (no I/O)FreeZero new piecesCan't revoke sessions; token leak = ~hours of risk

For solomock with 5 concurrent users, DB sessions is the right answer — adding Redis is pure complexity. For an app with 50,000 concurrent users, Redis becomes worth the operational cost. The skill is matching the answer to the actual scale, not the imagined one.

Habit: when you make a non-trivial decision, write down (in the commit message or a one-line note) the alternative you rejected and why. Six months later you'll have a journal of your own judgment to learn from.

4. Reading code

Most of your career is reading code, not writing it. The skill is undertrained because tutorials only teach writing.

Method for opening an unfamiliar codebase:

  1. Find the entry point. Look at package.json"main" / "scripts". For web apps, follow the route that matches the URL you care about.
  2. Read the types first. In TypeScript, the type definitions (or interfaces, or Zod schemas) tell you the data shape in seconds. The function bodies are noise until you know the shape.
  3. Read the tests. Tests are documentation of intended behaviour with concrete inputs and outputs. Often clearer than the implementation.
  4. Trace one feature end-to-end. Pick a single user action (e.g. "submit contact form"); follow it from button → handler → API → DB → response. Once you've traced one path, the rest of the codebase maps onto the same structure.
  5. Don't try to understand everything. Skim. The 80% you don't need this week will still be there next week.

Practice deliberately: read the source of one small library you use every week. Hono (~5k lines), Zod, or Preact are all small enough to read in an afternoon and teach more than a year of tutorials.

5. Writing things down

Half of senior engineering is communication artefacts that outlive your memory. The bar is low and easy to clear; most engineers don't bother, which is why doing it makes you stand out.

// Bad commit message
fix bug

// Good commit message
fix: prevent double-submit on contact form when network is slow

The submit button was disabled on click but re-enabled by React's
re-render before the fetch resolved. Move the disabled state into
a useTransition so it tracks the actual pending state.

Closes #47.

The good message answers: what changed, why, and what would prove it's fixed. Future-you, debugging in six months, will thank present-you. Same goes for PR descriptions, READMEs, and especially postmortems — a one-page write-up of every non-trivial bug you fix, with the root cause, the symptom, and what you'd do to prevent the class of bug. After a year you have an external brain.

Why this matters for you

You're a solo builder right now. None of this seems urgent — there's no team to communicate with, no reviewer catching your judgment. That's exactly when bad habits set in invisibly. The first time you join a team or open-source project, the engineers around you will read your judgment in your commit log, your PRs, and your debugging approach before they read your code. Build the artefacts now while the stakes are low.

How to practice

Three concrete habits: (1) keep a mistakes.md file where you write a 3-line note every time a bug surprises you — what you expected, what happened, root cause. Re-read it monthly. (2) Ask for code review from anyone better than you, even informally — post a snippet in a Discord, share a PR. (3) When you read code you didn't write, look up why the author chose that approach (commit message, blame, related issues) — the reasoning is the lesson.

First step

For your next non-trivial bug fix on any project, force yourself to write a 3-paragraph commit message: symptom, root cause, fix. It feels like overkill for one commit; it pays back the first time you have to figure out what past-you was thinking.

Systems thinking FOUNDATION

Treating your code as a thing that runs in a real environment, not a thing that executes in your head. The unifying question is always "what happens if X?" — where X is the unexpected, the failed, the simultaneous, the retried.

1. Failure modes: every operation can break in three places

A network call is not "success or error." It's "succeeded and you know it, succeeded but you don't know it, or failed." That middle case is where most production bugs live.

sequenceDiagram participant C as Client participant S as Server participant DB as Database C->>S: POST /charge S->>DB: INSERT charge DB-->>S: ok Note over S: 💥 server crashes before responding S--xC: (no response) Note over C: timeout — client retries C->>S: POST /charge (retry) S->>DB: INSERT charge DB-->>S: ok S-->>C: 200 ok Note over DB: 2 charges. User billed twice.
The DB committed the first charge before the server died. The client never knew. The retry duplicated it.

This shape repeats everywhere. Any non-trivial mutation should be designed assuming it might run twice.

2. Idempotency: making retries safe

An operation is idempotent if running it twice has the same effect as running it once. Reads are naturally idempotent. Writes are not — unless you design them to be.

// NOT idempotent — retry double-charges the customer
await stripe.charges.create({ amount: 5000, customer: "cus_123" });

// Idempotent — Stripe deduplicates by the key. Safe to retry.
await stripe.charges.create(
  { amount: 5000, customer: "cus_123" },
  { idempotencyKey: "order-789-charge" }
);

Three common patterns to make your own operations idempotent:

  • Unique constraint + ON CONFLICT: INSERT ... ON CONFLICT DO NOTHING. Second insert with the same key is a no-op.
  • State-machine guards: UPDATE orders SET status='paid' WHERE id=? AND status='pending'. The second call finds no row in 'pending' and silently does nothing.
  • Idempotency key table: store the (request_id, response) pair on first call; second call with the same request_id returns the cached response without re-executing.

Anywhere money, email, or external side effects are involved: assume the operation might run twice and design accordingly.

3. The observability mental model: three pillars

Sentry and PostHog are the tools; the model behind them is what matters.

  • Logs: discrete events with context. "User 42 clicked checkout at 14:23." Useful for debugging a specific issue after the fact. Cheap to write, expensive to query at scale.
  • Metrics: aggregated numbers over time. "p95 API latency was 230ms over the last 5 minutes." Cheap to query, lose individual context. Use for dashboards, alerts, SLOs.
  • Traces: the full tree of operations triggered by one request — "this request → 3 DB queries → 1 external API call → 2 cache hits, here's the time spent in each." The right tool when you want to know why a request was slow.

Two ideas worth Googling once: SLO (service-level objective — "99.9% of requests under 500ms over 30 days") and cardinality (how many distinct values a metric label can have; high cardinality blows up your bill — e.g. tagging metrics by user_id is almost always wrong).

Default logging hygiene: log on entry and exit of any operation that touches the network; include a request ID that flows through; never log secrets or PII.

4. Caching: the part that bites

The famous quote: "There are only two hard things in computer science: cache invalidation and naming things." The hard part of caching isn't the cache — it's knowing when the cached value is wrong.

Three failure modes worth knowing the names of:

  • Stale data: you updated the source but forgot to invalidate the cache. Users see the old value. Most common in CDN-cached pages after a fix-and-redeploy.
  • Cache stampede: the cache for a hot key expires at exactly 12:00:00. The next 1,000 requests all miss simultaneously, all hit the database, the database falls over. Fix: jitter the expiry, or use a "single-flight" pattern (only one request rebuilds the cache; others wait).
  • Cache poisoning: a request with user-controlled inputs gets cached under a key that includes those inputs. An attacker pollutes the cache so other users see bad data. Always think about what's in the cache key.

Caching layers, fastest to slowest:

LayerLatencyWhen to use
In-process (a JS Map)~microsecondsExpensive function, same instance reuses result
Redis / Memcached~0.5msShared across instances; sessions; rate-limit counters
HTTP cache (CDN)~10msStatic assets; public, identical-for-all responses
Browser cache~0msFiles the user has downloaded before

Default: don't cache. Add caching only when measurements show a specific operation is slow. A wrongly-cached page is worse than a slow page.

5. Zero-downtime schema migrations: expand & contract

A migration that drops or renames a column will break any code still reading the old shape. If old and new code run simultaneously (which they do during any rolling deploy), you crash. The pattern that works is expand then contract.

Renaming users.full_name to users.display_name with zero downtime:

  1. Expand: ALTER TABLE users ADD COLUMN display_name TEXT. Old code is untouched.
  2. Dual-write: deploy code that writes to both full_name and display_name on every update. Reads still use full_name.
  3. Backfill: UPDATE users SET display_name = full_name WHERE display_name IS NULL, in batches.
  4. Switch reads: deploy code that reads display_name. Writes are still dual.
  5. Stop writing the old column: deploy code that only writes display_name.
  6. Contract: ALTER TABLE users DROP COLUMN full_name.

Each step is independently deployable and reversible. A "rename column" migration that does all of this in one go will work fine on your laptop and break in production the moment two app instances run different versions during a deploy.

Why this matters for you

Every project you've built has a "what if X?" you haven't asked yet. SoloMock saving the transcript: what if the LLM call succeeds, the DB save fails, the user reloads — does the transcript exist or not? URL shortener: what if two users submit the same long URL simultaneously, both get assigned the same random slug — what happens? Contact form: what if the user double-clicks Send, the first request succeeds but the response is slow — do they see two confirmations, no confirmation, or one? You don't have to fix all of these. You have to notice them.

How to practice

For every mutation you write, list three things that could fail: the network, the database, the user. For each, ask what the user sees. The Google SRE book (free online) is the canonical resource; chapters 1, 3, 4, and the postmortem chapter are the highest-leverage reading. Watch any "X outage postmortem" talk on YouTube — Cloudflare, GitHub, and AWS all publish thorough ones. The patterns repeat across every outage in the industry.

First step

Pick the transcript-save in SoloMock. Write four lines: (a) what if the LLM call succeeds but the DB save fails? (b) what if the user clicks Save twice within 300ms? (c) what if the DB save succeeds but the user's network drops before the response arrives, and they retry? (d) what if two browsers are open and both save at once? Then fix whichever answer scares you most. You'll have written your first idempotent mutation.

Security beyond HTTPS FOUNDATION

The category most engineers learn after a breach. The asymmetry is the point: most days security adds friction, the one day it matters it saves your career. Six classes of bug that account for the overwhelming majority of real-world web compromises — learn to spot them in code.

1. Authn vs authz — and why authz is the harder one

Authentication = proving who you are. Login. Authorisation = checking what you're allowed to do. Most apps get authentication right (Clerk, Better Auth, OAuth providers all just work) and get authorisation wrong, because authz lives in every single endpoint, not in one library.

The classic authz bug is IDOR (Insecure Direct Object Reference): the server checks that you're logged in, but doesn't check that the thing you're asking for actually belongs to you.

// BAD — logged in is the only check. Any user can fetch any order.
app.get("/api/orders/:id", requireAuth, async (req, res) => {
  const order = await db.select().from(orders).where(eq(orders.id, req.params.id));
  res.json(order);
});
// Attacker increments :id in the URL. Sees everyone's orders.

// GOOD — scope the query to the current user. Always.
app.get("/api/orders/:id", requireAuth, async (req, res) => {
  const [order] = await db.select().from(orders).where(
    and(eq(orders.id, req.params.id), eq(orders.userId, req.session.userId))
  );
  if (!order) return res.status(404).end();
  res.json(order);
});

The defensive habit: every query that returns user-owned data should include the ownership check in the WHERE clause, not as a separate check after the fetch. The "fetch then check" pattern is also correct but easier to forget — and if it leaks data into a log or response before the check fails, you've already lost.

2. SQL injection (and why parameterised queries fix it)

The oldest one. It's still on the OWASP top 10 because new junior code still does this.

// BAD — string interpolation into SQL. Game over.
const q = `SELECT * FROM users WHERE email = '${email}'`;
await db.execute(q);

// What an attacker submits as `email`:
// '; DROP TABLE users; --
// Final query: SELECT * FROM users WHERE email = ''; DROP TABLE users; --'

// GOOD — parameters are sent separately from the query text
await db.execute("SELECT * FROM users WHERE email = $1", [email]);
// The DB driver knows $1 is data, never code.

Drizzle, Prisma, and any ORM that uses prepared statements gives you this for free — as long as you don't reach for raw SQL with template-string interpolation. Your Python backend's psycopg2 usage is worth auditing: cursor.execute("SELECT ... WHERE id = %s", (id,)) is safe, cursor.execute(f"SELECT ... WHERE id = {id}") is the bug.

3. XSS: never trust strings rendered as HTML

Cross-site scripting: an attacker submits content containing a <script> tag, your app renders it as HTML, every visitor's browser runs the attacker's JS. The attacker steals cookies, hijacks sessions, impersonates the user.

// BAD — directly injecting user input as HTML
<div dangerouslySetInnerHTML={{ __html: comment.body }} />
// If comment.body is `<script>fetch('/api/token').then(...attacker)</script>`...

// GOOD — React's default. Text is rendered as text, not HTML.
<div>{comment.body}</div>

React, Vue, Svelte all escape strings by default — the bug only surfaces when you opt out (dangerouslySetInnerHTML, v-html, {@html}). If you must render user-submitted HTML (a rich-text editor), sanitise it through DOMPurify. Add a strict Content Security Policy header as defence-in-depth — it blocks inline scripts even if an XSS bug slips through.

4. SSRF: user-controlled URLs that your server fetches

Server-side request forgery: your server fetches a URL the user provided, but "user-provided" includes the URLs an attacker chose. The classic kill: an attacker sends http://169.254.169.254/latest/meta-data/ — the AWS internal metadata endpoint — and your server fetches it, returning the cloud instance's IAM credentials.

// BAD — fetch any URL the user provides
app.post("/api/preview-url", async (req, res) => {
  const r = await fetch(req.body.url);
  res.send(await r.text());
});
// Attacker: { url: "http://169.254.169.254/latest/meta-data/iam/security-credentials/" }
// Your server obediently fetches your own cloud credentials and returns them.

// GOOD — allowlist, resolve DNS yourself, block private/loopback ranges
const url = new URL(req.body.url);
if (!ALLOWED_HOSTS.has(url.hostname)) return res.status(400).end();
// Resolve the hostname, verify it's not 10.x, 172.16.x, 192.168.x, 127.x, 169.254.x

This bug is in your all-in-one-URL shape of project — anywhere the server makes requests on behalf of user input (URL previews, webhook URLs, "import from URL" features). Always allowlist; never blocklist.

5. Secret management and the leak you've already had

Everyone has accidentally committed a secret to git. The instinct is to delete the commit — but the secret is already in everyone's clones, GitHub's API, every scraping bot's cache. The only correct response is to rotate the secret (revoke + replace) the moment you notice.

Layered defences:

  • .env files for local development, never committed (.env in .gitignore).
  • Environment variables on the host (Vercel/Render/Cloudflare dashboards) for production.
  • A pre-commit hook (gitleaks, trufflehog) that refuses to commit known secret patterns.
  • For larger setups: a secrets manager (AWS Secrets Manager, Doppler, Infisical) so secrets aren't lying around in deploy configs.

Your existing pattern in SoloMock — minting an ephemeral client_secret server-side and never letting the long-lived OpenAI key reach the browser — is principle of least privilege done right. The browser gets a token that works for 60 seconds, only for one operation, only for one user. Even if intercepted, the damage is bounded. Apply this thinking everywhere a service has to act on a user's behalf.

6. Validate at every boundary

Every byte entering your system from outside is suspect until proven otherwise: user form input, query strings, request bodies, API responses from third parties, environment variables, file uploads, and (increasingly important) LLM output.

// LLM tool-calling — the model's output is "external input" too
const RunCodeArgs = z.object({
  language: z.enum(["python", "javascript"]),
  source: z.string().max(10_000),
});

// Never JSON.parse + trust. Always parse + validate.
const args = RunCodeArgs.parse(JSON.parse(toolCall.function.arguments));
await sandboxed.run(args.language, args.source);

Zod (Tier 1) is the practical tool; the mindset — "this came from outside, what would a malicious version of it do?" — is the actual skill. Apply it to every req.body, every process.env at startup, every fetch response, every tool-call argument from an LLM.

The mental check before you ship any new endpoint

For every new route, walk through this 30-second checklist:

  1. Authn: is the user logged in if they need to be?
  2. Authz: are they allowed to access this specific resource? Is the ownership check in the WHERE clause?
  3. Input: is every field from the request validated against a schema before it's used?
  4. Output: are you accidentally returning fields the user shouldn't see (password hashes, other users' data, internal IDs)?
  5. Side effects: if this endpoint is called twice, what's the second-call behaviour? (Back to idempotency.)

Why this matters for you

all-in-one-URL, personal-site, and roofing-site are all internet-facing. solomock will be soon. The threat isn't a targeted hacker — it's bots that scan the entire internet for the same five known vulnerability classes. Closing those classes once means the bots find nothing and move on.

How to practice

PortSwigger's Web Security Academy is free, hands-on, and the gold standard. Work through the SQL injection, XSS, and access-control labs — they take an hour each and you'll never write those bugs again. Read the OWASP Top 10 once a year. Run npm audit in every project monthly and actually read what comes back.

First step

Pick one deployed project. Do the audit: (1) git grep the codebase for hard-coded secrets and process.env usage (any uppercase strings that look like keys); (2) for every endpoint returning user data, confirm the ownership check is in the SQL WHERE clause; (3) for every endpoint accepting input, confirm something validates each field's type and length before it reaches the DB or an external API. You will find at least one issue. Fix it before reading the next section.

Part IV — Meta-skills

How to keep learning after this guide ends. Four habits that compound across an entire career — most engineers never deliberately train them, which is why most engineers plateau.

How to actually learn META

You will spend the rest of your career learning new tools. The thing nobody teaches: how to learn deliberately, in a way that compounds, instead of bouncing between tutorials and feeling like you're moving without going anywhere.

1. The "build it" rule

You don't learn from reading. You don't learn from watching tutorials. You learn from trying to do something, getting stuck, and figuring it out. Every concept in this guide has a project for a reason — building exposes the gaps reading hides. If you've "read about" something but never built with it, you don't know it. Assume that bar.

2. The 3:1 ratio

For every hour you spend consuming material (reading, watching), spend three hours building. That ratio is roughly what differentiates "I've heard of React" from "I can build with React." If you reverse it (mostly tutorials, occasional building) you'll be stuck at "heard of" indefinitely.

3. The "explain it back" test

Just-finished a chapter or video? Close the tab. Write 3 sentences explaining what you just learned, as if to a friend who's never heard of it. If you can't, you didn't learn it — re-read, then try again. This is what cognitive scientists call retrieval practice; it's the single highest-leverage learning technique that exists.

4. Spaced repetition (the cheap version)

The brain forgets faster than feels reasonable. Counter it by revisiting concepts on a schedule: 1 day after first learning, then 3 days, then a week, then a month. You don't need flashcard software — just a recurring calendar reminder to revisit your notes from last week. Five minutes prevents forgetting most of what you spent ten hours learning.

5. The "shipping ratchet"

Set a rule for yourself: nothing learned counts until it ships into something. A blog post you wrote, a feature deployed, a repo on GitHub with a real README, a side project a stranger could find. This forces every learning session to terminate in something concrete. Half-finished projects are how skills evaporate.

6. Pick one thing at a time

Beginner mistake: learning React, Tailwind, Next.js, TypeScript, and Prisma all in the same week. You learn none of them. Sequence them — TypeScript first (Stage 5), then Tailwind (Stage 7), then Next.js (Stage 8). Each builds on the last. Holding too many new variables at once means none of them get encoded.

The compounding effect. If you do this consistently — build more than you read, explain back, revisit on a schedule, ship — you'll be ahead of 90% of self-taught developers within a year. Not because you're smarter. Because most people skip these steps.

Using AI tools as a learner META

ChatGPT, Claude, Copilot, and Cursor are the most powerful learning accelerators ever made for programmers. They're also the easiest way to short-circuit actually learning anything. The difference is entirely in how you use them. Three rules.

Rule 1: Use AI to explain, not to do

The exact same prompt has two opposite effects depending on framing. "Write me a function that does X" gets you code you don't understand. "Explain how I'd approach writing a function that does X, then show me a simple version with comments on every line" gets you understanding.

// ❌ Stops your learning cold
"Write a React component for a todo list with localStorage."

// ✅ Trains your understanding
"I'm learning React. I know useState. I want to build a todo list that persists
in localStorage. Walk me through the design decisions first — what state do I
need, where does it live, when do I read/write localStorage. Then show me a
short example. Explain every hook call."

Rule 2: Always try first, ask second

If you reach for the AI before you've spent 15 minutes trying yourself, you're robbing yourself of the thing that produces growth: struggling, then resolving. The frustration is the workout. Outsource it and you stay the same shape. Order: think, try, fail, try differently, fail again, then ask AI — and ask for a hint, not a solution.

Rule 3: Verify everything AI tells you, especially when it sounds confident

LLMs hallucinate. They invent function names that don't exist, cite API endpoints that were never real, confidently misexplain language semantics. Treat any AI answer as a draft hypothesis: check it against the actual docs (MDN, the library's official site) before relying on it. The skill of verifying AI output is the skill that separates "AI makes me faster" from "AI makes me ship subtly broken code."

What AI is great for as a learner

  • Explaining unfamiliar code. Paste a snippet from a library you're reading, ask "what does this do, line by line." Often clearer than the docs.
  • Generating practice problems. "Give me 5 array-manipulation exercises, increasing in difficulty, with hidden solutions."
  • Rubber-ducking design decisions. "I'm debating whether to put auth state in React Context or in a route loader. What are the tradeoffs?"
  • Translating error messages. Paste a cryptic TypeScript error; ask for a plain-English explanation and the likely cause.
  • Reviewing your code. "Critique this function. What would a senior dev change?"

What AI is terrible for as a learner

  • Doing your projects for you. You'll feel productive and learn nothing.
  • Anything where being wrong costs you. Security advice, production database operations, authoritative framework recommendations — verify against primary sources.
  • Replacing the docs. The official documentation is more accurate, more current, and gives you the structure of a topic that scraps of AI answers never will.

The Cursor / Copilot question

AI-integrated editors are wonderful as a productivity tool for engineers who already know the basics — they make you faster at what you can already do. As a learning tool they're double-edged: it's far too easy to tab-complete your way through a project without ever forming the muscle memory yourself. For Part I, turn AI autocomplete OFF. Type every keyword, every API call, every type signature. Once you finish Part I, turn it back on — by then you'll know what it's doing and can use it well.

Asking good questions META

At some point you'll be stuck on something the docs don't cover and AI gets wrong. You'll need to ask a human — on Stack Overflow, Discord, Reddit, or a colleague. Bad questions get ignored or dismissive answers. Good questions get gold-standard responses from senior engineers who love to help. The difference is technique, not luck.

The anatomy of a question that gets answered

  1. Context — the bigger picture in one sentence. "I'm building a Next.js app with Drizzle on Postgres." Without this, helpers waste their first reply asking what you're doing.
  2. Goal — what you want to happen. "I want to filter posts by tag and paginate the results."
  3. What you tried — specific code, with what happened. "I tried this: [paste]. I expected X, got Y. The error was Z."
  4. What you've already ruled out — "I checked the docs at link and tried suggestion A from this Stack Overflow answer — same result." This proves you've done work and saves people from suggesting things you already tried.
  5. A specific question — "Why is my Drizzle query returning duplicate rows after I add the second join?" — not "why is this broken?"

Bad question vs good question

// Bad — gets ignored
"react not working help"

// Good — gets a thoughtful answer in an hour
"In Next.js 15 with the App Router, my client component's useState is reset
on every navigation between two pages that use the same component. I expected
state to persist when navigating between sibling routes. Repro: [link to
github gist with 20 lines]. I read the docs on 'Linking and Navigating' and
tried wrapping in a layout, no difference. Is this expected behaviour or a
bug in how I'm structuring routes?"

The Socratic move

Sometimes writing the good version of the question answers it for you — the act of articulating the problem precisely is the act of understanding it. Senior engineers call this rubber-duck debugging: explaining the problem to a rubber duck (or a markdown doc) and realising mid-sentence what's wrong. Always try writing the question fully before posting; you'll need to post less than half the time.

Where to ask

  • Stack Overflow — best for specific technical errors with reproducible examples. Read their "How to Ask" page once, follow it forever.
  • Discord — every major framework has an official Discord (React, Next.js, Tailwind, Drizzle). Best for "is this the right approach" questions where conversation helps.
  • GitHub Issues / Discussions — best for "I think this might be a bug" reports. Always check existing issues first.
  • Reddit (r/learnprogramming, r/reactjs, r/webdev) — best for broader, more opinion-driven questions.

Escaping the tutorial trap META

"Tutorial hell" is the term for the loop where you keep watching tutorials and following along but never build anything independent. It feels productive — you understand the videos! — but you don't actually gain the skill of building. Almost every self-taught developer goes through this. The escape is the same regardless of stack.

The diagnostic

If you can't open a blank VS Code project right now and build a simple version of something you've seen in tutorials without referring to the tutorial, you're in the trap. Not because you're not smart — because tutorials build a different skill (following) than the one you need (building).

The escape sequence

  1. Stop starting new tutorials. Finish the one you're in (or quit it) and don't start another for two weeks.
  2. Pick a project slightly above your current ability. Not "build YouTube" — but "build a simple version of something you've used." A weather app, a unit converter, a flashcard app, a Wordle clone.
  3. Start with zero tutorial. Open an empty project. Sketch what you want on paper. Start typing. You will be stuck within 5 minutes — that's the point.
  4. When stuck, search a specific question, not "tutorial X." "How do I conditionally render a class in React" — not "React tutorial." The first gives you a 2-minute answer; the second pulls you back into hours of passive viewing.
  5. Finish it. The finishing is the skill. A scrappy completed project teaches more than ten elegant abandoned ones.
  6. Repeat with the next-harder project. Each one you finish raises the floor of what you can attempt independently.

The "ugly first, polished later" mindset

Tutorials produce polished code. That's their job. Your first attempts won't look like that. That is normal and correct. Get something ugly working end-to-end before you make any single part beautiful. Then iterate. Trying to write tutorial-quality code on your first pass is how projects die before they finish.

When tutorials ARE the right tool

Tutorials are right when you need a structured overview of an entire unfamiliar topic — the first day with TypeScript, the first hour with Tailwind. They're wrong as a way to build skill at a topic you've already been introduced to. Rule of thumb: one tutorial per concept, and only to orient. After that, building.

The shape of progression. Tutorials → building with heavy reference to docs → building with occasional reference to docs → building from memory, looking things up as needed. That last state is "knowing the thing." The middle states are normal and necessary; don't skip them, but don't camp out in them either.

Suggested order to actually do this

If you're starting from zero, do Part I in order. If you can already write some JavaScript and HTML, skim Part I as a checklist and dive into Part II. The compact path:

  1. Stages 0–3 (~6–8 weeks): set up, JS, HTML/CSS, browser JS. Build the projects.
  2. Stage 4 (~1 week): Git. Put your previous work on GitHub.
  3. Stage 5 (~2–3 weeks): TypeScript. Port a previous project.
  4. Stages 6–8 (~6–10 weeks): React, Tailwind, Next.js. This is the heart of modern web dev.
  5. Stage 9 (~1–2 weeks): Ship your portfolio at a custom domain.
  6. Stages 10–11 (~6–8 weeks): Backend basics + first full-stack project.
  7. Stage 12 (~3–6 weeks intensive): Going professional. Docker, CI/CD, code review, environments, cloud literacy, Kubernetes basics. This is the stage that turns "I built a thing" into "I can work on a team."
  8. —— You are now genuinely employable as a junior web developer. ——
  9. Part II — Tier 1: one item at a time, applied to a real project. Start with Sentry, add one test, adopt shadcn, then RSC patterns, then AI features, finally Drizzle when you next touch a backend.
  10. Part II — Tier 2: don't pre-learn. Add each when a specific project asks for it.
  11. Part III — Beyond the stack: in parallel with everything above. One concept per month, deliberately. CS fundamentals, judgment, systems, security. The fundamentals are a five-year project — you don't finish them, you compound them.
  12. Part IV — Meta-skills: re-read every few months. The habits that compound while you're not paying attention.

How long does all this take? honest version

If someone tells you they learned to be a "good web developer" in three months, one of three things is true: (a) they had a strong CS background already, (b) they're confusing "I built one tutorial app" with competence, or (c) they're selling a course. Honest ranges, assuming 8–15 hours per week of consistent effort:

MilestoneTimeWhat you can do at this point
End of Stage 32–3 monthsBuild static pages with interactivity. Read other people's JS.
End of Stage 95–7 monthsBuild and deploy a polished frontend on the modern stack. Genuinely "I can make websites."
End of Stage 117–9 monthsShip full-stack apps end-to-end solo. Solid personal-project competence.
End of Stage 128–11 monthsTeam-ready. Can join a real codebase, follow a real workflow, ship in production. This is "employable as a junior."
Comfortable with Part II Tier 1~13–15 monthsProductive on a modern team's stack. Recognised as "intermediate."
Part III internalised3–5 years"Good engineer." Knows why things work, debugs systematically, makes calls colleagues trust.

A few important caveats:

The most important number on this page: ~9–11 months of consistent part-time effort gets you to "I can build full-stack web apps AND work on a team that ships them." That's a lot less than university (4 years) and a lot more than bootcamp marketing (3 months). Plan accordingly.

If you take one thing from this guide: Build more than you read. Ship more than you start. The developers who get good aren't the ones who consumed the most material — they're the ones who finished the most projects, even when those projects were small and ugly and embarrassing in retrospect. Start the next one tonight.

Built as a static HTML file. To deploy: drag this folder onto Netlify Drop, or push it to a GitHub repo and enable Pages. To iterate: just edit index.html and reload the file in your browser.