Project case study

DevBuddy

A Tinder-style networking app where developers swipe through profiles, connect, and chat in real time.

React 19 + Vite 7Redux ToolkitReact Router 7Tailwind CSS + daisyUIFramer MotionAxiosSocket.io clientNode.js + Express 5JWT + bcrypt + cookie-parservalidatorSocket.ioAWS SES (SDK v3)node-cron + date-fnsMongoDB + Mongoose 8Vercel + Render
DevBuddy screenshot

Objective

What this project sets out to do

Finding someone to build a side project with, pair on interview prep or just talk code with usually means cold messages on LinkedIn or random Discord servers. DevBuddy borrows the one interaction everyone already understands - swipe right or left - and applies it to developers: browse profiles with their skills, show interest, and once both sides agree, talk in a real-time chat. The interesting part is the backend: a REST API on Express and MongoDB that owns authentication, the connection rules and the feed logic, plus a Socket.io server for chat and AWS SES for email.

  • Match developers through a simple, swipeable feed of profiles with skills, age and a short bio.
  • Make connections mutual: a request is only a connection once the other person accepts it.
  • Never show the same profile twice - not yourself, and not anyone you have already swiped on or connected with.
  • Let connected developers chat instantly, with the conversation saved so it is still there next time.
  • Keep accounts secure: hashed passwords, httpOnly cookie sessions and validation on every input.

Features

What you can do in the app

  • Sign up and log in with email and password; the session is a JWT in an httpOnly cookie that lasts 8 hours.
  • Profiles: name, photo, age, gender, a 200-character bio and up to 15 skills, plus a separate change-password flow.
  • Developer feed: one card at a time with Interested or Ignore; the card swipes off-screen with a Framer Motion animation.
  • Connection requests: a list of people interested in you, each with Accept and Reject.
  • Connections: everyone you have matched with, each with a button to open a chat.
  • Real-time chat with Socket.io; messages are saved in MongoDB and loaded when the chat opens.
  • Email notifications through AWS SES when someone shows interest in you, and a daily 8 AM node-cron job that looks for requests still waiting for a reply.
  • Premium page with Silver and Gold plans (Razorpay checkout is in progress).

Tech stack

What it's built with, and why

Frontend

React 19 + Vite 7
Component UI with a fast dev server and production build.
Redux Toolkit
One store for the user, feed, requests and connections, shared across pages without prop drilling.
React Router 7
Nested routes under one layout: feed, profile, requests, connections, premium and /chat/:targetUserId.
Tailwind CSS + daisyUI
Utility styles and ready-made components (cards, buttons, chat bubbles) with a custom theme.
Framer Motion
The swipe-left / swipe-right exit animation on feed cards.
Axios
API calls with withCredentials so the session cookie travels with every request.
Socket.io client
A live connection to the chat server for sending and receiving messages.

Backend

Node.js + Express 5
REST API split into routers: auth, profile, requests, users/feed and chat.
JWT + bcrypt + cookie-parser
Passwords hashed with bcrypt; sessions as signed JWTs in httpOnly cookies.
validator
Email, strong-password and URL checks, in the routes and in the schema.
Socket.io
Real-time chat on the same HTTP server as the API.
AWS SES (SDK v3)
Transactional email when someone sends you a request.
node-cron + date-fns
A scheduled job every morning that finds the previous day's pending requests.

Database

MongoDB + Mongoose 8
Three collections - users, connection requests and chats - with schema validation, timestamps, references with populate, and a compound index.

Hosting

Vercel + Render
The React app on Vercel; the Express + Socket.io server on Render, with allowed origins set by environment variable.

State management

How state is managed with Redux Toolkit, with the rules enforced on the server

On the client, Redux Toolkit holds four small slices: the signed-in user, the feed, incoming requests and connections. Pages fetch their data once, keep it in the store, and update it locally after an action (removing a swiped card or an answered request) instead of refetching. The real logic lives on the server: MongoDB is the source of truth, the connection request is a small state machine, and every rule - who can see whom, who can accept what - is enforced by the API, not the UI.

The store

// utils/appStore.js
import { configureStore } from "@reduxjs/toolkit";
import userReducer from "./userSlice";
import feedReducer from "./feedSlice";
import connectionsReducer from "./connectionsSlice";
import requestReducer from "./requestSlice";

const appStore = configureStore({
  reducer: {
    user: userReducer,          // the signed-in developer (or null)
    feed: feedReducer,          // profiles still to swipe on
    connection: connectionsReducer, // accepted matches
    request: requestReducer,    // requests waiting for my answer
  },
});

The slices

user

The signed-in developer's profile, or null when logged out.

addUserremoveUser
Written by:
Body (restores the session via GET /profile/view), the login and sign-up form, profile edit and logout.
Read by:
The header, profile page, chat (to know which messages are mine) and route-level redirects.

feed

The list of profiles from GET /feed that you have not swiped on yet.

addFeedremoveUserFromFeed
Written by:
Feed loads it once; FeedCard removes a profile after the swipe animation and request.
Read by:
Feed, which shows only the first card.

request

Requests sent to you with status interested, each with the sender's public profile.

addRequestremoveRequest
Written by:
Requests loads GET /user/requests/received; Accept or Reject removes the card.
Read by:
The requests page.

connection

Everyone you are connected with (accepted in either direction).

addConnectionsremoveConnections
Written by:
Connections loads GET /user/connections.
Read by:
The connections page and its chat buttons.

Server (MongoDB)

users, connectionrequests (fromUserId, toUserId, status) and chats (participants plus embedded messages).

POST /request/send/:status/:toUserIdPOST /request/review/:status/:requestIdGET /feedsocket: sendMessage
Written by:
Only the Express API and the Socket.io server, after the auth middleware has identified the user.
Read by:
Every page, through the API.

How data flows

1. Sessions: a JWT in an httpOnly cookie, checked by one middleware

On sign-up or login the server hashes or checks the password with bcrypt, signs a JWT with the user's id and sets it as an httpOnly cookie - JavaScript in the page can never read it. Every protected route runs userAuth first: it reads the cookie, verifies the token, loads the user and attaches it to req.user. Because the frontend (Vercel) and API (Render) are on different domains, the cookie is secure and sameSite: "none" in production.

  1. POST /login

    bcrypt.compare

  2. user.getJWT()

    signed, expires in 8h

  3. Set-Cookie

    httpOnly, secure, sameSite

  4. userAuth

    verify token → load user

  5. req.user

    available to every route

middlewares/auth.js - the guard in front of every private route
const userAuth = async (req, res, next) => {
  try {
    const { token } = req.cookies;
    if (!token) return res.status(401).send("Please Login");

    const { _id } = jwt.verify(token, process.env.JWT_SECRET);
    const user = await User.findById(_id);
    if (!user) throw new Error("User not found");

    req.user = user; // routes use req.user, never an id from the body
    next();
  } catch (err) {
    res.status(400).send("ERROR: " + err.message);
  }
};

2. Connection requests: a small state machine with guard rails

A swipe creates a ConnectionRequest with status interested or ignored. Only the person it was sent to can then move an interested request to accepted or rejected. Before saving, the API rejects any other status, a request to yourself, an invalid or unknown user, and - with one $or query - a request that already exists in either direction, so two people can never have two requests between them.

  1. Swipe

    interested / ignored

  2. Validate

    status, self, ObjectId, user exists

  3. Duplicate check

    A→B or B→A

  4. Save + SES email

    to the receiver

  5. Review

    receiver: accepted / rejected

routes/request.js - checks before a request is saved
const allowedStatus = ["ignored", "interested"];
if (!allowedStatus.includes(status))
  return res.status(400).json({ message: "Invalid Status type " + status });

if (fromUserId.toString() === toUserId)
  return res.status(400).json({ message: "You cannot send a request to yourself" });

// One request per pair of people, whichever way round it was sent
const existing = await ConnectionRequest.findOne({
  $or: [
    { fromUserId, toUserId },
    { fromUserId: toUserId, toUserId: fromUserId },
  ],
});
if (existing) return res.status(400).json({ message: "Connection Request Already Exist!!" });

// Reviewing: only the receiver, and only a pending "interested" request
const request = await ConnectionRequest.findOne({
  _id: requestId,
  toUserId: loggedInUser._id,
  status: "interested",
});

3. The feed: everyone you have not met yet, a page at a time

The feed query starts from every request involving you - sent or received, any status - and collects both sides into a Set of ids to hide. It then asks MongoDB for users not in that set and not you, returns only public fields, and paginates with skip and limit (capped at 50 per page). On the client the feed is fetched once into Redux; each swipe removes the top card locally, so the next profile appears instantly without another request.

  1. Your requests

    from you or to you

  2. Hide set

    both user ids

  3. User.find

    $nin hidden, $ne you

  4. Safe fields

    no email or password

  5. Paginate

    limit ≤ 50

routes/user.js - building the feed
const USER_SAFE_DATA = "firstName lastName photoUrl age gender about skills";

const requests = await ConnectionRequest.find({
  $or: [{ fromUserId: loggedInUser._id }, { toUserId: loggedInUser._id }],
}).select("fromUserId toUserId");

const hideUsersFromFeed = new Set();
requests.forEach((r) => {
  hideUsersFromFeed.add(r.fromUserId.toString());
  hideUsersFromFeed.add(r.toUserId.toString());
});

const limit = Math.min(parseInt(req.query.limit) || 10, 50);
const users = await User.find({
  $and: [
    { _id: { $nin: Array.from(hideUsersFromFeed) } },
    { _id: { $ne: loggedInUser._id } },
  ],
})
  .select(USER_SAFE_DATA)
  .skip((page - 1) * limit)
  .limit(limit);

4. Real-time chat: a private room per pair, saved to MongoDB

Opening a chat loads the history over REST (GET /chat/:targetUserId, creating the chat document on first use) and opens a Socket.io connection. Both people join the same room, whose id is a SHA-256 hash of their two user ids sorted - so it is identical for both of them, but not guessable from outside. Sending a message saves it into the chat's messages array and then broadcasts it to the room.

  1. Open /chat/:id

    GET history

  2. joinChat

    roomId = sha256(sorted ids)

  3. sendMessage

    push into Chat.messages

  4. Save

    MongoDB

  5. messageReceived

    emitted to the room

utils/socket.js - one room per pair of developers
const getSecretRoomId = (userId, targetUserId) =>
  crypto
    .createHash("sha256")
    .update([userId, targetUserId].sort().join("_"))
    .digest("hex");

io.on("connection", (socket) => {
  socket.on("joinChat", ({ userId, targetUserId }) => {
    socket.join(getSecretRoomId(userId, targetUserId));
  });

  socket.on("sendMessage", async ({ userId, targetUserId, text, ...sender }) => {
    const roomId = getSecretRoomId(userId, targetUserId);
    let chat = await Chat.findOne({ participants: { $all: [userId, targetUserId] } });
    if (!chat) chat = new Chat({ participants: [userId, targetUserId], messages: [] });

    chat.messages.push({ senderId: userId, text });
    await chat.save();
    io.to(roomId).emit("messageReceived", { ...sender, text });
  });
});

5. Email and scheduled jobs

When someone shows interest, the request route sends the receiver an email through AWS SES straight away. Separately, a node-cron job runs every day at 8 AM: it uses date-fns to work out the start and end of yesterday, finds every request from that window that is still interested, de-duplicates the receivers with a Set, and loops through them to send a reminder.

  1. cron 0 8 * * *

    every day, 8 AM

  2. Yesterday

    startOfDay / endOfDay

  3. Pending requests

    status interested

  4. Unique emails

    new Set()

  5. SES

    SendEmailCommand

Design decisions

  • Cookies, not localStorage, for the session. An httpOnly cookie cannot be read by injected scripts, and withCredentials on Axios and Socket.io sends it automatically.
  • Two layers of validation. Routes check input up front (strong password, valid email, allowed statuses), and the Mongoose schema enforces it again (name length and letters only, age 18-100, URL photos, at most 15 skills), so bad data cannot reach the database another way.
  • Whitelisted profile edits. PATCH /profile/edit rejects the request if it contains any field outside an allowed list, so nobody can sneak a password or _id change through the profile form. Passwords change only through their own route, which checks the current password first.
  • Never leak private fields. Every query that returns other people uses one USER_SAFE_DATA projection, so emails and password hashes are never sent to another user.
  • Requests cannot be duplicated or hijacked. The duplicate check covers both directions, and a review only matches a request addressed to the logged-in user that is still interested.
  • A compound index on { fromUserId, toUserId } keeps request lookups fast as the collection grows.
  • Deterministic, private chat rooms. Hashing the sorted pair of ids gives both people the same room without storing a room table, and without exposing raw user ids as room names.
  • Optimistic UI. Swipes and request answers update the Redux store immediately after the call, instead of refetching the whole list.

See it in action

Try the live app or browse the full source on GitHub.