@utilsy/cms-nextjs
v0.7.0
Published
Headless Next.js SDK for Utilsy gateway-cms public blog, content, leads, and newsletter APIs
Downloads
1,052
Maintainers
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-nextjsPeer 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:
?siteId=query param (SDK appends this whensiteIdis configured), orHost/x-originheader 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. filtersare exact matches ondata.<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_CAPTUREandapiAccess: PUBLIC - Field keys in
datamust match the content type schema; CRM mapping usesleadCaptureConfigin 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 optionalpathPrefix) subscribe:emailrequired; optionalsource,listId,customFieldsunsubscribe/updatePreferences: providetokenoremailupdatePreferences: at least one ofcustomFields,tags, orlistId; optionaltagsAction(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
