npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

@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

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