What it does
Memories is a full-stack social app for posting life moments: a title, a message, an image, and a set of tags. Other users can browse the feed, search it, tag-filter it, and like posts. It's the kind of app that looks simple on the surface but forces you to make real decisions about auth, data modeling, and state — which is exactly why I built it end to end rather than just wiring up a CRUD demo.
The stack is deliberately unopinionated MERN: MongoDB Atlas for storage, Express for the API, React for the client, all glued together with Redux and redux-thunk for async state.
| Layer | Choice | Why |
|---|---|---|
| Client | React + Redux + Material UI v4 | Predictable state container for a feed that mutates from multiple places (create, like, search, paginate) |
| API | Express + Mongoose | Thin controller layer, schema-flexible documents |
| Auth | JWT (custom) + Google OAuth | Users shouldn't be forced to create a password just to post a memory |
| DB | MongoDB Atlas | Document shape matches the post object almost 1:1 — no join overhead for a feed read |
Dual authentication, one middleware
The interesting problem here wasn't "add login" — it was supporting two credential types without duplicating every protected route. Custom sign-up issues its own JWT signed with a server secret; Google Sign-In hands back a Google-issued token that the server never signed. Both need to resolve to the same req.userId downstream.
The fix is a single length check in the auth middleware: Google's tokens are long JWTs (hundreds of characters), while the app's own tokens are short-lived and compact. That's enough to branch between jwt.verify (for tokens the server can validate against its own secret) and jwt.decode (for tokens it can only read, since it isn't the issuer):
const auth = async (req, res, next) => {
try {
const token = req.headers.authorization.split(" ")[1];
const isCustomAuth = token.length < 500;
let decodedData;
if (token && isCustomAuth) {
decodedData = jwt.verify(token, secret);
req.userId = decodedData?.id;
} else {
decodedData = jwt.decode(token);
req.userId = decodedData?.sub;
}
next();
} catch (error) {
console.log(error);
}
};
It's a pragmatic heuristic rather than a "correct" one — a length threshold, not a signature check on the Google side — but it's cheap, it works for exactly the two token shapes the app actually issues, and it keeps every downstream route (likePost, createPost, updatePost) agnostic to which auth path the user came through. id vs sub is the other small landmine: custom tokens carry the Mongo _id, Google tokens carry the OAuth sub claim, and the middleware normalizes both into req.userId before anything else touches it.
Search and tags share one query
Rather than building separate "search by title" and "filter by tag" endpoints, both collapse into a single Mongo query using $or:
export const getPostsBySearch = async (req, res) => {
const { searchQuery, tags } = req.query;
const title = new RegExp(searchQuery, "i");
const posts = await PostMessage.find({
$or: [{ title }, { tags: { $in: tags.split(',') } }],
});
res.json({ data: posts });
};
A case-insensitive regex against title and an $in match against the tags array run in the same query, so a search for "beach" surfaces posts titled "Beach Day" and posts merely tagged #beach. On the client, the tag input is a chip field (material-ui-chip-input) rather than free text, which keeps tag values normalized instead of letting users type five variants of the same tag.
Pagination lives in the URL, not just in state
Feed pagination is server-driven — skip/limit against _id-sorted documents, with the total page count computed from countDocuments:
const LIMIT = 8;
const startIndex = (Number(page) - 1) * LIMIT;
const total = await PostMessage.countDocuments({});
const posts = await PostMessage.find().sort({ _id: -1 }).limit(LIMIT).skip(startIndex);
res.json({ data: posts, currentPage: Number(page), numberOfPages: Math.ceil(total / LIMIT) });
The detail that matters on the client side is that page is read from useLocation().search, not from component state:
function useQuery() {
return new URLSearchParams(useLocation().search);
}
const page = query.get('page') || 1;
That one choice means a paginated feed URL is shareable and bookmarkable, and the browser back button moves through pages correctly for free — a small thing that a state-only pagination implementation doesn't give you.
Trade-off: images as base64, not object storage
Uploaded images go through react-file-base64 and are stored as base64 strings directly on the PostMessage document, rather than uploaded to S3/Cloudinary with a URL reference saved instead. That's the right call for a small personal-scale app — one write, no separate storage bucket, no signed-URL expiry logic to manage — but it doesn't scale past a modest number of posts before document size and read bandwidth become a real cost. It's a conscious "ship the simplest thing that's correct for the current scale" decision, not an oversight, and the swap to object storage later is isolated to Form.js and the schema — nothing else in the app cares how the image string was produced.
Likes without a separate collection
Likes are a [String] array of user IDs on the post document itself, toggled with an index lookup:
const index = post.likes.findIndex((id) => id === String(req.userId));
if (index === -1) post.likes.push(req.userId);
else post.likes = post.likes.filter((id) => id !== req.userId);
No join table, no separate Like model — for a feed where "who liked this" isn't queried independently of the post, embedding wins on both simplicity and read performance.
Takeaway
None of the individual pieces here are exotic — JWT auth, Mongo pagination, a search endpoint. What this project demonstrates is the habit of treating each of those as a real decision rather than a default: picking a token-shape heuristic that fits the exact two auth providers in use, putting pagination state in the URL because it changes what "shareable" means, and choosing document-embedded images and likes because the read pattern doesn't justify normalizing them. That's the kind of judgment that matters more in production code than in a tutorial — knowing which corners are safe to cut for the scale you're actually at, and which aren't.