Skip to content

Browser and build tools

  • Lesson 4
  • 55 min
  • Module 1: Web foundations bridge

What you will be able to do

Move the first page into the tools of a real project. They are npm for packages, a development server, TypeScript for checks before the code runs, tests and a production build. And know where each piece of code runs, because the browser and the server have different rules.

Before you startWhat to know and to have readyAbout 55 minutes

What you need to know

What you need on your computer

  • ~/projects/ticket-desk with bridge/, and the mock API running in terminal 1.
  • A connection: npm install downloads about 64 MB of packages.

At the end, your folder is the snapshot end-of-m01.

You wrote bridge/index.html with one list item, “Loading…”. The page now shows ten items. So where are they? Not in the file: the file did not change. They are in the DOM, the browser’s live tree of the page’s elements, which app.js changed.

The browser’s developer tools show that tree. Open http://127.0.0.1:8100/, open the developer tools, and select the Elements tab:

Chrome's developer tools next to the first page. The Elements panel shows the tree: html, head, body with header class top, main class layout with a grid badge, and the script app.js. Under it, the Styles pane for body shows the rule margin 0 from styles.css line 14, the browser's own rule margin 8px crossed out, and the custom properties of :root from styles.css line 2, such as --ink #1d2733.
The Elements panel shows the DOM, folded at the body. The Styles pane shows where each rule comes from: styles.css overrides the browser's margin of 8px.

Open <main>, then the second <section>, then <ul>, with the small triangles: the list holds ten <li> elements. The Elements panel is the DOM as it is now. View page source (right-click on the page) shows the file as the server sent it, with “Loading…”. In the Console tab, you can ask the DOM questions with JavaScript. Type this, and press Enter:

document.querySelectorAll('#tickets li').length

It answers 10. When a page shows something unexpected, look at the DOM first, then at the Network tab for the requests. The DOM tells you what the page has; the Network tab tells you what the server sent.

The problem: one folder of files does not scale

Section titled “The problem: one folder of files does not scale”

The first page works with three files and no tools. Grace’s real screen needs much more: a list of thousands of tickets, a ticket view, a streamed draft, tests. Sam lists what goes wrong if they continue in the same way:

  • Mistakes appear only when the code runs. Write ticket.subjet instead of ticket.subject, and the list shows undefined instead of a subject. No message, no warning: the agent finds the mistake.
  • Other people’s code. Module 2 uses React, a library of about 64 kB (compressed) that Sam will not write. It must be downloaded, in an exact version, on every computer of the team.
  • Production is different. The real screen should be small files that load fast, not the files as Sam types them.

A cook in a busy kitchen has the same problems. She writes her recipes with exact brands and amounts, so another cook can make the same dish. She tastes while she cooks, and she plates the final dish differently from the pan. The tools of this lesson do these three jobs: npm keeps the exact list of ingredients, the development server lets you taste while you work, and the build plates the dish.

npm: the packages and their exact versions

Section titled “npm: the packages and their exact versions”

A package is code that someone published for others to use, such as Vite or React. npm (it came from “Node Package Manager”) downloads packages from the npm registry, a public store, into the folder node_modules. It reads the list of packages from package.json, the project’s description.

Copy these files from the snapshot end-of-m01 into your ticket-desk folder: package.json, package-lock.json, .env.example, index.html, tsconfig.json, vite.config.ts, README.md (it now describes the page too), and the folder src. In terminal 2, in the ticket-desk folder:

Terminal window
S=../nextia-learning-labs/C22/snapshots/end-of-m01
cp $S/package.json $S/package-lock.json $S/.env.example $S/index.html $S/tsconfig.json $S/vite.config.ts $S/README.md .
cp -R $S/src .

The commands expect the labs repository next to ticket-desk, in ~/projects. Then read package.json:

package.json
{
"name": "ticket-desk",
"version": "0.1.0",
"private": true,
"description": "Larkfield's help-desk interface: the running project of the frontend course of Nextia Learning.",
"license": "MIT",
"type": "module",
"engines": {
"node": "^22.22.2 || >=24.15.0"
},
"scripts": {
"api": "node mock-api/server.mjs",
"dev": "vite",
"build": "tsc --noEmit && vite build",
"preview": "vite preview",
"typecheck": "tsc --noEmit",
"test": "node --test \"mock-api/*.test.mjs\" && vitest run"
},
"devDependencies": {
"typescript": "7.0.2",
"vite": "8.3.4",
"vitest": "5.0.3"
}
}

Three parts matter:

  • scripts are short names for commands. npm run dev runs vite; npm test runs both sets of tests. npm run api runs node mock-api/server.mjs: from now on, you can start the mock API with it. A new person does not need to know the long commands.
  • devDependencies are the packages that the project needs while you build it: TypeScript, Vite and Vitest. Each has an exact version, such as 8.3.4, so every computer gets the same code.
  • "type": "module" says that the .js files are modules, as in the last lesson.

Each of the three packages needs other packages, which need others again. package-lock.json records the exact version of every one of them. Commit both files; never edit the lock file by hand.

Install them, in terminal 2:

Terminal window
npm install

The author’s run printed this. The course team’s computer had the packages in npm’s cache, so it took less than a second. Your first install downloads them and takes longer:

added 38 packages, and audited 39 packages in 422ms
12 packages are looking for funding
run `npm fund` for details
found 0 vulnerabilities
npm warn allow-scripts 1 package has install scripts not yet covered by allowScripts:
npm warn allow-scripts fsevents@2.3.3 (install: (install scripts present))
npm warn allow-scripts
npm warn allow-scripts Run `npm approve-scripts --allow-scripts-pending` to review, or `npm approve-scripts <pkg>` to allow.

You asked for three packages, and npm added 38. That is normal: Vite and Vitest bring their helpers. The warning appears on macOS only. fsevents is a macOS helper that tells Vite when a file changes. npm did not run its install script, because nobody approved it. Vite works without it: every command in this course ran with this warning. You can ignore it.

node_modules is now 64 MB. It grows as the course adds packages, and it is never committed: .gitignore lists it, and npm install makes it again from the lock file.

The size of node_modules at the end of each module of this course, on the course team's Mac. Module 2 adds React and the testing library; Module 6 adds the end-to-end test tool.
Text description of the chart

A column chart of the size of node_modules at the end of each module. It is 64 MB after Module 1, 107 after Module 2, 114 after Modules 3, 4 and 5, and 135 after Module 6. The biggest steps are React and its testing tools in Module 2, and the end-to-end test tool in Module 6. None of these megabytes goes to the browser as they are: the build takes only the code that the page uses.

Why not open index.html as a file, by double-clicking it? Because a browser loads modules only from a server, and it does not understand TypeScript. A development server is a small server for your own computer while you work. It sends the project’s files to the browser, and turns each file into what the browser understands on the way. It also reloads the page when you save a file. This course uses Vite.

Start it in a third terminal, terminal 3. Terminal 1 keeps the mock API running.

Terminal window
npm run dev
VITE v8.3.4 ready in 100 ms
➜ Local: http://localhost:5173/
➜ Network: use --host to expose

Open http://localhost:5173/. You see the same page as before, now built from index.html and src/main.ts, with the shared styles of the course. src/main.ts is bridge/app.js again, in TypeScript: it loads the ten newest tickets and sends the form with FormData and fetch. Its requests live in src/api.ts, and the next section looks at its types.

The ticket desk at localhost:5173: the New ticket form on the left, and on the right the list of the ten newest tickets, one line each, such as T-80010: Return label for my order LK-300210 (Test Customer · other · open).
The same page, served by Vite's development server and written in TypeScript. The list comes from the mock API on another port.

Two servers now run on your computer, with different jobs. Vite sends the page’s files. The mock API sends the data. In production, Vite does not run at all: the build makes plain files that any web server can send.

TypeScript: mistakes found before the code runs

Section titled “TypeScript: mistakes found before the code runs”

Remember the ticket.subjet mistake: JavaScript gives undefined and continues. TypeScript is JavaScript with types: notes that say what shape each value has. A tool reads the notes and checks the code before it runs. The types of the API’s data are in src/types.ts:

src/types.ts (the start)
export interface TicketSummary {
ticket_id: string;
customer_id: string;
customer_name: string;
subject: string;
team: string;
priority: number;
status: string;
created_at: string;
}

An interface names the fields of an object and the type of each one: string for text, number for numbers. src/api.ts uses it: function ticketLine(ticket: TicketSummary): string says “this function takes a ticket summary and gives back text”.

Now make the mistake on purpose. In src/api.ts, change ticket.subject to ticket.subjet in ticketLine, save, and run the type check:

Terminal window
npx tsc --noEmit
src/api.ts(33,41): error TS2551: Property 'subjet' does not exist on type 'TicketSummary'. Did you mean 'subject'?

The checker gives the file, the line (33), the column (41) and even the fix. npx runs a program from node_modules; tsc is the TypeScript checker, and --noEmit means “check only”. Change the word back, and the check prints nothing: no errors.

The types exist only for the check. The browser never sees them: Vite removes them when it sends a file. So TypeScript costs no time in the browser. It costs some time at your desk: you write the types, and you keep them the same as the API’s contract.

The project has two kinds of tests. The mock API’s 21 checks run with Node.js. src/api.test.ts checks the page’s code with Vitest, a test runner for Vite projects. One of its tests:

src/api.test.ts (one test)
test('loadTickets fails with the status when the API answers an error', async () => {
vi.stubGlobal('fetch', async () => new Response('{}', { status: 503 }));
await expect(loadTickets(10)).rejects.toThrow('The API answered 503');
});

vi.stubGlobal replaces fetch with a fake that answers 503 at once, so the test needs no API. Run all the tests:

Terminal window
npm test

The output lists the 21 checks of the mock API, then Vitest’s summary:

ℹ tests 21
ℹ pass 21
...
Test Files 1 passed (1)
Tests 4 passed (4)

Then build the files for production:

Terminal window
npm run build
vite v8.3.4 building client environment for production...
transforming...
✓ 6 modules transformed.
rendering chunks...
computing gzip size...
dist/index.html 1.38 kB │ gzip: 0.62 kB
dist/assets/index-Cq7Ckq9E.css 4.20 kB │ gzip: 1.48 kB
dist/assets/index-jgaqHoif.js 1.91 kB │ gzip: 0.98 kB
✓ built in 206ms
A flow from left to right. The files src/types.ts, src/api.ts, src/main.ts and src/styles.css go to tsc --noEmit, which checks the types and writes no file. With no errors, they go to vite build, which removes the types and joins and shrinks the files. An error goes to: fix the code. vite build writes dist/index.html 1.38 kB, dist/assets/index-hash.js 1.91 kB and dist/assets/index-hash.css 4.20 kB.
What npm run build does: first the type check, then Vite's build. A type error stops the build.
Text description of the diagram

npm run build runs two programs. First, tsc --noEmit checks the types of all the files in src/; it writes nothing. If it finds an error, the build stops, and you fix the code. If not, vite build removes the types, joins the modules into one JavaScript file and makes it smaller. It writes the result to dist/: an HTML file of 1.38 kB, a JavaScript file of 1.91 kB and a CSS file of 4.20 kB.

dist/ is what a real web server would send. The JavaScript is 1.91 kB, and 0.98 kB after gzip, the compression that servers use on the way. The file names have a hash, a code made from the content, such as index-jgaqHoif.js. When the content changes, the name changes, so a browser never uses an old copy. Module 5 measures these files when the app is much bigger.

Two addresses: where the CORS check happens

Section titled “Two addresses: where the CORS check happens”

Look at the first lines of src/api.ts:

src/api.ts (the start)
export const API_URL: string = import.meta.env.VITE_API_URL ?? 'http://127.0.0.1:8100';

The page comes from http://localhost:5173, and the data from http://127.0.0.1:8100. These are two origins: an origin is the scheme, the host and the port together. So the paths cannot start with / any more, as in bridge/app.js: the page uses the API’s full address. import.meta.env.VITE_API_URL reads a setting from a file .env, if you make one from .env.example; ?? gives the default when there is none.

A browser does not let a page read an answer from another origin, unless the server allows it. That is the same-origin rule and CORS (Cross-Origin Resource Sharing), from Browser access and CORS. The mock API allows http://localhost:5173 and http://localhost:4173. See it from the terminal, as the browser sends it, with an Origin header:

Terminal window
curl -i -X POST http://127.0.0.1:8100/v1/tickets -H "Content-Type: application/json" -H "Origin: http://localhost:5173" -d '{"customer_id": "C-0022", "subject": "Parcel lost", "body": "It did not arrive."}'
HTTP/1.1 202 Accepted
X-Request-ID: 2a596651-427e-45a5-b167-c14804cfbfb1
Access-Control-Allow-Origin: http://localhost:5173
Vary: Origin
Access-Control-Expose-Headers: Location, Retry-After, X-Request-ID, Idempotent-Replayed
Content-Type: application/json
Location: /v1/jobs/1
...
{"ticket_id":"T-500001","job_id":1,"state":"queued","status_url":"/v1/jobs/1","queued_ahead":0,"estimated_wait_seconds":3.7}

On Windows PowerShell, use curl.exe, and put the JSON in a file as Inspect a request shows; the quotes work differently there. Now ask from an origin that the API does not allow:

Terminal window
curl -i "http://127.0.0.1:8100/v1/tickets?limit=1" -H "Origin: http://evil.example"
HTTP/1.1 200 OK
X-Request-ID: f87a16c3-f5bc-4254-885b-b61d37c128c7
Content-Type: application/json
...

The API answered 200 with the data, but with no Access-Control-Allow-Origin header. curl shows the data anyway; a browser would hide it from the page. So the check happens in the browser, to protect the user, not in the API. The diagram puts all the pieces of this module in their places.

Three areas. On the left, the browser, in red and marked public: the user can read and change everything on this side. It holds the page (HTML for what is there, CSS for how it looks, JavaScript for what it does), the DOM, the live tree of the page's elements, and a fetch to http://127.0.0.1:8100/v1/tickets?limit=10 with the header Origin http://localhost:5173. At the top right, the Vite development server at http://localhost:5173 sends the files index.html, src/main.ts as JavaScript and styles.css; npm run build makes the same files in dist. At the bottom right, the mock ticket API, a server, at http://127.0.0.1:8100: the tickets as JSON; it checks every request again; later, keys and secrets stay here. Arrow 1 from the browser to Vite: GET / and the files. Arrow 2 from the browser to the API: GET /v1/tickets, through a red box CORS check. Arrow 3 back: JSON and Access-Control-Allow-Origin. A note: the API answers every request; the browser gives the answer to the page only if the header names the page's origin; curl has no such check, so CORS protects the user in the browser, not the API.
The browser–frontend–API boundary. Everything in the browser is public; the server keeps the data and the secrets, and checks every request.
Text description of the diagram

The left area is the browser, and everything in it is public: the HTML, the CSS, the JavaScript and the DOM. It talks to two servers. Vite sends the page’s files (in production, any web server sends dist/). The mock API sends the data. The browser gives an API answer to the page only if the Access-Control-Allow-Origin header names the page’s origin: that is the CORS check. The API answers anyway, so it must check every request itself.

The same picture answers the module’s main question: which code runs where? HTML, CSS and JavaScript run in the browser. The person at the browser can read every line, every VITE_ setting, and every request in the Network tab. So a key, a password or any rule that must be enforced belongs on a server, never in the page. .env.example says so in its first lines: Vite puts each VITE_ value into the JavaScript that the browser downloads. A later lesson on browser security shows this with a fake key.

Use npm, a development server and TypeScript for any page that grows beyond a few files, or that a team shares. The lock file gives everyone the same packages, and the type check finds a class of mistakes before any agent does.

They are not free. node_modules takes 64 MB now and 135 MB at the end of the course. Every package is code from other people that you trust; npm install reports known vulnerabilities, here found 0. And the build is one more step that can fail. For one page with a short script, the bridge/ way, with no tools, is fine.

Common mistake

The page runs on another port, and the list fails: a CORS error

Symptom: port 5173 is busy, so Sam starts Vite on another one: npm run dev -- --port 5174. The page opens at http://localhost:5174/, but the list says:

The ticket desk at localhost:5174: the New ticket form on the left, and on the right the heading Newest tickets with the text Could not load the tickets. Failed to fetch.
The page loads from Vite, but the list request fails. The page only knows that no usable answer arrived.

The developer tools’ Console tab says why:

Console
Access to fetch at 'http://127.0.0.1:8100/v1/tickets?limit=10' from origin 'http://localhost:5174' has been blocked by CORS policy: No 'Access-Control-Allow-Origin' header is present on the requested resource.
Failed to load resource: net::ERR_FAILED

Cause: the mock API allows only http://localhost:5173 and http://localhost:4173. A different port is a different origin, so the answer has no Access-Control-Allow-Origin header for it, and the browser does not give it to the page. For the page’s code, this looks exactly like a network failure: fetch rejects with Failed to fetch.

Fix: use port 5173, or allow the new origin. The mock API reads the allowed origins from the setting CORS_ORIGINS when it starts. Stop it in terminal 1, and start it again with both origins:

Terminal window
CORS_ORIGINS=http://localhost:5173,http://localhost:5174 node mock-api/server.mjs

Reload the page: the ten tickets appear. Allow only the origins that you use. A real API keeps this list short, as the API course explains.

Guided practice: a form that calls the API

Section titled “Guided practice: a form that calls the API”

This is the module’s worked example: a small page with a form that calls the mock API and shows its answer. Follow it with every intermediate value visible. It reads one ticket by its ID.

  1. Make bridge/find.html, a page with one form. It uses the same styles.css:

    bridge/find.html (the body)
    <main class="layout">
    <section aria-labelledby="find-heading" class="card">
    <h2 id="find-heading">Find a ticket</h2>
    <form id="find">
    <label for="ticket-id">Ticket ID</label>
    <input id="ticket-id" name="ticket_id" required pattern="T-[0-9]+" placeholder="T-80008" />
    <button type="submit">Find</button>
    </form>
    <p id="result" role="status"></p>
    </section>
    </main>
    <script type="module" src="find.js"></script>

    The <head> is the same as in index.html, with the title Find a ticket.

  2. Make bridge/find.js. Each console.log shows one step:

    bridge/find.js
    // Guided practice of Module 1: read one ticket by its ID and show it, or the API's error.
    const form = document.querySelector('#find');
    const result = document.querySelector('#result');
    form.addEventListener('submit', async (event) => {
    event.preventDefault();
    const id = new FormData(form).get('ticket_id');
    const url = `/v1/tickets/${encodeURIComponent(id)}`;
    console.log('1. request', url);
    result.textContent = 'Looking…';
    try {
    const response = await fetch(url);
    console.log('2. status', response.status);
    const body = await response.json();
    console.log('3. body', JSON.stringify(body).slice(0, 70) + '…');
    result.textContent = response.ok
    ? `${body.ticket_id}: ${body.subject} (${body.status}, ${body.messages.length} messages)`
    : `Not found: ${body.error.message}`;
    } catch (error) {
    result.textContent = `The API did not answer. Is it running? (${error.message})`;
    }
    });

    encodeURIComponent makes the typed text safe to put in an address.

  3. Open http://127.0.0.1:8100/find.html, open the Console tab, type T-80008, and select Find.

  4. Read the three lines in the Console, then the page.

The course team’s run printed these lines in the Console:

Console
1. request /v1/tickets/T-80008
2. status 200
3. body {"ticket_id":"T-80008","customer_id":"C-81265","customer_name":"Tariq …
The Find a ticket page: the field Ticket ID with T-80008, the Find button, and under it the answer T-80008: Hello, where is my order LK-581106? (open, 0 messages).
The worked example's answer for T-80008.

Step by step: the form’s value became the address /v1/tickets/T-80008. The API answered 200. The body is one ticket, with messages as an array, so body.messages.length is 0. Now type T-99999. The status is 404, response.ok is false, and the page shows Not found: There is no ticket T-99999., the API’s own message. The snapshot end-of-m01 does not have find.html and find.js: delete them, or keep them out of the way, when you are done.

Build a second page, bridge/reply.html with bridge/reply.js. Its form has a ticket ID and a reply text. It sends POST /v1/tickets/{id}/messages with the JSON body {"author": "agent", "body": "…"}. On success (201), it shows the new message’s message_id. On an error, it shows the API’s message and the wrong fields. If the API does not answer, it says so. Test three cases, and predict each answer first: a real reply to T-80008, a reply of three spaces, and a reply to T-99999.

Show a solution

bridge/reply.html is find.html with a second field: <label for="reply-body">Reply</label> and <textarea id="reply-body" name="body" required rows="3" maxlength="5000"></textarea>, a button Send reply, and the script reply.js:

bridge/reply.js
const form = document.querySelector('#reply');
const result = document.querySelector('#result');
form.addEventListener('submit', async (event) => {
event.preventDefault();
const data = new FormData(form);
const id = data.get('ticket_id');
result.textContent = 'Sending…';
try {
const response = await fetch(`/v1/tickets/${encodeURIComponent(id)}/messages`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ author: 'agent', body: data.get('body') }),
});
const body = await response.json();
if (response.ok) {
result.textContent = `Saved as message ${body.message_id} on ${id}.`;
form.reset();
} else {
result.textContent = `Not saved: ${body.error.message} ${body.error.fields.length ? `Fields: ${body.error.fields.join(', ')}` : ''}`.trim();
}
} catch (error) {
result.textContent = `The API did not answer. Is it running? (${error.message})`;
}
});

The course team’s run showed these answers:

Ticket and reply The page says
T-80008, We are checking with the courier. Saved as message 332 on T-80008.
T-80008, three spaces Not saved: The request is not valid. Fields: body
T-99999, Hello Not saved: There is no ticket T-99999.

Your message number can differ if you sent other messages before: the mock counts on from the highest number in its data. After the first reply, find.html shows T-80008 with 1 messages. Restart the API to reset it. Check your own solution against the same three cases, and also with the API stopped. The pages in this practice are not in the snapshot.

Check your understanding

The Elements panel shows ten list items, but View page source shows one item, Loading…. Explain the difference.

Show the answer

View page source shows the file index.html as the server sent it, with one item. The Elements panel shows the DOM: the browser’s live tree of the page, as it is now. app.js replaced the “Loading…” item with ten new elements in the DOM; the file never changed. When a page looks wrong, the DOM is what the user sees.

Check your understanding

Predict: you run npm run build after a typing mistake in src/api.ts (ticket.subjet). What happens, and does dist/ change?

Show the answer

The build stops at its first step. npm run build runs tsc --noEmit && vite build, and tsc fails with error TS2551: Property 'subjet' does not exist on type 'TicketSummary'. Did you mean 'subject'?. The && runs vite build only when the first command succeeds, so dist/ is not written again: it keeps the files of the last good build, or stays missing.

Check your understanding

curl gets the tickets from the mock API with Origin: http://evil.example. Is that a security hole in the API's CORS setting? Explain.

Show the answer

No. CORS is a rule that the browser follows to protect its user: it hides the answer from a page whose origin the API did not name. The API still answers every request. curl does not follow the rule, so it shows the data. CORS never protects the data from a client outside a browser. If the data must be protected, the API must check who is asking, with authentication and authorization, on the server.

Check your understanding

Module completion check: explain which code of the ticket desk runs in the browser, and which code must run on a server. Give one example of something that must never be in the browser, and say why.

Show the answer

In the browser: bridge/index.html, styles.css and app.js, and the TypeScript page after Vite’s build (dist/). The browser downloads them, builds the DOM, and runs the JavaScript on the user’s computer. On a server: the mock API (mock-api/server.mjs, with Node.js). It keeps the tickets, checks every request again and decides the answer. A web server sends the files (Vite while you work). The user controls the browser: they can read every line of the script, every VITE_ value and every request in the Network tab, and they can change or skip any check. So an API key or a password must never be in the browser. A rule that must hold, such as who may read a ticket, belongs on the server, even if the page checks it too.

Recap

  • The DOM is the browser’s live tree of the page; the Elements panel shows it, and the Network panel shows the requests.
  • npm installs the packages of package.json (TypeScript 7.0.2, Vite 8.3.4, Vitest 5.0.3) at the exact versions of package-lock.json. That is 38 packages and 64 MB, never committed.
  • Vite serves the page while you work (npm run dev, http://localhost:5173) and builds it for production (npm run build: 1.91 kB of JavaScript, 0.98 kB gzip).
  • TypeScript adds types that tsc --noEmit checks before the code runs: ticket.subjet became error TS2551. The browser never sees the types.
  • The page and the API have different origins, so the browser’s CORS check decides whether the page may read an answer. The API answers anyway: CORS protects the user, not the data.

Module 1 in one paragraph. You ran the mock ticket API on your computer with Node.js. You asked it for tickets with curl and the browser. You wrote the first page with semantic HTML, labelled fields and a responsive CSS grid. You made it work with JavaScript: objects and arrays, functions, modules, a submit event, and fetch with await. The page loads ten tickets and sends a new one (202, T-500001). Then you moved it to npm, Vite and TypeScript, with 25 passing tests and a 1.91 kB build. And you can say where each piece runs: the page in the browser, in public; the data and every rule that must hold, on the server.

Next, Module 2 builds the interface from React components.

Next: React basics