Building an AI-Ready SaaS Discovery Engine with Next.js, PostgreSQL, and MCP

Software discovery used to be a straightforward search problem. A user typed a category, opened a few result pages, compared features, and selected a product. That flow is changing. Today, a software product may be discovered through a search engine, a curated directory, an AI assistant, a comparison article, a chatbot, or an autonomous agent assembling a shortlist. The same product data must therefore serve several different consumers: Human visitors who need clear descriptions and trustworthy
Software discovery used to be a straightforward search problem. A user typed a category, opened a few result pages, compared features, and selected a product.
That flow is changing.
Today, a software product may be discovered through a search engine, a curated directory, an AI assistant, a comparison article, a chatbot, or an autonomous agent assembling a shortlist. The same product data must therefore serve several different consumers:
- Human visitors who need clear descriptions and trustworthy evidence
- Search engines that need crawlable pages and structured metadata
- Recommendation systems that need normalized attributes
- AI assistants that need concise, consistent facts
- Internal operators who need submission, moderation, and analytics workflows
This creates an interesting engineering challenge. A modern SaaS directory is no longer just a table of links. It is a product knowledge system with public pages, ranking logic, data-quality controls, APIs, and machine-readable interfaces.
In this guide, we will design such a system using Next.js, TypeScript, PostgreSQL, JSON-LD, and the Model Context Protocol. The goal is not to clone a particular directory. The goal is to understand the architecture patterns behind a discovery platform that can serve both people and AI systems.
What We Are Building
Our application will support:
- Product profiles with categories, audiences, pricing models, features, screenshots, and FAQs
- Search, filters, and category pages
- A transparent visibility score
- Upvotes, follows, ratings, and reviews
- Similar-product recommendations
- Public, crawlable product pages
- JSON-LD structured data
- A read-only public API
- An MCP interface for AI clients
- Moderation, anti-spam, and analytics
A simplified architecture looks like this:
+----------------------+
| Founder Dashboard |
+----------+-----------+
|
v
+-------------+ +----------+-----------+ +----------------+
| Human Users | -------> | Next.js Application | <------- | Search Crawlers|
+-------------+ +----------+-----------+ +----------------+
|
+--------------+--------------+
| |
v v
+--------+---------+ +--------+---------+
| PostgreSQL | | Object Storage |
| products, events | | images, videos |
+--------+---------+ +------------------+
|
+-------+--------+
| Public API/MCP |
+-------+--------+
|
v
+------+------+
| AI Clients |
+-------------+
Enter fullscreen mode Exit fullscreen mode
The most important design principle is simple:
Store product facts once, then generate every public representation from the same canonical record.
Without a single source of truth, the homepage may call a product an automation tool, a directory listing may call it a productivity platform, and an API response may classify it as a developer tool. Humans can sometimes resolve that inconsistency. Machines are much less forgiving.
1. Design the Product Entity Before the UI
It is tempting to start with cards, filters, and landing pages. Start with the entity model instead.
A useful product profile needs more than a name and description. It should capture stable identity, classification, commercial information, proof, and discovery metadata.
Here is a Prisma model that provides a reasonable foundation:
enum ProductStatus {
DRAFT
PENDING_REVIEW
PUBLISHED
REJECTED
ARCHIVED
}
enum PricingModel {
FREE
FREEMIUM
SUBSCRIPTION
USAGE_BASED
ONE_TIME
ENTERPRISE
OPEN_SOURCE
}
model Product {
id String @id @default(cuid())
slug String @unique
name String
tagline String
shortDescription String
description String
websiteUrl String
logoUrl String?
pricingModel PricingModel
isAiNative Boolean @default(false)
status ProductStatus @default(DRAFT)
foundedYear Int?
companyName String?
countryCode String?
categories ProductCategory[]
audiences ProductAudience[]
features Feature[]
faqs Faq[]
media ProductMedia[]
socialLinks SocialLink[]
votes Vote[]
follows Follow[]
reviews Review[]
events ProductEvent[]
visibilityScore Int @default(0)
publishedAt DateTime?
lastVerifiedAt DateTime?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@index([status, publishedAt])
@@index([visibilityScore])
}
model Category {
id String @id @default(cuid())
slug String @unique
name String
description String?
products ProductCategory[]
}
model ProductCategory {
productId String
categoryId String
product Product @relation(fields: [productId], references: [id], onDelete: Cascade)
category Category @relation(fields: [categoryId], references: [id], onDelete: Cascade)
@@id([productId, categoryId])
@@index([categoryId])
}
model Audience {
id String @id @default(cuid())
slug String @unique
name String
products ProductAudience[]
}
model ProductAudience {
productId String
audienceId String
product Product @relation(fields: [productId], references: [id], onDelete: Cascade)
audience Audience @relation(fields: [audienceId], references: [id], onDelete: Cascade)
@@id([productId, audienceId])
}
model Feature {
id String @id @default(cuid())
productId String
name String
position Int @default(0)
product Product @relation(fields: [productId], references: [id], onDelete: Cascade)
@@index([productId, position])
}
model Faq {
id String @id @default(cuid())
productId String
question String
answer String
position Int @default(0)
product Product @relation(fields: [productId], references: [id], onDelete: Cascade)
@@index([productId, position])
}
model ProductMedia {
id String @id @default(cuid())
productId String
type String
url String
altText String
position Int @default(0)
product Product @relation(fields: [productId], references: [id], onDelete: Cascade)
}
model SocialLink {
id String @id @default(cuid())
productId String
platform String
url String
product Product @relation(fields: [productId], references: [id], onDelete: Cascade)
@@unique([productId, platform])
}
Enter fullscreen mode Exit fullscreen mode
This schema deliberately separates categories, audiences, features, FAQs, and media. Storing all of them in one JSON column would make initial development faster, but it would make filtering, analytics, validation, and recommendations harder later.
JSON columns still have a place. They are useful for rarely queried metadata or provider-specific payloads. They are not a good default for the fields that power navigation and ranking.
Identity Fields Versus Descriptive Fields
Separate fields by purpose.
Identity fields should change rarely:
- Product name
- Canonical URL
- Slug
- Company
- Primary category
Descriptive fields can evolve:
- Tagline
- Long description
- Features
- Screenshots
- FAQs
Operational fields are maintained by the platform:
- Moderation status
- Visibility score
- Verification date
- Engagement counts
- Publication timestamp
This distinction helps with permissions. A founder may edit a tagline, but should not directly edit a visibility score or moderation status.
2. Build a Strict Submission Pipeline
Public directories attract inconsistent data. The same URL may appear with tracking parameters, category names may be duplicated with different capitalization, and descriptions may contain unsupported claims.
A submission endpoint should therefore perform four stages:
- Parse and validate
- Normalize
- Detect duplicates
- Store as pending review
Use Zod to define the input boundary:
import { z } from "zod";
const productSubmissionSchema = z.object({
name: z.string().trim().min(2).max(80),
tagline: z.string().trim().min(10).max(140),
shortDescription: z.string().trim().min(40).max(300),
description: z.string().trim().min(150).max(5000),
websiteUrl: z.string().url(),
pricingModel: z.enum([
"FREE",
"FREEMIUM",
"SUBSCRIPTION",
"USAGE_BASED",
"ONE_TIME",
"ENTERPRISE",
"OPEN_SOURCE",
]),
isAiNative: z.boolean().default(false),
categoryIds: z.array(z.string()).min(1).max(3),
audienceIds: z.array(z.string()).max(6),
features: z
.array(z.string().trim().min(2).max(100))
.min(3)
.max(12),
faqs: z
.array(
z.object({
question: z.string().trim().min(10).max(180),
answer: z.string().trim().min(20).max(700),
}),
)
.max(10),
});
Enter fullscreen mode Exit fullscreen mode
Next, canonicalize the website URL:
export function canonicalizeWebsiteUrl(input: string): string {
const url = new URL(input);
url.hash = "";
const removableParams = [
"utm_source",
"utm_medium",
"utm_campaign",
"utm_term",
"utm_content",
"ref",
];
for (const param of removableParams) {
url.searchParams.delete(param);
}
url.hostname = url.hostname.toLowerCase().replace(/^www\./, "");
url.pathname = url.pathname.replace(/\/+$/, "") || "/";
return url.toString();
}
Enter fullscreen mode Exit fullscreen mode
For duplicate detection, compare more than exact URLs. Useful signals include:
- Canonical hostname
- Normalized product name
- Redirect destination
- Company domain
- Similarity between descriptions
A simple first pass can reject exact hostname duplicates. A more mature system can flag suspicious matches for moderation.
const hostname = new URL(canonicalUrl).hostname;
const duplicate = await prisma.product.findFirst({
where: {
OR: [
{ websiteUrl: canonicalUrl },
{ websiteHost: hostname },
{ normalizedName: normalizeName(input.name) },
],
},
select: { id: true, name: true, status: true },
});
if (duplicate) {
return Response.json(
{
error: "A matching product may already exist.",
existingProduct: duplicate,
},
{ status: 409 },
);
}
Enter fullscreen mode Exit fullscreen mode
Avoid publishing directly from a public form. Even with email verification, moderation gives you a place to detect copied listings, misleading pricing, broken sites, malicious URLs, and low-quality content.
3. Treat Taxonomy as Product Infrastructure
Categories are not decorative labels. They determine navigation, landing pages, recommendations, analytics, and the language used by external systems.
A weak taxonomy grows through user-entered tags:
AI tool
AI tools
Artificial intelligence
AI software
Generative AI
GenAI
Enter fullscreen mode Exit fullscreen mode
A controlled taxonomy maps these variations to stable entities.
Good category rules:
- Use nouns or established market labels
- Keep slugs stable
- Store synonyms separately
- Allow multiple categories, but require one primary category
- Avoid categories based only on temporary trends
- Separate category from audience and capability
For example:
{
"primaryCategory": "developer-tools",
"secondaryCategories": ["automation", "ai-agents"],
"audiences": ["developers", "startup-teams"],
"capabilities": ["code-generation", "workflow-automation"]
}
Enter fullscreen mode Exit fullscreen mode
This separation improves retrieval. A query such as “automation tools for startup developers” can match a category, an audience, and a capability independently.
You can store synonyms in a small table:
model CategoryAlias {
id String @id @default(cuid())
categoryId String
alias String @unique
category Category @relation(fields: [categoryId], references: [id], onDelete: Cascade)
}
Enter fullscreen mode Exit fullscreen mode
When ingesting user input, resolve aliases to canonical categories instead of creating new categories automatically.
4. Implement Search with PostgreSQL First
Many teams adopt a hosted search engine before understanding their search requirements. PostgreSQL full-text search is often enough for an early directory.
Create a generated search vector:
ALTER TABLE "Product"
ADD COLUMN search_document tsvector
GENERATED ALWAYS AS (
setweight(to_tsvector('english', coalesce("name", '')), 'A') ||
setweight(to_tsvector('english', coalesce("tagline", '')), 'A') ||
setweight(to_tsvector('english', coalesce("shortDescription", '')), 'B') ||
setweight(to_tsvector('english', coalesce("description", '')), 'C')
) STORED;
CREATE INDEX product_search_document_idx
ON "Product"
USING GIN (search_document);
Enter fullscreen mode Exit fullscreen mode
Then query it:
SELECT
id,
slug,
name,
tagline,
"visibilityScore",
ts_rank(
search_document,
websearch_to_tsquery('english', $1)
) AS rank
FROM "Product"
WHERE status = 'PUBLISHED'
AND search_document @@ websearch_to_tsquery('english', $1)
ORDER BY
rank DESC,
"visibilityScore" DESC,
"publishedAt" DESC
LIMIT $2
OFFSET $3;
Enter fullscreen mode Exit fullscreen mode
websearch_to_tsquery gives users familiar search behavior and handles quoted phrases more gracefully than manually assembling tokens.
Combine Text Relevance with Filters
A directory search endpoint usually needs:
- Query
- Category
- Audience
- Pricing model
- AI-native flag
- Sort order
- Pagination
Represent filters explicitly:
type ProductSearchParams = {
query?: string;
category?: string;
audience?: string;
pricing?: string;
aiNative?: boolean;
sort?: "relevance" | "newest" | "visibility" | "popular";
page?: number;
};
Enter fullscreen mode Exit fullscreen mode
Do not hide all ranking behavior behind a single unexplained score. If the user selects “newest,” sort by publication time. If the user selects “most followed,” sort by follows. Relevance should be the default only when a text query exists.
When to Add a Dedicated Search Service
Move to OpenSearch, Typesense, Meilisearch, or another search system when you need capabilities such as:
- Typo tolerance at large scale
- Fast faceting over millions of records
- Complex synonym management
- Geographic search
- Vector and keyword hybrid retrieval
- Independent search scaling
- Advanced query analytics
Until then, PostgreSQL keeps the architecture smaller and makes transactional updates simpler.
5. Create a Visibility Score Users Can Understand
A visibility score can be useful, but only if it measures actionable completeness rather than popularity disguised as quality.
A good score might include:
- Profile completeness
- Category and audience clarity
- Number and quality of features
- FAQ coverage
- Media coverage
- Website health
- Verification freshness
- External profile consistency
- Authentic engagement
Do not allow raw votes to dominate. Otherwise, old or coordinated listings become permanently unbeatable.
Here is a simple scoring function:
type VisibilityInput = {
hasLogo: boolean;
screenshotCount: number;
featureCount: number;
faqCount: number;
categoryCount: number;
audienceCount: number;
hasPricing: boolean;
hasSocialLinks: boolean;
websiteReachable: boolean;
daysSinceVerification: number | null;
uniqueVotes30d: number;
uniqueFollowers30d: number;
};
export function calculateVisibilityScore(
input: VisibilityInput,
): number {
let score = 0;
score += input.hasLogo ? 8 : 0;
score += Math.min(input.screenshotCount, 4) * 3;
score += Math.min(input.featureCount, 8) * 2;
score += Math.min(input.faqCount, 5) * 2;
score += Math.min(input.categoryCount, 3) * 3;
score += Math.min(input.audienceCount, 4) * 2;
score += input.hasPricing ? 5 : 0;
score += input.hasSocialLinks ? 4 : 0;
score += input.websiteReachable ? 10 : 0;
if (input.daysSinceVerification !== null) {
if (input.daysSinceVerification <= 30) score += 10;
else if (input.daysSinceVerification <= 90) score += 6;
else if (input.daysSinceVerification <= 180) score += 3;
}
score += Math.min(
Math.log2(input.uniqueVotes30d + 1) * 2,
6,
);
score += Math.min(
Math.log2(input.uniqueFollowers30d + 1) * 2,
6,
);
return Math.max(0, Math.min(100, Math.round(score)));
}
Enter fullscreen mode Exit fullscreen mode
The logarithmic engagement component prevents one viral spike from overwhelming the rest of the score.
The scoring system should also produce recommendations:
type ScoreRecommendation = {
key: string;
message: string;
potentialGain: number;
};
export function getRecommendations(
input: VisibilityInput,
): ScoreRecommendation[] {
const items: ScoreRecommendation[] = [];
if (!input.hasLogo) {
items.push({
key: "add-logo",
message: "Add a recognizable product logo.",
potentialGain: 8,
});
}
if (input.screenshotCount < 3) {
items.push({
key: "add-screenshots",
message: "Add at least three screenshots showing real workflows.",
potentialGain: (3 - input.screenshotCount) * 3,
});
}
if (input.faqCount < 5) {
items.push({
key: "expand-faq",
message: "Answer common buyer and implementation questions.",
potentialGain: (5 - input.faqCount) * 2,
});
}
return items.sort(
(a, b) => b.potentialGain - a.potentialGain,
);
}
Enter fullscreen mode Exit fullscreen mode
This turns the score from a vanity number into an operational checklist.
6. Build Public Product Pages for Humans and Machines
Each product needs a stable, indexable URL:
/products/{product-slug}
Enter fullscreen mode Exit fullscreen mode
The page should render meaningful HTML on the server. Do not make the main description, categories, and FAQs dependent on a client-side request after hydration.
A Next.js page can load a published product by slug:
import { notFound } from "next/navigation";
import type { Metadata } from "next";
import { prisma } from "@/lib/prisma";
type ProductPageProps = {
params: Promise<{ slug: string }>;
};
async function getProduct(slug: string) {
return prisma.product.findFirst({
where: {
slug,
status: "PUBLISHED",
},
include: {
categories: {
include: { category: true },
},
audiences: {
include: { audience: true },
},
features: {
orderBy: { position: "asc" },
},
faqs: {
orderBy: { position: "asc" },
},
media: {
orderBy: { position: "asc" },
},
reviews: {
where: { status: "PUBLISHED" },
orderBy: { createdAt: "desc" },
take: 20,
},
},
});
}
export async function generateMetadata(
props: ProductPageProps,
): Promise<Metadata> {
const { slug } = await props.params;
const product = await getProduct(slug);
if (!product) return {};
return {
title: `${product.name} | Software Directory`,
description: product.shortDescription,
alternates: {
canonical: `/products/${product.slug}`,
},
openGraph: {
title: product.name,
description: product.shortDescription,
type: "website",
images: product.logoUrl ? [product.logoUrl] : [],
},
};
}
export default async function ProductPage(
props: ProductPageProps,
) {
const { slug } = await props.params;
const product = await getProduct(slug);
if (!product) notFound();
return (
<main>
<header>
<h1>{product.name}</h1>
<p>{product.tagline}</p>
</header>
<section aria-labelledby="overview">
<h2 id="overview">Overview</h2>
<p>{product.description}</p>
</section>
<section aria-labelledby="features">
<h2 id="features">Features</h2>
<ul>
{product.features.map((feature) => (
<li key={feature.id}>{feature.name}</li>
))}
</ul>
</section>
</main>
);
}
Enter fullscreen mode Exit fullscreen mode
Add JSON-LD
JSON-LD gives machines a structured representation of the visible page. For software pages, use the SoftwareApplication vocabulary where appropriate.
function ProductJsonLd({
product,
}: {
product: ProductWithRelations;
}) {
const ratingCount = product.reviews.length;
const ratingValue =
ratingCount > 0
? product.reviews.reduce(
(sum, review) => sum + review.rating,
0,
) / ratingCount
: undefined;
const jsonLd = {
"@context": "https://schema.org",
"@type": "SoftwareApplication",
name: product.name,
description: product.shortDescription,
url: product.websiteUrl,
applicationCategory:
product.categories[0]?.category.name ??
"BusinessApplication",
operatingSystem: "Web",
offers: {
"@type": "Offer",
price:
product.pricingModel === "FREE"
? "0"
: undefined,
priceCurrency: "USD",
category: product.pricingModel,
},
featureList: product.features.map(
(feature) => feature.name,
),
...(ratingCount > 0
? {
aggregateRating: {
"@type": "AggregateRating",
ratingValue: Number(
ratingValue?.toFixed(1),
),
ratingCount,
},
}
: {}),
};
return (
<script
type="application/ld+json"
dangerouslySetInnerHTML={{
__html: JSON.stringify(jsonLd).replace(
/</g,
"\\u003c",
),
}}
/>
);
}
Enter fullscreen mode Exit fullscreen mode
Only include data that is present on the page and supported by real records. Never generate ratings, prices, or reviews only to make structured data look complete.
At this point, it can be useful to inspect a live software-discovery implementation and compare how product identity, categories, audiences, engagement, and visibility signals are presented on the same public page.
The value of that exercise is not the visual design. It is seeing how several data models become one understandable product profile.
7. Make Engagement Transactional and Abuse-Resistant
Votes, follows, ratings, and reviews create useful signals. They also create incentives for manipulation.
Start with unique constraints:
model Vote {
id String @id @default(cuid())
productId String
userId String
product Product @relation(fields: [productId], references: [id], onDelete: Cascade)
createdAt DateTime @default(now())
@@unique([productId, userId])
@@index([productId, createdAt])
}
model Follow {
id String @id @default(cuid())
productId String
userId String
product Product @relation(fields: [productId], references: [id], onDelete: Cascade)
createdAt DateTime @default(now())
@@unique([productId, userId])
}
model Review {
id String @id @default(cuid())
productId String
userId String
rating Int
body String
status String @default("PENDING")
product Product @relation(fields: [productId], references: [id], onDelete: Cascade)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@unique([productId, userId])
}
Enter fullscreen mode Exit fullscreen mode
A vote toggle should run in a transaction:
await prisma.$transaction(async (tx) => {
const existing = await tx.vote.findUnique({
where: {
productId_userId: {
productId,
userId,
},
},
});
if (existing) {
await tx.vote.delete({
where: { id: existing.id },
});
} else {
await tx.vote.create({
data: { productId, userId },
});
}
await tx.productEvent.create({
data: {
productId,
actorId: userId,
type: existing
? "VOTE_REMOVED"
: "VOTE_ADDED",
},
});
});
Enter fullscreen mode Exit fullscreen mode
Additional protections:
- Require verified accounts for public reviews
- Rate-limit writes by account and IP
- Detect repeated behavior across many new accounts
- Delay the ranking effect of suspicious engagement
- Separate displayed counts from ranking weights
- Keep immutable audit events
- Moderate review text
- Prevent product owners from reviewing their own products
Do not expose anti-abuse thresholds in the client. Return a generic rate-limit response and record detailed reasons privately.
8. Generate Similar Products from Structured Signals
“Similar products” should not be random or based only on shared tags.
A simple candidate score can combine:
similarity =
0.40 * category_overlap +
0.20 * audience_overlap +
0.20 * capability_overlap +
0.10 * pricing_similarity +
0.10 * text_similarity
Enter fullscreen mode Exit fullscreen mode
For an early version, use SQL overlap and full-text rank. Later, add embeddings for descriptions and feature lists.
A rule-based TypeScript scorer is easy to test:
function jaccard(
a: Set<string>,
b: Set<string>,
): number {
const intersection = new Set(
[...a].filter((value) => b.has(value)),
);
const union = new Set([...a, ...b]);
return union.size === 0
? 0
: intersection.size / union.size;
}
type RecommendationProduct = {
id: string;
categories: string[];
audiences: string[];
capabilities: string[];
pricingModel: string;
};
export function similarity(
source: RecommendationProduct,
candidate: RecommendationProduct,
): number {
const categoryScore = jaccard(
new Set(source.categories),
new Set(candidate.categories),
);
const audienceScore = jaccard(
new Set(source.audiences),
new Set(candidate.audiences),
);
const capabilityScore = jaccard(
new Set(source.capabilities),
new Set(candidate.capabilities),
);
const pricingScore =
source.pricingModel === candidate.pricingModel
? 1
: 0;
return (
categoryScore * 0.4 +
audienceScore * 0.2 +
capabilityScore * 0.2 +
pricingScore * 0.1
);
}
Enter fullscreen mode Exit fullscreen mode
The missing 0.1 can come from normalized full-text or embedding similarity.
Always exclude:
- The current product
- Unpublished products
- Blocked domains
- Products rejected during moderation
- Products with unavailable websites
Recommendations should be explainable. The interface can display “Similar category,” “Built for developers,” or “Same pricing model.” This is more trustworthy than pretending a black-box ranking is objective.
9. Expose a Stable Public API
AI clients, integrations, browser extensions, and partner sites should not scrape rendered HTML when you can provide a documented API.
Create versioned routes:
GET /api/v1/products
GET /api/v1/products/{slug}
GET /api/v1/categories
GET /api/v1/search?q=...
Enter fullscreen mode Exit fullscreen mode
A Next.js Route Handler can expose a sanitized product:
import { NextRequest } from "next/server";
import { prisma } from "@/lib/prisma";
export async function GET(
request: NextRequest,
context: {
params: Promise<{ slug: string }>;
},
) {
const { slug } = await context.params;
const product = await prisma.product.findFirst({
where: {
slug,
status: "PUBLISHED",
},
include: {
categories: {
include: { category: true },
},
audiences: {
include: { audience: true },
},
features: {
orderBy: { position: "asc" },
},
faqs: {
orderBy: { position: "asc" },
},
},
});
if (!product) {
return Response.json(
{ error: "Product not found" },
{ status: 404 },
);
}
return Response.json(
{
data: {
id: product.id,
slug: product.slug,
name: product.name,
tagline: product.tagline,
description: product.shortDescription,
website: product.websiteUrl,
pricingModel: product.pricingModel,
aiNative: product.isAiNative,
categories: product.categories.map(
(item) => ({
slug: item.category.slug,
name: item.category.name,
}),
),
audiences: product.audiences.map(
(item) => ({
slug: item.audience.slug,
name: item.audience.name,
}),
),
features: product.features.map(
(item) => item.name,
),
faqs: product.faqs.map((item) => ({
question: item.question,
answer: item.answer,
})),
visibilityScore:
product.visibilityScore,
updatedAt:
product.updatedAt.toISOString(),
},
},
{
headers: {
"Cache-Control":
"public, s-maxage=300, stale-while-revalidate=3600",
},
},
);
}
Enter fullscreen mode Exit fullscreen mode
Avoid exposing private owner data, moderation notes, internal trust scores, email addresses, or anti-abuse signals.
API Design Details That Matter
Use stable identifiers. Slugs can change, so return both an immutable ID and a human-readable slug.
Return update timestamps. Consumers need a way to determine freshness.
Version the contract. Changing a field from a string to an object can break integrations.
Limit nested data. List endpoints should return summaries. Detail endpoints can return features and FAQs.
Add pagination metadata.
{
"data": [],
"pagination": {
"page": 1,
"pageSize": 20,
"total": 415,
"totalPages": 21
}
}
Enter fullscreen mode Exit fullscreen mode
Document errors. Use predictable codes such as INVALID_FILTER, RATE_LIMITED, and PRODUCT_NOT_FOUND.
10. Add an MCP Layer for AI Clients
A REST API makes data available. An MCP server makes its capabilities easier for compatible AI clients to discover and invoke.
For a directory, useful read-only tools could include:
search_productsget_productlist_categoriescompare_productsget_trending_products
The tool descriptions matter. An AI model selects tools partly from their names, descriptions, and input schemas.
A simplified server using the TypeScript SDK might look like this:
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
import { z } from "zod";
const server = new McpServer({
name: "software-discovery",
version: "1.0.0",
});
server.tool(
"search_products",
"Search published software products by text, category, audience, and pricing model.",
{
query: z.string().optional(),
category: z.string().optional(),
audience: z.string().optional(),
pricingModel: z.string().optional(),
limit: z
.number()
.int()
.min(1)
.max(20)
.default(10),
},
async (input) => {
const products =
await searchPublishedProducts(input);
return {
content: [
{
type: "text",
text: JSON.stringify(
products.map((product) => ({
name: product.name,
slug: product.slug,
tagline: product.tagline,
website: product.websiteUrl,
categories: product.categories,
pricingModel:
product.pricingModel,
visibilityScore:
product.visibilityScore,
})),
),
},
],
};
},
);
server.tool(
"get_product",
"Return a structured public profile for one published product.",
{
slug: z.string().min(1),
},
async ({ slug }) => {
const product =
await getPublishedProduct(slug);
if (!product) {
return {
isError: true,
content: [
{
type: "text",
text: "Product not found.",
},
],
};
}
return {
content: [
{
type: "text",
text: JSON.stringify(product),
},
],
};
},
);
const transport = new StdioServerTransport();
await server.connect(transport);
Enter fullscreen mode Exit fullscreen mode
For production, consider HTTP transport, authentication, request limits, observability, and caching.
Keep AI Tools Narrow
Do not expose one vague tool called query_database. Give the model task-specific operations with constrained inputs.
Bad:
run_query(sql)
Enter fullscreen mode Exit fullscreen mode
Better:
search_products(query, category, audience, limit)
compare_products(slugs)
get_product(slug)
Enter fullscreen mode Exit fullscreen mode
Narrow tools are easier to secure, test, document, and monitor.
Return Facts, Not Marketing Claims
An AI-facing response should prioritize:
- Canonical name
- URL
- Category
- Audience
- Pricing model
- Features
- Verification timestamp
- Source page URL
Avoid adjectives such as “best,” “revolutionary,” or “industry-leading” unless they are clearly attributed claims. Structured facts are more reusable than promotional copy.
11. Use Caching Without Serving Stale Product Facts Forever
Directory traffic is read-heavy. Product records change much less often than they are viewed.
A sensible strategy:
- Cache public product pages
- Cache API detail responses
- Cache category result pages briefly
- Do not cache personalized dashboard pages publicly
- Revalidate after an approved product update
- Use stale-while-revalidate for read endpoints
In Next.js, tag product-related reads:
import { unstable_cache } from "next/cache";
export const getCachedProduct = (
slug: string,
) =>
unstable_cache(
async () =>
getProductFromDatabase(slug),
["product", slug],
{
tags: [`product:${slug}`],
revalidate: 3600,
},
)();
Enter fullscreen mode Exit fullscreen mode
After moderation approves a change:
import {
revalidatePath,
revalidateTag,
} from "next/cache";
revalidateTag(`product:${product.slug}`);
revalidatePath(`/products/${product.slug}`);
revalidatePath(
`/categories/${primaryCategorySlug}`,
);
Enter fullscreen mode Exit fullscreen mode
Be careful with cache keys. A category page filtered by pricing, audience, and sort order must include those values in its key.
12. Model Analytics as Events, Not Only Counters
A product card may display views, follows, and votes. Counters are convenient, but raw events are more valuable.
model ProductEvent {
id String @id @default(cuid())
productId String
actorId String?
sessionId String?
type String
source String?
referrer String?
metadata Json?
occurredAt DateTime @default(now())
product Product @relation(fields: [productId], references: [id], onDelete: Cascade)
@@index([productId, type, occurredAt])
@@index([occurredAt])
}
Enter fullscreen mode Exit fullscreen mode
Example event types:
PRODUCT_VIEWED
WEBSITE_CLICKED
PRODUCT_SHARED
VOTE_ADDED
VOTE_REMOVED
FOLLOW_ADDED
REVIEW_SUBMITTED
SEARCH_IMPRESSION
SEARCH_CLICK
Enter fullscreen mode Exit fullscreen mode
From these events, you can calculate:
- Search click-through rate
- Website click-through rate
- Conversion by category
- Trending products
- Returning visitor interest
- Position bias
- Suspicious engagement bursts
- Stale listings with declining interaction
Counters can then be materialized from events:
SELECT
"productId",
COUNT(*) FILTER (
WHERE type = 'PRODUCT_VIEWED'
) AS views,
COUNT(*) FILTER (
WHERE type = 'WEBSITE_CLICKED'
) AS outbound_clicks
FROM "ProductEvent"
WHERE "occurredAt" >=
NOW() - INTERVAL '30 days'
GROUP BY "productId";
Enter fullscreen mode Exit fullscreen mode
Do not log unnecessary personal data. Hash or rotate identifiers where possible, define retention periods, and keep analytics separate from authentication secrets.
13. Build a Real Moderation Workflow
Moderation is not a boolean column. Treat it as a workflow.
Useful states:
DRAFT
PENDING_REVIEW
CHANGES_REQUESTED
APPROVED
PUBLISHED
REJECTED
ARCHIVED
Enter fullscreen mode Exit fullscreen mode
Store review actions:
model ModerationAction {
id String @id @default(cuid())
productId String
moderatorId String
action String
reasonCode String?
note String?
createdAt DateTime @default(now())
@@index([productId, createdAt])
}
Enter fullscreen mode Exit fullscreen mode
A moderation screen should highlight:
- Duplicate-domain matches
- Redirect chains
- Broken website checks
- Missing legal or contact pages
- Suspicious claims
- Copied descriptions
- Unsafe external links
- Image dimensions and file types
- Category mismatch
- Owner verification state
Automate checks, but let humans make ambiguous decisions. An automated system can confirm that a URL returns a valid response. It cannot reliably determine whether the product description fairly represents the service.
14. Test the System at Three Levels
Unit Tests
Test pure functions:
- URL canonicalization
- Slug generation
- Visibility scoring
- Recommendation scoring
- Filter parsing
- Structured-data generation
import {
describe,
expect,
it,
} from "vitest";
describe("canonicalizeWebsiteUrl", () => {
it("removes www, tracking parameters, and trailing slashes", () => {
expect(
canonicalizeWebsiteUrl(
"https://www.example.com/?utm_source=test#pricing",
),
).toBe("https://example.com/");
});
});
Enter fullscreen mode Exit fullscreen mode
Integration Tests
Run against a test database:
- Submitting a duplicate product returns
409 - Unpublished products are absent from the public API
- One user cannot vote twice
- Deleting a product cascades related records
- Search filters return only matching products
- Moderation approval triggers publication
End-to-End Tests
Use Playwright for user flows:
- Submit a product
- Review it as a moderator
- Publish it
- Open the public page
- Search for it
- Vote and follow
- Confirm the counts update
- Confirm JSON-LD is present
- Confirm the API returns the public record
Also test anonymous users, expired sessions, inaccessible products, malformed slugs, and rate-limit responses.
15. Deploy the Smallest Architecture That Can Evolve
An early production setup can be simple:
Next.js application
PostgreSQL database
S3-compatible object storage
Background worker
Transactional email provider
CDN and web application firewall
Enter fullscreen mode Exit fullscreen mode
Use the background worker for:
- Website health checks
- Screenshot processing
- Score recalculation
- Stale-profile reminders
- Event aggregation
- Sitemap generation
- Duplicate analysis
Do not run these tasks inside the request that publishes a product. Queue them and return once the database transaction succeeds.
Suggested Service Boundaries
Keep these as internal modules before turning them into microservices:
modules/
products/
search/
taxonomy/
engagement/
moderation/
analytics/
visibility/
recommendations/
public-api/
mcp/
Enter fullscreen mode Exit fullscreen mode
Separate services only when there is a clear scaling, security, or ownership reason. A modular monolith is usually easier to operate than several tiny services connected through fragile network calls.
Common Engineering Mistakes
Mistake 1: Ranking Only by Votes
This rewards age, audience size, and manipulation. Blend relevance, freshness, completeness, verification, and trusted engagement.
Mistake 2: Allowing Uncontrolled Tags
Free-form tags quickly become duplicates and spelling variants. Use a controlled taxonomy plus aliases.
Mistake 3: Rendering Important Content Only on the Client
Public product facts should be available in server-rendered HTML.
Mistake 4: Using Generated Copy as Verified Truth
AI can help rewrite a founder’s input, but it should not invent pricing, customers, integrations, or security claims.
Mistake 5: Exposing Internal Data Through an AI Interface
An MCP tool should call the same sanitized service layer used by the public API. It should not query unrestricted tables.
Mistake 6: Treating Visibility as One Unexplained Number
Show the components and recommendations behind the score.
Mistake 7: Storing Only Aggregate Counters
Event logs provide trend analysis, fraud detection, and attribution. Counters alone do not.
Mistake 8: Building a Separate Data Path for Every Channel
The public page, API, sitemap, JSON-LD, recommendation engine, and MCP server should all read from the same approved product record.
A Practical Build Order
Trying to build every feature at once creates a wide but unreliable platform. A better sequence is:
Phase 1: Canonical Directory
- Product schema
- Submission
- Moderation
- Public product pages
- Categories
- Basic search
- Sitemap
- Metadata
Phase 2: Trust and Engagement
- Verified ownership
- Votes and follows
- Reviews
- Website health checks
- Event analytics
- Visibility score
Phase 3: Discovery Intelligence
- Better ranking
- Similar products
- Trending calculations
- Search analytics
- Competitor and category comparisons
Phase 4: Machine Interfaces
- Public API
- JSON-LD refinement
- MCP tools
- Change feeds
- Partner integrations
This order is important. AI interfaces amplify the quality of the underlying data. They do not repair a weak data model.
Final Thoughts
The difficult part of building a modern SaaS directory is not displaying cards in a grid. It is creating a dependable product knowledge layer.
That layer needs:
- Stable identity
- Normalized taxonomy
- Strict validation
- Moderated claims
- Searchable text
- Explainable ranking
- Structured public pages
- Freshness signals
- Safe engagement
- Versioned machine interfaces
Once those foundations exist, the same approved product record can power human browsing, search indexing, recommendations, analytics, APIs, and AI-agent tools.
That is the broader architectural lesson. Build one trustworthy source of product truth, then expose it through interfaces designed for each consumer. The result is easier to maintain, easier to search, harder to manipulate, and far more useful than a conventional link directory.

