CMS + Next.js: Queries

A reference for website and administrative GraphQL operations in the erxes CMS.

Work through Setup first. Execute these documents on the server with the credential appropriate to the operation. The browser example exposes only a fixed published-post search endpoint.

Naming convention: cp* versus cms*

The CMS exposes two parallel families of operations.

CMS cp* operations serve client-portal integrations. Operations marked forClientPortal: true bypass the normal team-member permission wrapper; inspect each resolver's context and argument handling. cpPostList, for example, uses the Client Portal identified by x-app-token. See wrapperResolvers and cpPostList.

cms* operations are for the erxes admin UI and authenticated tooling. They go through the permission wrapper, they require a logged-in team member or an Apps token, and most of them require an explicit clientPortalId argument: cmsPages, cmsPageList, cmsMenus, cmsMenuList, and cmsMenu all throw clientPortalId is required without one. See queries/page.ts and queries/menu.ts.

The examples below are an API reference, not a browser operation allow-list. cp* does not imply published-only results or read-only behavior. Public post lists must enforce status: published; detail and page results need their own publication checks before exposure. Administrative cms* integrations can run on an authorized server without exposing their credentials to visitors.

Pagination: two incompatible styles

This is the single most common source of Unknown argument errors.

Most list operations use the shared cursor arguments, defined once in apollo/constants.ts. The shared cursor helper defaults to 20 items and rejects limits outside 1–100:

  • limit: Int
  • cursor: String
  • cursorMode: CURSOR_MODE: inclusive or exclusive
  • direction: CURSOR_DIRECTION: forward or backward
  • orderBy: JSON
  • sortMode: String
  • aggregationPipeline: [JSON]

These are backend schema capabilities. Do not pass arbitrary browser-supplied aggregation pipelines, ordering objects, or filters through a public website endpoint. Fix the operation and validate the inputs that the website actually supports.

page and perPage are not part of that set. They belong to the offset style, which in the CMS is used by exactly one operation: cpPostListWithPagination. Its arguments come from GQL_OFFSET_PARAM_DEFS: page, perPage, sortField, sortDirection.

List operations that paginate return an envelope, not a bare array. PostList, PageList, PostTagList, and PostCategoryListResponse all carry pageInfo, whose shape is PageInfo: hasNextPage, hasPreviousPage, startCursor, endCursor. Feed endCursor back in as cursor to get the next page.

Operations ending in a bare list (cpPosts, cpPages, cpMenus, cpCmsMenuList, cpMostViewedPosts, cpCustomPostTypes, cpCustomFieldGroups) return an array with no totalCount, even when the schema accepts the shared cursor arguments.

The CMS list helper recognizes lowercase sortDirection: "asc"; other values select descending order. An explicit orderBy takes precedence over sortField. See the pagination implementation and CMS base resolvers.

Posts

cpPostList: paginated post list

The workhorse for blog indexes, category archives, and search results.

query CpPostList(
  $type: String
  $featured: Boolean
  $categoryIds: [String]
  $tagIds: [String]
  $searchValue: String
  $status: PostStatus
  $authorId: String
  $language: String
  $webId: String
  $dateField: PostDateField
  $dateFrom: Date
  $dateTo: Date
  $sortField: String
  $sortDirection: String
  $limit: Int
  $cursor: String
) {
  cpPostList(
    type: $type
    featured: $featured
    categoryIds: $categoryIds
    tagIds: $tagIds
    searchValue: $searchValue
    status: $status
    authorId: $authorId
    language: $language
    webId: $webId
    dateField: $dateField
    dateFrom: $dateFrom
    dateTo: $dateTo
    sortField: $sortField
    sortDirection: $sortDirection
    limit: $limit
    cursor: $cursor
  ) {
    totalCount
    pageInfo {
      hasNextPage
      hasPreviousPage
      startCursor
      endCursor
    }
    posts {
      _id
      title
      slug
      excerpt
      type
      status
      featured
      publishedDate
      createdAt
      updatedAt
      viewCount
      authorKind
      authorId
      categoryIds
      thumbnail {
        url
        name
        type
        size
      }
      customPostType {
        _id
        code
        label
      }
      tags {
        _id
        name
        slug
        colorCode
      }
      categories {
        _id
        name
        slug
      }
    }
  }
}

Argument notes, all from schemas/posts.ts:

  • categoryIds is [String], plural. There is no categoryId argument on any post operation.
  • status is the PostStatus enum: draft, published, scheduled, archived. Pass "published" for public listings.
  • type filters by post type; combine it with cpCustomPostTypes below to build a type-aware archive.
  • The schema accepts dateField, dateFrom, and dateTo. Verify date-filter behavior in your installed resolver before depending on it.
  • webId scopes results to one Web Builder site. Omit it for a standalone CMS.
  • language requests translated field values. The resolver falls back to the stored values when no translation exists.

Omit content from list queries. It is the full HTML body and dominates payload size; fetch it only in the detail operation.

cpPost: single post

Resolves by _id, by slug, or by identifier.

query CpPost(
  $id: String
  $slug: String
  $identifier: String
  $count: Int
  $language: String
) {
  cpPost(
    _id: $id
    slug: $slug
    identifier: $identifier
    count: $count
    language: $language
  ) {
    _id
    title
    slug
    excerpt
    content
    type
    status
    featured
    publishedDate
    scheduledDate
    createdAt
    updatedAt
    viewCount
    authorKind
    authorId
    categoryIds
    seoTitle
    seoDescription
    videoUrl
    thumbnail {
      url
      name
      type
      size
    }
    images {
      url
      name
      type
      size
    }
    video {
      url
      name
      type
      size
    }
    documents {
      url
      name
      type
      size
    }
    attachments {
      url
      name
      type
      size
    }
    pdfAttachment {
      pdf {
        url
        name
        type
      }
      pages {
        url
        name
        type
      }
    }
    customFieldsData
    customFieldsMap
    translations {
      _id
      objectId
      language
      title
      excerpt
    }
  }
}

cpPost increments the view counter when it finds a post. Repeated requests, prefetching, and caching therefore affect the count; it is not a count of unique visitors. See increaseViewCount and the SSR caching explanation. The resolver does not filter publication status; request status and check it before exposing a record.

The clientPortalId argument appears in the schema but the resolver ignores it and always uses the portal from the token. See cpPost.

There is no cpPostDetail. Older guides and migrated codebases refer to it; the operation is cpPost.

cpPosts: list without a pagination envelope

Same arguments as cpPostList but returns [Post] directly, with no totalCount and no pageInfo. Use it when you need a fixed, small set (the three latest posts in a sidebar, for example).

query CpPosts($type: String, $status: PostStatus, $limit: Int, $sortField: String, $sortDirection: String) {
  cpPosts(
    type: $type
    status: $status
    limit: $limit
    sortField: $sortField
    sortDirection: $sortDirection
  ) {
    _id
    title
    slug
    excerpt
    publishedDate
    thumbnail {
      url
      name
      type
    }
  }
}

cpPostListWithPagination: offset pagination

The only CMS operation that accepts page and perPage. It returns PostListPagination, which has posts and totalCount but no pageInfo. See schemas/posts.ts.

query CpPostListWithPagination(
  $page: Int
  $perPage: Int
  $sortField: String
  $sortDirection: String
  $status: PostStatus
  $categoryIds: [String]
  $type: String
  $language: String
) {
  cpPostListWithPagination(
    page: $page
    perPage: $perPage
    sortField: $sortField
    sortDirection: $sortDirection
    status: $status
    categoryIds: $categoryIds
    type: $type
    language: $language
  ) {
    totalCount
    posts {
      _id
      title
      slug
      excerpt
      publishedDate
      thumbnail {
        url
        name
        type
      }
    }
  }
}

Prefer this over cursor pagination when you need numbered page links (?page=3). Prefer cpPostList when you need infinite scroll.

cpMostViewedPosts: popularity list

days is required and must be a positive integer. It is capped at the backend's 30-day view-retention window, and limit defaults to 10 and is clamped between 1 and 100. See findMostViewedPosts.

query CpMostViewedPosts($days: Int!, $limit: Int, $type: String, $language: String, $webId: String) {
  cpMostViewedPosts(days: $days, limit: $limit, type: $type, language: $language, webId: $webId) {
    _id
    title
    slug
    viewCount
    recentViewCount
    thumbnail {
      url
      name
      type
    }
  }
}

recentViewCount is the count inside the requested window and is only present on this operation.

Pages

Pages carry the section list a template renders. Page.pageItems is the field that matters for composable layouts; each item has a type, an order, and a free-form config object. See schemas/page.ts.

cpPageList: all pages

query CpPageList($language: String, $limit: Int, $cursor: String) {
  cpPageList(language: $language, limit: $limit, cursor: $cursor) {
    totalCount
    pageInfo {
      hasNextPage
      endCursor
    }
    pages {
      _id
      name
      slug
      description
      coverImage
      type
      status
      createdUserId
      createdAt
      updatedAt
      customFieldsData
      customFieldsMap
    }
  }
}

cpPages(language) is the unpaginated variant returning [Page]. Use it to enumerate routes for generateStaticParams.

cpCmsPageDetail: one page with its sections

query CpPage($slug: String, $id: String, $language: String) {
  cpCmsPageDetail(slug: $slug, _id: $id, language: $language) {
    _id
    name
    slug
    description
    coverImage
    type
    status
    content
    createdAt
    updatedAt
    customFieldsData
    customFieldsMap
    pageItems {
      _id
      name
      type
      content
      order
      objectType
      objectId
      config
    }
  }
}

Pass either slug or _id; if you pass neither, the resolver returns null rather than an error. See cpCmsPageDetail. The portal is applied from the token, so a slug from another portal cannot be read.

config is a JSON scalar. The GraphQL schema does not describe section-specific fields inside it. Validate that content against the selected template's expected fields at the rendering boundary.

Page.createdUser resolves to a User from the core subgraph. createdUserId is a nullable scalar field that can be selected without those user details. Choose the author information the website needs.

Categories

cpCategories: category list

Returns PostCategoryListResponse, whose array field is named list, not categories, and not the objects themselves. See schemas/category.ts.

query CpCategories(
  $status: CategoryStatus
  $searchValue: String
  $language: String
  $sortField: String
  $sortDirection: String
  $limit: Int
  $cursor: String
) {
  cpCategories(
    status: $status
    searchValue: $searchValue
    language: $language
    sortField: $sortField
    sortDirection: $sortDirection
    limit: $limit
    cursor: $cursor
  ) {
    totalCount
    pageInfo {
      hasNextPage
      endCursor
    }
    list {
      _id
      name
      slug
      description
      parentId
      status
      createdAt
      updatedAt
      customFieldsData
      parent {
        _id
        name
        slug
      }
    }
  }
}

status is the CategoryStatus enum: active or inactive. Pass "active" for public navigation.

cpCategories honours an explicit clientPortalId argument, falling back to the token's portal when it is absent: args.clientPortalId || clientPortal?._id. See cpCategories. For a website, keep portal selection on the server and do not accept a browser-supplied portal ID.

cpCmsCategoryDetail: one category

query CpCategory($slug: String, $id: String, $language: String) {
  cpCmsCategoryDetail(slug: $slug, _id: $id, language: $language) {
    _id
    name
    slug
    description
    parentId
    status
    customFieldsData
    customFieldsMap
  }
}

Pair it with cpPostList(categoryIds: [$id], status: "published") to render a category archive.

Menu items are returned hydrated, so parent is populated and the flat list can be assembled into a tree client-side. kind is a free-form string; the Web Builder deployer looks for main and footer. See deploy.

cpMenus: navigation for a site

query CpMenus($kind: String, $language: String, $webId: String) {
  cpMenus(kind: $kind, language: $language, webId: $webId) {
    _id
    parentId
    label
    contentType
    contentTypeId
    type
    linkType
    kind
    icon
    url
    order
    openInNewTab
    target
    linkedContent {
      _id
      title
      slug
      linkType
    }
    parent {
      _id
      label
      url
      order
    }
  }
}

The field is contentTypeId, lower-case d. contentTypeID does not exist and is a frequent typo in migrated code. See MenuItem.

linkType is the MenuLinkType enum (URL, PAGE, POST, CATEGORY, TAG) and is what you should branch on when building a link. linkedContent gives you the target's _id, title, and slug so you can build the href without a second round trip.

cpCmsMenuList(clientPortalId, kind, language, ...) accepts the shared cursor arguments but still returns a bare [MenuItem] array; there is no MenuItemResponse envelope in the result. Unlike cpMenus it ignores webId, and its clientPortalId argument is also ignored: the resolver always uses the portal from the token. See cpCmsMenuList.

Tags and custom post types

cpCmsTags

Returns PostTagList; the array field is tags.

query CpTags($searchValue: String, $language: String, $sortField: String, $sortDirection: String, $limit: Int) {
  cpCmsTags(
    searchValue: $searchValue
    language: $language
    sortField: $sortField
    sortDirection: $sortDirection
    limit: $limit
  ) {
    totalCount
    pageInfo {
      hasNextPage
      endCursor
    }
    tags {
      _id
      name
      slug
      colorCode
    }
  }
}

cpCustomPostTypes

Use this to discover the post type values your instance has, then feed one into cpPostList(type:).

query CpCustomPostTypes($searchValue: String) {
  cpCustomPostTypes(searchValue: $searchValue) {
    _id
    code
    label
    pluralLabel
    description
    createdAt
  }
}

cpCustomFieldGroups(searchValue, pageId, categoryId, postType, postId) returns the field-group definitions behind customFieldsData and customFieldsMap. See schemas/customPostType.ts.

Post reactions

The schema includes reaction and view-counter mutations. The cp* family also contains other writes, so its prefix must not be used as a read-only access rule.

mutation CpPostsReact($id: String!, $reaction: PostReactionType!, $action: ReactionModifyType!) {
  cpPostsReact(_id: $id, reaction: $reaction, action: $action) {
    _id
    reactions
    reactionCounts
  }
}

PostReactionType is like | love | angry | sad | happy; ReactionModifyType is inc | dec. reactionCounts is a JSON map of reaction to count. cpPostsIncrementViewCount(_id: String!) is the explicit counter bump, which you only need if you deliberately avoid cpPost. See schemas/posts.ts.

Implement reactions through a separately authorized endpoint with input validation and appropriate request limits. The post-search endpoint in the CSR guide does not accept mutations.

The author field

Post.author is declared as union Author = User, and User comes from the core subgraph, not from content_api. See schemas/posts.ts:39.

Two consequences:

  • There is no ClientPortalUser type in the union. A fragment ... on ClientPortalUser is a schema validation error. The Client Portal user type in core is CPUser, and it is not a member of Author.
  • Selecting subfields of author requires the federated schema to include User. Introspect your own gateway before relying on it:
set -a; source .env.local; set +a
curl -sS "$ERXES_API_URL" \
  -H "content-type: application/json" \
  -H "x-app-token: $ERXES_APP_TOKEN" \
  -d '{"query":"query { __type(name: \"Author\") { kind possibleTypes { name fields { name } } } }"}'

authorKind and authorId can be selected without fetching the federated user's fields. Decide which author information the website should expose; an identifier alone is not a human-readable byline.

Admin-side operations

Listed for completeness. These need a team-member session or an Apps token and must stay server-side.

cmsPostList

query CmsPostList(
  $clientPortalId: String
  $status: PostStatus
  $categoryIds: [String]
  $searchValue: String
  $type: String
  $language: String
  $limit: Int
  $cursor: String
) {
  cmsPostList(
    clientPortalId: $clientPortalId
    status: $status
    categoryIds: $categoryIds
    searchValue: $searchValue
    type: $type
    language: $language
    limit: $limit
    cursor: $cursor
  ) {
    totalCount
    pageInfo {
      hasNextPage
      endCursor
    }
    posts {
      _id
      title
      slug
      status
      type
      authorKind
      authorId
      createdAt
      updatedAt
      scheduledDate
      publishedDate
    }
  }
}

Both cmsPostList and cpPostList can return unpublished records when no publication filter is applied. Keep preview routes authenticated and enforce the intended publication policy on public endpoints.

cmsPages and cmsPage

query CmsPages($clientPortalId: String, $searchValue: String, $language: String, $limit: Int) {
  cmsPages(clientPortalId: $clientPortalId, searchValue: $searchValue, language: $language, limit: $limit) {
    _id
    name
    slug
    description
    type
    status
    createdUserId
    createdAt
    updatedAt
  }
}
query CmsPage($id: String, $slug: String, $clientPortalId: String, $language: String) {
  cmsPage(_id: $id, slug: $slug, clientPortalId: $clientPortalId, language: $language) {
    _id
    name
    slug
    description
    status
    content
    pageItems {
      _id
      name
      type
      order
      objectType
      objectId
      config
    }
  }
}

cmsPage returns null when neither _id nor slug is supplied.

cmsCategories

query CmsCategories($clientPortalId: String, $status: CategoryStatus, $searchValue: String, $language: String, $limit: Int) {
  cmsCategories(
    clientPortalId: $clientPortalId
    status: $status
    searchValue: $searchValue
    language: $language
    limit: $limit
  ) {
    totalCount
    pageInfo {
      hasNextPage
      endCursor
    }
    list {
      _id
      name
      slug
      parentId
      status
    }
  }
}

cmsMenuList

query CmsMenuList($clientPortalId: String, $kind: String, $language: String) {
  cmsMenuList(clientPortalId: $clientPortalId, kind: $kind, language: $language) {
    _id
    parentId
    label
    contentType
    contentTypeId
    linkType
    kind
    icon
    url
    order
    openInNewTab
    target
  }
}

clientPortalId is required at runtime even though the schema marks it nullable.

cmsTags and contentCMSList

query CmsTags($clientPortalId: String, $searchValue: String, $language: String, $limit: Int) {
  cmsTags(clientPortalId: $clientPortalId, searchValue: $searchValue, language: $language, limit: $limit) {
    totalCount
    tags {
      _id
      name
      slug
      colorCode
    }
  }
}
query ContentCmsList {
  contentCMSList {
    _id
    name
    description
    clientPortalId
    domain
    publicUrl
    language
    languages
    postUrlField
    postUrlPrefix
    accessPolicy
    allowComments
    defaultPostStatus
  }
}

contentCMSList is how you discover the clientPortalId values a team member can reach. accessPolicy is open or assigned; with assigned, only the members in assignedMemberIds plus owners can use that CMS. See cms-access.ts.

Execute a document on the server

Copy the operation you need into a server module and call the erxesQuery helper from Setup:

import "server-only";
import { erxesQuery } from "@/lib/erxes/server";

const query = `
  query WebsitePostCount {
    cpPostList(status: published, limit: 1) {
      totalCount
    }
  }
`;

export async function getPublishedPostCount() {
  const data = await erxesQuery<{
    cpPostList: { totalCount: number | null } | null;
  }>(query);
  return data.cpPostList?.totalCount ?? 0;
}

These documents also work with an appropriately configured GraphQL client. Select only the fields the website renders. Keep server-side operations and credential handling separate from the browser's allowed inputs.

Social publishing operations

The Postiz GraphQL module adds CMS-side operations for a separately configured social-publishing integration:

  • cmsPostizOptions(clientPortalId: String!, language: String) returns enabled, canManage, and channels with id, name, provider, and usable.
  • cmsPostizDeliveries(postId: String!, language: String!) returns up to 50 recent deliveries for the selected post and language.
  • cmsPostizValidate(input: CmsPostizShareInput!) validates a share request before queuing it.
  • cmsPostizShare(input: CmsPostizShareInput!) queues deliveries and returns their records.
  • cmsPostizRetry(id: String!, reviewedInPostiz: Boolean!) requests a retry through the service's state checks.
  • cmsPostizEnable(clientPortalId: String!, language: String!, enabled: Boolean!) changes sharing enablement through the external integration.

For an authenticated administration screen, inspect available channels before offering a share action:

query CmsSocialPublishingOptions($clientPortalId: String!, $language: String) {
  cmsPostizOptions(clientPortalId: $clientPortalId, language: $language) {
    enabled
    canManage
    channels {
      id
      name
      provider
      usable
    }
  }
}

The input validator requires postId, a UUID requestId, language, one to ten channelIds, a nonempty caption of at most 2,800 characters, and a media array of up to four URLs. Selected media must already belong to the post's thumbnail or images. The backend appends the public article URL to the caption and validates each selected channel through the bridge.

Only ordinary posts with status: published are shareable. The URL builder uses the CMS public URL or domain, its post URL prefix, and its configured _id, slug, or count identifier. Configure these to match your website before sharing; a valid CMS record alone does not establish a working public article URL.

These operations require administrative access and a running agent service providing postizCms.execute. This repository has the Content-side bridge, not that service implementation. A queued delivery is not proof of publication; inspect its delivery state and returned URL. Do not expose these operations through the public website endpoint.

Troubleshooting

  • HTTP 401/403 or "forbidden" GraphQL errors: the credential is missing, invalid, or the wrong family. Public content reads need the Client Portal token in x-app-token; cms* operations need a team-member session or an Apps token in erxes-app-token.
  • A successful query returns an empty list: the token matched a portal, but the records belong to a different clientPortalId. Confirm the portal association of your posts, pages, and menus; do not fix it by passing a browser-supplied clientPortalId.
  • Unknown argument "page" / "perPage": only cpPostListWithPagination uses offset pagination. Every other list takes limit/cursor.
  • Cannot return null for non-nullable on clientPortalId: some CMS records require a portal association; audit the data rather than removing the field from the selection set.
Was this helpful?