Daniel Halse
Feb 4, 2026
  653
(5 votes)

Graph access with only JS and Fetch

Postman is a popular tool for testing APIs. However, when testing an API like Optimizely Graph that I will be consuming in the front-end I prefer to do this with JS + Fetch.

This allows me to identify any quirks involved in consuming the API in JavaScript. The code can then be passed to the front-end team for use in the relevant framework, and JSON results can be saved for Storybook mock data.

Example

import projectConfig from "../../CMS12/appsettings.json" assert { type: "json" };

// Define the GraphQL query to fetch StandardPages in the "Acme" category with their associated form templates
const formQuery =
`fragment FormContainerBlock on FormContainerBlock { __typename FormRenderTemplate }
query MyQuery { 
    StandardPage( where: { PrimaryCategory: { Name: { eq: "Acme" } } } ) 
    { items { Name Url MainContent { ContentLink { Expanded { ...FormContainerBlock } } } } }
}`

// Wrap the query in the expected JSON structure for the Content Graph API Request Body
const queryWrapper = `{"query":${JSON.stringify(formQuery)}}`;

// Public key access - Review security implications before using outside of local development
const publicKey = projectConfig.Optimizely.ContentGraph.SingleKey;
const graphUrl = `https://cg.optimizely.com/content/v2?auth=${publicKey}`;

const outputData = (data) => {
    const formPages = data.StandardPage.items
        .map(i => { 
            return { 
                name: i.Name, 
                url: i.Url, 
                forms: i.MainContent
                    .filter(b => b.ContentLink.Expanded.FormRenderTemplate)
                    .map(b => b.ContentLink.Expanded.FormRenderTemplate) 
                }; 
        })
        .filter(i => i.forms?.length);
    formPages.forEach(page => page.forms.forEach(f => console.log(f)));
}

const executeQuery = async () => {
    return fetch(graphUrl, {
        method: "POST",
        headers: { "Content-Type": "application/json" },
        body: queryWrapper
    })
    .then((response) => {
        if (response.status >= 400) {
            throw new Error(`Error fetching data`, {cause: response});
        } else {
         return response.json();
        }
    })
    .then((data) => outputData(data.data))
    .catch(async error => {
        if (error.cause.status === 400) {
            // Graph returns JSON on most errors. Normally this is caused by a query syntax error.
            const result = await error.cause.json();
            console.log(`CODE: ${result.code}`);
            console.log(`DETAILS:\n${JSON.stringify(result.details)}`);
        }
    })
}

const result = await executeQuery();
console.log("Completed");

Skip to breakdown

How to run

First of all there are some prerequisites:

  • Access to an Optimizely Graph index.
  • Request a developer index.

We only need a public key for this example.

How to debug in VS Code

We can setup VS Code to debug the JavaScript. This gives us the usual access to variables to inspect and review.

Debug Current File

This should be added to the launch.json file in the .vscode folder in your source root. This allows you to run and debug the currently viewed JavaScript file.

{
    "version": "0.2.0",
      "configurations": [
        {
          "type": "node",
          "request": "launch",
          "name": "Launch Current Opened File",
          "program": "${file}"
        }
      ]
 }

If you are running a front-end framework then you will typically need to run the framework and attach the debugging using a standard configuration.

Breakdown

So I will do a breakdown of what is going on in this code.

Settings and Variables

import projectConfig from "../../CMS12/appsettings.json" assert { type: "json" };

// Define the GraphQL query to fetch StandardPages in the "Acme" category with their associated form templates
const formQuery =
`fragment FormContainerBlock on FormContainerBlock { __typename FormRenderTemplate }
query MyQuery { 
    StandardPage( where: { PrimaryCategory: { Name: { eq: "Acme" } } } ) 
    { items { Name Url MainContent { ContentLink { Expanded { ...FormContainerBlock } } } } }
}`

// Wrap the query in the expected JSON structure for the Content Graph API Request Body
const queryWrapper = `{"query":${JSON.stringify(formQuery)}}`;

// Public key access - Review security implications before using outside of local development
const publicKey = projectConfig.Optimizely.ContentGraph.SingleKey;
const graphUrl = `https://cg.optimizely.com/content/v2?auth=${publicKey}`;

First I am importing the config file from a project that contains the usual Optimizely Graph settings. You will want to target one with the developer settings if you have multiple configs.

Then we have the GraphQL query. This can be copy and pasted from the GraphiQL page. This example query finds the Form Blocks within the Main Content of the Standard Page Type. This was written for the Headless Forms BETA which nested the form data in a property of the Form Container Block.

The wrapper builds the standard request body that Optimizely Graph expects.

Finally we extract the single key from the shared config and build the URL.

CAUTION: See security concerns

Render Function

const outputData = (data) => {
    const formPages = data.StandardPage.items
        .map(i => { 
            return { 
                name: i.Name, 
                url: i.Url, 
                forms: i.MainContent
                    .filter(b => b.ContentLink.Expanded.FormRenderTemplate)
                    .map(b => b.ContentLink.Expanded.FormRenderTemplate) 
                }; 
        })
        .filter(i => i.forms?.length);
    formPages.forEach(page => page.forms.forEach(f => console.log(f)));
}

This function handles the Graph results data. As we are working in JavaScript and the results are JSON we can work with this dynamically. Using the debugger we can inspect the data structure then access it as needed. In this example I am drilling down to the FromRenderTemplate. In the Headless Forms beta this was a blob of JSON containing all of the form elements and form settings.

For my testing I normally output to the console which will show up in VS Code. I would experiment mapping the data here using JSON.stringify() to dump data to console or view via the debugger inspector.

Fetch -> Then -> Catch Chain Function

const executeQuery = async () => {
    return fetch(graphUrl, {
        method: "POST",
        headers: { "Content-Type": "application/json" },
        body: queryWrapper
    })
    .then((response) => {
        if (response.status >= 400) {
            throw new Error(`Error fetching data`, {cause: response});
        } else {
         return response.json();
        }
    })
    .then((data) => outputData(data.data))
    .catch(async error => {
        if (error.cause.status === 400) {
            // Graph returns JSON on most errors. Normally this is caused by a query syntax error.
            const result = await error.cause.json();
            console.log(`CODE: ${result.code}`);
            console.log(`DETAILS:\n${JSON.stringify(result.details)}`);
        }
    })
}

This is a fairly standard API call chain. If you drop the debugging it's very simple but we will get GraphQL syntax errors while experimenting, so this detects and logs these.

Fetch

The fetch sends the POST call with the wrapped query to a public URL. If you have secured your Graph index then you will also need to add an authentication header in the Fetch call.

For example:
return fetch(graphUrl, {
        method: "POST",
        headers: { 
            "Content-Type": "application/json",
            "Authorization": `Bearer ${projectConfig.Authorization_Token}` 
        },
        body: queryWrapper
    })

If you are working with an API that has a seperate call to request authorization then do that Fetch call, extract the token and pass to the main Fetch call. In production code you will want to cache the key based on lifetime the API states.

Then - Response

The first then checks the status code. Most APIs will return data on 4xx responses but this will often be a different format to the working response. So here we raise an error so we can handle it separately.

response.JSON() grabs the body for the success path.

Then - Data

Here we delegate the data to the output function.

Catch

In the catch function we expect to read the GraphQL errors so we log the data to the console.

Summary

This process can be used for any API you would consume on the front-end.

Graph is a good example as both the queries and data responses can be complicated and verbose. You will find nested data has multiple wrappers before you hit the expected properties. Testing from JS you can also see how missing data affects the use of the data.

Next Steps

Next time I will expand this explanation out to TypeScript with simple types that allow dynamic processing of child objects in data and strong typing for the rendering in templates.

References

Security Considerations

Note that many production implementations will want to control and obfuscate access to the graph data. This can be done in a number of ways. For example pushing the keys and access to Graph to server components or SSG the page or site.

Feb 04, 2026

Comments

Please login to comment.
Latest blogs
From Prompting to Production: Optimizely Opal University Cohort and the Future of Agentic MarTech

Most organizations today are still playing with AI. They experiment with prompts, test ideas in isolated chats, and occasionally automate a task or...

Augusto Davalos | Apr 28, 2026

Six Compelling Reasons for Upgrading to CMS 13

Most software updates ask you to keep up. Optimizely CMS 13 asks something different — it asks whether your digital strategy is built for a world...

Muhammad Talha | Apr 28, 2026

Optimizely CMS 13 breaking changes: GetContentTypePropertyDisplayName

When upgrading from CMS 12 to 13, resolving property display names may not work as before. Here’s what changed.

Tomas Hensrud Gulla | Apr 27, 2026 |

Accelerate Optimizely DAM Adoption: Unlocking Business Value with Metadata Bulk Import

Accelerating Optimizely DAM Adoption How a Metadata-Driven Bulk Import Utility Unlocks Real Business Value Executive Summary For enterprises runnin...

Vaibhav | Apr 27, 2026