Client ProjectFull-Stack CMSProduction-Ready

Paella Valencia

Ryan Simpson Experiences — bilingual website with custom CMS

A premium bilingual website built for a British chef with over 24 years of culinary experience in Spain, offering private paella cooking experiences across Valencia, Alicante, and the Costa Blanca. The project required building a complete digital presence from scratch — a marketing website, a content management system, and a media delivery pipeline — all tailored to a client with zero technical background who needed to manage his own content independently after handover. No WordPress, no Contentful. Everything custom-built.

Status: Fully built and deployed to Vercel staging. The platform is complete and functional — CMS, bilingual system, media pipeline, and admin panel are all operational. Awaiting client photography and final content before domain connection and public launch.

Role

Sole Developer & Designer

Timeline

February 2026

Client

Ryan Simpson, Valencia, Spain

Next.js 16React 19TypeScriptTailwind CSSMongoDB AtlasMongooseCloudinaryVercelREST APICustom CSS
Paella Valencia — homepage hero section (staging with placeholder imagery)
Click to enlarge

Paella Valencia — homepage hero section (staging with placeholder imagery)

Not just a website

I built a system the client runs like his own app — managing content, photos, pricing, and languages from a browser.

The public website is the surface. Behind it: a custom admin panel, a Cloudinary media pipeline, a typed MongoDB schema with Mongoose ODM, RESTful API routes, and a bilingual content architecture — all designed so a non-technical chef can manage his business presence independently.

01

Bilingual Architecture (EN / ES)

The entire website operates in English and Spanish, switchable from the navigation bar with flag icons (🇬🇧 / 🇪🇸) and instant language toggling — no page reload. The implementation uses a custom React Context (LanguageContext) that persists the selected language across the session and is accessible to every component. Every piece of user-facing text — page headings, service descriptions, pricing labels, WhatsApp messages, navigation links, footer content, CTA buttons — exists in both languages. In the database, all text fields use a reusable BilingualString schema type ({ en: string, es: string }), and a single t() helper function resolves the correct language at render time throughout the entire codebase.

Key Details

Custom React Context (LanguageContext) for session-persistent state

BilingualString type ({ en, es }) on every text field in MongoDB

Single t(field, lang) helper — consistent across all components

Instant switch with flag icons, zero page reload

English version — navigation, hero, and service cards
Click to enlarge

English version — navigation, hero, and service cards

Spanish version — same page, all content switched instantly
Click to enlarge

Spanish version — same page, all content switched instantly

02

Dynamic Service Pages

Each of Ryan's four cooking experiences — Chalet Experience, Private Class, Corporate Events, and Paella Wars — has its own dynamically rendered page at /[slug]. Pages are server-rendered using generateStaticParams for SEO, pulling data from MongoDB at build time and re-validating on request. Each service page includes a full-screen hero with gradient overlay, an intro section, a pricing table with seasonal tiers and bilingual labels, an "included" checklist with emoji icons and bilingual descriptions, a "perfect for" tag cloud per language, a photo gallery, a testimonial section, and a WhatsApp booking CTA. Every element is bilingual and fully editable from the admin panel.

Key Details

4 services with dynamic [slug] routing

Server-rendered with generateStaticParams for SEO

Seasonal pricing tables with bilingual labels and notes

"What's Included" checklist with emoji icons per item

"Perfect For" tag clouds — separate tags per language

Testimonial section and WhatsApp booking CTA

Service page — hero section with gradient overlay
Click to enlarge

Service page — hero section with gradient overlay

Service page — pricing table, included items, and perfect for tags
Click to enlarge

Service page — pricing table, included items, and perfect for tags

03

Custom Admin Panel

This is the heart of the project. A fully custom content management system at /admin — no WordPress, no Contentful, no third-party CMS. The client opens a browser, sees all his services, and can edit everything: text in both languages via an EN/ES tab switcher, photos via inline Cloudinary upload, seasonal pricing rows (add/remove with bilingual labels and notes), "What's Included" items with emoji icon input, "Perfect For" tags per language, and service visibility (show/hide from the website without deleting). Every change saves to MongoDB via a PUT API with real-time success and error feedback. Ryan manages his own business presence the way you'd manage an Instagram profile — but with full control over structure, not just posts.

Key Details

Service listing with visibility status and quick edit links

EN/ES tab switcher for all text fields

Inline Cloudinary upload for card image, hero image, and up to 6 gallery photos

Thumbnail preview grid with individual remove buttons

Add/remove seasonal pricing rows with bilingual label and note fields

"What's Included" editor with emoji icon input per item

"Perfect For" tag editor — add/remove per language

Service visibility toggle (show/hide without deleting)

Save with real-time success/error feedback via PUT API

Admin: Service listing with visibility status
Click to enlarge

Admin: Service listing with visibility status

Admin: Bilingual editor with EN/ES tabs
Click to enlarge

Admin: Bilingual editor with EN/ES tabs

Admin: Gallery upload with Cloudinary thumbnails
Click to enlarge

Admin: Gallery upload with Cloudinary thumbnails

Admin: Seasonal pricing editor with bilingual labels
Click to enlarge

Admin: Seasonal pricing editor with bilingual labels

Admin: "What's Included" editor with emoji input
Click to enlarge

Admin: "What's Included" editor with emoji input

04

Photo Gallery & Lightbox

Each service supports up to 6 gallery photos, stored as Cloudinary URLs in the MongoDB gallery array. On the frontend, photos display in a responsive CSS grid that adapts column count based on the number of images (1, 2, or 3 columns). Clicking any photo opens a full-screen lightbox with a dark overlay, left/right navigation arrows, a photo counter (e.g. 2/6), and close button. The lightbox is built entirely in React state — no external library, using useState for the active index with keyboard-friendly close behaviour.

Key Details

Up to 6 gallery photos per service via Cloudinary

Responsive grid adapting columns to photo count

Custom full-screen lightbox — built in React, zero dependencies

Left/right navigation, photo counter, keyboard close

Gallery grid on a service page
Click to enlarge

Gallery grid on a service page

Lightbox open with navigation arrows and photo counter
Click to enlarge

Lightbox open with navigation arrows and photo counter

05

Cloudinary Media Pipeline

All images flow through Cloudinary — card images, hero banners, and gallery photos. The admin panel provides inline upload with a visual thumbnail preview grid and individual remove buttons for each photo slot. When the client uploads an image, it goes to Cloudinary via a POST /api/upload endpoint, which returns an optimised CDN URL that is saved to MongoDB. Cloudinary handles image transformation and global CDN delivery, keeping the database lean and the frontend fast.

Key Details

POST /api/upload endpoint for Cloudinary integration

Inline upload from admin with real-time thumbnail preview

Individual remove buttons per gallery slot

Cloudinary CDN for transformation and global delivery

Optimised URLs stored in MongoDB — no local image storage

06

MongoDB Data Architecture

The database schema was designed for a bilingual, content-rich business site. Each service document in MongoDB Atlas contains nested BilingualString fields for every text element (title, tagline, description, long description), pricing arrays with seasonal rows (each with bilingual label and note), included items arrays with emoji icons and bilingual descriptions, perfectFor tag arrays per language, gallery URL arrays, and bilingual WhatsApp messages. The Mongoose ODM provides full type safety with a typed IService schema and model. RESTful API routes handle all CRUD operations: GET/PUT /api/services/[slug] for individual services, GET /api/services for listing, and POST /api/upload for Cloudinary.

Key Details

Typed Mongoose schema with IService interface

Nested BilingualString for all user-facing fields

Pricing arrays with seasonal rows, labels, and notes

RESTful API: GET/PUT /api/services/[slug], GET /api/services

POST /api/upload for Cloudinary integration

07

WhatsApp Booking Integration

Every service card and service page includes a direct WhatsApp booking button that pre-populates a custom message in the visitor's language. If the visitor is browsing in Spanish, the WhatsApp message is in Spanish. The message text is stored per service in MongoDB and fully editable from the admin panel — the client can adjust his booking prompts for each experience without touching code. Built with the wa.me deep link protocol and encodeURIComponent for safe URL encoding.

Key Details

Bilingual pre-filled WhatsApp messages

Per-service message stored in MongoDB

Client-editable from admin panel — no code needed

wa.me deep link with encodeURIComponent

WhatsApp booking button with bilingual pre-filled message
Click to enlarge

WhatsApp booking button with bilingual pre-filled message

08

SEO & Performance

Built for discoverability and speed. Service pages are server-side rendered with generateStaticParams for fast initial load and SEO indexing. The HTML uses semantic structure with proper heading hierarchy (h1 → h2 → h3), alt text on all images loaded from bilingual service titles, and next/image optimisation for local assets with an unoptimized flag for external Cloudinary URLs. Meta tags and Open Graph metadata are configured via the Next.js metadata API for social sharing previews. The static generation approach means pages load instantly from Vercel's edge CDN.

Key Details

Server-side rendered with generateStaticParams

Semantic HTML with proper heading hierarchy

Bilingual alt text on all images

next/image with Cloudinary-aware optimisation

Open Graph metadata via Next.js metadata API

Edge CDN delivery through Vercel

09

Design System & Responsive Layout

The visual identity was built from scratch to reflect the warmth, authenticity, and premium positioning of Ryan's brand. The palette is built around terracotta (#9B4E2E), deep espresso brown (#2C1A0E), warm parchment (#FAF6F0), and sand (#D4C4A8) — evoking wood fire, Spanish earth, and Mediterranean light. Typography pairs Cormorant Garamond for elegant headings with Jost for clean body text, all using clamp() for fluid scaling. The custom CSS design system uses CSS variables for the palette, reusable component classes (btn-primary, service-card, divider), and animation keyframes (fadeUp with delay variants). The layout is fully responsive: hamburger navigation on mobile with slide-in overlay, collapsing grid layouts, and SSR-safe responsive logic using a mounted-state pattern to avoid hydration mismatches.

Key Details

Custom CSS design system with CSS variables for palette

Terracotta, espresso, parchment, and sand colour palette

Cormorant Garamond + Jost typography with clamp() fluid scaling

Reusable component classes and fadeUp animation keyframes

SSR-safe responsive logic (mounted-state pattern)

Hamburger navigation with slide-in overlay on mobile

Engineering Challenges

Problems I solved along the way

Problem

Nested <a> tags causing React hydration errors

Solution

When service cards were wrapped in Next.js Link, the WhatsApp button inside (also an <a>) caused HTML validation errors and hydration mismatches. Replaced the outer Link with a div using onClick navigation, allowing the inner WhatsApp anchor to use e.stopPropagation() to prevent the card click from firing.

Problem

SSR hydration mismatch on responsive components

Solution

Navigation and footer use window.innerWidth for layout, but window is unavailable during SSR. Implemented a mounted-state pattern — components render a neutral default on first paint, then apply responsive logic after mount via useEffect, ensuring identical server and client HTML.

Problem

Bilingual content at scale across 4 services

Solution

With 4 services each having titles, taglines, descriptions, pricing labels, notes, included items, perfectFor tags, and WhatsApp messages — all in two languages — the data model needed to be both flexible and consistent. Designed a reusable BilingualString type applied to every text field in the Mongoose schema, with a single t(field, lang) helper keeping the entire codebase DRY.

Problem

Client with zero technical background

Solution

Ryan needs to manage his own content after handover without any developer assistance. Built the admin panel as a zero-learning-curve interface — clearly labelled fields, visual thumbnails, familiar tab patterns for language switching, and real-time feedback (upload progress, save confirmation, error messages). No CLI, no code editor, no deployment knowledge required.

Reflection

What this project taught me

This project pushed me beyond building websites into building systems. The biggest shift was thinking about the client as a second user — someone who never sees the code but needs to operate the product daily. Every admin interface decision came down to: “Would Ryan understand this without me explaining it?”

It also deepened my understanding of data architecture. Designing a bilingual schema where every field has two versions, every API route handles both, and every component resolves the right one — that's a different kind of complexity than building a single-language site.

Integrating Cloudinary as a media pipeline taught me how real production systems handle image hosting, transformation, and delivery at scale. And working with MongoDB and Mongoose gave me a full NoSQL perspective to complement my PostgreSQL experience on other projects — understanding when each database model fits the use case.

Skills Demonstrated

Full-stack architecture: frontend, API routes, database, media pipeline

Custom CMS design for non-technical end users

Bilingual data architecture with typed Mongoose schemas

MongoDB Atlas + Mongoose ODM with full TypeScript interfaces

Cloudinary integration for image upload, storage, and CDN delivery

RESTful API design with Next.js API routes (GET/PUT/POST)

SSR-safe responsive patterns avoiding hydration mismatches

Client-ready product designed for independent handoff

Git discipline — descriptive commits per feature

Outcome

A bilingual website with a full custom CMS — built, deployed, and ready for the client to run independently.

Production-ready on Vercel with CI/CD. MongoDB Atlas for data. Cloudinary for media. Mongoose ODM with typed schemas. TypeScript end to end. RESTful API. And an admin panel designed so a chef with no technical background can manage four bilingual service pages, seasonal pricing, photo galleries, and WhatsApp booking messages — all from a browser.

View Source Code →
← Back to PortfolioNext: Santiago Voget →