DEV Community

Jack M
Jack M

Posted on

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 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:

  1. Product profiles with categories, audiences, pricing models, features, screenshots, and FAQs
  2. Search, filters, and category pages
  3. A transparent visibility score
  4. Upvotes, follows, ratings, and reviews
  5. Similar-product recommendations
  6. Public, crawlable product pages
  7. JSON-LD structured data
  8. A read-only public API
  9. An MCP interface for AI clients
  10. 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:

  1. Parse and validate
  2. Normalize
  3. Detect duplicates
  4. 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_products
  • get_product
  • list_categories
  • compare_products
  • get_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:

  1. Submit a product
  2. Review it as a moderator
  3. Publish it
  4. Open the public page
  5. Search for it
  6. Vote and follow
  7. Confirm the counts update
  8. Confirm JSON-LD is present
  9. 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.

Top comments (0)