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: Intcursor: StringcursorMode: CURSOR_MODE:inclusiveorexclusivedirection: CURSOR_DIRECTION:forwardorbackwardorderBy: JSONsortMode: StringaggregationPipeline: [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:
categoryIdsis[String], plural. There is nocategoryIdargument on any post operation.statusis thePostStatusenum:draft,published,scheduled,archived. Pass"published"for public listings.typefilters by post type; combine it withcpCustomPostTypesbelow to build a type-aware archive.- The schema accepts
dateField,dateFrom, anddateTo. Verify date-filter behavior in your installed resolver before depending on it. webIdscopes results to one Web Builder site. Omit it for a standalone CMS.languagerequests 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.
Menus
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
ClientPortalUsertype in the union. A fragment... on ClientPortalUseris a schema validation error. The Client Portal user type in core isCPUser, and it is not a member ofAuthor. - Selecting subfields of
authorrequires the federated schema to includeUser. 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)returnsenabled,canManage, and channels withid,name,provider, andusable.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 inerxes-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-suppliedclientPortalId. Unknown argument "page"/"perPage": onlycpPostListWithPaginationuses offset pagination. Every other list takeslimit/cursor.Cannot return null for non-nullableonclientPortalId: some CMS records require a portal association; audit the data rather than removing the field from the selection set.