Supercommerce API Docs
Full Module Docs

Dynamic Link Module

Admin-curated link groupings rendered on the storefront landing page. A DynamicLinkGroup has a slug used as the public lookup key; each DynamicLink belongs to exactly one group…

Admin-curated link groupings rendered on the storefront landing page. A DynamicLinkGroup has a slug used as the public lookup key; each DynamicLink belongs to exactly one group and renders as some combination of image / url / text. Typical use: "Top Categories", "Featured Brands", "Promo Tiles", "Footer Quick Links".

Source: api-modules/dynamic-link (registered via DynamicLinkModule.forRoot() in apps/api/src/app.module.ts).

The module is pluggable: removing the DynamicLinkModule.forRoot() line from app.module.ts disables the feature entirely without breaking the rest of the app. Schema lives in api-modules/db/src/schema/dynamic-link.ts (kept central with the other schemas so the existing Drizzle migration pipeline picks it up). Future consumers integrate via the DYNAMIC_LINK_GROUP_PORT / DYNAMIC_LINK_PORT injection tokens — concrete service classes are intentionally not exported.


Conventions

Authentication

Admin endpoints require a Better-Auth bearer session and a role with the relevant permission (granted to superAdmin and admin by default). Public /store/... endpoints are unauthenticated.

Authorization: Bearer <session-token>
Endpoint groupRequired permission
GET /admin/dynamic-link-groups, GET /admin/dynamic-link-groups/:iddynamicLinkGroup: read
POST /admin/dynamic-link-groups, POST /admin/dynamic-link-groups/:id/duplicatedynamicLinkGroup: create
PUT /admin/dynamic-link-groups/:iddynamicLinkGroup: update
DELETE /admin/dynamic-link-groups/:iddynamicLinkGroup: delete
GET /admin/dynamic-link-groups/:groupId/linksdynamicLink: read
POST /admin/dynamic-link-groups/:groupId/links, POST .../links/:linkId/duplicatedynamicLink: create
PUT /admin/dynamic-link-groups/:groupId/links/:linkId, PATCH .../links/reorderdynamicLink: update
DELETE /admin/dynamic-link-groups/:groupId/links/:linkIddynamicLink: delete
GET /store/dynamic-link-groups/slug/:slugnone (public)

A caller missing the required permission gets 403 Forbidden (FORBIDDEN).

Response envelope

Successful responses are wrapped by ResponseInterceptor:

{
  "data": <payload>,
  "message": "Success",
  "statusCode": 200,
  "metadata": { /* pagination, when applicable */ }
}

Error envelope

Failures are normalized by HttpExceptionFilter:

{
  "data": null,
  "message": "Human-readable summary",
  "statusCode": 400,
  "errorCode": "BAD_REQUEST",
  "errors": [ /* zod issues, when validation fails */ ]
}
statusCodeerrorCode examples
400BAD_REQUEST, VALIDATION_ERROR
401UNAUTHORIZED
403FORBIDDEN
404NOT_FOUND
409CONFLICT (duplicate slug)
500INTERNAL_SERVER_ERROR, DATABASE_ERROR

Lifecycle

Hard-delete only. No isActive flag, no deletedAt column. To remove a group or link from the storefront, delete it. Deleting a group cascades at the DB level: all child links are removed in the same statement (ON DELETE CASCADE on the FK).

A deleted slug is immediately reusable. Storefront URLs that previously pointed to a deleted group's slug return 404 once the group is gone.

Slug rules

Slugs are admin-typed (no auto-generation). Validated against:

/^[a-z0-9]+(?:-[a-z0-9]+)*$/
  • Lowercase only
  • Alphanumeric segments separated by single hyphens
  • No leading, trailing, or consecutive hyphens
  • 1..255 characters

A duplicate slug returns 409 Conflict (CONFLICT). The service does not auto-suffix on collision — the admin must pick a different slug.


Domain types

DynamicLinkGroup

type DynamicLinkGroup = {
  id: string;                                    // UUID
  title: string;                                 // 1..255
  slug: string;                                  // see slug rules
  metadata: Record<string, unknown> | null;      // free-form JSONB
  createdAt: string;                             // ISO datetime
  updatedAt: string;                             // ISO datetime
};
type DynamicLink = {
  id: string;                                    // UUID
  groupId: string;                               // FKDynamicLinkGroup.id (CASCADE on delete)
  image: string | null;                          // optional URL or storage key, max 2048
  url: string | null;                            // optional click destination, max 2048
  text: string | null;                           // optional label, max 1024
  order: number;                                 // int0; sort key within the group
  metadata: Record<string, unknown> | null;
  createdAt: string;                             // ISO datetime
  updatedAt: string;                             // ISO datetime
};

Returned by admin GET /:id and the public lookup. Group fields with a nested links array sorted by order ASC, createdAt ASC.

type DynamicLinkGroupWithLinks = DynamicLinkGroup & {
  links: DynamicLink[];
};

A DynamicLink must have at least one of image, url, or text populated — otherwise there is nothing to render and the request is rejected with 400 VALIDATION_ERROR.

Empty strings ("") and whitespace-only strings (" ") are normalized to null before validation. Trailing/leading whitespace is trimmed on string values. This means:

  • { "image": "", "url": "", "text": "Hey" } → stored as image=null, url=null, text="Hey" (one field is enough).
  • { "image": "", "url": "", "text": "" } → rejected: "At least one of image, url, or text must be provided".
  • { "metadata": { "icon": "🎁" } } → rejected (no renderable content).
  • { "text": " Limited time " } → stored as text="Limited time".

The same rule re-fires server-side on PUT (update) — the merged record (existing fields + patch) must still satisfy the rule. You can clear individual fields by sending null or "", as long as at least one renderable field remains.


Admin endpoints — Groups

Base path: /admin/dynamic-link-groups

GET / — List groups

Paginated list of groups. Inherits the standard @sc/core/query shape (search, filter, sort, pagination).

Query

NameTypeDefaultNotes
searchValuestring?Search term (use with searchField)
searchField"title" | "slug"Column to search
searchOperator"contains" | "starts_with" | "ends_with""contains"
sortBy"title" | "slug" | "createdAt" | "updatedAt"— (defaults to createdAt asc)
sortDirection"asc" | "desc""desc"
limitint1001..500
offsetint0≥ 0

Example

GET /admin/dynamic-link-groups?searchValue=promo&searchField=title&limit=20
Authorization: Bearer <token>

Response 200

{
  "data": [
    {
      "id": "0d1a3f8e-...",
      "title": "Top Categories",
      "slug": "top-categories",
      "metadata": null,
      "createdAt": "2026-05-02T10:00:00.000Z",
      "updatedAt": "2026-05-02T10:00:00.000Z"
    }
  ],
  "message": "Success",
  "statusCode": 200,
  "metadata": { "total": 4, "limit": 20, "offset": 0, "hasMore": false }
}

Returns the group and its links sorted by order ASC, createdAt ASC. Use this when rendering an admin detail page so you don't need a follow-up call to GET /:groupId/links.

Example

GET /admin/dynamic-link-groups/0d1a3f8e-...
Authorization: Bearer <token>

Response 200

{
  "data": {
    "id": "0d1a3f8e-...",
    "title": "Top Categories",
    "slug": "top-categories",
    "metadata": { "layout": "grid-3" },
    "links": [
      {
        "id": "9c4f2d11-...",
        "groupId": "0d1a3f8e-...",
        "image": "https://cdn.example.com/cat-skincare.jpg",
        "url": "/categories/skincare",
        "text": "Skincare",
        "order": 0,
        "metadata": null,
        "createdAt": "2026-05-02T10:05:00.000Z",
        "updatedAt": "2026-05-02T10:05:00.000Z"
      }
    ],
    "createdAt": "2026-05-02T10:00:00.000Z",
    "updatedAt": "2026-05-02T10:00:00.000Z"
  },
  "message": "Success",
  "statusCode": 200
}

404 when no group has that ID:

{
  "data": null,
  "message": "DynamicLinkGroup with id \"0d1a3f8e-...\" not found",
  "statusCode": 404,
  "errorCode": "NOT_FOUND"
}

POST / — Create a group

Body

type CreateDynamicLinkGroupBody = {
  title: string;                          // 1..255
  slug: string;                           // see slug rules
  metadata?: Record<string, unknown> | null;
};

Example

POST /admin/dynamic-link-groups
Authorization: Bearer <token>
Content-Type: application/json

{
  "title": "Top Categories",
  "slug": "top-categories",
  "metadata": { "layout": "grid-3" }
}

Response 201 — the created group (without links; use GET /:id if you want the array).

409 Conflict when the slug is already taken:

{
  "data": null,
  "message": "DynamicLinkGroup with slug \"top-categories\" already exists",
  "statusCode": 409,
  "errorCode": "CONFLICT"
}

PUT /:id — Update a group

All fields optional. Send only the fields you want to change. If you change slug, uniqueness is re-checked.

Body

type UpdateDynamicLinkGroupBody = Partial<{
  title: string;
  slug: string;
  metadata: Record<string, unknown> | null;
}>;

Example

PUT /admin/dynamic-link-groups/0d1a3f8e-...
Authorization: Bearer <token>
Content-Type: application/json

{ "metadata": { "layout": "grid-4" } }

Response 200 — the updated group.


DELETE /:id — Delete a group

Hard-delete. Cascades to all child links via DB FK.

Example

DELETE /admin/dynamic-link-groups/0d1a3f8e-...
Authorization: Bearer <token>

Response 204 — no body.


POST /:id/duplicate — Duplicate a group

Clones the source group and all its links in a single transaction. The new group gets the title and slug from the request body; child link content (image, url, text, metadata, order) is copied verbatim.

Body

type DuplicateDynamicLinkGroupBody = {
  title: string;        // required
  slug: string;         // required, must be unique
};

The source group's metadata is cloned into the new group as-is — no override is supported in v1 (modify with PUT /:id afterwards if needed).

Example

POST /admin/dynamic-link-groups/0d1a3f8e-.../duplicate
Authorization: Bearer <token>
Content-Type: application/json

{ "title": "Top Categories (Copy)", "slug": "top-categories-copy" }

Response 201 — the cloned group with its cloned links nested.

{
  "data": {
    "id": "<new-group-id>",
    "title": "Top Categories (Copy)",
    "slug": "top-categories-copy",
    "metadata": { "layout": "grid-3" },
    "links": [
      {
        "id": "<new-link-id>",
        "groupId": "<new-group-id>",
        "image": "https://cdn.example.com/cat-skincare.jpg",
        "url": "/categories/skincare",
        "text": "Skincare",
        "order": 0,
        "metadata": null,
        "createdAt": "2026-05-02T10:30:00.000Z",
        "updatedAt": "2026-05-02T10:30:00.000Z"
      }
    ],
    "createdAt": "2026-05-02T10:30:00.000Z",
    "updatedAt": "2026-05-02T10:30:00.000Z"
  },
  "message": "Created successfully",
  "statusCode": 201
}

409 Conflict when the new slug is taken. 404 Not Found when the source :id doesn't exist.


Base path: /admin/dynamic-link-groups/:groupId/links

Every endpoint validates that any referenced link belongs to :groupId. Cross-group access (e.g. PUT /admin/dynamic-link-groups/<groupB>/links/<linkInGroupA>) returns 404, not 200 — preventing accidental tampering.

Returns all links for :groupId sorted by order ASC, createdAt ASC. Not paginated in v1 (groups are expected to hold under ~100 links).

Example

GET /admin/dynamic-link-groups/0d1a3f8e-.../links
Authorization: Bearer <token>

Response 200

{
  "data": [
    { "id": "9c4f2d11-...", "groupId": "0d1a3f8e-...", "image": "...", "url": "/categories/skincare", "text": "Skincare", "order": 0, "metadata": null, "createdAt": "...", "updatedAt": "..." },
    { "id": "73e0a812-...", "groupId": "0d1a3f8e-...", "image": "...", "url": "/categories/hair-care", "text": "Hair Care", "order": 1, "metadata": null, "createdAt": "...", "updatedAt": "..." }
  ],
  "message": "Success",
  "statusCode": 200
}

404 when the group doesn't exist.


POST / — Create a link

groupId comes from the URL — do not send it in the body.

Body

type CreateDynamicLinkBody = {
  image?: string | null;                  // max 2048; ""null
  url?: string | null;                    // max 2048; ""null
  text?: string | null;                   // max 1024; ""null
  order?: number;                         // int0; default 0
  metadata?: Record<string, unknown> | null;
};

At least one of image, url, or text must be present (after the empty-string-to-null normalization).

Example — image + url + text

POST /admin/dynamic-link-groups/0d1a3f8e-.../links
Authorization: Bearer <token>
Content-Type: application/json

{
  "image": "https://cdn.example.com/cat-skincare.jpg",
  "url": "/categories/skincare",
  "text": "Skincare",
  "order": 0
}

Example — text-only (image and url empty)

The empty strings normalize to null and the link is accepted because text is present.

POST /admin/dynamic-link-groups/0d1a3f8e-.../links
Authorization: Bearer <token>
Content-Type: application/json

{ "image": "", "url": "", "text": "Limited offer", "order": 5 }

Response 201

{
  "data": {
    "id": "9c4f2d11-...",
    "groupId": "0d1a3f8e-...",
    "image": null,
    "url": null,
    "text": "Limited offer",
    "order": 5,
    "metadata": null,
    "createdAt": "2026-05-02T10:05:00.000Z",
    "updatedAt": "2026-05-02T10:05:00.000Z"
  },
  "message": "Created successfully",
  "statusCode": 201
}

400 Validation Error — sending no renderable content:

POST /admin/dynamic-link-groups/0d1a3f8e-.../links
Authorization: Bearer <token>
Content-Type: application/json

{ "metadata": { "note": "placeholder" } }
{
  "data": null,
  "message": "Validation failed",
  "statusCode": 400,
  "errorCode": "VALIDATION_ERROR",
  "errors": [
    {
      "code": "custom",
      "message": "At least one of image, url, or text must be provided",
      "path": ["image"]
    }
  ]
}

Accepts an array of { linkId, order } and updates them in a single transaction. All linkIds must belong to :groupId. Duplicate linkIds in the payload are rejected. The response is the full reordered list.

Body

type ReorderDynamicLinksBody = {
  items: Array<{
    linkId: string;       // must belong to :groupId
    order: number;        // int0
  }>;                     // length1
};

Example

PATCH /admin/dynamic-link-groups/0d1a3f8e-.../links/reorder
Authorization: Bearer <token>
Content-Type: application/json

{
  "items": [
    { "linkId": "9c4f2d11-...", "order": 1 },
    { "linkId": "73e0a812-...", "order": 0 }
  ]
}

Response 200 — the links in their new order.

400 Bad Request when:

  • items is empty
  • A linkId is referenced more than once
  • Any linkId does not belong to :groupId

Route ordering notePATCH /reorder is declared in the controller before PUT /:linkId so the literal path reorder is not captured as a UUID parameter. If you ever add another action under this controller, declare it above the :linkId routes too.


All fields optional. The "at least one of image / url / text" rule is re-validated server-side against the merged record (existing row + patch). You can clear an individual field by sending null or "", as long as the remaining fields keep the rule satisfied.

Body

type UpdateDynamicLinkBody = Partial<{
  image: string | null;
  url: string | null;
  text: string | null;
  order: number;
  metadata: Record<string, unknown> | null;
}>;

Example — clear the image, keep text

PUT /admin/dynamic-link-groups/0d1a3f8e-.../links/9c4f2d11-...
Authorization: Bearer <token>
Content-Type: application/json

{ "image": null }

Response 200 — the updated link.

400 Bad Request if the patch would leave the link with no renderable content:

PUT /admin/dynamic-link-groups/0d1a3f8e-.../links/9c4f2d11-...
Authorization: Bearer <token>
Content-Type: application/json

{ "image": "", "url": "", "text": "" }
{
  "data": null,
  "message": "At least one of image, url, or text must be provided",
  "statusCode": 400,
  "errorCode": "BAD_REQUEST"
}

404 when :linkId doesn't belong to :groupId.


Hard-delete. The parent group is unaffected.

Example

DELETE /admin/dynamic-link-groups/0d1a3f8e-.../links/9c4f2d11-...
Authorization: Bearer <token>

Response 204 — no body.

404 when :linkId doesn't belong to :groupId.


POST /:linkId/duplicate — Duplicate a link

Clones a link within the same group. The clone's order is set to max(existing order in group) + 1, so it appears at the end of the list. Cross-group duplication is not supported — open the target group and create the link there if you need it.

Example

POST /admin/dynamic-link-groups/0d1a3f8e-.../links/9c4f2d11-.../duplicate
Authorization: Bearer <token>

Response 201 — the cloned link.

{
  "data": {
    "id": "<new-link-id>",
    "groupId": "0d1a3f8e-...",
    "image": "https://cdn.example.com/cat-skincare.jpg",
    "url": "/categories/skincare",
    "text": "Skincare",
    "order": 7,
    "metadata": null,
    "createdAt": "2026-05-02T10:40:00.000Z",
    "updatedAt": "2026-05-02T10:40:00.000Z"
  },
  "message": "Created successfully",
  "statusCode": 201
}

404 when :linkId doesn't belong to :groupId.


Public storefront endpoint

Base path: /store/dynamic-link-groups

No authentication. No paging. No filtering — all links of the requested group are returned. Storefront should hide groups it doesn't recognize rather than relying on a feature flag.

GET /slug/:slug — Fetch a group by slug

Returns the group + its links sorted by order ASC, createdAt ASC. 404 if no group has that slug (e.g. it was deleted, or the storefront has a stale URL).

Example

GET /store/dynamic-link-groups/slug/top-categories

Response 200

{
  "data": {
    "id": "0d1a3f8e-...",
    "title": "Top Categories",
    "slug": "top-categories",
    "metadata": { "layout": "grid-3" },
    "links": [
      {
        "id": "9c4f2d11-...",
        "groupId": "0d1a3f8e-...",
        "image": "https://cdn.example.com/cat-skincare.jpg",
        "url": "/categories/skincare",
        "text": "Skincare",
        "order": 0,
        "metadata": null,
        "createdAt": "2026-05-02T10:05:00.000Z",
        "updatedAt": "2026-05-02T10:05:00.000Z"
      },
      {
        "id": "73e0a812-...",
        "groupId": "0d1a3f8e-...",
        "image": "https://cdn.example.com/cat-haircare.jpg",
        "url": "/categories/hair-care",
        "text": "Hair Care",
        "order": 1,
        "metadata": null,
        "createdAt": "2026-05-02T10:06:00.000Z",
        "updatedAt": "2026-05-02T10:06:00.000Z"
      }
    ],
    "createdAt": "2026-05-02T10:00:00.000Z",
    "updatedAt": "2026-05-02T10:00:00.000Z"
  },
  "message": "Success",
  "statusCode": 200
}

Response 404 — group not found:

{
  "data": null,
  "message": "DynamicLinkGroup with slug \"top-categories\" not found",
  "statusCode": 404,
  "errorCode": "NOT_FOUND"
}

Events

Emitted via @nestjs/event-emitter (EventEmitter2). Event names use dotted lowercase strings; constants are UPPER_SNAKE_CASE. One event per file under events/dynamic-link-group/ and events/dynamic-link/.

ConstantEvent namePayload classFired when
DYNAMIC_LINK_GROUP_CREATEDdynamic-link-group.createdDynamicLinkGroupCreatedEvent { groupId, slug, actorId }POST /admin/dynamic-link-groups
DYNAMIC_LINK_GROUP_UPDATEDdynamic-link-group.updatedDynamicLinkGroupUpdatedEvent { groupId, slug, actorId, changedFields }PUT /admin/dynamic-link-groups/:id
DYNAMIC_LINK_GROUP_DELETEDdynamic-link-group.deletedDynamicLinkGroupDeletedEvent { groupId, slug, actorId }DELETE /admin/dynamic-link-groups/:id
DYNAMIC_LINK_GROUP_DUPLICATEDdynamic-link-group.duplicatedDynamicLinkGroupDuplicatedEvent { newGroupId, sourceGroupId, newSlug, clonedLinkCount, actorId }POST /admin/dynamic-link-groups/:id/duplicate
DYNAMIC_LINK_CREATEDdynamic-link.createdDynamicLinkCreatedEvent { linkId, groupId, actorId }POST .../links
DYNAMIC_LINK_UPDATEDdynamic-link.updatedDynamicLinkUpdatedEvent { linkId, groupId, actorId, changedFields }PUT .../links/:linkId
DYNAMIC_LINK_DELETEDdynamic-link.deletedDynamicLinkDeletedEvent { linkId, groupId, actorId }DELETE .../links/:linkId
DYNAMIC_LINK_DUPLICATEDdynamic-link.duplicatedDynamicLinkDuplicatedEvent { newLinkId, sourceLinkId, groupId, actorId }POST .../links/:linkId/duplicate
DYNAMIC_LINK_REORDEREDdynamic-link.reorderedDynamicLinkReorderedEvent { groupId, count, actorId }PATCH .../links/reorder

The per-link clones inside a group-duplicate flow are bundled into the single DYNAMIC_LINK_GROUP_DUPLICATED event (with clonedLinkCount) — no individual DYNAMIC_LINK_CREATED / DYNAMIC_LINK_DUPLICATED events fire for the cloned children, to keep the event stream readable.

The DB-cascade on group delete fires no per-link DYNAMIC_LINK_DELETED events either — listeners that need to react to child deletion should subscribe to DYNAMIC_LINK_GROUP_DELETED and look up affected links themselves before the cascade.


Plugin integration

The module exports two ports as DI tokens. Concrete service classes are not exported.

import {
  DYNAMIC_LINK_GROUP_PORT,
  DYNAMIC_LINK_PORT,
  type DynamicLinkGroupPort,
  type DynamicLinkPort,
} from "@sc/dynamic-link";

@Injectable()
class MyConsumer {
  constructor(
    @Inject(DYNAMIC_LINK_GROUP_PORT) private readonly groupPort: DynamicLinkGroupPort,
    @Inject(DYNAMIC_LINK_PORT) private readonly linkPort: DynamicLinkPort,
  ) {}
}

Within the module, DynamicLinkService injects DYNAMIC_LINK_GROUP_PORT (not the concrete DynamicLinkGroupService) for cross-service group lookups. This keeps inter-service coupling at the port level so the implementation can be swapped without touching consumers.


Schema reference

// api-modules/db/src/schema/dynamic-link.ts
dynamicLinkGroup {
  id          text PK ($defaultFn = crypto.randomUUID)
  title       text NOT NULL
  slug        text NOT NULL
  metadata    jsonb NULL
  createdAt   timestamp NOT NULL DEFAULT now()
  updatedAt   timestamp NOT NULL DEFAULT now() ($onUpdate = new Date)

  UNIQUE INDEX dynamic_link_group_slug_idx (slug)
}

dynamicLink {
  id          text PK ($defaultFn = crypto.randomUUID)
  groupId     text NOT NULL  (FK → dynamic_link_group.id, ON DELETE CASCADE)
  image       text NULL
  url         text NULL
  text        text NULL
  order       integer NOT NULL DEFAULT 0
  metadata    jsonb NULL
  createdAt   timestamp NOT NULL DEFAULT now()
  updatedAt   timestamp NOT NULL DEFAULT now() ($onUpdate = new Date)

  INDEX dynamic_link_group_id_idx (group_id)
  INDEX dynamic_link_order_idx    (group_id, "order")
}

// Drizzle relations
dynamicLinkGroup.links  → many(dynamicLink)
dynamicLink.group       → one(dynamicLinkGroup, { fields: [groupId], references: [id] })

Migration: api-modules/db/drizzle/0012_sharp_freak.sql.


Out of scope (deferred)

  • Bulk endpoints beyond reorder — no bulk-create or bulk-delete in v1.
  • Link paginationGET .../links returns all links unpaginated.
  • Cross-group link duplicate — duplicate is same-group only.
  • Group-level reordering — groups are looked up by slug, not ordered as a list.
  • Visibility/active flag — hard-delete is the only off-state.
  • Soft delete / restore — same.
  • Media-table FK for image — stored as plain text URL.
  • Conditional/feature-flag plugin loader — module is registered via direct forRoot() import.

On this page