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

  1. The production build
  2. Reviewing the bundle size before publishing
  3. Environment variables: what's actually public
  4. The 404 on reload: client-side routing on a static server
  5. Step-by-step deployment on a static platform
  6. The container alternative: Docker and Nginx
  7. The API in production: what's genuinely missing
  8. After deployment: monitoring and real metrics
  9. Launch checklist
  10. Next steps for this project
  11. How to keep learning

  1. 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:

npm run build
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:

npm run preview
# Serves dist/ at http://localhost:4173

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.

  1. 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:

npx vite-bundle-visualizer

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.

  1. 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.env ends 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 bundle

From 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.

  1. 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.

  1. 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.

  1. 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).

  1. 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.

  1. 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.

  1. 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.

  1. 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.

  1. 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 useQuery with 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.html for a long time. Users get stuck on the previous version even though the new assets are already published. index.html with no cache, assets with a hash and a long cache.
  • Trusting ProtectedRoute as 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 preview or 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: immutable

In 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.

// package.json
{
  "scripts": {
    "presupuesto": "node scripts/comprobar-presupuesto.js"
  }
}
// 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 grown

The 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

Module 2: React Components

Module 3: Working with Events

Module 4: Advanced Component Concepts

Module 5: React Hooks

Module 6: Routing in React

Module 7: State Management

Module 8: Performance Optimization

Module 9: Testing React Applications

Module 10: Advanced Topics

Module 11: Project: Building a Complete Application

© Copyright 2026. All rights reserved