The previous module closed out the technical tour and ended with a promise: build CicloUrbano end to end, from scratch, with Vite and React Router. This lesson is the first step, and it's the one most people skip: before writing a single line of JSX you need to know what you're building, for whom, with what data, and with what technical decisions. A project that starts with npm create vite without having answered those questions ends up, without exception, being refactored halfway through.
You're going to do three things, in this order. First, define the product: the scope in one sentence, the two user personas, the stories with their acceptance criteria, and — just as important as the rest — the explicit list of what doesn't make it into the first version. Second, pin down the architecture decisions in a record that justifies every choice and names the alternative that was rejected, so that three months from now nobody has to reconstruct the reasoning. And third, set up the real scaffolding: Vite, dependencies, scripts, linter, formatter, environment variables, folder structure, a seeded json-server, and a first commit that already runs.
By the end you'll have a repository that clones, installs, and runs, with the development API responding and not a single screen written yet. The screens are the next lesson.
Contents
- The product in one sentence
- User personas and their goals
- User stories and acceptance criteria
- What's out of scope for the first version, and why
- The final data model
- The complete initial
db.json - Screen map and route tree
- The architecture decision record
- Creating the project with Vite
- Dependencies: what gets installed and why
- The complete
package.json - Code quality: ESLint, Prettier, and
.editorconfig - Environment variables with
import.meta.env - Folder structure: by type or by feature
- The development API:
json-serverup and running - Team conventions
- The work plan: vertical slices
- The first commit
- The product in one sentence
If you can't describe what you're building in one sentence, you don't yet know what you're building. This project's sentence:
CicloUrbano is a private web application that lets a customer book bikes from a station-based urban network, and lets an operator maintain the status of the fleet.
That sentence carries more information than it looks like:
| Fragment | What it decides |
|---|---|
| "web application" | It's not mobile (though 10-05 left the door open) |
| "private" | Everything interesting happens after signing in: there's no public content to index |
| "booking bikes" | The product's money path. Everything else serves it |
| "by station" | Stations are a navigation axis, not decoration |
| "maintaining the fleet's status" | There's a second role with different permissions |
The word "private" is the one that decides the rendering architecture, and it does so before any tool gets discussed. We'll come back to it in section 8.
- User personas and their goals
Two personas, not one more. A product with five profiles in its first version doesn't get any of them right.
| Customer | Operator | |
|---|---|---|
| Domain example | Ana Ribera (usr-01) |
Marc Solé (usr-02) |
| Main goal | Get a bike when they need one | Keep the fleet operational |
| Usage frequency | Short bursts, several times a week | Long sessions, daily |
| Context | Mobile, on the street, in a hurry | Desktop, at the workshop |
| What frustrates them | Not knowing if a bike will be available when they arrive | Not knowing which bike has been sitting idle for days |
| What they need to see first | Available bikes nearby | Bikes in maintenance |
| Permissions | View the catalogue, book, cancel their own | All of the above plus changing a bike's status |
This table produces two design consequences that aren't up for discussion afterward:
- The home screen is the catalogue, not a dashboard. The customer is the one who opens the app most, and does so in a hurry.
- The workshop is a separate screen with role-based access, not a hidden mode of the catalogue. Mixing both uses into one screen would force hiding controls by permission on every card, which is exactly the kind of complexity you pay for over years.
- User stories and acceptance criteria
A user story has a fixed shape — as an X I want Y so that Z — and it's only useful if it carries testable acceptance criteria. "The catalogue should work well" isn't a criterion; "filtering by electric only shows bikes of type electrica" is. In 11-04 each of these criteria turns into a test, which is why they're written this way starting today.
Must-haves
| ID | Story | Acceptance criteria |
|---|---|---|
| H1 | As a customer I want to see the bike catalogue so I know what's available | All 5 bikes are listed with model, type, status, station, and price/hour · While loading, a skeleton is shown, not text · If the API fails, there's an error message and a retry button |
| H2 | As a customer I want to filter by type and search by model so I can quickly find what I need | The filter lives in the URL (?tipo=electrica) and is shareable by link · The text search is local and debounced · Filter and search combine |
| H3 | As a customer I want to see a bike's detail page so I can decide whether to book it | Route /bicicletas/:bicicletaId · Shows station, price, and status · A nonexistent id gives its own 404, not a blank screen |
| H4 | As a user I want to sign in so I can access what's mine | Form with accessible validation · Signing in returns you to the originating screen, not to the home page · The session survives a reload |
| H5 | As a customer I want to book an available bike so I can use it | Form with bike, start time, hours, and terms · A bike that isn't disponible can't be booked · On creation, a success notice and a redirect to /reservas · The catalogue reflects the change |
| H6 | As a customer I want to see and cancel my bookings so I can manage my plans | /reservas lists only the signed-in user's own bookings · Cancelling asks for confirmation · The change shows instantly and is confirmed against the server |
| H7 | As an operator I want to change a bike's status so I can pull it from service or return it | /taller accessible only with role operario · A customer who navigates there directly sees "forbidden" · The change is reflected in the public catalogue |
| H8 | As a user I want to see the stations and their fleet so I can find my way around the city | /estaciones with all 3 stations · /estaciones/:estacionId with fleet and incident tabs · The active tab is in the URL |
Nice-to-haves
| ID | Story | Why it's nice-to-have, not a must-have |
|---|---|---|
| D1 | Light/dark theme with remembered preference | A real improvement, but nobody stops booking for lack of it |
| D2 | App-wide temporary success and error notices | You can start with inline messages; global notices polish the experience |
| D3 | Notice when the connection drops | Only matters on mobile, on the street; the case degrades acceptably without it |
| D4 | Fleet summary by status in the catalogue | Informative. Useful for the operator, dispensable for the customer |
The distinction isn't cosmetic: if the deadline tightens, you cut at D1-D4 without touching H1-H8. Having that line drawn before you start is what keeps a project from shipping ten half-finished things instead of eight finished ones.
- What's out of scope for the first version, and why
This list matters as much as the previous one, and it's worth writing down and sharing, because whatever isn't written down eventually gets requested halfway through the project.
| Out of scope | Reason |
|---|---|
| Real payments | Requires a payment gateway, regulatory compliance, and auditing. The price is shown and calculated; nothing is charged |
| User registration and password recovery | Users are seeded in db.json. Real sign-in is a server concern (11-05) |
| Geographic map of stations | Adds a heavy library and a service key. They're listed by district instead |
| Push notifications | Requires a server, permissions, and a service worker. Out |
| Stats dashboard | Neither persona has asked for it to reach their main goal |
| Internationalization | The application is for one city and one language. Noted as a roadmap item in 11-05 |
| Offline mode | High complexity, uncertain value until there are real users |
| Public showcase with SEO | That's public content: if it's ever built, it's built with Next.js (10-02), as a separate project |
Notice that last row. Deciding something is out of scope isn't deciding it will never happen: it's deciding it doesn't compete for this version's time, while also noting down how it would be done if the moment comes.
- The final data model
Four entities. You know them from the whole course; here they're pinned down along with their relationships.
erDiagram
STATION ||--o{ BIKE : "houses"
BIKE ||--o{ BOOKING : "is the subject of"
USER ||--o{ BOOKING : "makes"
STATION {
string id PK "est-01"
string name "Main Square"
string district "Downtown"
number docks "20"
}
BIKE {
string id PK "bici-001"
string model "Classic Urban"
string type "urbana | electrica | carga"
string status "disponible | alquilada | mantenimiento"
string stationId FK "est-01"
number pricePerHour "2.5"
}
USER {
string id PK "usr-01"
string name "Ana Ribera"
string email "[email protected]"
string role "cliente | operario"
}
BOOKING {
string id PK "res-01"
string bicicletaId FK "bici-002"
string user FK "usr-01"
string startDate "2026-05-04T09:00"
number hours "2"
string status "activa | confirmada | cancelada"
}
Three modeling decisions worth understanding before you start coding:
- Relationships are stored by identifier, never by nesting the whole object. A booking stores
bicicletaId, not a copy of the bike. If it nested the object, the booking's price would freeze at the moment of creation, and any change to the bike would leave out-of-sync copies scattered across the database. It's the same normalization applied to Redux state in 07-04, for the same reason. - Status is a string from a closed set, not a boolean.
disponible | alquilada | mantenimientoallows for a fourth status the day one is needed;disponible: true/falseforces you to add another boolean and reason about impossible combinations. - The booking field is called
userand holds an identifier. It's a naming inconsistency withbicicletaId, and it's kept because that's how it was fixed throughout the course: changing it now would breakvalidateBooking, the tests, and the MSW handlers. It's noted as minor technical debt in the record, which is exactly what you do in a real project with a harmless, already widespread inconsistency.
- The complete initial
db.json
db.jsonThis file is the development database and, at the same time, the seed that 11-04 will restore before every end-to-end test. It goes in the root of the repository.
{
"bicicletas": [
{ "id": "bici-001", "model": "Classic Urban", "type": "urbana", "status": "disponible", "stationId": "est-01", "pricePerHour": 2.5 },
{ "id": "bici-002", "model": "Electric Pro", "type": "electrica", "status": "alquilada", "stationId": "est-01", "pricePerHour": 4.0 },
{ "id": "bici-003", "model": "Cargo Max", "type": "carga", "status": "mantenimiento", "stationId": "est-02", "pricePerHour": 5.5 },
{ "id": "bici-004", "model": "Classic Urban", "type": "urbana", "status": "disponible", "stationId": "est-03", "pricePerHour": 2.5 },
{ "id": "bici-005", "model": "Electric Pro", "type": "electrica", "status": "disponible", "stationId": "est-02", "pricePerHour": 4.0 }
],
"estaciones": [
{ "id": "est-01", "name": "Main Square", "district": "Downtown", "docks": 20 },
{ "id": "est-02", "name": "North Park", "district": "North", "docks": 15 },
{ "id": "est-03", "name": "Central Station", "district": "Riverside", "docks": 30 }
],
"usuarios": [
{ "id": "usr-01", "name": "Ana Ribera", "email": "[email protected]", "role": "cliente" },
{ "id": "usr-02", "name": "Marc Solé", "email": "[email protected]", "role": "operario" }
],
"reservas": [
{ "id": "res-01", "bicicletaId": "bici-002", "user": "usr-01", "startDate": "2026-05-04T09:00", "hours": 2, "status": "activa" }
]
}The dataset is chosen so that every status and every edge case has at least one representative, which is the property any development dataset should have:
| Case you need to be able to test | Data that covers it |
|---|---|
| Bookable bike | bici-001, bici-004, bici-005 |
| Bike not bookable because it's rented | bici-002 |
| Bike not bookable because it's in maintenance | bici-003 |
| Two bikes with the same model (list keys) | bici-001 and bici-004 |
| Station with several bikes | est-01 and est-02 |
| Station with no available bikes | est-01 has one rented; est-02, one in maintenance |
| User with bookings | usr-01 |
| User with no bookings at all (empty state) | usr-02 |
| All three bike types | urban, electric, and cargo |
That second-to-last case is the one most often forgotten, and the one that produces the most bugs: if you never see the empty list in development, you don't design it, and the user opening the app for the first time hits a blank gap.
- Screen map and route tree
The routes were fixed back in module 6. Here we check that each one serves a story, and that no story is left without a screen.
flowchart TD
RAIZ["/ · Layout (Outlet)"]
RAIZ --> IDX["index · CataloguePage · H1 H2"]
RAIZ --> BICI["bicicletas/:bicicletaId · BikeDetailPage · H3"]
RAIZ --> EST["estaciones"]
EST --> ESTI["index · StationsPage · H8"]
EST --> DET[":estacionId · StationDetailPage · H8"]
DET --> FLO["index · FleetTab"]
DET --> INC["incidencias · IncidentsTab"]
RAIZ --> ACC["acceso · SignInPage · H4"]
RAIZ --> PROT["🔒 ProtectedRoute (no path)"]
PROT --> RES["reservas"]
RES --> RESI["index · BookingsPage · H6"]
RES --> NUE["nueva · NewBookingPage · H5"]
PROT --> ROL["🔒 RequireRole operario (no path)"]
ROL --> TAL["taller · WorkshopPage · H7"]
RAIZ --> NF["* · NotFoundPage"]
style PROT fill:#fde68a
style ROL fill:#fed7aa
style TAL fill:#fecaca
And the coverage table, which is what gets reviewed to spot gaps:
| Route | Screen | Story | Access |
|---|---|---|---|
/ |
CataloguePage |
H1, H2 | Public |
/bicicletas/:bicicletaId |
BikeDetailPage |
H3 | Public |
/estaciones |
StationsPage |
H8 | Public |
/estaciones/:estacionId |
StationDetailPage (+ tabs) |
H8 | Public |
/acceso |
SignInPage |
H4 | Public |
/reservas |
BookingsPage |
H6 | Signed in |
/reservas/nueva |
NewBookingPage |
H5 | Signed in |
/taller |
WorkshopPage |
H7 | Role operario |
* |
NotFoundPage |
— | Public |
| (route error) | RouteErrorPage via errorElement |
— | — |
| (forbidden) | ForbiddenPage |
H7 | — |
One detail that changes from module 6: /reservas now sits inside the protected branch. Back then it was left public so as not to complicate the examples; in the real project, viewing bookings requires a session, because they're personal data. It's exactly the kind of adjustment that surfaces while building this table, which is why you build the table.
- The architecture decision record
This is the most valuable document in the lesson. Each row records what gets decided, why, and what gets rejected. It's saved in the repository as DECISIONS.md and updated whenever something changes; a row is never deleted — a new one is added with its date instead.
| # | Decision | Why | Rejected alternative and reason |
|---|---|---|---|
| A1 | Vite + React Router, client-side application | The application is private, interactive, and behind sign-in: there's no SEO to gain and first paint isn't a business factor. Instant dev server startup and deployment as static files | Next.js: brings SSR/SSG that go unused here, and adds a server to maintain. Criterion applied in 10-02 |
| A2 | React Router v7 in data mode (createBrowserRouter + RouterProvider) |
Nested routes, an errorElement per branch, and declarative lazy loading |
BrowserRouter with <Routes>: no errorElement or data API. Rejected in 06-01 |
| A3 | TanStack Query for server state | Cache, deduplication, revalidation, loading/error states, and invalidation after mutating: everything you'd otherwise have to write by hand | Redux for everything: forces you to reimplement cache and lifecycle (07-06). useEffect + fetch: no cache, no deduplication |
| A4 | Redux Toolkit for shared client state (session, catalogue) | Granular selection, DevTools with time travel, pure reducers that are easy to test | Context for everything: repaints every consumer on any change (07-02) |
| A5 | Context for theme and notices | They change rarely and the whole tree needs them. It's exactly their use case | Redux: valid, but adds ceremony for two trivial pieces of data |
| A6 | The URL for the type filter | A filtered catalogue must be shareable by link and survive a reload | Local state or Redux: lost on reload and not shareable |
| A7 | CSS Modules | Local scoping with no dependencies, no runtime cost, supported by Vite out of the box | Tailwind: excellent, but adds configuration and its own learning curve. CSS-in-JS: runtime cost and friction with RSC (10-03) |
| A8 | Vitest + Testing Library + MSW + Cypress | Vitest shares configuration with Vite; the testing trophy from 09-01, applied as-is | Jest: requires its own transformer and duplicating configuration. No e2e: leaves out real routing, CSS, and persistence |
| A9 | JavaScript, with a planned migration to TypeScript | The team masters it and the first version has to ship. The structure is already compatible: types in one file, centralized validation | TypeScript from day one: better in the medium term, but slows the start. Planned for 11-05, building on 10-04 |
| A10 | json-server in development only |
Gives you a complete REST API over a JSON file in a minute | A custom API: out of scope. 11-05 documents what it would really take |
And the state assignment, which is the literal application of 07-01's taxonomy to this project:
| State type | Example in CicloUrbano | Tool | Why |
|---|---|---|---|
| Server | Bikes, stations, bookings, users | TanStack Query | It's a local copy of something remote; needs cache and revalidation |
| Shared client | Signed-in user, search term, sort order | Redux Toolkit | Several distant components read and write it |
| Global UI | Theme, notices | Context | Rarely changes, many consumers |
| URL | ?tipo=electrica, active tab |
React Router | Must be shareable and navigable |
| Local | Open modal, form draft | useState |
Nobody else needs it |
- Creating the project with Vite
With the record signed off, the scaffolding is mechanical.
What each line did:
npm create vite@latestdownloads and runs the official generator. The--separatesnpm's own arguments from the generator's; without it,npmwould try to interpret--templateas its own.--template reactpicks the React template with JavaScript.react-tsexists for TypeScript andreact-swcfor using SWC instead of Babel; with React 19 and the official plugin, the speed difference on a project this size is irrelevant.npm run devstarts the development server athttp://localhost:5173.
Check the React version before moving on, because the project assumes React 19:
npm ls react
# [email protected]
# └── [email protected]
- Dependencies: what gets installed and why
Nothing gets installed "just because." Every package answers to a row in the record.
# Production
npm install react-router @reduxjs/toolkit react-redux @tanstack/react-query
# Development
npm install -D @tanstack/react-query-devtools
npm install -D vitest jsdom @testing-library/react @testing-library/jest-dom @testing-library/user-event
npm install -D msw cypress start-server-and-test
npm install -D eslint-plugin-jsx-a11y prettier eslint-config-prettier
npm install -D json-server npm-run-all| Package | Where | What for | Decision |
|---|---|---|---|
react-router |
Production | Client-side routing (v7, single package) | A2 |
@reduxjs/toolkit |
Production | createSlice, configureStore, Immer included |
A4 |
react-redux |
Production | useSelector, useDispatch, Provider |
A4 |
@tanstack/react-query |
Production | Server state with caching | A3 |
@tanstack/react-query-devtools |
Development | Cache inspector. Doesn't ship in the production bundle | A3 |
vitest + jsdom |
Development | Test runner and simulated DOM | A8 |
@testing-library/* |
Development | Rendering, assertions, and realistic interaction | A8 |
msw |
Development | Network mocking at the request level | A8 |
cypress |
Development | End-to-end tests in a real browser | A8 |
start-server-and-test |
Development | Waits for Vite and the API to respond before launching Cypress | A8 |
eslint-plugin-jsx-a11y |
Development | Accessibility rules in the linter | Accessibility as a requirement |
prettier + eslint-config-prettier |
Development | Automatic formatting without fighting ESLint | Conventions |
json-server |
Development | Development REST API | A10 |
npm-run-all |
Development | Run Vite and the API in parallel with a single command | Convenience |
The distinction between dependencies and devDependencies isn't bureaucracy: whatever is in dependencies can end up in the bundle the user downloads. Putting json-server or Cypress there wouldn't break Vite's build, but it would bloat every production install and give a false signal about what the application actually needs to run.
- The complete
package.json
package.json{
"name": "ciclourbano",
"private": true,
"version": "1.0.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "vite build",
"preview": "vite preview",
"api": "json-server --watch db.json --port 3001",
"dev:all": "npm-run-all --parallel dev api",
"lint": "eslint . --max-warnings 0",
"format": "prettier --write \"src/**/*.{js,jsx,css,json}\"",
"format:check": "prettier --check \"src/**/*.{js,jsx,css,json}\"",
"test": "vitest",
"test:run": "vitest run",
"coverage": "vitest run --coverage",
"cy:open": "cypress open",
"cy:run": "cypress run",
"e2e": "start-server-and-test dev:all \"http://localhost:5173|http://localhost:3001/bicicletas\" cy:run",
"test:all": "npm-run-all lint test:run e2e"
},
"dependencies": {
"@reduxjs/toolkit": "^2.5.0",
"@tanstack/react-query": "^5.62.0",
"react": "^19.0.0",
"react-dom": "^19.0.0",
"react-redux": "^9.2.0",
"react-router": "^7.1.0"
},
"devDependencies": {
"@tanstack/react-query-devtools": "^5.62.0",
"@testing-library/jest-dom": "^6.6.0",
"@testing-library/react": "^16.1.0",
"@testing-library/user-event": "^14.5.0",
"@vitejs/plugin-react": "^4.3.0",
"@vitest/coverage-v8": "^2.1.0",
"cypress": "^13.17.0",
"eslint": "^9.17.0",
"eslint-config-prettier": "^9.1.0",
"eslint-plugin-jsx-a11y": "^6.10.0",
"eslint-plugin-react-hooks": "^5.1.0",
"eslint-plugin-react-refresh": "^0.4.0",
"jsdom": "^25.0.0",
"json-server": "^0.17.4",
"msw": "^2.7.0",
"npm-run-all": "^4.1.5",
"prettier": "^3.4.0",
"start-server-and-test": "^2.0.0",
"vite": "^6.0.0",
"vitest": "^2.1.0"
}
}The scripts deserve a comment, because they're the project's interface for anyone new who shows up:
| Script | What it does | When it's used |
|---|---|---|
dev |
Vite's dev server on 5173 | Daily |
api |
json-server on 3001 watching db.json |
Daily, in another terminal |
dev:all |
Both, in parallel | The everyday working command |
build |
Builds dist/ for production |
Before deploying (11-05) |
preview |
Serves dist/ to check it |
After build |
lint |
ESLint with zero warnings tolerated | On every commit and in CI |
format |
Prettier rewrites the files | On save, or before committing |
format:check |
Prettier only checks, doesn't write | In CI |
test |
Vitest in watch mode | While coding |
test:run |
Vitest runs once and exits | In CI |
coverage |
Coverage report | Periodic reviews |
e2e |
Boots everything, waits, and launches Cypress | In CI and before a delivery |
test:all |
Linter + unit + e2e, in that order | The full quality gate |
The --max-warnings 0 on lint is deliberate: a warning that breaks nothing piles up until there are a hundred and twenty of them and nobody looks anymore. Either they matter and fail the build, or the rule gets disabled.
- Code quality: ESLint, Prettier, and
.editorconfig
.editorconfigVite generates a basic eslint.config.js. Here's the project's version, with the two additions the record calls for: accessibility and hooks rules.
// eslint.config.js
import js from '@eslint/js';
import globals from 'globals';
import reactHooks from 'eslint-plugin-react-hooks';
import reactRefresh from 'eslint-plugin-react-refresh';
import jsxA11y from 'eslint-plugin-jsx-a11y';
import prettier from 'eslint-config-prettier';
export default [
{ ignores: ['dist', 'coverage', 'cypress/videos', 'cypress/screenshots'] },
{
files: ['**/*.{js,jsx}'],
languageOptions: {
ecmaVersion: 2022,
globals: globals.browser,
parserOptions: {
ecmaFeatures: { jsx: true },
sourceType: 'module'
}
},
plugins: {
'react-hooks': reactHooks,
'react-refresh': reactRefresh,
'jsx-a11y': jsxA11y
},
rules: {
...js.configs.recommended.rules,
...reactHooks.configs.recommended.rules,
...jsxA11y.configs.recommended.rules,
// An unused import is noise; an error variable is allowed to stay
'no-unused-vars': ['error', { varsIgnorePattern: '^[A-Z_]' }],
// Vite needs every module to export only components for fast refresh
'react-refresh/only-export-components': ['warn', { allowConstantExport: true }],
// Raised to error: these are the mistakes modules 5 and 3 spent the most time explaining
'react-hooks/rules-of-hooks': 'error',
'react-hooks/exhaustive-deps': 'error',
'jsx-a11y/label-has-associated-control': 'error',
'jsx-a11y/no-autofocus': 'warn'
}
},
prettier // ALWAYS last: it disables the style rules that clash with Prettier
];Three points worth understanding, not copying:
react-hooks/exhaustive-depsaserror, notwarn. It's the most debated setting in any React configuration, and here it's a deliberate choice: 05-02 showed that a missing dependency produces stale data that doesn't fail in development but does in production. As a warning, it gets ignored; as an error, it forces you to fix it or justify the exception with aneslint-disable-next-linecomment that stays visible in code review.jsx-a11yin recommended mode. It catches, right in the editor, the image with noalt, theonClickon adivwith no role or keyboard handling, and the label with no associated control. It doesn't replace the manual review from 03-06, but it removes 80% of the usual mistakes before they exist.prettiergoes last in the array.eslint-config-prettierdoesn't add rules: it disables the ESLint ones that collide with formatting. If it were placed earlier, the rules that follow would turn them back on, and you'd have the linter and the formatter fighting each other on every save.
// .prettierrc
{
"semi": true,
"singleQuote": true,
"printWidth": 100,
"trailingComma": "none",
"arrowParens": "always"
}# .editorconfig
root = true
[*]
charset = utf-8
end_of_line = lf
insert_final_newline = true
indent_style = space
indent_size = 2
trim_trailing_whitespace = true
[*.md]
trim_trailing_whitespace = false.editorconfig is what avoids the classic "the whole file shows up modified in the diff" when someone works on Windows: it fixes line endings and indentation at the editor level, before Prettier ever steps in.
# .gitignore
node_modules
dist
dist-ssr
coverage
*.local
# Environment: real .env files are ignored, the example is versioned
.env
.env.*
!.env.example
# Cypress
cypress/videos
cypress/screenshots
cypress/downloads
# Editor and system
.vscode/*
!.vscode/extensions.json
.idea
.DS_Store
*.log
- Environment variables with
import.meta.env
import.meta.envThe API's URL can't be hand-written into fifteen files. Vite exposes environment variables through import.meta.env, with one strict rule: only the ones that start with VITE_ reach the client code.
# .env.example (IS versioned: it's the template)
# Copy this file to .env and adjust the values.
# Base URL of the development REST API (json-server)
VITE_URL_API=http://localhost:3001
# Visible name of the application
VITE_NOMBRE_APP=CicloUrbano// src/config.js
export const API_URL = import.meta.env.VITE_URL_API ?? 'http://localhost:3001';
export const APP_NAME = import.meta.env.VITE_NOMBRE_APP ?? 'CicloUrbano';
export const IS_DEV = import.meta.env.DEV; // boolean Vite always injectsWhy a config.js module instead of reading import.meta.env wherever it's needed:
- A single source of truth. The day the variable's name changes, you touch one file.
- Default values in one place, with
??, so that cloning the repository and forgetting the.envdoesn't break startup. - It can be swapped out in tests with a
vi.mockof your own module; scatteredimport.meta.envcalls can't be.
And the warning that 11-05 will repeat in full: import.meta.env is text that gets embedded in the JavaScript the browser downloads. Anyone can read it in two clicks. A private API key, a password, or an admin token never goes there, not even "just temporarily while we test."
- Folder structure: by type or by feature
There are two schools of thought, and both are right in their context:
By type (components/, hooks/, pages/) |
By feature (bookings/, catalogue/, session/) |
|
|---|---|---|
| Finding a component by name | Immediate | You need to know which feature it belongs to |
| Working on a whole feature | You jump between four folders | Everything together |
| Deleting an entire feature | You have to hunt down files everywhere | You delete the folder |
| Small project (< 30 files) | Comfortable | Overkill |
| Large project or multiple teams | 60-file folders | Scales better |
| Typical risk | An unmanageable components/ |
Arguing over which feature each thing belongs to |
CicloUrbano adopts a hybrid, which is what most real projects of this size do: by type for what's shared, by feature for domain state. That last part was already decided back in 07-05.
ciclourbano/ ├── cypress/ │ ├── e2e/ # sign-in.cy.js, booking.cy.js, cancel.cy.js │ └── support/ # commands.js: cy.byTestId, cy.seedData, cy.signInAs ├── public/ # files served as-is (favicon, robots.txt) ├── src/ │ ├── api/ # client.js and per-resource functions. Knows nothing about React │ ├── store/ # store.js: configureStore and its composition │ ├── components/ # reusable, with no route of their own │ │ └── base/ # design system: Button, Field, Panel, Label, Modal │ ├── queries/ # queryClient.js, keys.js, and the TanStack Query hooks │ ├── contexts/ # ThemeProvider, notice contexts, Providers.jsx │ ├── data/ # domain.js: sample data until there's a real network (11-02) │ ├── features/ # client state organized by domain │ │ ├── catalogue/ # catalogueSlice.js and its selectors │ │ ├── bookings/ # bookingsSlice.js and its selectors │ │ └── session/ # sessionSlice.js and its selectors │ ├── hooks/ # useToggle, useLocalStorage, useDebounce, useKeyEvent… │ ├── pages/ # one per route: CataloguePage, BookingsPage… │ ├── tests/ # setup.js, utils.jsx, handlers.js, server.js │ ├── utils/ # classNames.js, validateBooking.js, monitoring.js, availability.js │ ├── config.js # single read of import.meta.env │ ├── routes.jsx # createBrowserRouter: the full map │ ├── index.css # reset + :root variables + dark theme │ └── main.jsx # provider composition ├── .editorconfig ├── .env.example ├── .gitignore ├── .prettierrc ├── DECISIONS.md # the record from section 8 ├── db.json # development database and test seed ├── eslint.config.js ├── package.json ├── README.md └── vite.config.js
Two placement rules that avoid most arguments:
- A test file lives next to the code it tests (
BikeCard.jsxandBikeCard.test.jsxin the same folder). Only the testing infrastructure lives insrc/tests/. That way, deleting a component takes its test with it. - A component moves up to
components/when a second screen uses it, not before. Until then, it lives next to its page. Generalizing from a single use case produces the wrong abstractions.
- The development API:
json-server up and running
json-server up and running \{^_^}/ hi!
Loading db.json
Done
Resources
http://localhost:3001/bicicletas
http://localhost:3001/estaciones
http://localhost:3001/usuarios
http://localhost:3001/reservasMandatory check before moving on. If this doesn't respond, no screen in 11-03 will work, and you'll waste an hour looking for the bug in React:
# Full list
curl http://localhost:3001/bicicletas
# Filter by field: json-server gives you this for free
curl "http://localhost:3001/bicicletas?tipo=electrica"
# One specific resource
curl http://localhost:3001/bicicletas/bici-003
# Create (what H5 will do)
curl -X POST http://localhost:3001/reservas \
-H "Content-Type: application/json" \
-d '{"id":"res-99","bicicletaId":"bici-001","user":"usr-01","startDate":"2026-06-01T10:00","hours":2,"status":"activa"}'
# And undo the experiment
curl -X DELETE http://localhost:3001/reservas/res-99What json-server gives you out of the box, and what the project is going to use:
| Capability | Example | Used in |
|---|---|---|
| Listing | GET /bicicletas |
H1 |
| Detail | GET /bicicletas/bici-001 |
H3 |
| Filter by field | GET /bicicletas?tipo=urbana |
H2 |
| Filter by relation | GET /reservas?user=usr-01 |
H6 |
| Creation | POST /reservas |
H5 |
| Partial update | PATCH /bicicletas/bici-001 |
H7 |
| Real 404 | GET /bicicletas/no-existe |
H3 |
And what it doesn't give you, and you need to keep in mind from now on: it validates nothing, authenticates nobody, and checks no permissions. A PATCH from the browser console changes any bike's status with no session at all. That's acceptable in development, and it's why 11-05 insists that real authorization is always checked on the server.
- Team conventions
Written down in README.md, because a convention that only lives in the head of whoever invented it isn't a convention.
Names
| Element | Convention | Example |
|---|---|---|
| Component | PascalCase, .jsx file matching the component |
BikeCard.jsx |
| Page | Page suffix |
NewBookingPage.jsx |
| Hook | use + camelCase |
useLocalStorage.js |
| Utility | camelCase, named export |
validateBooking.js |
| Styles | Component.module.css next to the component |
BikeCard.module.css |
| Internal handler | handleX |
handleSubmit |
| Callback prop | onX |
onSelect |
| Redux action | Past-tense phrase | bookingConfirmed |
| Selector | selectX |
selectUser |
| Files | Plain ASCII: no accents or special characters | Layout.jsx |
Exports: export default for components and pages; named exports for hooks, utilities, slices, and selectors. Mixing the two criteria within the same file type is what produces inconsistent imports.
Import order, always the same, with a blank line between groups:
// 1. React and external libraries
import { useState } from 'react';
import { useNavigate } from 'react-router';
import { useSelector } from 'react-redux';
// 2. Your own modules, from most general to most specific
import { useCreateBooking } from '../queries/bookings.js';
import { validateBooking } from '../utils/validateBooking.js';
import BookingForm from '../components/BookingForm.jsx';
// 3. Styles, always last
import styles from './NewBookingPage.module.css';Commit messages, in conventional format, in English and in the imperative:
feat(catalogue): filter bikes by type from the URL fix(bookings): prevent booking a bike that's in maintenance test(form): cover the accessible validation messages refactor(api): extract the fetch wrapper to src/api/client.js docs(readme): document startup with dev:all chore(deps): bump vitest to 2.1
The prefix isn't decoration: it lets you generate the changelog automatically and, above all, it forces a commit to do exactly one thing. If you're torn between feat and fix, the commit probably contains two changes and needs to be split.
- The work plan: vertical slices
Here's the methodology decision that most influences how the project feels while it's being built.
| By layer (horizontal) | By vertical slice | |
|---|---|---|
| Work order | All the components → the whole API → all the tests | One complete story end to end, then the next |
| First possible demo | At the end | When the first story is done |
| Risk discovered | Late, once everything is written | Early, at the first integration |
| Sense of progress | None for weeks | Continuous |
| Real risk | Discovering in the last week that the model doesn't fit | Refactoring something already done once the third story arrives |
Vertical is the choice, with an honest caveat: this module is organized by layer — interface, state, testing, deployment — because explaining requires grouping concepts, while building requires delivering value early. It's an important distinction worth keeping straight: the order of the book isn't the order of the workshop.
Even so, within each lesson the work happens by complete stories. And if you were building this project on a real team, the order would be this:
flowchart LR
subgraph L1["11-01 · Scaffolding"]
A1["Product and record"] --> A2["Vite, linter, environment"] --> A3["db.json + live API"]
end
subgraph L2["11-02 · Interface"]
B1["Design system"] --> B2["Layout + routes"] --> B3["Screens with static data"]
end
subgraph L3["11-03 · State and API"]
C1["Data layer"] --> C2["Query + mutations"] --> C3["Redux, context, URL"]
end
subgraph L4["11-04 · Testing"]
D1["Unit"] --> D2["Components"] --> D3["Integration"] --> D4["E2E + CI"]
end
subgraph L5["11-05 · Production"]
E1["Build and environments"] --> E2["Deployment"] --> E3["Monitoring"]
end
L1 --> L2 --> L3 --> L4 --> L5
And the order of the stories, prioritized by risk first:
- H4 (sign-in) before anything else: it's the gateway to everything else and touches session, form, redirect, and persistence. If something's going to go wrong in the architecture, it goes wrong here.
- H1 + H2 (catalogue and filter): the most-visited screen, and the one that validates the data layer.
- H5 (booking): the money path. Touches mutation, validation, invalidation, and navigation.
- H6 (my bookings and cancel): reuses almost everything before it.
- H3, H8 (detail page and stations): pure reads, low risk.
- H7 (workshop): the role gets tested with everything else already standing.
- D1-D4 if there's time left.
The rule governing the list: whatever can sink the project goes first. Leaving sign-in for last is the classic mistake, because it's exactly the feature that cuts across every layer and forces you to redo what was already considered done.
- The first commit
An initial commit has to satisfy one condition: whoever clones it can start it up.
git init
git add .
git commit -m "chore: initial CicloUrbano scaffolding with Vite, linter, and development API"And the README.md that ships with it, which is the first thing anyone reads:
# CicloUrbano Web application for renting urban bikes by station. ## Requirements - Node.js 20 or higher ## Getting started
npm install cp .env.example .env npm run dev:all # Vite on :5173 and json-server on :3001
## Scripts | Command | What it does | |---|---| | `npm run dev:all` | App and API in parallel | | `npm run lint` | ESLint with zero tolerance for warnings | | `npm test` | Vitest in watch mode | | `npm run e2e` | Cypress against the running application | | `npm run build` | Production build in `dist/` | ## Documentation - `DECISIONS.md`: architecture decision record - `db.json`: development data and test seed
The checklist before calling the scaffolding done:
- [ ]
npm installworks on a clean clone - [ ]
npm run devserves the application on 5173 - [ ]
npm run apiresponds on 3001 with all four resources - [ ]
npm run lintfinishes with no errors or warnings - [ ]
.envis ignored and.env.exampleis versioned - [ ]
DECISIONS.mdhas all ten rows of the record - [ ]
README.mdlets you start up without asking anyone anything
Common Mistakes and Tips
- Starting with the code and planning on the fly. This is the underlying mistake of the whole lesson. Without stories with acceptance criteria you don't know when you're done, and without a decision record, every technical discussion repeats itself every two weeks. Half a day of planning saves weeks.
- Stories with no testable criteria. "The catalogue should be fast" can't be verified or turned into a test. "The catalogue shows the skeleton while loading and the list as soon as the data arrives" can.
- Not writing down what's out of scope. Scope that isn't explicitly denied is assumed included. The table in section 4 is what protects the delivery.
- Nesting objects in the data model. Storing the whole bike inside the booking feels convenient on day one and produces out-of-sync data on day two. Identifiers, always.
- A development dataset with no edge cases. If every bike is available and every user has bookings, you'll never see the empty state or the disabled button, and they'll reach production broken.
- Putting development tools in
dependencies. Cypress andjson-serverin production are a symptom that nobody has looked atpackage.jsonsince it was generated. - Leaving
exhaustive-depsas a warning. Over time, dozens pile up and nobody reads them anymore. As an error, it gets resolved or justified with a visible comment. - Putting
eslint-config-prettieranywhere but last. It stops having any effect, and you end up with the linter and the formatter fighting on every save. - Secrets in
VITE_variables. Everything that starts withVITE_travels to the browser in plain text. There's no exception, not even "just while we test." - Tip: create
DECISIONS.mdfrom day one and add a row every time you discuss a technical choice. The value isn't in the document, it's in never having the same conversation twice. - Tip: verify the API with
curlbefore writing the firstfetch. Ruling out half the system in thirty seconds saves incredibly long debugging sessions.
Exercises
Exercise 1. The client requests a new story: "as a customer I want to receive a notice 10 minutes before my booking ends." Write it using the format from section 3, with testable acceptance criteria. Then decide whether it's a must-have or a nice-to-have, and whether it makes it into the first version, justifying your answer with the scope from section 1 and the table from section 4. If you decide to leave it out, write the corresponding row.
Exercise 2. The team proposes adding an Incidencia (Incident) entity for the incidents tab of /estaciones/:estacionId, holding the details of a reported fault. Define its fields and relationships, add it to the entity-relationship diagram and to db.json with two example records consistent with the canon, and explain which new resources would appear in json-server. Also state which TanStack Query key would correspond to it, following the project's factory.
Exercise 3. A teammate proposes three changes to the record: (a) using BrowserRouter with <Routes> because "it's simpler," (b) storing the bike list in Redux "to keep everything in one place," and (c) putting a map service key in VITE_CLAVE_MAPAS to use it from the client. Answer each one with the corresponding technical argument and say which row of the record covers it. For the third, also explain what it would take to use that service without exposing the key.
Solutions
Solution 1.
| ID | Story | Acceptance criteria |
|---|---|---|
| D5 | As a customer I want to receive a notice 10 minutes before my booking ends so I can return the bike on time | With a booking that is activa or confirmada and whose end time is less than 10 minutes away, a persistent notice shows the remaining time and a link to the booking · The notice disappears when the booking ends or is cancelled · If several bookings are about to expire, the closest one is shown · The calculation uses startDate + hours and updates every minute |
Classification: nice-to-have (D5), out of the first version. The three arguments:
- It doesn't block either persona's main goal. Ana can book and use the bike without the notice; Marc doesn't need it at all.
- The genuinely useful notice is the one that arrives with the app closed, and that means push notifications, which are explicitly out of scope because they require a server, permissions, and a service worker. A notice that only shows with the tab open solves a small fraction of the real problem and can create a false sense of coverage.
- It requires a global timer that re-evaluates every minute and a source of truth for the current time, which complicates testing (you have to freeze the clock) for a marginal benefit in this version.
Row for the table in section 4:
| Out of scope | Reason |
|---|---|
| Booking-end notices | The valuable version requires push notifications, which are already out of scope. The in-tab variant covers few real cases and adds a global timer that's hard to test. Reconsidered once there's a real server (11-05) |
And the honest note: once the real API arrives, this story is promoted to must-have, because the business penalizes late returns and warning the customer is cheaper than charging surcharges.
Solution 2.
Fields and relationships. An incident belongs to one bike (which in turn is at a station) and is reported by one user:
erDiagram
BIKE ||--o{ INCIDENT : "accumulates"
USER ||--o{ INCIDENT : "reports"
INCIDENT {
string id PK "inc-01"
string bicicletaId FK "bici-003"
string reportedBy FK "usr-01"
string date "2026-05-02T18:30"
string type "frenos | rueda | bateria | otro"
string description "El freno trasero patina"
string status "abierta | en_curso | resuelta"
}
Records for db.json, consistent with the canon — bici-003 is in maintenance, so it's the natural candidate to have an open incident, and bici-002 can have one already resolved:
{
"incidencias": [
{
"id": "inc-01",
"bicicletaId": "bici-003",
"reportedBy": "usr-01",
"date": "2026-05-02T18:30",
"type": "frenos",
"description": "The rear brake slips under load.",
"status": "abierta"
},
{
"id": "inc-02",
"bicicletaId": "bici-002",
"reportedBy": "usr-02",
"date": "2026-04-28T09:15",
"type": "bateria",
"description": "The battery didn't reach the advertised 60% range.",
"status": "resuelta"
}
]
}Important modeling decision: the incident is associated with the bike, not the station, even though the tab that shows it belongs to a station. The reason is that a fault travels with the bike: if bici-003 moves to est-03, its history has to go with it. A station's incidents tab is then obtained by deriving: that station's bikes, and those bikes' incidents. Modeling the relationship the other way around would force rewriting the stationId of every incident on every transfer.
Resources that show up in json-server:
| Request | Use |
|---|---|
GET /incidencias |
All of them |
GET /incidencias?bicicletaId=bici-003 |
One bike's history |
GET /incidencias?status=abierta |
The workshop's work queue (H7) |
POST /incidencias |
Report a new one |
PATCH /incidencias/inc-01 |
Change its status |
And the query key, making use of the fact that the project's factory already anticipated it:
// src/queries/keys.js
stations: {
all: () => ['stations'],
detail: (id) => ['stations', id],
incidents: (id) => ['stations', id, 'incidents'] // already existed
},
incidents: {
all: () => ['incidents'],
byBike: (bicicletaId) => ['incidents', { bike: bicicletaId }]
}The hierarchy matters: invalidating ['stations', 'est-02'] also invalidates ['stations', 'est-02', 'incidents'], because TanStack Query compares keys by prefix. That's exactly the behavior you want when resolving an incident.
Solution 3.
(a) BrowserRouter with <Routes>. Covered by row A2. It's true that it's simpler to write, but the project needs three things that mode doesn't give you:
- An
errorElementper branch: without it, a failure loading a bike's detail page brings down the whole application instead of leaving the header and menu standing. It's the difference between a blank screen and a contained error. - Route-level
lazywith the router managing the loading, which is what makes the code splitting from 08-04 possible without wrapping every screen by hand. - The data API (
useNavigation,useRouteError,handlefor breadcrumbs), already used inBreadcrumbs.
And the decisive argument: migrating later costs more than starting right, because it means rewriting the whole route tree once there are already screens built on top of it.
(b) The bike list in Redux. Covered by A3 and by the state assignment table. "Keeping everything in one place" sounds tidy, but it mixes two things of a different nature: bikes are server state, a local copy of data that lives on another machine and can change without the application knowing. Putting them in Redux forces you to write, and maintain, all of this by hand:
| Need | With TanStack Query | With Redux by hand |
|---|---|---|
| Cache by key | Included | A slice with its own structure |
| Deduplicating simultaneous requests | Included | A condition in the thunk |
| Loading and error states | isPending, isError |
Three fields per resource in extraReducers |
| Revalidation on tab refocus | refetchOnWindowFocus |
Your own effect with visibilitychange |
| Stale data and background refresh | staleTime |
Doesn't exist: either you have the data or you don't |
| Invalidating after a mutation | invalidateQueries |
Dispatch and reload by hand |
It's exactly the work 07-06 showed isn't worth rewriting. Redux keeps what's genuinely its own: session, search term, and sort order.
(c) The map service key in VITE_CLAVE_MAPAS. This is the most serious of the three. Everything that starts with VITE_ gets substituted literally into the code at build time and ends up in a file in dist/ that anyone can open:
No amount of obfuscation fixes this: the browser has to be able to read it to use it, so the user can too. The consequences are usage billed to your account and, depending on the service, access to data that shouldn't be exposed.
What to do instead, in order of preference:
- Make the request to the service leave from the server. The client calls your API, your API calls the service with the key and returns the result. The key never leaves the machine.
- If the service is meant for the client — many map providers offer public keys — use that kind of key and restrict it in the provider's dashboard by origin domain, quota, and minimum permissions. It's still visible, but it only works from your domain and with a spending cap.
- Never use a key with write or billing permissions from the client, under any circumstances.
And since the exercise starts from a story that's out of scope — the geographic map — the complete answer includes pointing that out: there's no need to solve the key problem yet, because the feature isn't part of this version.
Conclusion
This lesson has turned an idea into a project ready to start building, and it's done so in the right order: first the product, then the decisions, and only at the end the tools.
From the product, the essentials are fixed: CicloUrbano in one sentence that already decides the architecture by declaring itself private, two user personas with different goals and contexts that justify why the home screen is the catalogue and the workshop is a separate screen, eight must-have stories and four nice-to-haves with testable acceptance criteria — which in 11-04 literally become tests — and an explicit list of what's out of scope with its reasoning, which is what protects the delivery when someone proposes adding a map halfway through.
From the domain, what remains is the final model with its four entities and three rules honored without exception: relationships by identifier and never nested objects, statuses as a string from a closed set instead of booleans, and inherited inconsistencies noted as debt instead of fixed at the wrong time. db.json isn't a pile of filler data: every status, every edge case, and the empty state each have their representative, because what you don't see in development doesn't get designed.
From the technical direction, what remains is the decision record, ten rows with their justification and their rejected alternative: Vite instead of Next.js because the application is private and interactive; React Router in data mode for errorElement, lazy, and the data API; TanStack Query for the server and Redux Toolkit for the client, with context for theme and notices and the URL for the filter; CSS Modules; the Vitest, Testing Library, MSW, and Cypress quartet; and JavaScript now with the door open to TypeScript. Alongside the record, the state assignment table that answers in advance the question that comes up most often in a React project: where does this piece of data live?
And from the scaffolding, what remains is a working repository: Vite with React 19, dependencies chosen one by one and cleanly split between production and development, a package.json with fourteen scripts that make up the project's interface, ESLint with jsx-a11y and exhaustive-deps raised to an error, Prettier last in the chain, .editorconfig, .gitignore, environment variables centralized in config.js with a versioned .env.example and no secrets, a hybrid folder structure — by type for what's shared, by feature for domain state — json-server seeded and verified with curl, the team's conventions written down in README.md, and a work plan organized in vertical slices that tackles the highest-risk work first.
React Course
Module 1: Getting Started with React
- What Is React?
- Setting Up the Development Environment
- Hello World in React
- JSX: A JavaScript Syntax Extension
- How React Renders: Virtual DOM and Reconciliation
Module 2: React Components
- Understanding Components
- Function vs Class Components
- Props: Passing Data to Components
- State: Managing Component State
- Styling Components: CSS, Modules and Utilities
Module 3: Working with Events
- Handling Events in React
- Conditional Rendering
- Lists and Keys
- Forms and Controlled Components
- Form Validation and Uncontrolled Components
- Accessibility in Interactive Components
Module 4: Advanced Component Concepts
- Lifting State Up
- Composition vs Inheritance
- React Lifecycle Methods
- Hooks: Introduction and Basic Use
- Error Boundaries: Catching Failures in the UI
Module 5: React Hooks
- The useState Hook
- The useEffect Hook
- The useRef Hook and DOM Access
- The useContext Hook
- The useReducer Hook
- Custom Hooks
Module 6: Routing in React
- Introducing React Router
- Setting Up React Router
- Nested Routes
- Programmatic Navigation
- Protected Routes and Access Control
Module 7: State Management
- Introduction to State Management
- The Context API
- Redux: Introduction and Setup
- Redux: Actions and Reducers
- Redux: Connecting to React
- Server State: Fetching, Caching and Syncing
Module 8: Performance Optimization
- Performance Optimization Techniques in React
- Memoization with React.memo
- The useMemo and useCallback Hooks
- Code Splitting and Lazy Loading
- Measuring Performance with React DevTools Profiler
Module 9: Testing React Applications
- Introduction to Testing
- Unit Testing with Jest
- Component Testing with React Testing Library
- Testing Asynchronous Code and Mocking APIs
- End-to-End Testing with Cypress
Module 10: Advanced Topics
- Server-Side Rendering (SSR) with Next.js
- Static Site Generation (SSG) with Next.js
- Suspense and React Server Components
- TypeScript with React
- React Native: Building Mobile Apps
