JSPM

  • ESM via JSPM
  • ES Module Entrypoint
  • Export Map
  • Keywords
  • License
  • Repository URL
  • TypeScript Types
  • README
  • Created
  • Published
  • Downloads 29
  • Score
    100M100P100Q82610F
  • License MIT

Headless Next.js SDK for Utilsy gateway-cms public blog, content, leads, and newsletter APIs

Package Exports

  • @utilsy/cms-nextjs
  • @utilsy/cms-nextjs/react

Readme

@utilsy/cms-nextjs

Headless Next.js SDK for Utilsy gateway-cms public CMS APIs: blog (/public/blog) and dynamic content types (/public/api). Provides a typed HTTP client, domain mappers, server-friendly fetch helpers, and React data hooks—bring your own UI.

Install

npm install @utilsy/cms-nextjs

Peer dependencies: react ^18 or ^19. next is optional (only needed if you pass next.revalidate to fetch).

Quick start

1. Create a client

Direct gateway-cms (default path prefix):

import { createCmsClient } from "@utilsy/cms-nextjs";

export const cms = createCmsClient({
  baseUrl: process.env.NEXT_PUBLIC_CMS_BASE_URL!,
  siteId: process.env.NEXT_PUBLIC_CMS_SITE_ID,
});

Main API gateway (prefix before /public/blog and /public/api):

export const cms = createCmsClient({
  baseUrl: process.env.NEXT_PUBLIC_CMS_BASE_URL!,
  siteId: process.env.NEXT_PUBLIC_CMS_SITE_ID,
  pathPrefix: "/api/backend/cms",
});

2. Server Component (list posts)

import { cms } from "@/lib/cms";

export default async function BlogPage() {
  const result = await cms.blog.listMappedPosts(
    { page: 1, limit: 20 },
    { next: { revalidate: 60 } },
  );

  if (!result?.posts.length) {
    return <p>No posts yet.</p>;
  }

  return (
    <ul>
      {result.posts.map((post) => (
        <li key={post.postId}>
          <a href={`/blog/${post.slug}`}>{post.title}</a>
        </li>
      ))}
    </ul>
  );
}

3. Server Component (filtered content by type)

import { cms } from "@/lib/cms";

export default async function ServicesPage() {
  const result = await cms.content.listMappedEntries(
    "services",
    { page: 1, limit: 12, filters: { featured: true } },
    { next: { revalidate: 60, tags: ["cms:services"] } },
  );

  if (!result?.entries.length) {
    return <p>No services yet.</p>;
  }

  return (
    <ul>
      {result.entries.map((entry) => (
        <li key={entry.id}>
          <a href={`/services/${entry.data.slug}`}>{String(entry.data.title ?? "")}</a>
        </li>
      ))}
    </ul>
  );
}

4. Client engagement (likes & comments)

"use client";

import { CmsProvider, useBlogLike, useBlogComments } from "@utilsy/cms-nextjs/react";
import { cms } from "@/lib/cms";

export function PostEngagement({ postId }: { postId: string }) {
  return (
    <CmsProvider client={cms}>
      <Engagement postId={postId} />
    </CmsProvider>
  );
}

function Engagement({ postId }: { postId: string }) {
  const { liked, likeCount, toggle, toggling } = useBlogLike(postId);
  const { comments, submitComment, submitting } = useBlogComments(postId);

  return (
    <div>
      <button type="button" onClick={toggle} disabled={toggling}>
        {liked ? "Unlike" : "Like"} ({likeCount})
      </button>
      <ul>
        {comments.map((c) => (
          <li key={c.id}>{c.name}: {c.message}</li>
        ))}
      </ul>
    </div>
  );
}

5. Lead capture (custom form via Server Action)

Use a LEAD_CAPTURE content type in CMS (with field mapping configured). Submit from your own form UI—no hosted-form embed.

// app/actions/submit-lead.ts
"use server";

import { cms } from "@/lib/cms";

export async function submitContactLead(data: Record<string, unknown>) {
  return cms.leads.submit("contact-enquiries", data);
}
"use client";

import { submitContactLead } from "@/app/actions/submit-lead";

export function ContactForm() {
  return (
    <form
      action={async (formData) => {
        await submitContactLead({
          firstName: String(formData.get("firstName") ?? ""),
          email: String(formData.get("email") ?? ""),
          message: String(formData.get("message") ?? ""),
        });
      }}
    >
      {/* your fields */}
    </form>
  );
}

Optional honeypot: include _honeypot in data; submissions with a non-empty value are rejected.

6. Newsletter (subscribe, unsubscribe, preferences)

Use your own signup / preference-center UI—no hosted form embed.

// app/actions/newsletter.ts
"use server";

import { cms } from "@/lib/cms";

export async function subscribeNewsletter(email: string) {
  return cms.newsletter.subscribe({ email, source: "footer" });
}

export async function unsubscribeNewsletter(email: string) {
  return cms.newsletter.unsubscribe({ email });
}

export async function updateNewsletterPrefs(
  token: string,
  customFields: Record<string, unknown>,
) {
  return cms.newsletter.updatePreferences({ token, customFields });
}
"use client";

import { CmsProvider, useNewsletterSubscribe } from "@utilsy/cms-nextjs/react";
import { cms } from "@/lib/cms";

export function NewsletterSignup() {
  return (
    <CmsProvider client={cms}>
      <NewsletterSignupInner />
    </CmsProvider>
  );
}

function NewsletterSignupInner() {
  const { subscribe, submitting, error } = useNewsletterSubscribe();

  return (
    <form
      onSubmit={async (e) => {
        e.preventDefault();
        const fd = new FormData(e.currentTarget);
        await subscribe({ email: String(fd.get("email") ?? "") });
      }}
    >
      <input name="email" type="email" required />
      <button type="submit" disabled={submitting}>Subscribe</button>
      {error && <p>{error.message}</p>}
    </form>
  );
}

Confirmation links from email hit GET /public/newsletter/confirm/:token (no siteId required on the server; the SDK still sends siteId when configured).

7. Client content list (hooks)

"use client";

import { CmsProvider, useContentEntries } from "@utilsy/cms-nextjs/react";
import { cms } from "@/lib/cms";

export function ServicesList() {
  return (
    <CmsProvider client={cms}>
      <ServicesListInner />
    </CmsProvider>
  );
}

function ServicesListInner() {
  const { entries, loading, error } = useContentEntries("services", {
    page: 1,
    limit: 20,
  });

  if (loading) return <p>Loading…</p>;
  if (error) return <p>Error: {error.message}</p>;

  return (
    <ul>
      {entries.map((e) => (
        <li key={e.id}>{String(e.data.title ?? "")}</li>
      ))}
    </ul>
  );
}

Environment variables

Variable Description
NEXT_PUBLIC_CMS_BASE_URL Gateway or API base URL (no trailing slash)
NEXT_PUBLIC_CMS_SITE_ID CMS site Mongo id — recommended when Host does not match a CMS domain
NEXT_PUBLIC_CMS_PATH_PREFIX Optional, e.g. /api/backend/cms

Site resolution

The CMS resolves the site from:

  1. ?siteId= query param (SDK appends this when siteId is configured), or
  2. Host / x-origin header matching a registered domain.

For local dev or gateway-only deployments, always set siteId in the client config.

Content types and filters

  • Endpoint: GET /public/api/{contentTypeApiId} (published entries only).
  • Content types must have apiAccess: PUBLIC.
  • filters are exact matches on data.<field> (JSON query param). Example: { category: "news" }.
  • Use contentTypeApiId (kebab-case slug from CMS), not the Mongo content type id.

COMPONENT and DYNAMIC_ZONE fields

Published entry data may include nested structures from the CMS builder:

Field type Shape in entry.data
COMPONENT (single) { title: "…", … }
COMPONENT (repeatable) [{ … }, { … }]
DYNAMIC_ZONE [{ __component: "<apiId>", …fields }, …]

Helpers (no extra API calls):

import {
  getComponentItems,
  getDynamicZoneBlocks,
  filterDynamicZoneByComponent,
  parseContentTypeFields,
} from "@utilsy/cms-nextjs";

const entry = await cms.content.getMappedEntry("landing", "home");
if (!entry) return;

const slides = getComponentItems(entry, "slides");
const ctas = filterDynamicZoneByComponent(
  getDynamicZoneBlocks(entry, "blocks"),
  "cta",
);

// Optional: inspect schema from populated contentType on the entry DTO
const fields = parseContentTypeFields(entry.contentType?.fields);

Types: FieldType, ContentTypeField, DynamicZoneBlock, ContentTypeCategory.

API reference

Client (createCmsClient)

Blog

Method Description
blog.listPosts(query?, init?) Paginated list (summary fields only)
blog.getPostBySlug(slug, init?) Full post DTO
blog.listCategories(init?) Active categories
blog.listComments(postId, init?) Approved comments
blog.createComment(postId, body) Create comment (throws on error)
blog.getEngagement(postId, { visitorId? }, init?) Stats + likedByMe
blog.toggleLike(postId, { visitorId? }) Toggle like
blog.listMappedPosts(...) List + mapCmsPostToBlogPost
blog.getMappedPostBySlug(...) Single mapped BlogPost
blog.listMappedCategories(...) Mapped categories
blog.listMappedComments(...) Mapped comments

Content

Method Description
content.listEntries(contentTypeApiId, query?, init?) Paginated raw DTOs
content.getEntry(contentTypeApiId, idOrSlug, init?) Single raw DTO
content.listMappedEntries(...) List + mapCmsEntryToContentEntry
content.getMappedEntry(...) Single mapped ContentEntry
content.getSingleMappedEntry(...) First mapped entry for SINGLE types (limit=1)

Query params for listEntries / listMappedEntries: page, limit, search, sort, order, filters (object). For SINGLE content types the public API caps limit at 1 and may include category: "SINGLE" and maxEntries: 1 in the list response.

Single content types

Use a CMS content type with category SINGLE for one document per site (homepage settings, global SEO, etc.). Admin creates the type and edits the auto-created draft on the Content tab.

// Prefer for singleton types — no entry id/slug required
const settings = await client.content.getSingleMappedEntry("site-settings");

// Or with React
const { entry, loading } = useSingleContentEntry("site-settings");

listEntries / listMappedEntries still work; pass limit: 1 or use getSingleMappedEntry.

Helpers: serializeContentFilters, mapCmsEntryToContentEntry, getComponentItems, getDynamicZoneBlocks, filterDynamicZoneByComponent, parseContentTypeFields.

Leads

Method Description
leads.submit(contentTypeApiId, data, options?) Submit lead fields; creates content entry + CRM enquiry (throws on error)
  • Endpoint: POST /public/api/{contentTypeApiId}/submissions
  • Content type must be LEAD_CAPTURE and apiAccess: PUBLIC
  • Field keys in data must match the content type schema; CRM mapping uses leadCaptureConfig in admin

Newsletter

Method Description
newsletter.subscribe(input, init?) Subscribe by email; may send confirmation email
newsletter.unsubscribe(input, init?) Unsubscribe by token (from email) or email
newsletter.confirm(token, init?) Confirm subscription from email link
newsletter.updatePreferences(input, init?) Update customFields, tags, and/or listId
  • Base path: /public/newsletter (plus optional pathPrefix)
  • subscribe: email required; optional source, listId, customFields
  • unsubscribe / updatePreferences: provide token or email
  • updatePreferences: at least one of customFields, tags, or listId; optional tagsAction (set | add | remove)

React (@utilsy/cms-nextjs/react)

Export Description
CmsProvider Context for hooks
useCmsClient Access client instance
useVisitorId / getVisitorId Anonymous visitor id for likes
useBlogEngagement Load engagement stats
useBlogLike Like toggle + counts
useBlogComments List + submit comments
useContentEntries List mapped entries by content type
useContentEntry Single mapped entry by id or slug
useSingleContentEntry Mapped entry for SINGLE types (no id/slug)
useSubmitLead Submit lead from client (prefer Server Action for forms)
useNewsletterSubscribe Subscribe from client (prefer Server Action)
useNewsletterUnsubscribe Unsubscribe by token or email
useNewsletterUpdatePreferences Update subscriber preferences
useNewsletterConfirm Confirm subscription token (e.g. preference page)

CORS

Public CMS reads from the browser require either:

  • Same-origin proxy (e.g. Next.js Route Handler forwarding to gateway), or
  • Server Components / Route Handlers calling the SDK server-side.

Mutations (comments, likes, lead submit, newsletter subscribe/unsubscribe/preferences) should run server-side (Server Action / Route Handler) or via a same-origin proxy with CORS configured on the gateway.

License

MIT