Article Recommendation System with TypeScript and Supabase
We want a function with a simple contract:
Given a post P, return the posts most similar to P.
A practical way to do that is to label posts with categories and compare their category overlap. That gives us a recommendation system that is easy to explain, easy to debug, and good enough for a small content site.
To support that, we need a many-to-many relationship between posts and categories.

Once we have that structure, we can turn each post into a category vector and compare those vectors with cosine similarity.
export function getCosineSimilarity(a: boolean[], b: boolean[]): number {
let dotProduct = 0;
let normA = 0;
let normB = 0;
for (let i = 0; i < a.length; i++) {
const aValue = Number(a[i]);
const bValue = Number(b[i]);
dotProduct += aValue * bValue;
normA += aValue * aValue;
normB += bValue * bValue;
}
const denominator = Math.sqrt(normA) * Math.sqrt(normB);
return denominator === 0 ? 0 : dotProduct / denominator;
}
The vector order must match the category order exactly. For example, if we are describing posts about web development, the category list might look like this:
| Category | Rendering Patterns | Basics of Web Development |
|---|---|---|
| HTML | false | true |
| CSS | false | true |
| JavaScript | true | true |
| React | true | false |
In that example, the vector for Rendering Patterns would be [false, false, true, true].
We can generate those vectors in linear time with a Set lookup.
export function getCategoryVector(
postCategoryIds: Set<number>,
allCategoryIds: number[],
): boolean[] {
return allCategoryIds.map((categoryId) => postCategoryIds.has(categoryId));
}
That gives us the pieces we need to rank related posts.
The algorithm is straightforward:
- Fetch all posts and categories.
- Build a category vector for each post.
- Find the source post
P. - Compare every other post to
Pwith cosine similarity. - Drop posts with a similarity score of
0. - Return the remaining posts sorted from most similar to least similar.
export async function getRelatedPosts(postId: number): Promise<Post[]> {
const [{ data: allPosts }, { data: allCategories }] = await Promise.all([
supabase
.from<Post>('posts')
.select('post_id, post_categories (categories (category_id))')
.throwOnError(),
supabase
.from<Category>('categories')
.select('category_id')
.order('category_id')
.throwOnError(),
]);
if (allPosts == null || allCategories == null) {
return [];
}
const allCategoryIds = allCategories.map(({ category_id }) => category_id);
const posts = allPosts.map((post) => ({
...post,
categoryVector: getCategoryVector(
new Set<number>(
post.post_categories?.map(({ categories: { category_id } }) => category_id) ?? [],
),
allCategoryIds,
),
}));
const sourcePost = posts.find((post) => post.post_id === postId);
if (sourcePost == null) {
return [];
}
return posts
.filter((post) => post.post_id !== sourcePost.post_id)
.map((post): DBPostCosineSimilarity => ({
...post,
cosineSimilarity: getCosineSimilarity(sourcePost.categoryVector, post.categoryVector),
}))
.filter((post) => post.cosineSimilarity > 0)
.sort((a, b) => b.cosineSimilarity - a.cosineSimilarity);
}
This is a simple recommendation model, but it works well as a starting point. If you later need stronger relevance, you can layer on weights, tags, full-text search, or embeddings without changing the basic idea.