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
Paella Valencia — homepage hero section (staging with placeholder imagery)
Not just a website
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.
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
Spanish version — same page, all content switched instantly
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
Service page — pricing table, included items, and perfect for tags
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
Admin: Bilingual editor with EN/ES tabs
Admin: Gallery upload with Cloudinary thumbnails
Admin: Seasonal pricing editor with bilingual labels
Admin: "What's Included" editor with emoji input
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
Lightbox open with navigation arrows and photo counter
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
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
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
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
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
Nested <a> tags causing React hydration errors
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.
SSR hydration mismatch on responsive components
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.
Bilingual content at scale across 4 services
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.
Client with zero technical background
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
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
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.