A full-stack web application replicating part of the Tienda Prado online store. The project is split into three parts:
ssbw-api— Express + Node.js backend with REST API, PostgreSQL database, and server-rendered pagesssbw-react— React + Vite + Tailwind CSS frontend SPAssbw-astro— Astro static site with React islands and Static Site Generation (Tailwind + DaisyUI)
- Node.js >= 23
- Docker and Docker Compose
- npm
SSBW/
├── ssbw-api/ # Express backend
│ ├── docker-compose.yml # PostgreSQL container (development)
│ ├── docker-compose-prod.yml # Production stack: db + app + Caddy
│ ├── Dockerfile # Containerizes the Express app
│ ├── Caddyfile # Reverse proxy configuration
│ ├── .dockerignore # Excludes node_modules, .env, logs, generated
│ ├── .env # Environment variables (not committed)
│ ├── index.ts # Express server
│ ├── logger.ts # Winston logger configuration
│ ├── package.json
│ ├── tsconfig.json
│ ├── test-api.http # REST Client test file
│ ├── productos.json # Scraped product data
│ ├── imagenes/ # Downloaded product images
│ ├── logs/
│ │ ├── info.log # Info and above log entries
│ │ └── error.log # Error log entries
│ ├── prisma/
│ │ ├── schema.prisma # Database schema
│ │ ├── prisma.client.ts # Prisma client configuration
│ │ └── usuario.ts # Password hashing and authentication methods
│ ├── generated/
│ │ └── prisma/ # Auto-generated Prisma client
│ ├── routes/
│ │ ├── productos.ts # Product controllers (MVC)
│ │ ├── usuarios.ts # Authentication controllers
│ │ └── api.ts # RESTful API controllers
│ ├── types/
│ │ └── session.d.ts # Express session and request type declarations
│ ├── views/
│ │ ├── base.njk # Base template with cart offcanvas
│ │ ├── portada.njk # Home and search page
│ │ ├── detalle.njk # Product detail page
│ │ └── login.njk # Login page
│ └── scripts/
│ ├── scrap-tp.js # Web scraper (Playwright)
│ ├── seed.ts # Database seeder
│ └── registra_usuarios.ts # User registration script
│
└── ssbw-react/ # React frontend
├── index.html
├── package.json
├── vite.config.ts
├── tsconfig.json
└── src/
├── main.tsx # Entry point
├── App.tsx # Main component with router and tabs
├── index.css # Tailwind CSS + DaisyUI + Google Fonts
├── components/
│ ├── Perritos.tsx # Random dog image (useState + useEffect)
│ └── Cuadros.tsx # Random store image (SWR)
└── pages/
├── Portada.tsx # Home page
├── Tarea9.tsx # Perritos + Cuadros gallery page
└── Carrousel.tsx # Swiper product carousel page
ssbw-astro/ # Astro static site with React islands
├── astro.config.mjs
├── package.json
├── tsconfig.json
├── data/
│ └── productos.json # Local product data for SSG (no API dependency)
├── public/
│ └── images/ # Product images served statically
└── src/
├── styles/
│ └── global.css # Tailwind CSS + DaisyUI + Google Fonts + font-garamond theme
├── layouts/
│ ├── Layout.astro # Shared layout (Task 11 pages)
│ └── LayoutPrado.astro # Shared layout styled like the real Tienda Prado header
├── components/
│ ├── Welcome.astro # Astro home page component
│ ├── Carrousel.tsx # React island — Swiper carousel (fetches from API)
│ ├── CarrouselSSG.tsx # React island — Swiper carousel (receives products as props)
│ └── CardProducto.astro # Static product card linking to its detail page
└── pages/
├── index.astro # url / serves Welcome
├── carrousel.astro # url /carrousel serves CarrouselSSG with local data
├── ssg.astro # url /ssg "Destacados" — 12 featured products
└── productos/
└── [slug].astro # url /productos/{título} dynamic product detail page (getStaticPaths)
cd ssbw-api
npm installCreate a .env file inside ssbw-api:
POSTGRES_USER=yo
POSTGRES_PASSWORD=una_clave_muy_segura_123
POSTGRES_DB=ssbw
DATABASE_URL="postgresql://yo:una_clave_muy_segura_123@localhost:5432/ssbw?schema=public"
LOG_LEVEL=debug
SECRET_KEY=your_secret_key_heredocker compose up -dnpx prisma migrate dev --name esquema_inicial
npx prisma generatenpm run seednpm run registranpm run devBackend runs at http://localhost:3000
cd ssbw-react
npm installnpm run devFrontend runs at http://localhost:5173
Requires Node.js >= 22.
cd ssbw-astro
npm installnpm run devAstro site runs at http://localhost:4321
Make sure the backend (
ssbw-api) is also running, since the Carrousel page fetches products from it.
Sets up the base web server using:
- Express — web framework
- Nunjucks — templating engine
- Node.js 23 — native TypeScript support, watch mode,
.envfile support
npm run devScrapes all products from Tienda Prado Impresiones using Playwright.
Install browsers (first time only):
npx playwright installRun the scraper:
npm run scrapUses Prisma ORM with PostgreSQL (running in Docker).
The Producto model:
id— auto-increment primary keytítulo— product title (max 127 chars)descripción— full description (text)precio— decimal priceimagen— image filename (max 127 chars)
npm run seed # Seed database
npx prisma migrate reset # Reset database
npx prisma studio # Open visual DB browserMVC pattern with Prisma as Model and Nunjucks as View.
| Route | Description |
|---|---|
GET / |
Home page — all products grid |
GET /producto/:id |
Product detail page |
GET /buscar?busqueda= |
Search by title or description |
Logger — Winston with three transports:
| Transport | Level | Output |
|---|---|---|
| Console | debug | Colored output with timestamps |
| File | info | logs/info.log in JSON format |
| File | error | logs/error.log in JSON format |
Cart — Server-side sessions with express-session:
| Route | Description |
|---|---|
POST /al-carrito/:id |
Add product to cart |
JWT tokens stored in httpOnly cookies. The Usuario model:
email— primary keynombre— display namecontraseña— bcrypt hashed passwordadmin— boolean role flag
Test users:
| Password | Admin | |
|---|---|---|
| admin@prado.es | admin123 | Yes |
| user@prado.es | user123 | No |
| Route | Description |
|---|---|
GET /login |
Show login page |
POST /login |
Handle login form |
GET /logout |
Clear cookie and redirect |
| Method | Route | Description |
|---|---|---|
GET |
/api/productos |
Get all products (pagination + sorting) |
GET |
/api/productos/:id |
Get product by id |
POST |
/api/productos |
Create product |
PUT |
/api/productos/:id |
Update product |
DELETE |
/api/productos/:id |
Delete product |
GET |
/api/random |
Get a random product |
Pagination:
GET /api/productos?desde=0&hasta=20&ordenación=ascendente
Test with VSCode REST Client extension using test-api.http.
Login UX (views/login.njk):
- Autofocus on email field
- Specialized mobile keyboard (
inputmode="email") - Show/hide password toggle
- Email validation on blur
- Better button text
Cart Offcanvas — Bootstrap offcanvas sliding panel:
- Opens when clicking the cart icon
- Uses HTML
<template>+cloneNode()for dynamic rendering - Fetches cart data via
fetch()API - Remove items with trash button
| Method | Route | Description |
|---|---|---|
GET |
/api/carrito |
Get cart items with product details |
DELETE |
/api/carrito/:id |
Remove item from cart |
A separate React frontend (ssbw-react) that connects to the Express backend API.
Stack:
- Vite + React + TypeScript
- Tailwind CSS with Montserrat and EB Garamond fonts
- SWR for data fetching
Components:
<Perritos /> — Fetches a random dog image from dog.ceo using useState + useEffect. Click ¡Otro! 🐶 to load a new one.
<Cuadros /> — Fetches a random product image from the store's /api/random endpoint using SWR. Click ¡Otro! 🎨 to load a new one via mutate().
CORS is configured on the Express backend to allow requests from http://localhost:5173.
Both servers must run simultaneously:
# Terminal 1 — backend
cd ssbw-api && npm run dev
# Terminal 2 — frontend
cd ssbw-react && npm run devOpen http://localhost:5173 to see the gallery.
Expands the React frontend into a multi-page SPA using client-side routing.
Stack added:
react-router-domfor client-side routingdaisyuifor the tabs navigation componentswiperfor the image carousel
Pages:
| Route | Page | Description |
|---|---|---|
/ |
Portada.tsx |
Home / welcome page |
/tarea9 |
Tarea9.tsx |
Perritos and Cuadros gallery from Task 9 |
/carrousel |
Carrousel.tsx |
Swiper carousel showing real products |
Navigation — DaisyUI tabs tabs-boxed component built with NavLink from React Router, highlighting the active tab based on the current route.
Carousel — Fetches a batch of products from GET /api/productos?desde=0&hasta=12 and renders them in a Swiper slider with navigation arrows, pagination dots, autoplay, and responsive breakpoints (1/2/3 slides depending on screen width).
A new static site (ssbw-astro) built with Astro, using the Islands Architecture to embed an interactive React component inside an otherwise static page.
Stack added:
- Astro (requires Node.js >= 22)
- Tailwind CSS for Astro (
npx astro add tailwind) - DaisyUI for the tabs navigation component
@astrojs/reactintegration- Swiper (reused inside the React island)
Structure:
| File | Description |
|---|---|
layouts/Layout.astro |
Shared HTML structure with Google Fonts, global CSS, and DaisyUI tab navigation |
components/Welcome.astro |
Static Astro component for the home page (no JavaScript shipped) |
components/Carrousel.tsx |
React component (the "island") — same Swiper carousel logic as ssbw-react, fetching products from the Express API |
pages/index.astro |
url / — serves Welcome |
pages/carrousel.astro |
url /carrousel — serves the Carrousel island |
pages/ssg.astro |
url /ssg — placeholder for Task 12 (Static Site Generation) |
Astro Islands — By default Astro ships zero JavaScript to the browser. The Carrousel component only becomes interactive because it's explicitly hydrated with the client:load directive:
<Carrousel client:load />This means the home page (Welcome.astro) loads as pure static HTML, while only the carousel page loads the React/Swiper JavaScript needed for its slider, navigation, and autoplay.
CORS — The Express backend's CORS configuration was updated to also allow requests from http://localhost:4321 (the Astro dev server), in addition to http://localhost:5173 (the React dev server):
app.use(cors({
origin: ['http://localhost:5173', 'http://localhost:4321'],
credentials: true
}))Extends ssbw-astro into a fully static site (SSG) with featured products and individual product pages, no longer dependent on the Express backend at runtime.
Local data instead of API calls — productos.json and the product images were copied locally into the Astro project (data/productos.json and public/images/), so pages are generated entirely at build time:
mkdir ssbw-astro/data
Copy-Item ssbw-api/productos.json ssbw-astro/data/productos.json
Copy-Item -Recurse ssbw-api/imagenes ssbw-astro/public/imagesCarrouselSSG.tsx — same Swiper carousel as before, but receiving productos as a prop instead of fetching them:
<CarrouselSSG productos={productos} client:load/>LayoutPrado.astro — a layout styled closer to the real Tienda Prado header (uppercase EB Garamond logo, top bar, tab navigation), used by the home page, "Destacados" page, and product detail pages.
ssg.astro — "Destacados" page — shows the first 12 products from productos.json in a responsive grid using the CardProducto.astro static component:
| Route | Description |
|---|---|
/ssg |
Grid of 12 featured products, styled like Tienda Prado's home page |
Dynamic product detail pages — pages/productos/[slug].astro uses getStaticPaths() to pre-generate one static HTML page per product at build time:
export async function getStaticPaths() {
return productos.map((pr) => ({
params: { slug: pr.título.trim() },
props: { producto: pr }
}))
}| Route | Description |
|---|---|
/productos/{título} |
Static detail page for an individual product (image, full description, price) |
Note: the link (
href) and the route'sparams.slugboth use the plain, unencoded product title — letting the browser encode it once when navigating and Astro decode it once when matching the route. Encoding it manually on both ends causes a double-encoding mismatch and404s.
Build and preview the static site:
npm run build
npm run previewThe generated static site (dist/ folder) can then be deployed by uploading it to a static host like Netlify.
Adds a production-ready Docker Compose stack for ssbw-api, so the whole application (app + database + reverse proxy) can be deployed to any VPS or CaaS with a single command.
Stack — three services:
| Service | Image | Role |
|---|---|---|
db |
postgres:16-alpine |
PostgreSQL database |
tienda-prado |
built from local Dockerfile |
the Express application |
caddy |
caddy:alpine |
reverse proxy — only service exposed to the host (port 80) |
Dockerfile — builds the app on node:24-alpine, installs only production dependencies (npm ci --omit=dev), generates the Prisma client, and starts the app with npx tsx index.ts.
Caddyfile — forwards every request to the app container by its Docker Compose service name:
:80 {
handle_path /* {
reverse_proxy tienda-prado:3000
}
log {
output stdout
}
}
If :80 is replaced with a real domain name, Caddy automatically provisions and renews HTTPS certificates (via Let's Encrypt) with zero extra configuration.
Run the production stack locally:
docker compose -f docker-compose-prod.yml up --buildThen apply migrations, seed the database, and register test users inside the running container:
docker compose -f docker-compose-prod.yml exec tienda-prado npx prisma migrate deploy
docker compose -f docker-compose-prod.yml exec tienda-prado npx tsx scripts/seed.ts
docker compose -f docker-compose-prod.yml exec tienda-prado npx tsx scripts/registra_usuarios.tsVisit http://localhost (port 80, no port number needed) — only Caddy is published to the host; tienda-prado and db stay reachable only inside Docker's internal network.
Gotchas fixed along the way (useful if reproducing this setup):
tsxanddotenvmust be independencies, notdevDependencies—npm ci --omit=devstrips devDependencies, breaking both the Prisma client (which needstsx, not plainnode, to run) andprisma.config.ts(which importsdotenv/config)ENVinstructions in the Dockerfile must come beforeRUN npx prisma generate, since Prisma needsDATABASE_URLalready set at that point- A literal
$in anENVvalue (e.g. insideSECRET_KEY) must be escaped as$$, or Docker tries to substitute it as a variable - Database credentials are passed via
docker-compose-prod.yml'senvironment:section (sourced from.env) instead of hardcoded in the Dockerfile, so the app anddbalways agree .envitself must be excluded via.dockerignore— otherwise Prisma's client can pick it up at runtime and override the container'sDATABASE_URLimagenes/must not be excluded via.dockerignore— the app serves product images directly from disk
| Script | Command | Description |
|---|---|---|
npm run dev |
tsx --watch --env-file=.env index.ts |
Start server in watch mode |
npm run scrap |
node --env-file=.env scripts/scrap-tp.js |
Run web scraper |
npm run seed |
npx tsx --env-file=.env scripts/seed.ts |
Seed database |
npm run registra |
npx tsx --env-file=.env scripts/registra_usuarios.ts |
Register test users |
| Technology | Purpose |
|---|---|
| Node.js 23 | Runtime with native TypeScript support |
| Express | Web framework |
| Nunjucks | HTML templating engine |
| Playwright | Web scraping |
| PostgreSQL | Relational database |
| Prisma 7 | ORM |
| Docker | Database container |
| tsx | TypeScript script runner |
| Bootstrap 5 | CSS framework (backend views) |
| Winston | Logging |
| express-session | Session management |
| jsonwebtoken | JWT authentication |
| bcrypt | Password hashing |
| cookie-parser | Cookie middleware |
| cors | Cross-Origin Resource Sharing |
| React 18 | Frontend UI library |
| Vite | Frontend build tool |
| Tailwind CSS | Utility-first CSS framework |
| DaisyUI | Tailwind component library (tabs) |
| React Router | Client-side routing |
| Swiper | Carousel/slider component |
| SWR | React data fetching library |
| Astro | Static site framework with Islands Architecture |
| @astrojs/react | Astro integration for React components |
| Caddy | Reverse proxy with automatic HTTPS |
| Docker Compose | Multi-container production orchestration |