Skip to main content

How to build custom client

We usually use next.js to build our site client. This guide will show you how to build a custom client for your Comet DXP and render a typical content page from the page tree.

Page Tree Node List

To load/see all page tree node's, you need to query the pageTreeNodeList field in the Query type. This field allows you to fetch a list of PageTreeNode objects based on specific criteria such as contentScope and category. Those values differ from project to project and are defined by the project's requirements. There is also another query paginatedPageTreeNodes which is more performant for large page trees.

A Sample Page tree list can look like this:

Sample Page Tree List

The following example demonstrates how to query all page tree nodes and access their IDs, names, paths, and other relevant fields using fragments.

To fetch all PageTreeNode objects and access their IDs, we will use the following query:

query Pages($contentScope: PageTreeNodeScopeInput!, $category: String!) {
pageTreeNodeList(scope: $contentScope, category: $category) {
id
name
path
slug
}
}

A Response will then look similar to this:

{
"data": {
"pageTreeNodeList": [
{
"id": "7c151a7f-7e0c-4103-8d34-216421f4cdcf",
"name": "test",
"path": "/test",
"slug": "test"
}
]
}
}

With that information in place we can now query the page content, based on the pageTreeNode id.

Working with the Comet's GraphQL API

The following example demonstrates a Content Page that includes a HeadlineBlock, ImageBlock, RichTextBlock, and a ColumnBlock containing two ImageBlock elements.

Sample Content Page

Loading page data from the API

As said before, we are working with page content that are attached to a page tree node. The page tree is a hierarchical structure that contains all the pages in the system. Each page has an id that can be used to fetch the page data from the API.

Note: Blocks can be also be attached to other data (e.g. structured data, news, ...), loading and rendering will be similar, but also a bit different according to the use case.

To fetch the corresponding data we will make use of the pageTreeNode field in the Query type. The pageTreeNode field returns a PageTreeNode object that contains the necessary page data.

graphql.schema

type Query {
pageTreeNode(id: ID!): PageTreeNode
}

type PageTreeNode {
id: ID!
path: String!
document: PageContentUnion
}

union PageContentUnion = Page | Link # The PageContent Union can contain many more entries, we are focusing now on the Page type.
type Page implements DocumentInterface {
id: ID!
content: PageContentBlockData!
}

scalar PageContentBlockData
@specifiedBy(url: "http://www.ecma-international.org/publications/files/ECMA-ST/ECMA-404.pdf")

To query the page data we can use the following query:

query Page($id: ID!) {
pageTreeNode(id: $id) {
id
path
document {
... on Page {
content
}
}
}
}

There is also another way of querying page tree node data, based on the path of the page tree node (pageTreeNodeByPath). This can be useful if you have the path of the page tree node and not the id.

The received content field is a root block and from type PageContentBlockData and is a CometDXP Scalar PageContentBlockData what is basically a JSONObject that contains the block data.

Full Sample Response
{
"data": {
"pageTreeNode": {
"id": "7c151a7f-7e0c-4103-8d34-216421f4cdcf",
"path": "/test",
"document": {
"content": {
"blocks": [
{
"key": "9d261397-6d62-4d6e-8934-0b627f4af4e4",
"visible": true,
"type": "headline",
"props": {
"eyebrow": "",
"headline": {
"draftContent": {
"blocks": [
{
"key": "8i42j",
"text": "Lorem ipsum",
"type": "unstyled",
"depth": 0,
"inlineStyleRanges": [],
"entityRanges": [],
"data": {}
}
],
"entityMap": {}
}
},
"level": "header-one"
},
"userGroup": "All"
},
{
"key": "83a341d4-723c-422f-beea-5d3b1a31a10c",
"visible": true,
"type": "image",
"props": {
"attachedBlocks": [],
"block": {
"type": "pixelImage",
"props": {
"damFile": {
"id": "59d72281-9ae9-4351-b37b-6efc01a54842",
"name": "aerial-photo-of-mountains-1632044.jpg",
"size": "6412063",
"mimetype": "image/jpeg",
"contentHash": "4c16d34b98b993a8864a599cbbd419e0",
"title": null,
"altText": null,
"archived": false,
"scope": {
"domain": "main"
},
"importSourceId": null,
"importSourceType": null,
"image": {
"width": 6000,
"height": 4000,
"cropArea": {
"focalPoint": "SMART",
"width": null,
"height": null,
"x": null,
"y": null
},
"dominantColor": "#324f63"
}
},
"urlTemplate": "http://localhost:4000/dam/images/067e080ba5e484cde929c866d5d26814bead1b58/59d72281-9ae9-4351-b37b-6efc01a54842/crop:SMART/resize:$resizeWidth:$resizeHeight/aerial-photo-of-mountains-1632044"
}
},
"activeType": "pixelImage"
},
"userGroup": "All"
},
{
"key": "bdf6c7be-a1cc-4f83-85be-16a221d94eb2",
"visible": true,
"type": "richtext",
"props": {
"draftContent": {
"blocks": [
{
"key": "9uhi2",
"text": "Lorem ipsum ...",
"type": "unstyled",
"depth": 0,
"inlineStyleRanges": [],
"entityRanges": [],
"data": {}
},
{
"key": "adu0d",
"text": "",
"type": "unstyled",
"depth": 0,
"inlineStyleRanges": [],
"entityRanges": [],
"data": {}
},
{
"key": "8t7f1",
"text": "Lorem ipsum ...",
"type": "unstyled",
"depth": 0,
"inlineStyleRanges": [],
"entityRanges": [],
"data": {}
}
],
"entityMap": {}
}
},
"userGroup": "All"
},
{
"key": "6f49d864-3225-4549-a539-0678f1c5195e",
"visible": true,
"type": "columns",
"props": {
"layout": "two-columns",
"columns": [
{
"key": "6d1e7395-f460-4c5a-9be5-b046dea61ad4",
"visible": true,
"props": {
"blocks": [
{
"key": "0b0fbb35-64cf-47e5-a28f-993c792be28a",
"visible": true,
"type": "image",
"props": {
"attachedBlocks": [],
"block": {
"type": "pixelImage",
"props": {
"damFile": {
"id": "faac8d30-74ed-4818-9cdb-883ef3c3399f",
"name": "scenic-photo-of-lake-during-dawn-4124074.jpg",
"size": "5342794",
"mimetype": "image/jpeg",
"contentHash": "eb44dccb21566f10560b6650a7fd9de5",
"title": null,
"altText": null,
"archived": false,
"scope": {
"domain": "main"
},
"importSourceId": null,
"importSourceType": null,
"image": {
"width": 6000,
"height": 4000,
"cropArea": {
"focalPoint": "SMART",
"width": null,
"height": null,
"x": null,
"y": null
},
"dominantColor": "#3f3e26"
}
},
"urlTemplate": "http://localhost:4000/dam/images/8469b5607c52ad0a2f7760191812372072649a8e/faac8d30-74ed-4818-9cdb-883ef3c3399f/crop:SMART/resize:$resizeWidth:$resizeHeight/scenic-photo-of-lake-during-dawn-4124074"
}
},
"activeType": "pixelImage"
}
}
]
}
},
{
"key": "65da0978-1f56-4d41-92e8-9404c30ca29a",
"visible": true,
"props": {
"blocks": [
{
"key": "427fb0a9-949d-46ce-a4f2-5438ec583cd8",
"visible": true,
"type": "image",
"props": {
"attachedBlocks": [],
"block": {
"type": "pixelImage",
"props": {
"damFile": {
"id": "c87dafdf-d21c-4b1b-a956-9caf20434d2d",
"name": "adventure-alpine-altitude-austria-355241.jpg",
"size": "1739404",
"mimetype": "image/jpeg",
"contentHash": "b4622b2d3b71364a3d7d19e906136409",
"title": null,
"altText": null,
"archived": false,
"scope": {
"domain": "main"
},
"importSourceId": null,
"importSourceType": null,
"image": {
"width": 4608,
"height": 2592,
"cropArea": {
"focalPoint": "SMART",
"width": null,
"height": null,
"x": null,
"y": null
},
"dominantColor": "#6883a1"
}
},
"urlTemplate": "http://localhost:4000/dam/images/4cf3ef037210cc779fe257a70dd8079277e97cac/c87dafdf-d21c-4b1b-a956-9caf20434d2d/crop:SMART/resize:$resizeWidth:$resizeHeight/adventure-alpine-altitude-austria-355241"
}
},
"activeType": "pixelImage"
}
}
]
}
}
]
},
"userGroup": "All"
}
]
}
}
}
}
}

The data in the content field is a so called RootBlock, the Page document, can have multiple root blocks. Another RootBlock, which is not requested in this example would be the seo field. This RootBlock will contain all the necessary and available SEO data of the page in the block data structure.

The following JSON structure represents a HeadlineBlock in the page content. This block is used to display a headline with optional additional text (eyebrow) and a specific headline level.

Example:

{
"key": "9d261397-6d62-4d6e-8934-0b627f4af4e4",
"visible": true,
"type": "headline",
"props": {
"eyebrow": "",
"headline": {
"draftContent": {
"blocks": [
{
"key": "8i42j",
"text": "Lorem ipsum",
"type": "unstyled",
"depth": 0,
"inlineStyleRanges": [],
"entityRanges": [],
"data": {}
}
],
"entityMap": {}
}
},
"level": "header-one"
},
"userGroup": "All"
}

Block structure

  • key: A unique identifier for the block.
  • visible: A boolean indicating whether the block is visible.
  • type: The type of the block, in this case, "headline".
  • props: An object containing the block-specific data:
    • eyebrow: An optional string for additional text above the headline.
    • headline: An object containing the draftContent, which is a rich text structure:
    • level: The level of the headline, e.g., "header-one".
  • userGroup: Specifies the user group that can view the block, e.g., "All".

There are some special Block Types (BlocksBlock, ColumnsBlock, LinkBlock, ListBlock, OneOfBlock, OptionalBlock, RichTextBlock and more). More details can be found in the Block Factories section.

Due to the nature that each block can have a different structure, we need to handle each block type individually and implement the rendering logic for each block type.

The whole graphQL API is typesafe, but the block data gets delivered as an untyped JSON structure. That's because in the nature of graphQL, because it's not possible to deliver recursive structures at any level. Therefor Comet DXP offers a solution. We call this BlockMeta solution.

The Comet API creates this block-meta.json and this file gets symlinked to the admin and site services. The block-meta.json contains all the necessary information about the block types. If the api gets developed independently of the sitea symlink is not possible. The block-meta.json can be provided as a separate endpoint in the api.

@comet/cli provides a command to generate the available typescript types, based on a block-meta.json file.

package.json

{
"scripts": {
"generate-block-types": "comet generate-block-types",
"generate-block-types:watch": "chokidar -s \"**/block-meta.json\" -c \"npm run generate-block-types\""
}
}

With those tools in place, typescript files can be generated for the block data, and will be placed in ./src/blocks.generated.ts directory

blocks.generated.ts

export interface PageContentBlockData {
blocks: Array<{
key: string;
visible: boolean;
type: string;
props:
| DemoSpaceBlockData
| RichTextBlockData
| HeadlineBlockData
| DamImageBlockData
| TextImageBlockData
| LinkListBlockData
| FullWidthImageBlockData
| ColumnsBlockData
| AnchorBlockData
| TwoListsBlockData
| MediaBlockData
| TeaserBlockData
| NewsDetailBlockData
| ImageLinkBlockData
| NewsListBlockData
| LayoutBlockData;
userGroup: "All" | "Admin" | "User";
}>;
}

export interface HeadlineBlockData {
eyebrow?: string;
headline: RichTextBlockData;
level: "header-one" | "header-two" | "header-three" | "header-four" | "header-five" | "header-six";
}

# and other block data interfaces

Render blocks

Having the block data in place, we can now render the blocks. The rendering logic for each block type is recommended to be implemented in a separate component. Here as an example the HeadlineBlock from Comet Demo component.

Having all the block components implemented, we can now start to render the page content. Typically a page consists of a list of blocks, those BlocksBlock can be rendered with a component BlocksBlock. from the @comet/cms-site package.

import { BlocksBlock, PropsWithData, SupportedBlocks } from "@comet/cms-site";
import { PageContentBlockData } from "@src/blocks.generated";

const supportedBlocks: SupportedBlocks = {
heading: (props) => <HeadlineBlock data={props} />,
};

export const PageContentBlock = ({ data }: PropsWithData<PageContentBlockData>) => {
return <BlocksBlock data={data} supportedBlocks={supportedBlocks} />;
};

Handle preview

More Information how to integrate and work with Comet Admin's Preview can be found in the IFrameBridge section.

Further Reading / Information