Web Development
March 7, 2025
Building Recipe Hub: A Full-Stack Social Platform for Food Enthusiasts
How we created a scalable, real-time cooking community with MERN stack, WebSockets, and OAuth
SHARE

How We Built Recipe Hub: Architecture, Challenges, and Lessons Learned
When I started building Recipe Hub, I wanted to create more than just another recipe database. My vision was a vibrant community where food enthusiasts could connect, share, and collaborate in real-time. In this technical deep dive, I'll walk through our journey of building this full-stack social cooking platform, exploring the architecture decisions, technical challenges, and key learnings along the way.
The Recipe Hub Vision

Recipe Hub was designed with these core principles in mind:
- User-Centric Experience: Seamless authentication, intuitive navigation, and personalized content.
- Real-Time Social Interactions: Live chat, notifications, and collaborative cooking sessions.
- Rich Media Support: High-quality food photography and video tutorials.
- Performance & Scalability: Fast load times and efficient handling of growing user base.
- Mobile-First Approach: Perfect experience across all devices.
Technical Architecture
After evaluating several technology options, we settled on the MERN stack (MongoDB, Express, React, Node.js) with several additional technologies to address our specific requirements:
#### Frontend Architecture
javascript
// Key frontend technologies used:
// - React 18 with Functional Components and Hooks
// - Redux Toolkit for state management
// - React Router v6 for navigation
// - Styled Components for styling
// - Socket.io client for real-time features
// - Cloudinary React SDK for media uploads
Our frontend architecture followed a component-based design with careful attention to state management. Here's a simplified overview of our component structure:
jsx
// App structure with code-splitting for better performance
import React, { lazy, Suspense } from 'react';
import { Routes, Route } from 'react-router-dom';
import { ErrorBoundary } from 'react-error-boundary';
import AppLayout from './layouts/AppLayout';
import LoadingSpinner from './components/common/LoadingSpinner';
import ErrorFallback from './components/common/ErrorFallback';
// Lazy-loaded components
const Home = lazy(() => import('./pages/Home'));
const Explore = lazy(() => import('./pages/Explore'));
const RecipeDetail = lazy(() => import('./pages/RecipeDetail'));
const Profile = lazy(() => import('./pages/Profile'));
const Chat = lazy(() => import('./pages/Chat'));
function App() {
return (
<ErrorBoundary FallbackComponent={ErrorFallback}>
<AppLayout>
<Suspense fallback={<LoadingSpinner />}>
<Routes>
<Route path="/" element={<Home />} />
<Route path="/explore" element={<Explore />} />
<Route path="/recipe/:id" element={<RecipeDetail />} />
<Route path="/profile/:username" element={<Profile />} />
<Route path="/chat" element={<Chat />} />
</Routes>
</Suspense>
</AppLayout>
</ErrorBoundary>
);
}
#### Backend Architecture
The backend was built as a RESTful API using Express.js with WebSocket support via Socket.io:
javascript
// Backend architecture snippet
import express from 'express';
import http from 'http';
import { Server } from 'socket.io';
import mongoose from 'mongoose';
import cors from 'cors';
import cookieParser from 'cookie-parser';
import dotenv from 'dotenv';
// Route imports
import authRoutes from './routes/auth.routes.js';
import recipeRoutes from './routes/recipe.routes.js';
import userRoutes from './routes/user.routes.js';
import chatRoutes from './routes/chat.routes.js';
// Middleware imports
import { errorHandler } from './middleware/errorHandler.js';
import { authMiddleware } from './middleware/auth.js';
// Socket event handlers
import { setupSocketEvents } from './socket/socketHandler.js';
dotenv.config();
const app = express();
const server = http.createServer(app);
const io = new Server(server, {
cors: {
origin: process.env.FRONTEND_URL,
credentials: true
}
});
// Middleware
app.use(cors({ origin: process.env.FRONTEND_URL, credentials: true }));
app.use(express.json({ limit: '50mb' }));
app.use(cookieParser());
// Routes
app.use('/api/auth', authRoutes);
app.use('/api/recipes', recipeRoutes);
app.use('/api/users', userRoutes);
app.use('/api/chat', authMiddleware, chatRoutes);
// Socket.io setup
io.on('connection', (socket) => {
console.log('New client connected:', socket.id);
setupSocketEvents(io, socket);
});
// Error handling
app.use(errorHandler);
// Database connection
mongoose.connect(process.env.MONGODB_URI)
.then(() => {
console.log('Connected to MongoDB');
server.listen(process.env.PORT || 5000, () => {
console.log(Server running on port ${process.env.PORT || 5000}
);
});
})
.catch(err => {
console.error('MongoDB connection error:', err);
});
#### Database Schema Design
MongoDB provided the flexibility we needed for storing various types of content. Here's a simplified version of our main schema models:
javascript
// Recipe Schema
const RecipeSchema = new mongoose.Schema({
title: { type: String, required: true, index: true },
description: { type: String, required: true },
ingredients: [{
name: { type: String, required: true },
quantity: { type: String, required: true },
unit: { type: String }
}],
steps: [{ type: String, required: true }],
cookingTime: { type: Number, required: true },
difficulty: { type: String, enum: ['easy', 'medium', 'hard'], default: 'medium' },
servings: { type: Number, required: true },
images: [{ type: String }],
mainImage: { type: String, required: true },
category: [{ type: String, index: true }],
author: { type: mongoose.Schema.Types.ObjectId, ref: 'User', required: true },
likes: [{ type: mongoose.Schema.Types.ObjectId, ref: 'User' }],
comments: [{
user: { type: mongoose.Schema.Types.ObjectId, ref: 'User', required: true },
text: { type: String, required: true },
createdAt: { type: Date, default: Date.now }
}],
isPrivate: { type: Boolean, default: false },
createdAt: { type: Date, default: Date.now },
updatedAt: { type: Date, default: Date.now }
}, { timestamps: true });
// Add text search capabilities
RecipeSchema.index({ title: 'text', description: 'text' });
Key Technical Challenges and Solutions
#### Challenge 1: Real-Time Communication
Building a truly interactive cooking platform required robust real-time capabilities for chat, notifications, and collaborative cooking sessions.
Solution: We implemented a WebSocket architecture using Socket.io with room-based communication:
javascript
// Socket.io implementation for real-time chat
const setupSocketEvents = (io, socket) => {
// Join a room when entering a recipe page or chat
socket.on('join-room', (roomId) => {
socket.join(roomId);
console.log(User joined room: ${roomId}
);
});
// Leave a room
socket.on('leave-room', (roomId) => {
socket.leave(roomId);
console.log(User left room: ${roomId}
);
});
// Send a message in a recipe discussion
socket.on('send-message', async (data) => {
try {
const { roomId, message, userId } = data;
// Save message to database
const newMessage = await ChatMessage.create({
room: roomId,
sender: userId,
content: message
});
// Populate sender info
const populatedMessage = await ChatMessage.findById(newMessage._id)
.populate('sender', 'username profilePicture');
// Broadcast to everyone in the room
io.to(roomId).emit('new-message', populatedMessage);
} catch (error) {
console.error('Error sending message:', error);
}
});
// Handle typing indicators
socket.on('typing', (data) => {
const { roomId, username } = data;
socket.to(roomId).emit('user-typing', username);
});
socket.on('stop-typing', (roomId) => {
socket.to(roomId).emit('user-stopped-typing');
});
// Handle disconnection
socket.on('disconnect', () => {
console.log('Client disconnected:', socket.id);
});
};
This architecture enabled us to build features like live cooking sessions where users could join a virtual room and interact while following the same recipe.
#### Challenge 2: Media Management
Food content relies heavily on high-quality images and videos, but handling media uploads efficiently presented significant challenges.
Solution: We implemented a client-side optimization pipeline using the Cloudinary SDK:
javascript
// Media upload component with client-side optimization
import React, { useState } from 'react';
import { AdvancedImage } from '@cloudinary/react';
import { Cloudinary } from '@cloudinary/url-gen';
import { fill } from '@cloudinary/url-gen/actions/resize';
const ImageUploader = ({ onUploadComplete }) => {
const [isUploading, setIsUploading] = useState(false);
const [uploadProgress, setUploadProgress] = useState(0);
const [previewUrl, setPreviewUrl] = useState('');
// Initialize Cloudinary
const cld = new Cloudinary({
cloud: {
cloudName: process.env.REACT_APP_CLOUDINARY_CLOUD_NAME
}
});
const handleFileSelect = async (e) => {
const file = e.target.files[0];
if (!file) return;
// Client-side validation and optimization
if (!file.type.match('image.*')) {
alert('Please select an image file');
return;
}
// Create a local preview
const reader = new FileReader();
reader.onload = (e) => setPreviewUrl(e.target.result);
reader.readAsDataURL(file);
// Upload to Cloudinary with progress tracking
setIsUploading(true);
const formData = new FormData();
formData.append('file', file);
formData.append('upload_preset', process.env.REACT_APP_CLOUDINARY_UPLOAD_PRESET);
try {
const response = await fetch(
https://api.cloudinary.com/v1_1/${process.env.REACT_APP_CLOUDINARY_CLOUD_NAME}/image/upload
,
{
method: 'POST',
body: formData,
onUploadProgress: (progressEvent) => {
const progress = Math.round(
(progressEvent.loaded * 100) / progressEvent.total
);
setUploadProgress(progress);
}
}
);
const data = await response.json();
if (data.secure_url) {
onUploadComplete(data.secure_url, data.public_id);
}
} catch (error) {
console.error('Upload failed:', error);
alert('Upload failed. Please try again.');
} finally {
setIsUploading(false);
setUploadProgress(0);
}
};
return (
<div className="image-uploader">
<input
type="file"
accept="image/*"
onChange={handleFileSelect}
disabled={isUploading}
/>
{isUploading && (
<div className="progress-bar">
<div
className="progress"
style={{ width: ${uploadProgress}%
}}
/>
<span>{uploadProgress}%</span>
</div>
)}
{previewUrl && (
<div className="image-preview">
<img src={previewUrl} alt="Preview" />
</div>
)}
</div>
);
};
This approach provided several benefits:
- Client-side validation and preview before upload
- Progress tracking for larger files
- Automatic image optimization via Cloudinary
- Reduced server load by offloading processing to Cloudinary
#### Challenge 3: Authentication and Authorization
Building a social platform requires robust user authentication while keeping the onboarding process simple.
Solution: We implemented a hybrid authentication system with both traditional email/password and Google OAuth options:
javascript
// Auth controller with Google OAuth integration
import User from '../models/user.model.js';
import { OAuth2Client } from 'google-auth-library';
import jwt from 'jsonwebtoken';
import bcrypt from 'bcryptjs';
const googleClient = new OAuth2Client(process.env.GOOGLE_CLIENT_ID);
// Google OAuth verification
export const googleLogin = async (req, res) => {
try {
const { tokenId } = req.body;
// Verify the Google token
const ticket = await googleClient.verifyIdToken({
idToken: tokenId,
audience: process.env.GOOGLE_CLIENT_ID
});
const { email_verified, name, email, picture } = ticket.getPayload();
if (!email_verified) {
return res.status(400).json({ message: 'Email not verified with Google' });
}
// Check if user exists
let user = await User.findOne({ email });
if (!user) {
// Create new user if first time login
const username = name.replace(/\s+/g, '').toLowerCase() + Math.floor(Math.random() * 1000);
user = new User({
email,
username,
name,
profilePicture: picture,
authMethod: 'google',
emailVerified: true
});
await user.save();
}
// Generate JWT token
const token = jwt.sign(
{ id: user._id, role: user.role },
process.env.JWT_SECRET,
{ expiresIn: '7d' }
);
// Set HTTP-only cookie
res.cookie('token', token, {
httpOnly: true,
maxAge: 7 24 60 60 1000, // 7 days
secure: process.env.NODE_ENV === 'production',
sameSite: 'strict'
});
// Send user data (excluding password)
const userData = { ...user.toObject() };
delete userData.password;
return res.status(200).json({
message: 'Login successful',
user: userData
});
} catch (error) {
console.error('Google login error:', error);
return res.status(500).json({ message: 'Authentication failed' });
}
};
This authentication system provided several key benefits:
- Frictionless onboarding via Google OAuth
- Traditional email/password option for users without Google accounts
- Secure HTTP-only cookies for better security against XSS attacks
- JWT-based authentication for stateless API requests
Performance Optimizations
As our user base grew to over 5,000 active users, we implemented several performance optimizations:
- Server-Side Pagination and Filtering:
javascript
// Optimized recipe search with pagination, filtering, and aggregation
export const searchRecipes = async (req, res) => {
try {
const {
query,
page = 1,
limit = 12,
sortBy = 'createdAt',
sortOrder = -1,
difficulty,
cookingTime,
category
} = req.query;
// Build filter criteria
const filter = {};
// Text search if query provided
if (query) {
filter.$text = { $search: query };
}
// Apply additional filters
if (difficulty) filter.difficulty = difficulty;
if (category) filter.category = { $in: category.split(',') };
if (cookingTime) {
const [min, max] = cookingTime.split('-').map(Number);
filter.cookingTime = { $gte: min || 0 };
if (max) filter.cookingTime.$lte = max;
}
// Privacy filter - show only public recipes or user's own recipes
filter.$or = [
{ isPrivate: false },
{ author: req.user?._id }
];
// Calculate pagination
const skip = (page - 1) * limit;
// Execute count and find in parallel
const [totalResults, recipes] = await Promise.all([
Recipe.countDocuments(filter),
Recipe.find(filter)
.sort({ [sortBy]: sortOrder })
.skip(skip)
.limit(Number(limit))
.populate('author', 'username name profilePicture')
.lean()
]);
// Calculate likes count and user interaction status
const recipesWithMeta = recipes.map(recipe => ({
...recipe,
likesCount: recipe.likes?.length || 0,
isLiked: req.user ? recipe.likes?.includes(req.user._id) : false,
// Remove likes array to reduce payload size
likes: undefined
}));
return res.status(200).json({
recipes: recipesWithMeta,
pagination: {
total: totalResults,
page: Number(page),
pageSize: Number(limit),
totalPages: Math.ceil(totalResults / limit)
}
});
} catch (error) {
console.error('Recipe search error:', error);
return res.status(500).json({ message: 'Error searching recipes' });
}
};
- React Query for Efficient Data Fetching:
javascript
// Using React Query for caching and optimistic updates
import { useQuery, useMutation, useQueryClient } from 'react-query';
import { getRecipes, likeRecipe } from '../api/recipeService';
export const useRecipes = (filters) => {
return useQuery(
['recipes', filters],
() => getRecipes(filters),
{
keepPreviousData: true,
staleTime: 5 60 1000, // 5 minutes
}
);
};
export const useLikeRecipe = () => {
const queryClient = useQueryClient();
return useMutation(likeRecipe, {
// Optimistic update
onMutate: async ({ recipeId }) => {
// Cancel any outgoing refetches
await queryClient.cancelQueries('recipes');
// Snapshot previous value
const previousRecipes = queryClient.getQueryData('recipes');
// Optimistically update the UI
queryClient.setQueryData('recipes', old => {
return {
...old,
recipes: old.recipes.map(recipe => {
if (recipe._id === recipeId) {
return {
...recipe,
isLiked: !recipe.isLiked,
likesCount: recipe.isLiked
? recipe.likesCount - 1
: recipe.likesCount + 1
};
}
return recipe;
})
};
});
return { previousRecipes };
},
// Handle error
onError: (err, variables, context) => {
queryClient.setQueryData('recipes', context.previousRecipes);
},
// Always refetch after error or success
onSettled: () => {
queryClient.invalidateQueries('recipes');
}
});
};
- Image Optimization and Lazy Loading:
javascript
// Custom Image component with lazy loading and optimization
import React, { useState } from 'react';
import { LazyLoadImage } from 'react-lazy-load-image-component';
import 'react-lazy-load-image-component/src/effects/blur.css';
const OptimizedImage = ({ src, alt, className, aspectRatio = '1:1' }) => {
const [isLoaded, setIsLoaded] = useState(false);
const [error, setError] = useState(false);
// Extract Cloudinary URL if present
const isCloudinaryUrl = src?.includes('cloudinary');
// Generate optimized URL parameters for Cloudinary
const optimizedSrc = isCloudinaryUrl
? src.replace('/upload/', '/upload/q_auto,f_auto,c_limit,w_1000,h_1000/')
: src;
// Calculate aspect ratio for placeholder
const [width, height] = aspectRatio.split(':').map(Number);
const paddingTop = ${(height / width) * 100}%
;
return (
<div
className={image-container ${className}
}
style={{ position: 'relative', paddingTop, overflow: 'hidden' }}
>
{!error ? (
<LazyLoadImage
src={optimizedSrc}
alt={alt}
effect="blur"
afterLoad={() => setIsLoaded(true)}
onError={() => setError(true)}
style={{
position: 'absolute',
top: 0,
left: 0,
width: '100%',
height: '100%',
objectFit: 'cover',
transition: 'opacity 0.3s ease-in-out',
opacity: isLoaded ? 1 : 0
}}
/>
) : (
<div
className="error-placeholder"
style={{
position: 'absolute',
top: 0,
left: 0,
width: '100%',
height: '100%',
backgroundColor: '#f0f0f0',
display: 'flex',
alignItems: 'center',
justifyContent: 'center'
}}
>
Image not available
</div>
)}
</div>
);
};
Lessons Learned
Building Recipe Hub taught us several valuable lessons:
- Start with Clear Data Modeling
Investing time in designing clear, flexible data models upfront saved us countless hours of refactoring later. Our recipe schema evolved significantly as we added features, and having a solid foundation made these transitions smoother.
- Optimize for Mobile First
Over 70% of our users access Recipe Hub from mobile devices. Building with a mobile-first approach from day one ensured a seamless experience across all devices and reduced development time.
- Real-Time Features Require Thoughtful Architecture
Implementing real-time features like chat and collaborative cooking sessions required careful architecture planning. Our room-based WebSocket approach provided the flexibility we needed while maintaining performance.
- Security Cannot Be an Afterthought
As a social platform handling user data, security was paramount. Implementing features like HTTP-only cookies, strict content security policies, and proper input validation from the beginning helped us build user trust.
- Performance Optimization is Ongoing
As our user base grew, we continuously monitored and optimized performance. Techniques like pagination, lazy loading, and efficient state management were crucial for maintaining a responsive application.
Conclusion
Recipe Hub has grown from a simple recipe-sharing concept to a thriving community with over 5,000 active users. The technical architecture decisions and optimizations outlined in this article have been crucial to our success.
Building a full-stack social platform taught us the importance of balancing feature development with technical excellence. Each feature we added—from real-time chat to media management—required thoughtful implementation to maintain performance and security.
As we continue to evolve Recipe Hub, we're excited to explore new technologies like server components, edge computing, and AI-powered recipe recommendations to further enhance the user experience.
About the Author: Ajithkumar R is a full-stack developer specializing in building scalable web applications with React, Node.js, and MongoDB. He is passionate about creating intuitive user experiences and optimizing application performance.
Reader Comments
No comments yet.
Share Your Experience
Give a star rating and let me know your impressions.