CicloUrbano works and is tested, but it lives on localhost, with a database that's a JSON file and a dev server that should never be exposed to the internet. This lesson covers the last stretch: turning the project into a package of static files, understanding what can and can't be hidden in an application that runs in someone else's browser, solving the routing problem that makes /reservas return a 404 the moment it leaves localhost, deploying it, and knowing what to watch afterward. And since it's the course's last lesson, it also closes out the path traveled and points to where to go next.
Contents
- The production build
- Reviewing the bundle size before publishing
- Environment variables: what's actually public
- The 404 on reload: client-side routing on a static server
- Step-by-step deployment on a static platform
- The container alternative: Docker and Nginx
- The API in production: what's genuinely missing
- After deployment: monitoring and real metrics
- Launch checklist
- Next steps for this project
- How to keep learning
- The Production Build
Up to now everything has happened with npm run dev, where Vite serves modules unbundled and hot-reloads. That's convenient for development and it's not what gets published. The build is a different thing:
vite v6.0.5 building for production... ✓ 412 modules transformed. dist/index.html 0.48 kB │ gzip: 0.31 kB dist/assets/index-B7dK2p9x.css 14.82 kB │ gzip: 3.41 kB dist/assets/WorkshopPage-Qm3rT8vc.js 6.12 kB │ gzip: 2.18 kB dist/assets/StationDetailPage-Nx7.js 8.94 kB │ gzip: 3.02 kB dist/assets/NewBookingPage-Zk1p.js 11.37 kB │ gzip: 3.86 kB dist/assets/index-Yt4mR9Ka.js 268.41 kB │ gzip: 87.65 kB ✓ built in 3.42s
What it did, step by step:
| Task | What it means |
|---|---|
| Bundling | Hundreds of modules are combined into a handful of files, so the browser doesn't make hundreds of requests |
| Minification | Whitespace, comments, and long local variable names are stripped out |
| Dead code elimination | Anything not imported from anywhere doesn't get included |
| Code splitting into chunks | Every route with lazy produces its own file, as set up in Code Splitting |
| React's production mode | Development warnings, extra checks, and StrictMode's double render all disappear |
| Hash in the filename | index-Yt4mR9Ka.js carries a fingerprint of its content |
That hash is the piece most often misunderstood. It's what lets you tell the server "cache these files forever" without condemning the user to a stale version: if the content changes, the name changes, and the browser requests it as if it were a brand-new file. The practical consequence is a two-speed caching rule:
| File | Cache header | Why |
|---|---|---|
index.html |
no-cache (or very short) |
It's the only one without a hash: it's the one that points to the rest |
assets/* with a hash |
max-age=31536000, immutable |
Its name changes if its content changes; caching it for a year is safe |
Before publishing, you verify the build as it is, not the dev server:
This is the moment to check three things that are only visible here: that there are no console errors once the dev warnings are gone, that lazy chunks download as you navigate (check the network tab), and that each route's Suspense shows its skeleton instead of a blank jump.
- Reviewing the Bundle Size Before Publishing
The report above already gives the figure that matters: 87.65 kB compressed for the main bundle. The useful review isn't "is that a lot?" in the abstract, but comparing it against the budget you set and pinpointing who's taking up what:
A typical breakdown for this project:
| Dependency | Approx. (gzip) | Can it be reduced? |
|---|---|---|
react + react-dom |
~45 kB | No: it's the engine |
react-router |
~12 kB | No, and it already splits by route |
@reduxjs/toolkit + react-redux |
~15 kB | Only by removing Redux, which was justified here |
@tanstack/react-query |
~13 kB | No: it replaces a lot of custom code |
| CicloUrbano's own code | ~3 kB | Already split by route |
The lesson: in a well-split application, the weight is almost all dependencies, and the lever left to pull isn't better minification, it's choosing fewer libraries. If the budget is blown, the conversation is about architecture, not configuration.
- Environment Variables: What's Actually Public
Vite only exposes to the code the variables that start with VITE_, and it substitutes them at build time:
# .env.development
VITE_API_URL=http://localhost:3001
VITE_ENTORNO=development
# .env.production
VITE_API_URL=https://api.ciclourbano.example
VITE_ENTORNO=production// src/config.js — a single point of reading, as decided in 11-01
export const config = {
apiUrl: import.meta.env.VITE_API_URL,
environment: import.meta.env.VITE_ENTORNO,
isProduction: import.meta.env.PROD,
};And now the most important warning in this lesson, because it's a mistake made often and it has real consequences:
Anything you put in
import.meta.envends up written, in plain readable text, inside the JavaScript files that anyone visiting the site downloads. It isn't a "server-side" variable: it's a constant baked into the bundle. Anyone can open the browser's dev tools and read it.
# See for yourself after building:
grep -r "api.ciclourbano.example" dist/assets/
# → it shows up, verbatim, in the bundleFrom that follows a clear boundary:
Can go in VITE_* |
Can never go there |
|---|---|
| The API's public URL | API keys with write permissions |
| Public identifier of an analytics service | OAuth client secrets |
| Environment name, version, feature flags | Database credentials |
| Publishable key of a payment gateway | Secret key of the same gateway |
Anything that needs a secret needs a server to hold it and make the call for you. There's no shortcut. And .env.production is never committed: what goes in the repository is .env.example with the keys and no values, and the real values are configured on the deployment platform.
- The 404 on Reload: Client-Side Routing on a Static Server
This is the bug that catches everyone off guard on their first single-page application deployment. Locally it works; once deployed, navigating directly to https://ciclourbano.example/reservas returns a 404.
The reason is that there are two separate entities resolving routes, and only one of them knows yours:
sequenceDiagram
participant N as Browser
participant S as Static server
participant R as React Router
Note over N,R: Internal navigation: works
N->>R: click on "My bookings"
R->>N: renders BookingsPage and changes the URL
Note over N,R: Reload or direct link: 404
N->>S: GET /reservas
S->>S: does the file /reservas exist?
S--xN: 404 · React Router never got to run
React Router lives inside the JavaScript that index.html loads. If the server doesn't serve index.html, there's no React, no Router, and no route. The solution is always the same idea — return index.html for any route that isn't a real file — with different syntax on each platform:
| Platform | File | Content |
|---|---|---|
| Netlify | public/_redirects |
/* /index.html 200 |
| Vercel | vercel.json |
See below |
| GitHub Pages | public/404.html |
A copy of index.html (it doesn't support rewrites) |
| Nginx | server block |
try_files $uri $uri/ /index.html; |
| Apache | public/.htaccess |
See below |
| Cloudflare Pages | public/_redirects |
Same as Netlify |
// vercel.json
{
"rewrites": [{ "source": "/(.*)", "destination": "/index.html" }],
"headers": [
{
"source": "/assets/(.*)",
"headers": [{ "key": "Cache-Control", "value": "public, max-age=31536000, immutable" }]
}
]
}# public/.htaccess
<IfModule mod_rewrite.c>
RewriteEngine On
RewriteBase /
# If the file or directory exists, serve it as is
RewriteCond %{REQUEST_FILENAME} -f [OR]
RewriteCond %{REQUEST_FILENAME} -d
RewriteRule ^ - [L]
# Anything else: serve index.html and let React Router decide
RewriteRule ^ index.html [L]
</IfModule>Notice the detail of the 200 status code in Netlify: it's a rewrite, not a redirect. The URL the user sees stays /reservas, which is exactly what React Router needs to read. With a 301 or a 302 the URL would change to / and the user would end up on the catalogue.
And the check you should never skip after deploying: open a deep route in a new tab and reload it. Navigating there from the home page proves nothing, because that path never touches the server.
- Step-by-Step Deployment on a Static Platform
The project is static files, so almost any hosting works. With a platform connected to the repository, deployment becomes automated:
Step 1. Push the project to a remote repository (GitHub, GitLab). The CI workflow written in the previous lesson starts running on every change.
Step 2. Connect the repository to the platform and configure the build:
| Setting | Value |
|---|---|
| Build command | npm run build |
| Publish directory | dist |
| Node version | 20 (the same as in CI, so you don't discover differences late) |
| Install command | npm ci |
Step 3. Add the environment variables in the platform's dashboard: VITE_API_URL with the real API URL and VITE_ENTORNO=production. Never in the repository.
Step 4. Add the rewrite file from the previous section. Without it, the deployment "works" until someone shares a link.
Step 5. Deploy and check, in this order: the home page loads; a deep route reloaded in a new tab works; the catalogue pulls data from the real API; a nonexistent route shows your not-found page; the workshop panel is still off-limits to a customer; and the console is clean.
Step 6. Turn on per-branch preview deployments. Every branch and every pull request gets its own temporary URL, so reviews stop being "trust me" and become "try it here". It's, by far, the feature that changes team workflow the most.
- The Container Alternative: Docker and Nginx
If the deployment goes to the company's own infrastructure, the equivalent form is an image that builds and serves:
# Dockerfile
# --- Stage 1: build ---
FROM node:20-alpine AS build
WORKDIR /app
# Copying the manifests first takes advantage of layer caching:
# if they haven't changed, dependencies aren't reinstalled
COPY package*.json ./
RUN npm ci
COPY . .
ARG VITE_API_URL
ENV VITE_API_URL=$VITE_API_URL
RUN npm run build
# --- Stage 2: serve ---
# The final image ships neither Node nor node_modules: just static files and Nginx
FROM nginx:alpine AS serve
COPY --from=build /app/dist /usr/share/nginx/html
COPY nginx.conf /etc/nginx/conf.d/default.conf
EXPOSE 80
CMD ["nginx", "-g", "daemon off;"]# nginx.conf
server {
listen 80;
root /usr/share/nginx/html;
gzip on;
gzip_types text/css application/javascript application/json image/svg+xml;
# Hashed files can be cached indefinitely
location /assets/ {
expires 1y;
add_header Cache-Control "public, immutable";
}
# index.html is never cached: it's the one pointing to the new assets
location = /index.html {
add_header Cache-Control "no-cache";
}
# The key piece: any unknown route serves index.html
location / {
try_files $uri $uri/ /index.html;
}
}Two details worth understanding. The first is that the two-stage build leaves a final image just a few megabytes in size: Node only exists while building. The second is the ARG VITE_API_URL: since the variables are baked in at build time, a built image points to one specific API. You can't change it when starting the container. If you need one image that serves several environments, the URL has to be read at runtime instead (for instance, from a /config.json fetched when the application starts).
- The API in Production: What's Genuinely Missing
Let's say it plainly: json-server is a development tool. It accepts any write from anyone, has no authentication, validates nothing, and stores data in a file. Deploying it with real data would be a security incident, not a deployment.
What a real application like this needs behind it:
| Piece | Why | What it takes |
|---|---|---|
| A real API | Reliable persistence, transactions, integrity | A custom service (Node, Python, Java…) or a backend as a service platform |
| Authentication | Knowing who's asking for something | Session tokens in httpOnly + Secure + SameSite cookies; renewal and expiry |
| Server-side authorization | Making /taller genuinely operator-only |
Role check on every endpoint, not just in the UI |
| Server-side validation | validateBooking is a convenience, not a defense |
Repeat the rules on the server; it's the only copy that counts |
| CORS | Allowing your domain and only your domain | Headers with the list of allowed origins |
| Rate limiting | Preventing abuse and runaway cost | An application firewall or per-IP and per-user limits |
| Double-booking prevention | Two people booking bici-001 at the same time |
Uniqueness constraint and a database transaction |
It's worth rereading, in this light, what was said in Protected Routes: ProtectedRoute and RequireRole improve the experience and protect nothing. Anyone can open the browser's dev tools, alter the session state, and see the workshop screen. What stops it from doing harm isn't your component: it's the server rejecting the request.
One last note of professional common sense: the moment real people's data is involved — names, emails, locations, payments — the design of authentication, storage, and permissions must be reviewed by someone with security and data-protection experience before it's exposed to the public. This course's entire domain is fictional precisely so you can learn without that risk.
- After Deployment: Monitoring and Real Metrics
Publishing isn't finishing. In production there are browsers, networks, and people that weren't on your laptop, and there are two questions that only data from out there can answer.
Is something failing? reportError in src/utils/monitoring.js has been a console.error for the whole course. Now is when it connects to a real service:
// src/utils/monitoring.js
import { config } from '../config.js';
export function reportError(error, componentStack, context = {}) {
if (!config.isProduction) {
console.error('[CicloUrbano]', error, componentStack, context);
return;
}
// In production it's sent to the monitoring service.
// Never include personal data in the report: identifiers, not names or emails.
sendReport({
message: error.message,
stack: error.stack,
componentStack,
path: window.location.pathname,
version: config.version,
userId: context.userId ?? null,
});
}That hookup is already sitting exactly where it needed to be: the componentDidCatch of the ErrorBoundary written back in Error Boundaries. The value of having planned for it back then pays off now.
Is it fast for real people? The measurements from the Profiler were taken on a laptop with a good connection. Real-user metrics get collected right in the browser and sent off:
// src/main.jsx
if (config.isProduction) {
import('web-vitals').then(({ onLCP, onINP, onCLS }) => {
onLCP(sendMetric); // How long the main content takes to paint
onINP(sendMetric); // How long it takes to respond to an interaction
onCLS(sendMetric); // How much the content shifts while loading
});
}And there's a third thing needed that isn't technical: a feedback channel. A visible link to report a problem, and someone reading it. Most of the bugs that matter get reported by a person before any metric gives them away.
- Launch Checklist
| Area | Check |
|---|---|
| Build | npm run build with no warnings · full npm run preview walkthrough · size within budget |
| Routing | Deep route reloaded in a new tab · nonexistent route shows your 404 · /taller off-limits to a customer |
| Performance | LCP and INP measured on the production build · lazy chunks downloading as you navigate |
| Accessibility | Full Tab walkthrough · visible focus · labeled forms · sufficient contrast in both themes · browser audit |
| Basic SEO | <title> and description · language in <html lang="en"> · favicon · robots.txt |
| Errors | reportError connected · ErrorBoundary with a human message · no personal data in the reports |
| Security | No secrets in the bundle (grep over dist/) · HTTPS · security headers · authorization checked on the server |
| Data | Database backups · restore plan tested |
| Operations | Privacy-respecting analytics · alerts configured · rollback plan to the previous deployment |
| People | Channel for reporting bugs · someone on call on launch day |
The point most often forgotten, and the one that hurts most, is the rollback plan. Before publishing, you need to know how to get back to the previous version and how long it takes. Platforms connected to the repository hand you this with a single button; with containers, it means redeploying the previous tag. Figure it out before you need it.
- Next Steps for This Project
CicloUrbano is alive and has a natural roadmap, all of it resting on what you already know:
| Improvement | With what from the course | What it gains |
|---|---|---|
| Migrate to TypeScript | TypeScript with React, file by file starting with src/types/domain.ts |
Explicit contracts and safe refactors; fewer trivial tests |
| Public storefront | SSR and SSG with Next.js for the catalogue and detail pages | Indexable content and instant first paint, alongside management in the SPA |
| Mobile app | React Native with Expo, reusing validations, hooks, and slices | Native presence with most of the logic shared |
| Internationalization | Extract the copy and add Catalan and English | The application's real reach |
| Offline mode | A service worker and Query cache persistence | An app usable out on the street with bad coverage |
| Payments and billing | A real payment gateway, with the secret key on the server | The step from prototype to product |
| Metrics dashboard | The chart component, already isolated in a lazy chunk | Data-driven business decisions |
The best advice for choosing is the same one that ordered the project from the start: vertical slices. One complete improvement, from interface to test, before starting the next.
- How to Keep Learning
- React's official documentation is, today, excellent. Its pages on "you might not need an effect" and on how to structure state explain, better than any tutorial, why things are the way they are. Go back to them with the experience you now have: they read differently.
- Read the source of the libraries you use. React Router, Redux Toolkit, and TanStack Query are readable and well written. Understanding how they solve a problem teaches more than any course, this one included.
- Write your own scaled-down version of something you use: a minimal
useQuerywith a cache, a fifty-line router. Nothing cements a mental model like reimplementing it. - Reproduce the bugs you run into in a minimal example before asking for help. Half the time you'll solve it in the attempt, and the other half you'll get a much better answer.
- Contribute. Start with documentation and with reproducing issues; it's the most honest way in to an open-source project.
- Stay skeptical of what's new. The React ecosystem moves fast, and not everything that shows up survives. The way to tell a fad from a genuine improvement is to ask what specific problem it solves, and whether you actually have it.
Common Mistakes and Tips
- Deploying without the rewrite rule. The application seems to work until someone reloads or shares a link. Always check a deep route in a new tab.
- Putting a secret in a
VITE_variable. It ends up in plain readable text in the bundle. If something must be secret, it needs a server. - Caching
index.htmlfor a long time. Users get stuck on the previous version even though the newassetsare already published.index.htmlwith no cache,assetswith a hash and a long cache. - Trusting
ProtectedRouteas security. It's user experience. Authorization gets checked on the server, on every endpoint, always. - Testing performance on the dev server. The numbers don't resemble production's. Measure against
npm run previewor the real deployment. - Deploying on a Friday afternoon with no rollback plan. This isn't a joke: if you don't know how to roll back and how long it takes, you're not ready to publish.
- Final tip: automate the deployment the same day you do the first one by hand. A deployment that requires remembering steps is a deployment someone will get wrong.
Exercises
Exercise 1. Deploy CicloUrbano to a static platform with the correct rewrite and the caching headers from section 1. Document the procedure and the rollback plan in README.md, and verify with grep over dist/ that no secret has slipped in.
Exercise 2. Prepare the project to be served from a subpath (https://ejemplo.example/ciclourbano/) instead of the domain root. Work out what needs to change in Vite, in React Router, and in the server configuration.
Exercise 3. Add a step to the continuous integration flow that fails the build if the main bundle exceeds a budget of 100 kB compressed, so the size can't creep up without anyone noticing.
Solutions
Solution 1.
project/ ├── public/_redirects → /* /index.html 200 └── vercel.json (if it's Vercel, with rewrites and headers)
# Mandatory pre-check: no secrets in the bundle
npm run build
grep -rEi "secret|password|sk_live|private_key" dist/assets/ && echo "CHECK THIS" || echo "clean"
# Post-deployment check
curl -I https://ciclourbano.example/reservas # should return 200 and text/html
curl -I https://ciclourbano.example/assets/index-*.js # should carry Cache-Control: immutableIn README.md:
## Deployment
Automatic on merge to `main`. The platform runs `npm ci` and `npm run build`
and publishes `dist/`. Required variables: `VITE_API_URL`, `VITE_ENTORNO`.
### Post-deployment check
1. Open `/reservas` in a new tab and reload → should load (not 404).
2. Nonexistent route → your own not-found page.
3. `/taller` with a customer session → "forbidden".
4. Browser console with no errors.
### Rollback
Deployments panel → select the correct previous one → "Restore".
Estimated time: under 2 minutes. No rebuild required.Solution 2. Three places need touching, and forgetting any one of them produces a blank screen or broken links:
// 1. vite.config.js — prefix for the asset paths in index.html
export default defineConfig({
base: '/ciclourbano/',
plugins: [react()],
});// 2. src/routes.jsx — React Router must ignore the prefix when matching routes
export const router = createBrowserRouter(routes, {
basename: '/ciclourbano',
});# 3. Server — the rewrite must point to the subpath's index.html
location /ciclourbano/ {
alias /usr/share/nginx/html/;
try_files $uri $uri/ /ciclourbano/index.html;
}Check: dist/index.html should reference /ciclourbano/assets/..., and a reload at /ciclourbano/reservas should work. Internal links with <Link to="/reservas"> are still written without the prefix: the basename adds it.
Solution 3.
// scripts/comprobar-presupuesto.js
import { readdirSync, readFileSync } from 'node:fs';
import { gzipSync } from 'node:zlib';
import { join } from 'node:path';
const BUDGET_KB = 100;
const FOLDER = 'dist/assets';
// The main bundle is the one that starts with index- and ends in .js
const mainBundle = readdirSync(FOLDER).find((f) => /^index-.*\.js$/.test(f));
if (!mainBundle) {
console.error('Main bundle not found. Did you run npm run build?');
process.exit(1);
}
const bytes = gzipSync(readFileSync(join(FOLDER, mainBundle))).length;
const kb = bytes / 1024;
console.log(`${mainBundle}: ${kb.toFixed(2)} kB compressed (budget ${BUDGET_KB} kB)`);
if (kb > BUDGET_KB) {
console.error(
`\nBudget exceeded by ${(kb - BUDGET_KB).toFixed(2)} kB.\n` +
'Check the new dependencies with: npx vite-bundle-visualizer'
);
process.exit(1); // The nonzero exit code is what breaks the CI build
}# .github/workflows/tests.yml — added to the end-to-end job
- run: npm run build
- run: npm run presupuesto # Fails the job if the bundle has grownThe key is process.exit(1): without it, the script reports the problem but CI stays green, and the budget turns into a suggestion nobody follows.
Conclusion
You started this course with an empty <div id="root"> and the question of why anyone would need a library just to paint a list of bikes. You're finishing with a complete application, tested at four levels, measured, split into chunks, accessible, and deployed, and with judgment of your own for deciding how to build the next one.
Here's the path it took. In the fundamentals you understood that React is declarative and that the interface is a function of state, and you saw what genuinely happens between a state change and a pixel on the screen. With components, props, and state you learned to carve out pieces and parameterize them, and with events, forms, and accessibility you made them respond to people, not just data. In the advanced concepts discipline showed up: lifting state to the common ancestor, composing instead of inheriting, and catching failures before they swallow the screen. The hooks gave you the full vocabulary — state, syncing with the outside world, refs, context, reducers — and, above all, the ability to extract your own reusable logic. React Router turned a screen into an application with shareable URLs and controlled access. State management brought order to the chaos with an idea worth more than any library: every piece of data has a place it should live, and server data isn't yours. Performance taught you to measure before touching anything, and not to confuse optimization with decoration. Testing gave you the freedom to refactor without fear, by testing behavior and not implementation. The advanced topics widened the map out to the server, to types, and to mobile. And the project proved that all of it fits together.
What you're taking with you isn't a list of APIs, which will change. It's the way of thinking underneath: describe the interface as a function of state, put every piece of data in its place, measure before optimizing, test visible behavior, and never trust the client for what the server must guarantee. That stays true no matter what changes — the version, the trendy library, or the whole framework.
You already have what it takes. Go build something.
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
