Every product website can personalize easily once someone logs in — you know who they are, what they've bought before, and you can build them a profile worth targeting. That part's basically solved.
The real challenge is the visitor who never logs in at all. Most people don't sign up before they've even decided to buy anything, so if personalization only switches on after account creation, you miss the exact moment it matters most — their very first visit. That first-time, anonymous visit is also most of a site's traffic, and exactly the segment a marketing team is working hardest to convert.
That's the gap I wanted to close: could a homepage still personalize itself for someone with zero login, zero profile, and only a handful of page views to go on? Not a discount popup or an exit-intent banner — an actual recommendation that adapts to what they've shown interest in, from click one.
This Recommendation site is built with Next.js, GraphQL, and MongoDB — a storefront where the homepage learns from real browsing behavior, no account required.
🎥 Live Demo: https://recommendation-engine-sand.vercel.app/
📂 Source Code: https://github.com/Suresh4405/recommendation-engine
What It Does
- Shows a personalized "Recommended for you" strip that changes based on real browsing behavior
- First-time visitors see admin-curated "default" picks instead of an empty page
- Recognizes returning visitors without any login — just a cookie
- Ranks by how often you view something, not just the last thing you clicked
- Falls back gracefully — if you've only looked at 2 products in a category, it fills the rest of the row instead of showing 3 empty cards
- Full admin panel: add/edit products, reorder them by typing a position number, star specific ones as default picks
- Cursor-based pagination on the main grid (no page numbers, just "Load more")
- A dummy cart (localStorage-backed) so the flow feels complete end to end
The Tech Stack
Frontend: Next.js 14 (App Router), Tailwind CSS
API layer: GraphQL via Apollo Server, mounted as a Next.js route handler
Database: MongoDB + Mongoose
The "no login" trick: a single anonymous cookie, set in middleware
How It Works
Step 1: Recognizing a visitor without asking them to sign in
The whole system hangs off one idea — you don't need to know who someone is to remember that they've been here before. Middleware runs before every request and drops a random ID in a cookie if one doesn't already exist:
// middleware.js
export function middleware(request) {
const response = NextResponse.next();
const existing = request.cookies.get("guestId");
if (!existing) {
response.cookies.set("guestId", uuidv4(), {
maxAge: 60 * 60 * 24 * 365,
httpOnly: false,
sameSite: "lax",
path: "/",
});
}
return response;
}
That's it. No user table, no password, no session store. Just a UUID sitting in the browser for a year. httpOnly is deliberately false here — it's not sensitive, and client components need to read it to attach it to GraphQL calls.
Step 2: Logging what people actually look at
Every time a product page loads, a tiny client component fires a mutation and does nothing else — no UI, no loading state, just a side effect:
// components/ViewTracker.jsx
export default function ViewTracker({ productId }) {
useEffect(() => {
const guestId = getGuestId();
if (!guestId) return;
gqlFetch(TRACK_VIEW_MUTATION, { guestId, productId }).catch(() => {});
}, [productId]);
return null;
}
That mutation just writes one row to a separate interactions collection — it never touches the products collection itself:
async trackView(_parent, { guestId, productId }) {
await connectToDatabase();
const product = await Product.findById(productId).lean();
if (!product) return false;
await Interaction.create({
guestId,
productId,
category: product.category,
brand: product.brand,
action: "viewed",
});
return true;
}
Over time this collection becomes a plain log: this guestId looked at these categories, this often, this recently. Nothing fancier than that — no embeddings, no ML model, just a count.
Step 3: Turning that log into an actual recommendation
This is the part I rewrote the most. My first version weighted the most recent view the heaviest, on the theory that recent interest should matter more. In practice this backfired constantly — someone would browse mobiles twice, glance at a laptop once out of curiosity, and the whole homepage would flip to laptops. One click undoing two real signals felt wrong.
So I flipped the model: frequency is the primary signal, recency only breaks a tie.
const scoreByCategory = {};
const viewCountByProduct = {};
recentViews.forEach((view, idx) => {
// tiny recency bonus - max +0.01, only matters when categories are tied
const recencyBonus = (recentViews.length - idx) / recentViews.length / 100;
scoreByCategory[view.category] =
(scoreByCategory[view.category] || 0) + 1 + recencyBonus;
const pid = view.productId.toString();
viewCountByProduct[pid] = (viewCountByProduct[pid] || 0) + 1;
});
Then, within the winning category, individual products get re-ranked by how many times that specific product was viewed — not just newest or most discounted:
categoryProducts.sort((a, b) => {
const aViews = viewCountByProduct[a._id.toString()] || 0;
const bViews = viewCountByProduct[b._id.toString()] || 0;
if (bViews !== aViews) return bViews - aViews;
if (b.discountPercent !== a.discountPercent)
return b.discountPercent - a.discountPercent;
return new Date(b.createdAt) - new Date(a.createdAt);
});
If someone's never visited before, there's nothing to score — so it falls back to a curated default row:
const fallback = async () => {
const docs = await Product.find({})
.sort({ featured: -1, sortOrder: 1, createdAt: -1 })
.limit(count)
.lean();
return {
title: "New arrivals for you",
reason: "new_arrival",
products: docs.map(serializeProduct),
};
};
featured and sortOrder are both admin-controlled — more on that below.
Step 4: Letting admin actually curate this, not just watch it happen automatically
I also didn't want the admin to be stuck just watching this happen automatically with no way to guide it. So I added two simple controls:
- A position number per product (type
1, hit enter, it jumps there) — controls default order everywhere. - A featured star — marks a product as a guaranteed pick for first-time visitors, independent of its position.
async setProductPosition(_parent, { id, position }) {
await connectToDatabase();
const docs = await Product.find({})
.sort({ sortOrder: 1, createdAt: -1, _id: -1 })
.lean();
const index = docs.findIndex((d) => d._id.toString() === id);
if (index === -1) return false;
const [moved] = docs.splice(index, 1);
const targetIndex = Math.max(0, Math.min(position - 1, docs.length));
docs.splice(targetIndex, 0, moved);
await Promise.all(
docs.map((doc, i) => Product.updateOne({ _id: doc._id }, { sortOrder: i }))
);
return true;
}
Every reorder fully resequences the collection (0, 1, 2, ...) instead of trying to patch just two records — simpler to reason about, and it self-heals any products that never had an explicit order set.
What Broke First
Pagination started repeating products. Once I added manual ordering, the main grid's sort became 3 fields deep (sortOrder, createdAt, _id), but "Load more" was still only checking _id to know where to continue. That meant page 2 could re-fetch stuff already shown on page 1. Fixed by encoding all 3 sort values into the cursor itself:
export function encodeCursor(fields) {
return Buffer.from(JSON.stringify(fields)).toString("base64");
}
and building the "after" filter to match the sort exactly:
filter.$or = [
{ sortOrder: { $gt: so } },
{ sortOrder: so, createdAt: { $lt: crDate } },
{ sortOrder: so, createdAt: crDate, _id: { $lt: id } },
];
Schema and resolver drift. GraphQL will happily let you define a resolver for a field that doesn't exist in your schema, and it fails loudly and unhelpfully at server startup (Query.similarProducts defined in resolvers, but not in schema). Lesson learned: change the schema and the resolver in the same commit, every time, no exceptions.
Recency dominating frequency, covered above — took actually using the thing for a day to notice it felt wrong, not something I'd have caught from just reading the code.
Why I Log Interactions Separately Instead of Bolting Fields Onto Products
I could've just added a viewCount field directly on each product document and incremented it. Tempting, but:
- You lose who viewed it and when — which is the entire input to personalization
- You can't tell "5 different people viewed this once" from "1 person viewed it 5 times" — very different signals
Visit / for the storefront, /admin for the product panel (password-gated). Click into a couple of products, go back to the homepage, and the top strip should already reflect what you looked at.
🚀 Live Demo: https://sureshcodes.vercel.app/recommendation-engine
📂 Source Code: https://github.com/Suresh4405/recommendation-engine
Top comments (0)