Skip to main content

Build a Headless Editor customization page

Build a storefront page that loads the Headless Editor, opens a product from PIM, renders its design variants and text fields with your own controls, and creates a Customer's Canvas project when the customer finishes. The result is a working starting point for driving the editor entirely from your own UI.

Prerequisites

  • Node.js 22 or later
  • A Customer's Canvas Hub tenant prepared for frontend integration
  • A PIM product with at least one option and one connected design, configured in Customer's Canvas BackOffice

Set up the project

Create a Vite application that serves a static frontend and a small Express backend for Customer's Canvas API calls.

  1. Scaffold a Vite project.

    npm create vite@latest headless-quick-start -- --template vanilla-ts
    cd headless-quick-start
    npm install
  2. Install the backend dependencies.

    npm install express dotenv axios concurrently tsx
    npm install --save-dev @types/express @types/node @aurigma/axios-storefront-api-client
    npm install --save-dev @aurigma/workflow-elements
  3. Replace the scripts block in package.json so the frontend and backend start together.

    {
    "scripts": {
    "dev": "concurrently vite \"tsx watch server/server.ts\""
    }
    }

    The dev script starts Vite and the Express backend together. Leave the other Vite scripts unchanged.

Configure Customer's Canvas connection

  1. Create an .env file in the project root.

  2. Add your Customer's Canvas values.

    CCHUB_BASE_URL=https://customerscanvashub.com
    CCHUB_API_GATEWAY_URL=https://api.customerscanvashub.com
    CCHUB_ENVIRONMENT=us
    CCHUB_TENANT_ID=<tenant_id>
    CCHUB_STOREFRONT_ID=<storefront_id>
    CCHUB_CLIENT_ID=<client_id>
    CCHUB_CLIENT_SECRET=<client_secret>

    Use the EU or AU URLs and environment code when your tenant runs in another region. Keep CCHUB_CLIENT_SECRET only on the backend. Do not expose it in VITE_* variables or browser code.

Add the backend

Authenticate the backend

  1. Create server/cchub-auth.ts.

  2. Add the Customer's Canvas connection and OAuth2 helper.

    import axios from "axios";
    import dotenv from "dotenv";

    dotenv.config();

    type AuthResponse = {
    access_token: string;
    expires_in: number;
    token_type: "Bearer";
    scope: string;
    };

    export const cchubEnv = {
    baseUrl: requireEnv("CCHUB_BASE_URL"),
    apiGatewayUrl: requireEnv("CCHUB_API_GATEWAY_URL"),
    environment: requireEnv("CCHUB_ENVIRONMENT"),
    tenantId: Number(requireEnv("CCHUB_TENANT_ID")),
    storefrontId: Number(requireEnv("CCHUB_STOREFRONT_ID")),
    clientId: requireEnv("CCHUB_CLIENT_ID"),
    clientSecret: requireEnv("CCHUB_CLIENT_SECRET"),
    };

    let cachedAccessToken: { value: string; expiresAt: number } | null = null;

    export async function getAccessToken(): Promise<string> {
    if (cachedAccessToken !== null && cachedAccessToken.expiresAt > Date.now()) {
    return cachedAccessToken.value;
    }

    const body = new URLSearchParams({
    client_id: cchubEnv.clientId,
    client_secret: cchubEnv.clientSecret,
    grant_type: "client_credentials",
    });

    const { data } = await axios.post<AuthResponse>(
    `${cchubEnv.baseUrl}/connect/token`,
    body,
    {
    headers: {
    "Content-Type": "application/x-www-form-urlencoded",
    },
    }
    );

    cachedAccessToken = {
    value: data.access_token,
    expiresAt: Date.now() + (data.expires_in - 60) * 1000,
    };

    return data.access_token;
    }

    function requireEnv(name: string): string {
    const value = process.env[name];

    if (value === undefined || value === "") {
    throw new Error(`Missing ${name} environment variable.`);
    }

    return value;
    }

    This module keeps OAuth2 client credentials on the backend, requests an access token through the Client Credentials flow, and caches the token until shortly before expiration.

Create the Customer's Canvas API client

  1. Create server/cchub-api.ts.

  2. Add the storefront user registration and project creation helpers.

    import {
    ApiClientConfiguration,
    ProjectsApiClient,
    StorefrontUsersApiClient,
    ProjectItemProductType,
    } from "@aurigma/axios-storefront-api-client";
    import type {
    CreateSingleItemProjectDto,
    CreateStorefrontUserDto,
    } from "@aurigma/axios-storefront-api-client";
    import { cchubEnv, getAccessToken } from "./cchub-auth";

    export type LineItem = {
    productId?: number;
    productVariantId?: number;
    productVersionId?: number;
    sku?: string;
    quantity: number;
    properties: {
    _stateId: string[];
    _userId: string;
    };
    };

    export async function getStorefrontUserToken(userId: string): Promise<string> {
    const accessToken = await getAccessToken();
    const usersApi = new StorefrontUsersApiClient(createApiConfig(accessToken));

    try {
    const user: CreateStorefrontUserDto = {
    storefrontUserId: userId,
    isAnonymous: true,
    };

    await usersApi.create(cchubEnv.storefrontId, undefined, user);
    } catch (error) {
    if (getErrorStatus(error) !== 409) {
    throw new Error(`Cannot create storefront user. User ID = ${userId}.`, {
    cause: error,
    });
    }
    }

    try {
    return await usersApi.getToken(userId, cchubEnv.storefrontId);
    } catch (error) {
    throw new Error(`Cannot get storefront user token. User ID = ${userId}.`, {
    cause: error,
    });
    }
    }

    export async function createProject(lineItem: LineItem) {
    const accessToken = await getAccessToken();
    const projectsApi = new ProjectsApiClient(createApiConfig(accessToken));

    const body: CreateSingleItemProjectDto = {
    ownerId: lineItem.properties._userId,
    item: {
    productSpecifier: {
    type: ProjectItemProductType.Product,
    id: lineItem.productId,
    versionId: lineItem.productVersionId,
    variantId: lineItem.productVariantId,
    },
    designIds: lineItem.properties._stateId,
    sku: lineItem.sku,
    quantity: lineItem.quantity,
    },
    };

    try {
    return await projectsApi.createWithSingleItem(
    cchubEnv.storefrontId,
    undefined,
    body
    );
    } catch (error) {
    throw new Error("Cannot create a project from the Headless Editor result.", {
    cause: error,
    });
    }
    }

    function createApiConfig(accessToken: string): ApiClientConfiguration {
    const config = new ApiClientConfiguration();
    config.apiUrl = cchubEnv.apiGatewayUrl;
    config.setAuthorizationToken(accessToken);

    return config;
    }

    export function getErrorStatus(error: any): number | undefined {
    return error?.response?.status
    ?? error?.status
    ?? (error instanceof Error ? getErrorStatus(error.cause) : undefined);
    }

Add the Express server

  1. Create server/server.ts.

  2. Add the routes that prepare the editor session and create the project.

    import express from "express";
    import {
    createProject,
    getErrorStatus,
    getStorefrontUserToken,
    type LineItem,
    } from "./cchub-api";
    import { cchubEnv } from "./cchub-auth";

    const app = express();
    app.use(express.json());

    app.get("/", (request, response) => {
    response.send("Headless Editor quick start backend is running.");
    });

    app.get("/api/editor-session", async (request, response) => {
    const userId = String(request.query.userId ?? "quick-start-user");
    const productId = Number(request.query.productId);

    if (!Number.isInteger(productId) || productId <= 0) {
    response
    .status(400)
    .json({ message: "The productId query parameter is required." });
    return;
    }

    try {
    const userToken = await getStorefrontUserToken(userId);

    response.json({
    scriptUrl: `https://staticjs-aurigma.azureedge.net/libs/${cchubEnv.environment}/workflow-elements/headless-editor/index.js`,
    styleUrl: `https://staticjs-aurigma.azureedge.net/libs/${cchubEnv.environment}/workflow-elements/headless-editor/styles.css`,
    config: {
    configVersion: 2,
    input: {
    productId,
    },
    integration: {
    tenantId: cchubEnv.tenantId,
    storefrontId: cchubEnv.storefrontId,
    cchubUrl: cchubEnv.baseUrl,
    cchubApiGatewayUrl: cchubEnv.apiGatewayUrl,
    user: {
    id: userId,
    token: userToken,
    },
    },
    },
    });
    } catch (error) {
    sendError(response, error);
    }
    });

    app.post("/api/projects", async (request, response) => {
    try {
    const lineItem = request.body as LineItem;
    const project = await createProject(lineItem);

    response.json(project);
    } catch (error) {
    sendError(response, error);
    }
    });

    app.listen(3001, () => {
    console.log("Backend is running at http://localhost:3001");
    });

    function sendError(response: express.Response, error: any): void {
    const status = getErrorStatus(error) ?? 500;
    const cause =
    error instanceof Error && error.cause != null
    ? ` ${String(error.cause)}`
    : "";
    const message =
    error instanceof Error ? `${error.message}${cause}` : "Unexpected error";

    response.status(status).json({ message });
    }

    The server reads the environment once at startup, resolves the editor bundle URL for the configured environment, and creates a project from the line item returned by the editor.

Add the page

  1. Replace the content of index.html.

    <!doctype html>
    <html lang="en">
    <head>
    <meta charset="UTF-8" />
    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
    <title>Headless Editor quick start</title>
    <link rel="stylesheet" href="/src/style.css" />
    </head>
    <body>
    <main class="layout">
    <div id="viewer" class="viewer">
    <au-headless-editor></au-headless-editor>
    </div>
    <aside id="controls" class="controls">
    <h1 id="product-name"></h1>
    <section id="variants"></section>
    <section id="fields"></section>
    <button id="finish" type="button">Finish</button>
    </aside>
    </main>
    <script type="module" src="/src/main.ts"></script>
    </body>
    </html>

    The au-headless-editor element hosts the design viewer. The #controls panel holds the design variant tiles, the text inputs, and the finish button that the page renders itself.

  2. Replace the content of src/main.ts. Replace 7263 with your product ID.

    import type {
    IHeadlessEditor,
    IHeadlessEditorInitArgs,
    Product,
    } from "@aurigma/workflow-elements/headless-editor";

    const productId = 7263; // Replace with your product ID.

    type EditorSession = {
    scriptUrl: string;
    styleUrl: string;
    config: IHeadlessEditorInitArgs;
    };

    try {
    const session = await getEditorSession("quick-start-user", productId);

    if (session == null) {
    return;
    }

    await loadStyles(session.styleUrl);
    await loadScript(session.scriptUrl);
    await customElements.whenDefined("au-headless-editor");

    const editor = getEditor();

    if (editor == null) {
    showMessage("Cannot open Headless Editor", "The editor element is missing.");
    return;
    }

    await editor.init(session.config);

    editor.addEventListener("variantchanged", renderVariants);
    editor.addEventListener("designvariantchanged", renderTextFields);

    setProductName(editor.getCurrentProduct());
    renderVariants();
    renderTextFields();

    document
    .querySelector<HTMLButtonElement>("#finish")
    ?.addEventListener("click", async () => {
    const lineItem = await editor.addToCart();
    const project = await createProject(lineItem);

    console.log("Created project", project);
    });
    } catch (error) {
    showMessage("Cannot open Headless Editor", getErrorMessageText(error));
    }

    function setProductName(product: Product): void {
    const element = document.querySelector<HTMLElement>("#product-name");

    if (element != null) {
    element.textContent = product.name;
    }
    }

    function renderVariants(): void {
    const editor = getEditor();
    const variant = editor.getCurrentVariant();
    const container = document.querySelector<HTMLElement>("#variants");

    if (container == null) {
    return;
    }

    container.replaceChildren(heading("Design variants"));

    for (const designVariant of variant.designVariants) {
    const tile = document.createElement("button");
    tile.type = "button";
    tile.className = "tile";
    tile.textContent = designVariant.designName ?? "Default design";

    tile.addEventListener("click", async () => {
    await editor.setCurrentDesignVariant(designVariant.id);
    });

    container.append(tile);
    }
    }

    function renderTextFields(): void {
    const editor = getEditor();
    const container = document.querySelector<HTMLElement>("#fields");

    if (container == null) {
    return;
    }

    container.replaceChildren(heading("Text fields"));

    const textItems = editor.getCurrentSurfaceTextItems();

    for (const item of textItems) {
    const input = document.createElement("input");
    input.value = item.value;
    input.placeholder = item.schemaDefinition.prompt;
    input.required = item.schemaDefinition.required;
    input.setAttribute(
    "aria-label",
    item.schemaDefinition.displayName || item.name
    );

    if (item.lengthLimits.maxCharacters > 0) {
    input.maxLength = item.lengthLimits.maxCharacters;
    }

    input.addEventListener("change", async () => {
    await editor.setTextItemContent(item.id, input.value);
    });

    container.append(field(item.name, input));
    }

    if (textItems.length === 0) {
    container.append(document.createTextNode("No text fields on this design."));
    }
    }

    function field(labelText: string, input: HTMLInputElement): HTMLElement {
    const label = document.createElement("label");
    label.textContent = labelText;
    label.append(input);

    return label;
    }

    function heading(text: string): HTMLHeadingElement {
    const element = document.createElement("h2");
    element.textContent = text;
    return element;
    }

    function getEditor(): (HTMLElement & IHeadlessEditor) | null {
    return document.querySelector(
    "au-headless-editor"
    ) as (HTMLElement & IHeadlessEditor) | null;
    }

    async function getEditorSession(
    userId: string,
    id: number
    ): Promise<EditorSession | null> {
    const params = new URLSearchParams({
    userId,
    productId: String(id),
    });

    const response = await fetch(`/api/editor-session?${params.toString()}`);

    return readJson(response, "Cannot open Headless Editor");
    }

    async function createProject(lineItem: unknown) {
    const response = await fetch("/api/projects", {
    method: "POST",
    headers: {
    "Content-Type": "application/json",
    },
    body: JSON.stringify(lineItem),
    });

    return readJson(response, "Cannot create project");
    }

    async function readJson(response: Response, titleText: string) {
    if (!response.ok) {
    showMessage(titleText, await getResponseErrorMessage(response));
    return null;
    }

    return response.json();
    }

    async function getResponseErrorMessage(response: Response): Promise<string> {
    try {
    const body = await response.json();

    return body.message ?? response.statusText;
    } catch {
    return response.statusText;
    }
    }

    function showMessage(titleText: string, message: string): void {
    document.body.innerHTML = "";

    const container = document.createElement("main");
    const title = document.createElement("h1");
    const details = document.createElement("p");

    container.className = "message";
    title.textContent = titleText;
    details.textContent = message;

    container.append(title, details);
    document.body.append(container);
    }

    function getErrorMessageText(error: any): string {
    return error instanceof Error ? error.message : String(error);
    }

    async function loadStyles(url: string): Promise<void> {
    const link = document.createElement("link");
    link.href = url;
    link.rel = "stylesheet";
    document.head.appendChild(link);
    }

    async function loadScript(url: string): Promise<void> {
    await new Promise<void>((resolve, reject) => {
    const script = document.createElement("script");
    script.src = url;
    script.onload = () => resolve();
    script.onerror = () => reject(new Error(`Failed to load ${url}`));
    document.head.appendChild(script);
    });
    }

    The page renders the design variants of the current product variant as tiles. Selecting a tile calls setCurrentDesignVariant(), which triggers designvariantchanged and rebuilds the text inputs for the new design. Entering text updates the matching text item. The finish button calls addToCart() and sends the resulting line item to the backend, which creates the project.

    The example reads the product by productId. To open a product by its external ID, pass { productReferenceId: "<external_product_id>" } instead. See Integration guidelines.

  3. Replace the content of src/style.css.

    html,
    body {
    height: 100%;
    margin: 0;
    }

    .layout {
    display: grid;
    grid-template-columns: minmax(320px, 1fr) 360px;
    height: 100vh;
    }

    .viewer {
    height: 100%;
    }

    au-headless-editor {
    display: block;
    height: 100%;
    }

    .controls {
    overflow-y: auto;
    padding: 24px;
    font-family: system-ui, sans-serif;
    }

    .controls h1 {
    font-size: 20px;
    }

    .controls h2 {
    font-size: 16px;
    }

    .tile {
    display: block;
    width: 100%;
    margin: 0 0 8px;
    padding: 8px;
    }

    label {
    display: block;
    margin: 0 0 12px;
    }

    input {
    box-sizing: border-box;
    width: 100%;
    padding: 8px;
    }

Run the app

  1. Start the frontend and backend.

    npm run dev
  2. Open the local URL printed by Vite.

    The page loads the product from the productId value in src/main.ts and renders its design variants and text fields.

  3. Select a design variant tile and enter text into a field.

    The viewer updates the design after each change.

  4. Select Finish.

    The browser console prints the created project object.

Next steps

Was this page helpful?