@distinctagency/cms-client
v1.30.0
Published
Client library for Distinct CMS — query content, products, and manage orders
Maintainers
Readme
Connecting a Website to Distinct CMS
The canonical, copy-paste integration guide lives in the CMS admin — open it at Tenants → <your tenant> → Developer. It bakes in your tenant's API keys, real collection schemas, and every section below in one paste-able document. Drop it straight into a Claude Code session and the agent has everything it needs to build the site.
This file is the public, npm-shipped reference with the same content (minus tenant-specific values). It's what shows up if you
npm view @distinctagency/cms-clientor browse the GitHub repo.
This guide explains how to connect a Next.js website to Distinct CMS to read content (events, blog posts, etc.) managed through the admin dashboard.
Quick links
- How sync works — mental model
- Webhooks + on-demand ISR — instant cache invalidation
- Cache tags reference
- Tagging your reads
How sync works
Three-stage pipeline:
1. Edit 2. Notify 3. Invalidate
┌──────────┐ ┌──────────────────────┐ ┌────────────────────┐
│ Editor │ → │ CMS fires webhook │ → │ Tenant /api/ │
│ saves in │ │ POST /your/endpoint │ │ revalidate calls │
│ admin UI │ │ X-CMS-Event: <name> │ │ revalidateTag(...) │
└──────────┘ │ X-CMS-Signature: hex │ │ on cache_tags │
│ body: { │ └────────┬───────────┘
│ event, │ │
│ cache_tags: [...], │ ▼
│ resource_id, ... │ ┌────────────────────┐
│ } │ │ Next.js refetches │
└──────────────────────┘ │ on next request │
└────────────────────┘Three commitments to make this work:
- Subscribe. Configure a webhook in Tenants → Webhooks pointing at your site's
/api/revalidateroute. Pick events or use*. - Receive. In your route, verify the HMAC signature with
verifyWebhookSignature()and fan out cache invalidation withrevalidateAllTags()— the SDK exports both. - Tag your reads. Pass
{ next: { tags: [...] } }to your fetches using thecms:*namespace so the receiver'srevalidateTag()calls actually hit something. See Tagging your reads.
Without (3), the webhook fires but nothing is cached under those tags so nothing changes.
The getTrackingConfig() SDK method is already tagged with cms:tracking-config for you. Other reads need explicit tagging.
Prerequisites
You need three values from the Distinct CMS team:
| Variable | Description | Where to find |
|---|---|---|
| NEXT_PUBLIC_SUPABASE_URL | Supabase project URL | https://cms-edge.distinctstudio.co.nz |
| NEXT_PUBLIC_SUPABASE_ANON_KEY | Supabase public anon key | Provided by Distinct |
| CMS_API_KEY | Tenant-specific API key (UUID) | Provided by Distinct (from the CMS admin Tenants page) |
Important:
CMS_API_KEYis a secret. It should be in.env.local(not committed) and only used in server-side code (Server Components, API routes,getStaticProps). Never expose it to the browser.
1. Install dependencies
# Install the CMS client package and Supabase
pnpm add @distinctagency/cms-client @supabase/supabase-js2. Set environment variables
Add to .env.local:
NEXT_PUBLIC_SUPABASE_URL=https://cms-edge.distinctstudio.co.nz
NEXT_PUBLIC_SUPABASE_ANON_KEY=<anon-key-from-distinct>
CMS_API_KEY=<your-tenant-api-key>3. Create the CMS client
Create src/lib/cms.ts:
import { createClient } from "@supabase/supabase-js"
import { createCmsClient } from "@distinctagency/cms-client"
const supabase = createClient(
process.env.NEXT_PUBLIC_SUPABASE_URL!,
process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!
)
export const cms = createCmsClient(supabase, {
apiKey: process.env.CMS_API_KEY!,
})This file should only be imported in server-side code. The
CMS_API_KEYenv var has noNEXT_PUBLIC_prefix, so it is not available in the browser.
4. Fetch content in pages
List items (e.g. all published events)
// src/app/events/page.tsx
import { cms } from "@/lib/cms"
import type { ContentItem } from "@distinctagency/cms-client"
export default async function EventsPage() {
const events = await cms.getContentItems("events", {
status: "published",
orderBy: "published_at",
orderDirection: "desc",
})
return (
<div>
<h1>Events</h1>
{events.map((event) => (
<div key={event.id}>
<h2>{event.title}</h2>
<p>{event.excerpt}</p>
{/* Custom fields are in event.data */}
<p>Date: {event.data.date as string}</p>
<p>Location: {event.data.location as string}</p>
</div>
))}
</div>
)
}Single item by slug
// src/app/events/[slug]/page.tsx
import { cms } from "@/lib/cms"
import { notFound } from "next/navigation"
interface Props {
params: Promise<{ slug: string }>
}
export default async function EventPage({ params }: Props) {
const { slug } = await params
const event = await cms.getContentItemBySlug("events", slug)
if (!event) notFound()
return (
<div>
<h1>{event.title}</h1>
<p>{event.excerpt}</p>
<div>{event.data.body as string}</div>
</div>
)
}
// Generate static paths for all published events
export async function generateStaticParams() {
const slugs = await cms.getAllSlugs("events")
return slugs
}5. Available API methods
The cms client exposes these methods:
| Method | Description |
|---|---|
| cms.getContentItems(collectionSlug, options?) | List items. Options: status, orderBy, orderDirection, limit, offset |
| cms.getContentItemBySlug(collectionSlug, itemSlug) | Get one item by slug. Returns null if not found |
| cms.getContentType(collectionSlug) | Get the collection definition including its field schema |
| cms.getContentTypes() | List all collections for this tenant |
| cms.getAllSlugs(collectionSlug) | Get all published slugs (for generateStaticParams) |
| cms.getRedirects(collectionSlug) | Get slug redirects for 301s. Returns [{ old_slug, new_slug }] |
| cms.getCustomRedirects() | Get custom path redirects. Returns [{ source_path, destination_path, permanent }] |
| cms.getReviews(options?) | Get Google Reviews. Options: status, minRating, orderBy, orderDirection, limit, offset |
| getEmbedHtml(embedValue) | Generate responsive iframe HTML for an embed field. Returns empty string if invalid |
6. Content item shape
Every content item has these standard fields:
{
id: string
title: string
slug: string
status: "draft" | "published" | "archived"
published_at: string | null
excerpt: string | null
seo_title: string | null
seo_description: string | null
og_image: string | null
featured_image: string | null
sort_order: number
view_count: number // analytics: total page views
created_at: string
updated_at: string
// Collection-specific fields live here:
data: {
date: "2026-06-15",
location: "Business Mastery Office, Christchurch",
event_type: "Growth Intensive",
body: "Full description text...",
// ... whatever fields are defined in the collection schema
}
}The data field is typed as Record<string, unknown>. You can inspect the collection schema at runtime via cms.getContentType("events") to see what fields are available, or check the CMS admin under Collections.
7. Reference fields
Some collections have reference fields that point to items in other collections. For example, a Blog Post might have an author field that references the Authors collection.
The value stored in data.author is the ID of the referenced content item. To resolve it:
const post = await cms.getContentItemBySlug("blog_posts", "my-post")
const authorId = post?.data.author as string
// Fetch the referenced author
if (authorId) {
const authors = await cms.getContentItems("authors")
const author = authors.find((a) => a.id === authorId)
}7.1 Multi-reference fields
A multi-reference field stores an ordered array of item IDs from another collection (e.g. a Blog Post's related_posts).
The value stored in data.related_posts is string[] — an array of item IDs in the order the editor arranged them. To resolve:
const post = await cms.getContentItemBySlug("blog_posts", "my-post")
const relatedIds = (post?.data.related_posts as string[]) ?? []
// Fetch the target collection and resolve client-side, preserving editor order.
const all = await cms.getContentItems("blog_posts")
const byId = new Map(all.map((it) => [it.id, it]))
const related = relatedIds.map((id) => byId.get(id)).filter(Boolean)Schema-side, the type literal is "multi-reference". Optional min_items and max_items enforce a count range in the admin UI.
7.2 File fields
File fields store the public URL of an uploaded asset. The CMS admin lets editors configure:
accepted_file_types?: string[]— list of allowed mime types or extensions (e.g.["application/pdf", ".docx"]). When omitted, any file is accepted.max_file_size_mb?: number— per-field size cap. Subject to a global 25 MB ceiling. When omitted, the global ceiling applies.
These constraints affect what an editor can upload in the CMS admin. On the consumer side, the field's value is still just the URL string — no type info is carried in data.
7.3 Date fields
A date field can collect a full date (the default) or a partial date — year-only, month + year, month-only, or day + month (e.g. a birthday). The precision is set per-field in the CMS admin (Collections → field → Date precision) and exposed on the schema as date_granularity.
Values are stored as strings in partial-ISO form, so the shape tells you the precision:
| date_granularity | Stored value example | Meaning |
|---|---|---|
| full (default / unset) | "2026-06-15" | Day, month, year |
| month_year | "2026-06" | Month and year |
| year | "2026" | Year only |
| month | "--06" | Month only (no year) |
| day_month | "--06-15" | Day and month (no year), e.g. a birthday |
Existing date fields are unaffected — an unset
date_granularitybehaves exactly likefull, and the value is still"YYYY-MM-DD".
Formatting for display
The SDK ships two helpers so you don't hand-parse the partial-ISO shapes. formatPartialDate() is locale-aware and orders the parts correctly for the locale (e.g. "15 June" vs "June 15"):
import { formatPartialDate, parsePartialDate } from "@distinctagency/cms-client"
formatPartialDate("2026-06-15") // "15 June 2026" (locale-dependent)
formatPartialDate("2026-06", { locale: "en-NZ" }) // "June 2026"
formatPartialDate("2026") // "2026"
formatPartialDate("--06") // "June"
formatPartialDate("--06-15", { month: "short" }) // "15 Jun"
// Or parse into structured parts to format yourself:
parsePartialDate("--06-15")
// → { granularity: "day_month", month: 6, day: 15 }formatPartialDate(value, options?) accepts { locale?: string | string[]; month?: "long" | "short" | "narrow" | "numeric" | "2-digit" } and returns the raw string unchanged if it isn't a recognised partial date. parsePartialDate(value) returns { granularity, year?, month?, day? } or null.
To know a field's precision ahead of time (e.g. to pick a format), read the schema via
cms.getContentType("<collection>")and inspectfield.date_granularity. You rarely need to — the stored value's shape is self-describing and the helpers branch on it for you.
8. Slug Redirects (301s)
When content slugs are changed in the CMS, a redirect is created automatically. Wire these into your next.config.ts to serve 301 redirects and preserve SEO:
// next.config.ts
import { createClient } from "@supabase/supabase-js"
import { createCmsClient } from "@distinctagency/cms-client"
const cms = createCmsClient(
createClient(process.env.NEXT_PUBLIC_SUPABASE_URL!, process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!),
{ apiKey: process.env.CMS_API_KEY! }
)
export default {
async redirects() {
// Slug redirects (auto-created when CMS slugs change)
const eventRedirects = await cms.getRedirects("events")
const slugRedirects = eventRedirects.map((r) => ({
source: `/events/${r.old_slug}`,
destination: `/events/${r.new_slug}`,
permanent: true,
}))
// Custom redirects (manually added for site redesigns, legacy URLs)
const customRedirects = await cms.getCustomRedirects()
const customMapped = customRedirects.map((r) => ({
source: r.source_path,
destination: r.destination_path,
permanent: r.permanent,
}))
return [...slugRedirects, ...customMapped]
},
}Tagging your reads
For on-demand revalidation to work, the data fetches in your site need to be tagged with the same cms:* strings the webhook payload carries. Wrap your CMS reads in tagged fetch calls (or pass { next: { tags } } directly to the SDK methods that accept it).
| What you're rendering | Tag your fetch with | Webhook event(s) that invalidate it |
|----------------------------------|---------------------------------------------------|------------------------------------------------------------------|
| Content list (e.g. all events) | cms:content-type:events | content.published, content.unpublished, content.updated, content.deleted, content_type.updated |
| Single content item | cms:content:events:<slug> | content.* for that slug |
| Collection schema / SEO config | cms:content-type:<slug>:schema | content_type.updated |
| Tracking IDs (getTrackingConfig) | cms:tracking-config (SDK does this for you) | settings.tracking_updated |
| Brand (logo / colours) | cms:brand | settings.brand_updated |
| Integration config (Stripe, Resend, etc.) | cms:integration:<provider> | settings.integration_updated |
| Product list | cms:products | products.updated |
| Single product | cms:product:<slug> | products.updated (when slug-specific) |
| Product categories | cms:product-categories | product_categories.updated |
| Ticket tiers for an event | cms:ticket-tiers, cms:event:<event-id> | ticket_tiers.updated |
| Membership tiers | cms:membership-tiers | membership_tiers.updated |
| Redirects (if served at runtime) | cms:redirects | redirects.updated |
| Reviews (getReviews) | cms:reviews | reviews.synced |
| Flipbooks | cms:flipbooks, cms:flipbook:<id> | flipbook.ready |
| Motor Central vehicles | cms:content-type:<vehicles-collection>, cms:integration:motor-central | content.updated after each sync |
Example — tag a content list
// Inside a Server Component
const events = await fetch(
`${process.env.NEXT_PUBLIC_CMS_URL}/rest/v1/content_items?...`,
{
headers: { /* anon + api key */ },
next: { tags: ["cms:content-type:events"], revalidate: 3600 },
}
).then((r) => r.json())If you'd rather not hand-roll fetch calls, the SDK methods accept an options object you can pass through; for the methods that don't yet, wrap them in your own tiny helper that re-uses the same call signature.
Example — tag a tracking-config read (already wired)
import { cms } from "@/lib/cms"
const tracking = await cms.getTrackingConfig() // already tagged with cms:tracking-config9. Revalidation (ISR)
You have two options:
Time-based (simple)
Add to any page or layout:
// Revalidate this route at most every 60 seconds
export const revalidate = 60Pages stay cached for that interval. Fine for low-edit-frequency content; users
can see stale data for up to revalidate seconds after a publish.
On-demand from CMS webhooks (recommended for editorial sites)
The CMS fires an outbound webhook on every content change. Wire a single
/api/revalidate route that calls Next's
revalidatePath()
or revalidateTag().
Editors see updates within a second of pressing Publish, and the rest of the
site stays statically cached.
Configure the subscription
In the CMS, open Tenants → <your tenant> → Webhooks and add a row:
| Field | Value |
|--------------|----------------------------------------------------|
| Endpoint URL | https://yoursite.com/api/revalidate |
| Events | content.published, content.unpublished, content.updated, content.deleted (or * for everything) |
| Secret | Click Generate (32 random bytes) and copy it. Save it as CMS_WEBHOOK_SECRET in your site's env. |
The secret is encrypted at rest in the CMS using the per-tenant key.
Payload contract
Every delivery is POST application/json with these headers:
| Header | Value |
|---------------------|----------------------------------------------------|
| X-CMS-Event | The event name (e.g. content.published) |
| X-CMS-Signature | hmac_sha256(secret, raw_body) as lowercase hex (only when a secret is set) |
| X-CMS-Mode | live or staging — which URL the CMS chose to fire to |
| Content-Type | application/json |
Live + staging URLs. Each webhook subscription holds both a live URL and an optional staging URL. They share the same secret, events, and signature. A mode toggle on the row picks which URL fires. Use this during development to redirect revalidation traffic at your dev/preview site without breaking production.
Body shape:
{
"event": "content.published",
"tenant_id": "uuid",
"content_type_slug": "blog-posts",
"content_item_id": "uuid",
"resource_id": "uuid",
"slug": "hello-world",
"title": "Hello world",
"status": "published",
"cache_tags": ["cms:content-type:blog-posts", "cms:content:blog-posts:hello-world"],
"data": { "source": "editor" },
"timestamp": "2026-05-11T03:14:15.926Z"
}Every payload carries:
event— what happened (see the event list below).cache_tags— the Next.js cache tags that should be invalidated. All tags are namespaced withcms:so they don't collide with your own.resource_id— stable identifier for the changed resource (product id, flipbook id, integration provider name, etc.).data— event-specific extras (e.g.{ provider: "motor-central" },{ reviews_new: 3 },{ source: "import", action: "merge" }).
Content/commerce events also fill the legacy slug, title, status,
content_type_slug, content_item_id fields when they apply.
Example receiver — one-line tag invalidation (recommended)
The SDK ships revalidateAllTags(payload, revalidateTag) so most receivers
don't need to switch on event names. It walks payload.cache_tags and
forwards each one to Next's revalidateTag().
⚠️ Next 16 changed the
revalidateTagAPI — read this before copying.In Next 16,
revalidateTag(tag)becamerevalidateTag(tag, profile). The named profiles ("default","max","days", etc.) are stale-while-revalidate windows, not immediate invalidation modes — passing them silently leaves cached pages stale for up to 1 year. The only value that means "invalidate now" is{ expire: 0 }.Use SDK v1.17.1+ and the snippet below as-is —
revalidateAllTags()passes{ expire: 0 }internally, so it works on Next 14, 15, and 16 without a wrapper. If you're on an older SDK or callrevalidateTagfor path-specific work, write it explicitly:revalidateTag("cms:content-type:blog-posts", { expire: 0 })
// src/app/api/revalidate/route.ts
import { revalidateTag } from "next/cache"
import { NextResponse } from "next/server"
import {
verifyWebhookSignature,
revalidateAllTags,
type WebhookEventPayload,
} from "@distinctagency/cms-client" // v1.17.1+ — passes { expire: 0 } for Next 16
export async function POST(req: Request) {
const raw = await req.text()
const sig = req.headers.get("x-cms-signature")
const secret = process.env.CMS_WEBHOOK_SECRET
if (secret && !(await verifyWebhookSignature(secret, raw, sig))) {
return NextResponse.json({ error: "bad signature" }, { status: 401 })
}
const payload = JSON.parse(raw) as WebhookEventPayload
// Ignore the test ping the admin UI sends from the "test" button.
if (payload.content_type_slug === "_test") {
return NextResponse.json({ ok: true, ignored: "test" })
}
// SDK forwards every cache_tag to revalidateTag(tag, { expire: 0 }).
// Don't substitute a named profile like "default" — those are SWR
// windows, not immediate invalidation.
const invalidated = revalidateAllTags(payload, revalidateTag)
return NextResponse.json({ ok: true, invalidated })
}For this to do anything, your getContentItems / getTrackingConfig /
getProducts reads need to be tagged with the same cms:* tags. The SDK's
getTrackingConfig() already tags itself with TRACKING_CONFIG_TAG
(= "cms:tracking-config"); for other reads, pass { next: { tags: [...] } }
to your own fetch calls or wrap the SDK methods. See the tag table below.
If you'd rather use path-based revalidation, you can still inspect the event
name and call revalidatePath() instead — both styles are supported and can
be mixed.
verifyWebhookSignature(secret, rawBody, signatureHeader) returns true only
when the HMAC matches. Always pass the raw body, not a re-stringified
JSON object — re-serialization changes whitespace and invalidates the
signature.
Available events and their cache tags
| Event | Cache tags | Fires when |
|--------------------------------|---------------------------------------------------------------------|---------------------------------------|
| * | (subscribe to everything) | — |
| content.published | cms:content-type:<slug>, cms:content:<slug>:<item-slug> | Editor publishes an item |
| content.unpublished | cms:content-type:<slug>, cms:content:<slug>:<item-slug> | Editor moves an item back to draft |
| content.updated | cms:content-type:<slug>, cms:content:<slug>:<item-slug> | Published item edited; bulk syncs (e.g. Motor Central) |
| content.deleted | cms:content-type:<slug>, cms:content:<slug>:<item-slug> | Item removed |
| content_type.updated | cms:content-type:<slug>, cms:content-type:<slug>:schema | Schema or SEO config of a collection saved |
| content_type.deleted | cms:content-type:<slug> | Collection removed |
| settings.tracking_updated | cms:tracking-config | GA / GTM / Meta Pixel / Google Ads ID changed (diffed) |
| settings.brand_updated | cms:brand | Brand name / logo / colour changed (diffed) |
| settings.integration_updated | cms:integration:<provider> | Stripe / Resend / Anthropic / Motor Central / Google Reviews settings changed (diffed) |
| products.updated | cms:products, optionally cms:product:<slug> | Product create/edit, bulk import |
| product_categories.updated | cms:product-categories | Category create/edit/delete |
| ticket_tiers.updated | cms:ticket-tiers, cms:event:<event-id> | Ticket tier create/edit/delete |
| membership_tiers.updated | cms:membership-tiers | Membership tier create/edit/delete |
| redirects.updated | cms:redirects | Custom redirect added/removed |
| reviews.synced | cms:reviews | Google Reviews sync produced new rows |
| flipbook.ready | cms:flipbooks, cms:flipbook:<id> | PDF processing finished |
| order.created / .paid / .payment_failed / .refunded / .shipped | (event-specific) | Order lifecycle |
| booking.confirmed | (event-specific) | Free event booking confirmed |
| inventory.low_stock | (event-specific) | Variant stock crosses its low-stock threshold |
settings.* events are diffed on the server — they only fire when a
relevant field's value actually changed (or, for secret fields, when the
set/unset state flipped).
Delivery semantics
- Retries: 3 attempts with exponential backoff (1s / 4s / 9s). 4xx responses are treated as terminal — fix your endpoint, then re-publish to trigger redelivery.
- Timeouts: Aim to return within a few seconds.
revalidatePath()is near-instant; if you need to do more work, return200first and queue it. - Ordering: Not guaranteed. Treat the payload as a hint that something about that slug changed — don't assume it carries the latest field values.
- Last-delivery state: The Webhooks tab shows the most recent HTTP status per subscription, plus an error message on failure.
10. Analytics Tracking
Track page views, scroll depth, and time on page. Add to .env.local:
NEXT_PUBLIC_CMS_API_KEY=<your-tenant-api-key>Option A: Site-wide tracking (recommended) — add once in root layout:
// src/app/layout.tsx
import { PageTracker } from "@distinctagency/cms-client/client"
export default function RootLayout({ children }) {
return (
<html lang="en">
<body>
{children}
<PageTracker
trackingUrl={process.env.NEXT_PUBLIC_CMS_URL + "/api/track"}
apiKey={process.env.NEXT_PUBLIC_CMS_API_KEY!}
/>
</body>
</html>
)
}Option B: Per-page tracking — for specific content pages:
import { CmsAnalytics } from "@distinctagency/cms-client/client"
<CmsAnalytics
trackingUrl={process.env.NEXT_PUBLIC_CMS_URL + "/api/track"}
apiKey={process.env.NEXT_PUBLIC_CMS_API_KEY!}
contentTypeSlug="events"
itemSlug={slug}
contentItemId={item.id} // optional
/>Do NOT use both on the same page. Both components render nothing visually. Tracking is fire-and-forget, no cookies, GDPR-friendly.
11. Google Reviews
Display curated Google Reviews on your website. Reviews are pulled from Google via the tenant's Place ID, curated (approved/hidden) in the CMS admin, and served through the client SDK.
Prerequisites
The tenant must have:
- A Google Place ID configured in CMS admin → Integrations → Reviews tab
- At least one sync completed (automatic daily via cron, or manual "Sync Now" by a super admin)
- Some reviews marked as approved in the CMS admin → Reviews page
Fetch approved reviews
// src/app/reviews/page.tsx (or wherever you want to show them)
import { cms } from "@/lib/cms"
import type { GoogleReview } from "@distinctagency/cms-client"
export default async function ReviewsSection() {
const reviews = await cms.getReviews({
minRating: 4, // only 4+ star reviews
limit: 10,
})
return (
<section>
<h2>What Our Customers Say</h2>
{reviews.map((review) => (
<div key={review.id}>
<div>
{"★".repeat(review.rating)}{"☆".repeat(5 - review.rating)}
</div>
<p>{review.text}</p>
<span>— {review.author_name}</span>
</div>
))}
</section>
)
}Review shape
{
id: string
author_name: string
author_photo_url: string | null // Google profile photo URL
rating: number // 1–5
text: string | null // Some reviews are rating-only
review_timestamp: string // ISO date of the original review
}Query options
cms.getReviews({
status?: "approved" | "pending" | "hidden" // default: "approved"
minRating?: number // e.g. 4 for 4+ stars only
limit?: number // default: 50
offset?: number // for pagination
orderBy?: "rating" | "review_timestamp" // default: "review_timestamp"
orderDirection?: "asc" | "desc" // default: "desc" (newest first)
})Note: Client sites should only ever need
status: "approved"(the default). Pending and hidden reviews are for admin use only.
How reviews get into the system
- Tenant configures their Google Place ID in CMS admin → Integrations → Reviews
- A daily Vercel Cron job fetches reviews from Google Places API (max 5 per request — Google's limit)
- New reviews land as pending in the CMS
- If the tenant has an Anthropic API key + AI triage enabled, new reviews get an AI recommendation (approve/hide)
- Tenant (or super admin) approves or hides reviews in CMS admin → Reviews page
- Approved reviews are available via
cms.getReviews()
Over time, repeated syncs accumulate more reviews as Google rotates which 5 it returns.
Google attribution
Google's Terms of Service require displaying "Reviews from Google" branding when showing reviews. Add a small attribution line near your reviews:
<p className="text-xs text-muted-foreground mt-4">Reviews from Google</p>12. Embed Fields
Embed fields store a URL with dimensions and render as responsive, sandboxed iframes. Use them for Matterport tours, YouTube videos, Calendly widgets, and any other iframe-based embed.
Render with React
import { CmsEmbed } from '@distinctagency/cms-client/client'
<CmsEmbed field={item.data.virtual_tour} className="my-4" />Render as HTML (non-React)
import { getEmbedHtml } from '@distinctagency/cms-client'
const html = getEmbedHtml(item.data.virtual_tour)
// Returns iframe HTML string, or empty string if invalidEmbed field shape
{
url: string // Must be https://
width: string // CSS value: "100%", "640px", etc.
aspect_ratio: string // "16:9", "4:3", "1:1", "21:9", or custom "N:N"
}The iframe is sandboxed with allow-scripts allow-same-origin allow-popups allow-popups-to-escape-sandbox. Returns null/empty if the URL is missing or not HTTPS.
13. Motor Central (Vehicles)
If the tenant has Motor Central configured, vehicles are synced from their inventory system and stored as content items in a "Vehicles" collection. Query them like any other collection.
List available vehicles
const vehicles = await cms.getContentItems("vehicles", {
status: "published",
limit: 50,
})Filter out sold vehicles
Vehicles removed from Motor Central are flagged with car_status: "sold" in their data. Filter them client-side:
const available = vehicles.filter(v => v.data.car_status !== "sold")Vehicle images
featured_image— the hero/main photo (standard content item field)data.gallery— array of additional photo URLs
<img src={vehicle.featured_image} alt={vehicle.title} />
{(vehicle.data.gallery as string[] ?? []).map((url, i) => (
<img key={i} src={url} alt={`${vehicle.title} photo ${i + 2}`} />
))}Common vehicle data fields
All fields depend on the Motor Central field mapping configured by the admin. Common fields include:
| Field | Type | Description |
|---|---|---|
| data.make | string | Manufacturer |
| data.model | string | Model |
| data.year | number | Year of manufacture |
| data.variant | string | Variant/trim |
| data.price | number | Retail price |
| data.mileage | number | Odometer reading |
| data.transmission | string | Transmission type |
| data.fuel_type | string | Fuel type |
| data.body_style | string | Body style |
| data.color | string | Exterior colour |
| data.stock_no | string | Dealer stock number |
| data.vin | string | Vehicle identification number |
| data.car_status | string | "sold" if removed from inventory |
| data.description | string | Full description |
| data.dealership | string | Dealership location |
| data.gallery | string[] | Additional photo URLs |
Sync schedule
Vehicles sync automatically within the configured window (typically overnight). New vehicles appear as published immediately. Removed vehicles get car_status: "sold". Images are processed to WebP and served from CDN.
Instant sync via webhook
When a Motor Central sync touches at least one vehicle, the CMS fires a content.updated webhook with cache_tags: ["cms:content-type:<vehicles-collection>", "cms:integration:motor-central"]. If your getContentItems("vehicles") reads are tagged with cms:content-type:vehicles, your inventory pages refresh on the next request after the sync completes — no waiting for the next ISR interval.
Troubleshooting
| Problem | Cause | Fix |
|---|---|---|
| "Invalid CMS API key — no tenant found" | Wrong or missing CMS_API_KEY | Check .env.local matches the key shown in CMS admin > Tenants |
| Empty results | Content not published | Check content status is "published" in the CMS admin |
| Cannot find module '@distinctagency/cms-client' | Package not installed | Run pnpm add @distinctagency/cms-client |
| Data fields are unknown | TypeScript limitation | Cast: event.data.date as string or create typed wrappers |
| Analytics 403 | API key has trailing whitespace | Remove trailing whitespace/newline from NEXT_PUBLIC_CMS_API_KEY in .env.local and redeploy |
| Analytics not tracking | Component not client-side | Ensure PageTracker/CmsAnalytics renders in a client component (needs "use client" parent) |
| Analytics infinite loop | Rendered inside a loop | Only render tracking component once per page, never inside .map() |
| getReviews() returns empty | No approved reviews | Check CMS admin → Reviews — reviews must be approved before they appear |
| getReviews() returns empty | No Place ID configured | Configure Google Place ID in CMS admin → Integrations → Reviews tab |
| getReviews() returns empty | No sync run yet | Super admin must click "Sync Now" on Reviews page, or wait for daily cron |
| Webhook fires (200 response in CMS) but page doesn't refresh on Next 16 | revalidateTag was called with a named profile like "default" or "max" — those are stale-while-revalidate windows (~136 years for "max"), not immediate invalidation | Upgrade to @distinctagency/cms-client@^1.17.1 and use revalidateAllTags(payload, revalidateTag) as shown in §9. If calling revalidateTag directly, pass { expire: 0 } as the second argument |
| revalidateTag deprecation warning in Next 16 logs | Calling revalidateTag(tag) with one argument | Same fix — let revalidateAllTags() handle it, or pass { expire: 0 } |
| TypeScript error: "Expected 2 arguments, but got 1" on revalidateTag | Next 16 made the cache-profile arg required | Same fix — SDK v1.17.1+ wraps it for you, or call revalidateTag(tag, { expire: 0 }) |
Project locations
| Project | Path |
|---|---|
| Distinct CMS (admin + database) | /Users/alexbrowning/VSCode/distinct-cms |
| Client package source | /Users/alexbrowning/VSCode/distinct-cms/packages/client |
| Supabase project | tfictdetndaezlyearyj (ap-southeast-2) |
