Daniel Halse
Feb 4, 2026
  64
(3 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
Best Practices for Implementing Optimizely SaaS CMS: A Collective Wisdom Guide

This guide compiles collective insights and recommendations from Optimizely experts for implementing Optimizely SaaS CMS, focusing on achieving...

David Knipe | Feb 4, 2026 |

A day in the life of an Optimizely OMVP: Learning Optimizely Just Got Easier: Introducing the Optimizely Learning Centre

On the back of my last post about the Opti Graph Learning Centre, I am now happy to announce a revamped interactive learning platform that makes...

Graham Carr | Jan 31, 2026

Scheduled job for deleting content types and all related content

In my previous blog post which was about getting an overview of your sites content https://world.optimizely.com/blogs/Per-Nergard/Dates/2026/1/sche...

Per Nergård (MVP) | Jan 30, 2026