Dynamic Link Module — Storefront
HTTP surface for reading dynamic link groups by slug. Dynamic link groups are CMS-style ordered collections of {image, text, url} cards — used for the storefront home grid, promo…
HTTP surface for reading dynamic link groups by slug. Dynamic link groups are CMS-style ordered collections of {image, text, url} cards — used for the storefront home grid, promo tiles, "explore" rows, and any other content slot whose layout is data-driven rather than hard-coded.
Source:
api-modules/dynamic-link/src/controllers/public-dynamic-link-group.controller.ts.Admin CRUD for groups and links is in
docs/separated/admin/dynamic-link.md. The storefront uses one read endpoint to fetch a group + its ordered links in a single call.
Conventions
Authentication
| Endpoint | Auth |
|---|---|
GET /store/dynamic-link-groups/slug/:slug | none (public) |
Dynamic links are public marketing content — no session, no PII.
Response envelope
{
"data": <payload>,
"message": "Success",
"statusCode": 200
}Error envelope
statusCode | errorCode examples |
|---|---|
| 404 | NOT_FOUND |
| 500 | INTERNAL_SERVER_ERROR, DATABASE_ERROR |
Domain types
DynamicLinkResponse
type DynamicLinkResponse = {
id: string;
groupId: string;
image: string | null; // storage key — resolve via storage CDN
url: string | null; // click destination
text: string | null; // caption / label
order: number; // sort key — links are emitted in ASC order
isActive: boolean; // always true here — inactive links are filtered out server-side
platform: "APP" | "WEB" | "BOTH"; // default BOTH; the requested platform filter, when any, always matches
startsAt: string | null; // ISO; inclusive window start, null = no start bound
endsAt: string | null; // ISO; exclusive window end, null = no end bound
metadata: Record<string, unknown> | null;
createdAt: string; // ISO
updatedAt: string; // ISO
};A link carries an optional promotional window. Both ends are independent
and nullable, so a link with neither is always live. This endpoint already
applies the window, so every link in links is showing right now — startsAt
/ endsAt are exposed only so a client can label a limited-time banner.
DynamicLinkGroupWithLinksResponse
type DynamicLinkGroupWithLinksResponse = {
id: string;
title: string;
slug: string; // lowercase alnum + hyphens, e.g. "home-grid"
metadata: Record<string, unknown> | null;
createdAt: string; // ISO
updatedAt: string; // ISO
links: DynamicLinkResponse[]; // sorted by `order` ASC
nextTransitionAt: string | null; // ISO; when `links` next changes, null = nothing scheduled
};nextTransitionAt is the earliest schedule boundary still ahead of now across
the group's active, platform-matching links — the moment this payload stops
being correct. Clients should refresh exactly then rather than poll; null
means nothing is scheduled and the payload holds until an operator edits it.
Endpoints
GET /store/dynamic-link-groups/slug/:slug — Fetch a group with its links
Returns the group plus its nested links array, sorted by order ASC. Only links with isActive: true are included — links an admin has toggled off are silently omitted. Links outside their promotional window are omitted too: startsAt is inclusive and endsAt exclusive, so a window of [10:00, 12:00) shows the link at exactly 10:00:00 and drops it at exactly 12:00:00; a null boundary leaves that side unbounded. Optionally filter further by platform via ?platform=APP|WEB or the x-platform header (the query param wins if both are sent); a link is included when its platform is BOTH or matches the requested one. Used by the storefront to populate a named content slot in a single round-trip.
The response is time-dependent, so a cached copy goes stale with no write behind it. Two mechanisms keep consumers honest: nextTransitionAt tells a client when to refresh, and the API pushes the dynamic-links cache tag to the storefront's /api/revalidate/tags when a boundary passes (see Scheduling & cache invalidation below).
Path params
| Name | Notes |
|---|---|
slug | Group slug (lowercase alnum + hyphens) |
Query params
| Name | Notes |
|---|---|
platform | APP / WEB / BOTH, optional. Absent or BOTH returns links for every platform. |
Headers
| Name | Notes |
|---|---|
x-platform | APP / WEB / BOTH, optional fallback used only when the platform query param is omitted. |
Response 200 — DynamicLinkGroupWithLinksResponse.
{
"data": {
"id": "01J9...",
"title": "Home Grid",
"slug": "home-grid",
"metadata": null,
"createdAt": "2026-04-01T08:00:00.000Z",
"updatedAt": "2026-05-01T08:00:00.000Z",
"links": [
{
"id": "01J9...",
"groupId": "01J9...",
"image": "dlinks/2026-05/promo-1.jpg",
"url": "/sale",
"text": "Summer Sale",
"order": 0,
"isActive": true,
"platform": "BOTH",
"startsAt": null,
"endsAt": "2026-05-08T18:30:00.000Z",
"metadata": null,
"createdAt": "2026-05-01T08:00:00.000Z",
"updatedAt": "2026-05-01T08:00:00.000Z"
}
],
"nextTransitionAt": "2026-05-08T18:30:00.000Z"
},
"message": "Success",
"statusCode": 200
}Errors
| Status | Code | When |
|---|---|---|
| 404 | NOT_FOUND | No group matches the slug |
Scheduling & cache invalidation
A scheduled link starts and stops rendering with no write behind it, so nothing emits a domain event at the boundary. Three layers cover the gap:
- The endpoint is authoritative. The window is applied per request, so a direct caller is always correct — including the mobile app, which holds no Next.js cache.
- The API sweeps for boundaries. A worker-side repeatable job
(
dynamic-link-schedule-sweep, every 60s) looks for astartsAt/endsAtthat has just passed and pushes thedynamic-linkstag to the storefront's/api/revalidate/tags. The lookback is deliberately wider than the interval so one dropped tick still flushes; a duplicate flush is idempotent. - Clients refresh at
nextTransitionAt. A tag flush cannot reach a browser tab that is already open, so the storefront hook drives both its stale window and its refetch timer off this field.
A gap longer than the sweep's lookback — a worker outage — falls through to the storefront's own TTL on the tagged fetch (5 minutes). That is the worst-case staleness for the web; the endpoint itself never serves an out-of-window link.
Related modules
storage— resolvesimagekeys to CDN URLs on the client.banner— adjacent CMS surface for taxonomy-pinned promotional banners. Seebanner.md.
Discount Module — Storefront
Storefront surface for the discount plugin — the coupons a product page can advertise. Coupon apply/remove and the cart's browsable coupon list live on the cart routes.
Dynamic Product Listing Module — Storefront
Public storefront surface for reading dynamic product listing groups by slug and resolving dynamic product listings (saved filter specs) into live product results from the search engine.