DEV Community

Cover image for Design a Curated Mini-App Catalog Before You Build a Marketplace
FinClip Super-App
FinClip Super-App

Posted on

Design a Curated Mini-App Catalog Before You Build a Marketplace

Design a Curated Mini-App Catalog Before You Build a Marketplace

A mini-app platform does not need an open marketplace on its first release.

The first technical requirement is usually smaller: the host application must know which services exist, who owns them, which version is approved, which native capabilities they may request, and where customers may encounter them.

That is a registry problem.

In this tutorial, we will build a small TypeScript registry for a curated mini-app catalogue. It will support:

  • explicit service ownership;
  • capability allowlists;
  • review and lifecycle status;
  • minimum host-version checks;
  • regional and audience eligibility;
  • contextual placement instead of a generic store;
  • immediate suspension without waiting for a native release.

The result is intentionally narrower than a marketplace. There is no public developer signup, paid ranking, open submission, review marketplace, or revenue-share engine. Those products can be added when real supply and discovery pressure justify them.

Start with a manifest owned by the host

Every service needs a record the host can evaluate before loading code. Define the core types in catalog.ts:

export type Capability =
  | "identity.profile"
  | "payments.create"
  | "analytics.emit"
  | "location.coarse"
  | "storage.secure";

export type ServiceStatus =
  | "draft"
  | "review"
  | "approved"
  | "suspended"
  | "retired";

export type Placement =
  | { kind: "home"; priority: number }
  | { kind: "search"; keywords: string[] }
  | { kind: "context"; event: string; priority: number };

export interface MiniAppManifest {
  id: string;
  name: string;
  version: string;
  ownerTeam: string;
  supportContact: string;
  status: ServiceStatus;
  entryUrl: string;
  integritySha256: string;
  minimumHostVersion: string;
  allowedRegions: string[];
  allowedAudienceSegments: string[];
  requestedCapabilities: Capability[];
  placements: Placement[];
  approvedAt?: string;
  retiresAt?: string;
}
Enter fullscreen mode Exit fullscreen mode

The ownerTeam and supportContact fields are as important as entryUrl. A host should be able to route an operational problem without reading source-control history or searching a partner contract.

requestedCapabilities expresses the maximum capability set approved for the service. Runtime calls still need authentication, authorisation, consent, and request-level policy enforcement. A manifest allowlist is an initial boundary, not the entire security model.

The integrity digest allows the host to verify that the downloaded package matches the approved artifact. In a production platform, the manifest itself should also be signed and delivered over an authenticated channel.

Reject ambiguous manifests

A registry should fail closed. An approved service without an owner or integrity value should never become visible because a field happened to be optional in a dashboard.

Add a validator:

const CAPABILITIES: ReadonlySet<Capability> = new Set([
  "identity.profile",
  "payments.create",
  "analytics.emit",
  "location.coarse",
  "storage.secure",
]);

export function validateManifest(manifest: MiniAppManifest): string[] {
  const errors: string[] = [];

  if (!/^[a-z0-9]+(?:-[a-z0-9]+)*$/.test(manifest.id)) {
    errors.push("id must use lower-case kebab-case");
  }
  if (!/^\d+\.\d+\.\d+$/.test(manifest.version)) {
    errors.push("version must be semantic x.y.z");
  }
  if (!manifest.ownerTeam.trim()) errors.push("ownerTeam is required");
  if (!manifest.supportContact.trim()) errors.push("supportContact is required");
  if (!/^https:\/\//.test(manifest.entryUrl)) {
    errors.push("entryUrl must use HTTPS");
  }
  if (!/^[a-f0-9]{64}$/.test(manifest.integritySha256)) {
    errors.push("integritySha256 must contain 64 lower-case hex characters");
  }

  for (const capability of manifest.requestedCapabilities) {
    if (!CAPABILITIES.has(capability)) {
      errors.push(`unknown capability: ${capability}`);
    }
  }

  const duplicateCapabilities = manifest.requestedCapabilities.filter(
    (capability, index, all) => all.indexOf(capability) !== index
  );
  if (duplicateCapabilities.length > 0) {
    errors.push("requestedCapabilities contains duplicates");
  }

  if (manifest.status === "approved" && !manifest.approvedAt) {
    errors.push("approvedAt is required for an approved service");
  }

  return errors;
}
Enter fullscreen mode Exit fullscreen mode

You may want a schema library such as Zod or JSON Schema when manifests arrive over an API. The explicit validator keeps this example dependency-free and makes the policy easy to see.

Unknown capabilities are rejected. Silently ignoring one can produce confusing behaviour: the service may appear healthy until a customer reaches the feature that depends on it.

Keep review state separate from distribution

Approval answers whether a particular version may run. Placement answers where eligible customers can find it. Mixing the two creates fragile release logic.

Create an in-memory registry that stores one active manifest per service:

export class CuratedRegistry {
  private manifests = new Map<string, MiniAppManifest>();

  upsert(manifest: MiniAppManifest): void {
    const errors = validateManifest(manifest);
    if (errors.length > 0) {
      throw new Error(`Invalid manifest: ${errors.join("; ")}`);
    }
    this.manifests.set(manifest.id, structuredClone(manifest));
  }

  get(id: string): MiniAppManifest | undefined {
    const item = this.manifests.get(id);
    return item ? structuredClone(item) : undefined;
  }

  suspend(id: string): void {
    const item = this.manifests.get(id);
    if (!item) throw new Error(`Unknown service: ${id}`);
    this.manifests.set(id, { ...item, status: "suspended" });
  }

  all(): MiniAppManifest[] {
    return [...this.manifests.values()].map((item) => structuredClone(item));
  }
}
Enter fullscreen mode Exit fullscreen mode

structuredClone prevents a caller from mutating registry state after reading it. A real implementation would persist immutable manifest revisions and append audit events for submission, approval, suspension, and retirement.

The suspension operation is deliberately simple. When a service is compromised or broken, the host needs a remote control that removes it from discovery and prevents launch. Waiting for an iOS or Android release defeats one of the main operational benefits of modular delivery.

Resolve eligibility before ranking

A customer should never see a service that cannot run in their host version or region. Filter eligibility before applying placement rules.

export interface DiscoveryContext {
  region: string;
  audienceSegments: string[];
  hostVersion: string;
  event?: string;
  now: Date;
}

function compareVersion(a: string, b: string): number {
  const left = a.split(".").map(Number);
  const right = b.split(".").map(Number);
  for (let i = 0; i < 3; i += 1) {
    if (left[i] !== right[i]) return left[i] - right[i];
  }
  return 0;
}

function isEligible(
  manifest: MiniAppManifest,
  context: DiscoveryContext
): boolean {
  if (manifest.status !== "approved") return false;
  if (compareVersion(context.hostVersion, manifest.minimumHostVersion) < 0) {
    return false;
  }
  if (!manifest.allowedRegions.includes(context.region)) return false;

  const audienceMatch = manifest.allowedAudienceSegments.some((segment) =>
    context.audienceSegments.includes(segment)
  );
  if (!audienceMatch) return false;

  if (manifest.retiresAt && new Date(manifest.retiresAt) <= context.now) {
    return false;
  }
  return true;
}
Enter fullscreen mode Exit fullscreen mode

This example uses allowlists. Depending on the product, an empty region or audience list could mean “available to nobody” or “available to everyone.” Pick one meaning and encode it explicitly. Ambiguous empty lists are a common source of accidental exposure.

Audience membership should be computed by a trusted host service. Do not let a mini app assign itself to a privileged segment.

Prefer contextual discovery for a small catalogue

An early catalogue may not need search. The service can appear beside the customer journey that makes it useful.

Add a resolver:

export interface DiscoveryResult {
  id: string;
  name: string;
  reason: "context" | "home" | "search";
  priority: number;
}

export function discover(
  registry: CuratedRegistry,
  context: DiscoveryContext
): DiscoveryResult[] {
  const results: DiscoveryResult[] = [];

  for (const service of registry.all()) {
    if (!isEligible(service, context)) continue;

    for (const placement of service.placements) {
      if (placement.kind === "context" && placement.event === context.event) {
        results.push({
          id: service.id,
          name: service.name,
          reason: "context",
          priority: placement.priority + 100,
        });
      }
      if (placement.kind === "home") {
        results.push({
          id: service.id,
          name: service.name,
          reason: "home",
          priority: placement.priority,
        });
      }
    }
  }

  return results
    .sort((a, b) => b.priority - a.priority)
    .filter(
      (result, index, all) =>
        all.findIndex((candidate) => candidate.id === result.id) === index
    );
}
Enter fullscreen mode Exit fullscreen mode

Contextual results receive a boost because they respond to a current customer event. This is only an example policy. The essential design choice is that discovery logic belongs to the trusted host, not to each mini app.

A service can qualify for both home and contextual placement. The final filter keeps its highest-ranked occurrence. In a larger catalogue, you would want explainable ranking, frequency caps, experimentation controls, and separation between paid placement and organic relevance.

Create two real services

Here is a travel-insurance service that appears after an eligible travel purchase:

const registry = new CuratedRegistry();

registry.upsert({
  id: "travel-cover",
  name: "Travel Cover",
  version: "1.2.0",
  ownerTeam: "Protection Products",
  supportContact: "protection-operations",
  status: "approved",
  entryUrl: "https://services.example.com/travel-cover/1.2.0",
  integritySha256: "a".repeat(64),
  minimumHostVersion: "8.4.0",
  allowedRegions: ["GB", "SG"],
  allowedAudienceSegments: ["travel-eligible"],
  requestedCapabilities: [
    "identity.profile",
    "payments.create",
    "analytics.emit",
  ],
  placements: [
    { kind: "context", event: "travel.purchase.completed", priority: 80 },
  ],
  approvedAt: "2026-08-25T09:00:00Z",
});

registry.upsert({
  id: "merchant-rewards",
  name: "Merchant Rewards",
  version: "2.0.1",
  ownerTeam: "Rewards",
  supportContact: "rewards-operations",
  status: "approved",
  entryUrl: "https://services.example.com/merchant-rewards/2.0.1",
  integritySha256: "b".repeat(64),
  minimumHostVersion: "8.2.0",
  allowedRegions: ["GB"],
  allowedAudienceSegments: ["rewards-enrolled"],
  requestedCapabilities: ["identity.profile", "analytics.emit"],
  placements: [{ kind: "home", priority: 30 }],
  approvedAt: "2026-08-20T10:00:00Z",
});
Enter fullscreen mode Exit fullscreen mode

The two services share identity and analytics but have different owners, capability needs, audiences, and placements. That makes them a better test of platform reuse than two nearly identical campaign pages.

Discover services for a customer:

const visible = discover(registry, {
  region: "GB",
  audienceSegments: ["travel-eligible", "rewards-enrolled"],
  hostVersion: "8.5.0",
  event: "travel.purchase.completed",
  now: new Date("2026-08-25T12:00:00Z"),
});

console.log(visible);
// Travel Cover appears first because its context is currently relevant.
// Merchant Rewards remains available through its home placement.
Enter fullscreen mode Exit fullscreen mode

Test suspension and eligibility

Use Vitest to preserve the host’s safety rules:

import { describe, expect, it } from "vitest";
import { CuratedRegistry, discover, type MiniAppManifest } from "./catalog";

const approved: MiniAppManifest = {
  id: "local-service",
  name: "Local Service",
  version: "1.0.0",
  ownerTeam: "Local Partnerships",
  supportContact: "local-ops",
  status: "approved",
  entryUrl: "https://services.example.com/local/1.0.0",
  integritySha256: "c".repeat(64),
  minimumHostVersion: "3.0.0",
  allowedRegions: ["SG"],
  allowedAudienceSegments: ["consumer"],
  requestedCapabilities: ["identity.profile"],
  placements: [{ kind: "home", priority: 10 }],
  approvedAt: "2026-08-25T09:00:00Z",
};

const context = {
  region: "SG",
  audienceSegments: ["consumer"],
  hostVersion: "3.1.0",
  now: new Date("2026-08-25T12:00:00Z"),
};

describe("curated discovery", () => {
  it("shows an eligible approved service", () => {
    const registry = new CuratedRegistry();
    registry.upsert(approved);
    expect(discover(registry, context)).toHaveLength(1);
  });

  it("hides a service immediately after suspension", () => {
    const registry = new CuratedRegistry();
    registry.upsert(approved);
    registry.suspend("local-service");
    expect(discover(registry, context)).toHaveLength(0);
  });

  it("hides a service from an unsupported host version", () => {
    const registry = new CuratedRegistry();
    registry.upsert(approved);
    expect(
      discover(registry, { ...context, hostVersion: "2.9.9" })
    ).toHaveLength(0);
  });
});
Enter fullscreen mode Exit fullscreen mode

These tests cover three important guarantees: approved services can appear, suspension removes them, and an incompatible client never receives them.

Production tests should also cover expired services, unsupported regions, audience mismatches, duplicate placements, malformed semantic versions, unknown capabilities, and manifest-signature failures.

Add observability before adding openness

Every discovery and launch decision should produce a structured event. Record the manifest version, host version, placement reason, policy decision, and a privacy-safe customer or session reference.

Useful events include:

  • service_eligible
  • service_impression
  • service_launch_requested
  • service_launch_blocked
  • service_loaded
  • service_failed
  • service_suspended
  • service_completed

This data answers questions that matter before a marketplace exists. Are contextual placements helping customers complete a task? Which service creates support demand? How often do old host versions block access? How quickly can the team suspend a broken release?

It also creates a baseline for the second and third services. A platform becomes valuable when reuse improves delivery and operation, not when a catalogue reaches an arbitrary size.

What changes when a marketplace is justified?

The curated registry remains useful if the ecosystem grows. A marketplace adds systems around it:

  • provider identity and onboarding;
  • self-service submission and test environments;
  • multi-stage automated and manual review;
  • search indexing and explainable ranking;
  • commercial agreements, settlement, refunds, and tax handling;
  • ratings, complaints, abuse detection, and dispute resolution;
  • partner-level quotas, analytics, and support.

These functions should appear because manual onboarding and contextual discovery have become constraints. Adding them early increases the attack surface and operating workload before the platform knows what its participants need.

A small catalogue can still be a real platform

The code in this tutorial supports only a few services, yet it establishes durable platform boundaries. Services have owners. Capabilities are explicit. Approval is separate from placement. Eligibility is checked before discovery. Suspension is immediate. The host controls context and retains an audit-friendly record of what may run.

That is enough to learn from the first service and test reuse with the second.

When service supply grows, partners request a repeatable publishing path, and customers need help comparing choices, the same registry can sit beneath a broader marketplace. Until then, a curated catalogue keeps the system understandable and the customer experience deliberate.

Top comments (0)