Skip to main content

How to Add Product Recommendations to Your Next.js App (Without Building a Recommendation Engine)

· 18 min read
Eric Ngo
ML Engineer @dodo, ex-ML engineers @Meta/Samsung-Research/7-11

TL;DR: Building a recommendation engine from scratch takes weeks and a data scientist. Using an API takes 20 lines of code and an afternoon. This post shows you exactly how to do the latter with a working Next.js integration you can ship today.


Recommendations drive a disproportionate share of revenue in almost every product category. Amazon attributes roughly 35% of its revenue to its recommendation engine. Netflix says 80% of content watched comes from recommendations. You don't need Amazon's scale for this to matter — even a modest boost in click-through rate compounds fast when it's running 24/7 across every user session. If your app shows users a catalog of anything — products, articles, courses, listings — and you're not personalizing what they see, you're leaving conversions on the table.

The problem is that building recommendations properly is genuinely hard. But you don't have to build it. Let's look at both paths so you can make an informed decision — then we'll build the fast one.


Option A: Build It Yourself

Let's be honest about what this actually involves — because the tutorials that make it look easy are skipping the parts that will consume your next two months.

Step 1: Data pipeline

Before a single recommendation can fire, you need a data infrastructure that doesn't exist in your app yet. Your Postgres transactions table isn't enough. You need a dedicated event log capturing every meaningful user action — clicks, views, add-to-carts, purchases, scrolls, searches — in a structured format a model can consume. That means standing up a data warehouse, writing ETL jobs to pipe events into it, handling backfills, deduplication, and schema changes as your product evolves.

Most teams underestimate this completely. It feels like plumbing. It takes 1–2 weeks and it's not the interesting part — it's just the price of admission.

Step 2: Feature engineering

Here's the step that's almost never mentioned in the "build your own rec engine" tutorials. And it's the one that will humble you fastest if you don't have a data scientist on your team.

Your raw data — product names, prices, categories, timestamps — is completely useless to a recommendation model as-is. Models don't understand the word "Electronics". They don't know that a purchase made last week is more relevant than one made a year ago. They can't interpret a price of $999 in any meaningful relation to a price of $9. Every single piece of data has to be mathematically transformed into a form the model can learn from. That transformation process is called feature engineering, and it is where most DIY recommendation projects quietly die.

Consider just a few of the decisions you'll face. How do you encode product categories? If you turn each category into its own column (one-hot encoding), a catalog with 500 categories becomes a 500-column matrix that's 99% empty. If you map categories to integers, you're implying "Electronics = 3" is somehow greater than "Books = 1", which the model will incorrectly treat as meaningful. The right approach — learned embeddings — requires a neural network, training data, and someone who understands how to tune it without overfitting. Three options, each with significant tradeoffs, and you haven't written a line of model code yet.

Now do that for price. Do you normalize it linearly? Use a log scale because price distributions are heavily skewed? Use a robust scaler because your catalog has outliers? Each choice changes what your model learns. Each wrong choice degrades recommendation quality in ways that are genuinely hard to diagnose.

Now do that for time. A purchase timestamp means nothing to a model. You need to engineer recency explicitly — calculating how many days ago each purchase happened, applying an exponential decay so recent purchases carry more weight, computing purchase frequency per user, extracting seasonality signals like day of week and time of day. And all of this needs to run continuously as new data comes in, using the exact same logic that was used when the model was trained. That last part — keeping your training pipeline and your serving pipeline perfectly in sync — is called training-serving skew, and it's one of the most common and hardest-to-debug failure modes in production ML. When it breaks, your model doesn't throw an error. It just silently produces bad recommendations.

And none of this is a one-time decision. Every time your catalog changes, your user base evolves, or your model performance degrades, you revisit the feature choices. Large companies have entire teams — ML engineers, data scientists, MLOps — whose primary job is keeping this machinery running correctly. You're being asked to replicate that as a side project.

This step alone, done properly, takes 3–5 weeks. And you still haven't trained a model.


What features actually matter — and why each is hard to engineer

To make it concrete, here are the four categories of features data scientists build for recommendation systems. Each one sounds simple. None of them are.

User behavior features — what the user has done. These are the highest-signal inputs to any recommendation model.

USER BEHAVIOR FEATURES          Signal strength
────────────────────────────────────────────────
Purchase history ████████████ 95% ← Gold standard
Add-to-cart (didn't buy) ██████████ 85% ← Intent signal
Dwell time on product page ███████ 65% ← Interest proxy
Browsing / view history ██████ 55% ← Weak but useful
Search queries █████ 50% ← Explicit intent
Raw clicks ███ 30% ← Noisy, use carefully
────────────────────────────────────────────────
Each needs a different encoding strategy.

Item / catalog features — what you're recommending. Category is everything. Raw product descriptions are noise. Internal IDs are meaningless.

ITEM / CATALOG FEATURES         Impact
────────────────────────────────────────────────
Category + subcategory ████████████ Required
Price (normalized correctly) ██████████ Required
Brand / manufacturer ████████ Important
Average rating + review count ███████ Important
In-stock + availability ██████ Important — filter first
Product description (raw text) ██ Noisy — avoid unless embedded
Internal product ID █ Useless to the model
────────────────────────────────────────────────
Normalization strategy for price alone has 3+ valid options,
each producing meaningfully different model behavior.

Recency features — when things happened. A purchase from 2 years ago barely predicts what a user wants today. Recency decay is one of the highest-leverage transformations you can apply — and one of the trickiest to keep consistent between training and serving.

HOW RECENCY AFFECTS SIGNAL WEIGHT
────────────────────────────────────────────────────────────
This week ████████████████████ Full weight ~100%
Last month ████████████ Fading ~60%
3 months ago ██████ Weak ~30%
6 months ago ███ Very weak ~15%
1 year ago █ Near zero ~5%
2+ years ago · Ignored ~1%
────────────────────────────────────────────────────────────
Without recency decay, a user's 3-year-old purchase
dominates over what they bought yesterday.
Engineering this consistently across pipelines is non-trivial.

Contextual / session features — the circumstances of the request. Device type, time of day, referral source, session depth. These are effectively free signals that most DIY systems never capture because wiring them correctly across training and serving adds significant complexity.

CONTEXTUAL FEATURES             What they capture
────────────────────────────────────────────────────────────
Device type Mobile vs Desktop behavior differs
Time of day Morning browse ≠ Friday night impulse
Day of week Weekday vs Weekend purchase patterns
Session depth Page 1 visitor ≠ Page 10 deep researcher
Referral source Organic / Paid / Social / Email intent
Geography Price sensitivity + category preferences
────────────────────────────────────────────────────────────
Each requires its own encoding, validation, and pipeline.
Most teams skip these entirely — and lose meaningful signal.

Every one of these feature categories requires its own transformation logic, validation, and consistency guarantee between your training and serving pipelines. With Dodo, you skip all of it — you send raw fields and the platform handles the engineering.

Step 3: Model selection and training

Now the fun part — except it's not. You have to pick an algorithm, and each comes with a different set of failure modes and data requirements.

Collaborative filtering — recommending based on what similar users liked — requires tens of thousands of user interactions before it produces meaningful results. With sparse data, which every early-stage product has, it recommends garbage. Content-based filtering uses item metadata to find similar products and works with less data, but it never learns from actual user behavior and tends to produce repetitive, obvious recommendations. Matrix factorization is more powerful but exponentially harder to tune and explain when it goes wrong. LLM-based embedding approaches are increasingly effective but add latency and cost that most early products can't justify.

You will spend 2–3 weeks picking an approach, implementing it, discovering it doesn't perform as well as expected, and iterating. The iteration never fully stops.

Step 4: Serving infrastructure

A trained model on your laptop recommends nothing to your users. You need a model serving endpoint that returns recommendations at low latency under real traffic, a caching layer so you're not running expensive inference on every page load, a retraining pipeline to keep the model current as your data grows, monitoring to catch recommendation quality degradation before your users notice, and an A/B testing layer so you can actually measure whether any of this is working.

Each of these is a non-trivial engineering project. Together they're a platform.

The honest summary

What you're buildingTime
Data pipeline + event tracking1–2 weeks
Feature engineering3–5 weeks
Model selection + training2–3 weeks
Serving infrastructure1–2 weeks
A/B testing + monitoring1 week
Time to first recommendation in production8–13 weeks

After that: ongoing maintenance, retraining, and debugging — indefinitely. You're not adding a feature. You're committing to running a small data science organization inside your product team.

If recommendations are your core differentiator and you're at scale, that investment makes sense. For everyone else, there's a much faster path.


Option B: Use the Dodo API

Dodo is a personalization infrastructure API. You send it user context (purchase history, browsing behavior, whatever you have) and it returns ranked recommendations. No model to train, no pipeline to build, no infra to maintain.

Here is what the full integration looks like in a Next.js app.

What you will build

A catalog ingestion step to load your products into Dodo, a /api/recommendations route that calls Dodo at request time, and a <Recommendations /> component that renders the results. Total: about 30 lines of meaningful code.

Prerequisites

  • A Next.js app (App Router or Pages Router — we will cover both)
  • A Dodo API key (free tier at trydodo.xyz)
  • User context data — even just purchase history or recently viewed items works

Full Code Walkthrough

1. Store your credentials

Add your Dodo credentials to your environment variables. Never expose them client-side.

# .env.local
DODO_API_KEY=your_api_key_here
DODO_ACCESS_TOKEN=your_access_token_here
DODO_PRIMARY_KEY=your_primary_key_here
DODO_MODEL_KEY=your_model_key_here

2. Ingest your catalog

Before Dodo can recommend anything, it needs to know what's in your catalog. This is a one-time setup step (re-run it whenever your catalog changes significantly).

You fetch your products from your database, shape them into Dodo's entity format, and push them via the ingest endpoint. Each entity is a product ID mapped to its metadata — title, description, category, price, or whatever fields are relevant to your use case.

// scripts/ingest-catalog.ts
// Run this once to seed your catalog, then on a schedule as products change

async function ingestCatalog() {
// Step 1: Fetch your products from your database
const products = await db.product.findMany({
where: { active: true, in_stock: true },
select: {
id: true,
name: true,
description: true,
category: true,
subcategory: true,
price: true,
brand: true,
rating: true,
},
});

// Step 2: Shape into Dodo's entity format
// Keys are your product IDs, values are metadata Dodo uses for recommendations
const catalog: Record<string, object> = {};
for (const product of products) {
catalog[product.id] = {
title: product.name,
description: product.description,
category: product.category,
subcategory: product.subcategory,
price: product.price,
brand: product.brand,
rating: product.rating,
};
}

// Step 3: Push to Dodo via the ingest endpoint
const formData = new FormData();
formData.append("direct_input", JSON.stringify(catalog));
formData.append(
"template",
"Recommend products based on the user's purchase history: {context}. " +
"Prioritize items in the same category and similar price range."
);

const response = await fetch(
`https://api.trydodo.xyz/api/entities/ingest` +
`?api_key=${process.env.DODO_API_KEY}` +
`&primary_key=${process.env.DODO_PRIMARY_KEY}` +
`&model_key=${process.env.DODO_MODEL_KEY}` +
`&source=input`,
{
method: "POST",
headers: {
Authorization: `Bearer ${process.env.DODO_ACCESS_TOKEN}`,
},
body: formData,
}
);

if (!response.ok) {
throw new Error(`Catalog ingest failed: ${response.statusText}`);
}

console.log(`✓ Ingested ${products.length} products into Dodo`);
}

ingestCatalog();

Or via curl if you want to test it quickly without writing any code:

curl -X POST "https://api.trydodo.xyz/api/entities/ingest?api_key=${API_KEY}&primary_key=${PRIMARY_KEY}&model_key=${MODEL_KEY}&source=input" \
-H "Authorization: Bearer ${ACCESS_TOKEN}" \
-F "direct_input={\"product-1\": {\"title\": \"Running Shoes\", \"category\": \"footwear\", \"price\": 89}, \"product-2\": {\"title\": \"Yoga Mat\", \"category\": \"fitness\", \"price\": 35}}" \
-F "template=Recommend the next product based on the user's history: {context}"

What goes in the catalog entity? Include fields that are meaningful for recommendation decisions — category, subcategory, price, brand, rating. Skip internal IDs, admin flags, raw HTML descriptions, and anything that doesn't describe the product to a user. Cleaner catalog metadata = better recommendations.

When to re-ingest? Run the ingest script on a schedule (daily is usually fine) or trigger it from your product management flow whenever inventory changes significantly. Dodo doesn't need perfect real-time sync — it needs a reasonably fresh snapshot of what's available.

3. Create the recommendation API route

This route fetches recommendations server-side and returns them to the client. The API key stays on the server where it belongs.

App Router (/app/api/recommendations/route.ts):

import { NextRequest, NextResponse } from "next/server";

export async function POST(req: NextRequest) {
const { context, catalog } = await req.json();

const response = await fetch("https://api.trydodo.xyz/api/recommend", {
method: "POST",
headers: {
"Authorization": `Bearer ${process.env.DODO_API_KEY}`,
"Content-Type": "application/json",
},
body: JSON.stringify({
context,
catalog,
template:
"Recommend the next 4 products based on the user's purchase history: {context}. " +
"Prioritize items in the same category. Avoid recommending items the user already owns.",
}),
});

if (!response.ok) {
return NextResponse.json(
{ error: "Failed to fetch recommendations" },
{ status: response.status }
);
}

const data = await response.json();
return NextResponse.json(data);
}

Pages Router (/pages/api/recommendations.ts):

import type { NextApiRequest, NextApiResponse } from "next";

export default async function handler(
req: NextApiRequest,
res: NextApiResponse
) {
if (req.method !== "POST") {
return res.status(405).json({ error: "Method not allowed" });
}

const { context, catalog } = req.body;

const response = await fetch("https://api.trydodo.xyz/api/recommend", {
method: "POST",
headers: {
"Authorization": `Bearer ${process.env.DODO_API_KEY}`,
"Content-Type": "application/json",
},
body: JSON.stringify({
context,
catalog,
template:
"Recommend the next 4 products based on the user's purchase history: {context}. " +
"Prioritize items in the same category. Avoid recommending items the user already owns.",
}),
});

const data = await response.json();
res.status(response.ok ? 200 : 500).json(data);
}

4. Create a custom hook

This hook handles fetching, loading state, and errors cleanly.

// hooks/useRecommendations.ts
import { useState, useEffect } from "react";

interface Product {
id: string;
product_name: string;
price: number;
category: string;
image_url?: string;
}

interface UseRecommendationsProps {
context: Product[];
catalog: Record<string, Product>;
enabled?: boolean;
}

export function useRecommendations({
context,
catalog,
enabled = true,
}: UseRecommendationsProps) {
const [recommendations, setRecommendations] = useState<Product[]>([]);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);

useEffect(() => {
if (!enabled || context.length === 0) return;

const fetchRecommendations = async () => {
setLoading(true);
setError(null);

try {
const response = await fetch("/api/recommendations", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ context, catalog }),
});

if (!response.ok) throw new Error("Failed to fetch recommendations");

const data = await response.json();
setRecommendations(data.recommendations ?? []);
} catch (err) {
setError(err instanceof Error ? err.message : "Unknown error");
} finally {
setLoading(false);
}
};

fetchRecommendations();
}, [enabled, context.length]);

return { recommendations, loading, error };
}

5. Build the Recommendations component

// components/Recommendations.tsx
import { useRecommendations } from "@/hooks/useRecommendations";

interface Product {
id: string;
product_name: string;
price: number;
category: string;
image_url?: string;
}

interface RecommendationsProps {
userHistory: Product[];
catalog: Record<string, Product>;
onProductClick?: (product: Product) => void;
}

export function Recommendations({
userHistory,
catalog,
onProductClick,
}: RecommendationsProps) {
const { recommendations, loading, error } = useRecommendations({
context: userHistory,
catalog,
enabled: userHistory.length > 0,
});

if (userHistory.length === 0) return null;

if (loading) {
return (
<div className="recommendations">
<h2>Recommended for you</h2>
<div className="recommendations__grid">
{[...Array(4)].map((_, i) => (
<div key={i} className="recommendations__skeleton" />
))}
</div>
</div>
);
}

if (error || recommendations.length === 0) return null;

return (
<section className="recommendations">
<h2>Recommended for you</h2>
<div className="recommendations__grid">
{recommendations.map((product) => (
<button
key={product.id}
className="recommendations__card"
onClick={() => onProductClick?.(product)}
>
{product.image_url && (
<img
src={product.image_url}
alt={product.product_name}
className="recommendations__image"
/>
)}
<div className="recommendations__info">
<p className="recommendations__name">{product.product_name}</p>
<p className="recommendations__price">
${product.price.toFixed(2)}
</p>
</div>
</button>
))}
</div>
</section>
);
}

6. Use it in your page

Here is a realistic example — a product detail page that shows recommendations below the main product.

// app/products/[id]/page.tsx
import { Recommendations } from "@/components/Recommendations";

const CATALOG = {
"1": { id: "1", product_name: "MacBook Pro", price: 1999, category: "electronics" },
"2": { id: "2", product_name: "iPad Air", price: 599, category: "electronics" },
"3": { id: "3", product_name: "AirPods Pro", price: 249, category: "audio" },
"4": { id: "4", product_name: "Apple Watch", price: 399, category: "wearables" },
"5": { id: "5", product_name: "Magic Keyboard", price: 149, category: "accessories" },
};

export default function ProductPage({ params }: { params: { id: string } }) {
const userPurchaseHistory = [
{ id: "6", product_name: "iPhone 16", price: 999, category: "electronics" },
{ id: "7", product_name: "USB-C Cable", price: 19, category: "accessories" },
];

const currentProduct = CATALOG[params.id];

return (
<main>
<h1>{currentProduct?.product_name}</h1>
<p>${currentProduct?.price}</p>

<Recommendations
userHistory={userPurchaseHistory}
catalog={CATALOG}
onProductClick={(product) => {
console.log("Recommendation clicked:", product.product_name);
}}
/>
</main>
);
}

That is it. Dodo handles the ranking. You handle the rendering.


Common Gotchas

Gotcha 1: Calling the API client-side

Never call the Dodo API directly from the browser. Your API key will be exposed in network requests. Always proxy through a Next.js API route as shown above.

Gotcha 2: New users with no history

The component above returns null when userHistory.length === 0. This is correct behavior — showing random recommendations to new users is worse than showing nothing. For cold start, use popularity-based fallbacks (your bestsellers) served separately, not through the personalization API.

{userHistory.length > 0 ? (
<Recommendations userHistory={userHistory} catalog={catalog} />
) : (
<Bestsellers catalog={catalog} />
)}

Gotcha 3: Stale catalog data

If your catalog changes frequently (inventory, prices, new products), do not hardcode it. Fetch it fresh before sending to the API, or cache it with a short TTL:

const catalogResponse = await fetch("https://your-api.com/catalog", {
next: { revalidate: 300 }, // revalidate every 5 minutes
});
const catalog = await catalogResponse.json();

Gotcha 4: Sending too little context

The more signal you give Dodo, the better the recommendations. Minimum viable context:

{
"product_name": "iPhone 16",
"price": 999,
"category": "electronics",
"subcategory": "smartphones"
}

Better context with more signal:

{
"product_name": "iPhone 16",
"price": 999,
"category": "electronics",
"subcategory": "smartphones",
"brand": "Apple",
"purchase_date": "2024-11-01",
"quantity": 1
}

Gotcha 5: Not handling errors gracefully

Recommendation APIs are not critical path — a failed call should never break your page. The component above already handles this (returns null on error), but make sure your API route also handles Dodo API errors without throwing unhandled exceptions.

Gotcha 6: Re-fetching on every render

The hook uses context.length as its dependency rather than the full context array. This prevents infinite re-render loops from object reference changes. If you need to re-fetch when specific products change (not just the count), use a stable identifier:

const contextKey = userHistory.map((p) => p.id).join(",");

useEffect(() => {
fetchRecommendations();
}, [contextKey]);

Customizing the Recommendation Template

The template field is where you control recommendation behavior in plain English. This is more powerful than it looks. Some examples:

Cross-sell (complementary items):

"Recommend 4 products that complement {context}. Focus on accessories and add-ons
under $100. Avoid recommending items in the same primary category."

Upsell (higher-value alternatives):

"Recommend 3 premium alternatives to the products in {context}. Prioritize items
with better specs or higher ratings in the same category."

Price-sensitive users:

"Recommend 4 products similar to {context}. The user's average spend is $50 —
prioritize items in the $20-$80 range."

Category discovery:

"Recommend 4 products from categories the user has not explored yet, based on what
similar customers with history like {context} have purchased."

What This Looks Like in Production

When it is all wired together:

  1. User lands on a product page (or dashboard, cart, homepage — wherever you want recs)
  2. Your page fetches the user's purchase/view history from your database
  3. The <Recommendations /> component calls /api/recommendations with that history and your catalog
  4. Your API route calls Dodo and returns ranked product IDs
  5. The component renders the recommended products
  6. User sees relevant products, clicks one, converts

Latency is typically under 300ms. The integration shown above is production-ready as written — error handling, loading states, server-side API key protection, and sensible re-fetch logic all included.


Next Steps

You now have working product recommendations in your Next.js app. A few directions from here:

Measure it. Add click tracking to the onProductClick handler and measure conversion rate from recommendations vs non-recommendations. Use Dodo's built-in A/B testing to run a controlled experiment.

Improve your context. The better the data you send, the better the recommendations. Add timestamps to your history, include more catalog metadata, and start tracking browsing behavior (not just purchases) for richer signal.

Handle cold start. Build a proper cold start strategy for new users — either popular items, a quick onboarding question, or session-based context.

Try different templates. Experiment with different instructions for different pages (product detail vs. cart vs. homepage) and measure which drives more conversions.


Ready to try it? Get your free API key and have recommendations running in your app today: trydodo.xyz/docs/quickstart

The free tier covers 5,000 recommendation decisions per month — enough to test, iterate, and prove out the impact before spending anything.


Have questions about the integration? Join the Dodo Discord or open an issue on GitHub.