Exploring the Optimizely GraphQL API to deliver a high-performing static site – Part 1.

Featured

In this series of articles, I will demonstrate how to deliver a site using Optimizely, Next.js and Vercel. In this first instalment I am focusing on creating a simple POC where I reproduce a simplified version of the AlloyTech website but statically generated using Next.js and Vercel.

Solution Architecture

Optimizely is used to manage the content, with any content updates synced to the Content Graph.

The presentation layer is developed using Next.js, a React framework. Next.js can generate either a static site at build time, handle server-side rendering, or a combination of both.

Vercel is a global network providing hosting. The content is cached around the world.

Step 1 – Managing the content

The first step is straightforward and once complete you will end up with an instance of AlloyTech with its content synced to the Content Graph.

Install AlloyTech

Install, build and then run the demo site using the commands below. The first time you run the solution you will be prompted to create an admin account, once this is completed you will have a site you can use for testing.

dotnet new epi-alloy-mvc
dotnet build
dotnet run

Install Content Graph

Follow the steps below to add and configure Content Graph in the AlloyTech demo site. You will need to contact Optimizely to gain access to an AppKey.

dotnet add package Optimizely.ContentGraph.Cms
  "Optimizely": {
    "ContentGraph": {
      "GatewayAddress": "https://cg.optimizely.com",
      "AppKey": "",
      "Secret": "",
      "SingleKey": "",
      "AllowSendingLog": "true"
    }
  }

Once installed and configured you can then sync your content to the content graph using the scheduled job ‘Content Graph content synchronization job’. Content will also get synced when published.

Step 2 – Develop the site with Next.js

Creating a new site with Next.js is very simple, but take a look at https://nextjs.org/docs/getting-started for more detailed instructions.

npx create-next-app@latest --typescript
npm run dev

The site can now be accessed by typing ‘http://localhost:3000/’ in your browser.

Next.js doesn’t include any GraphQL libraries, I added the ApolloClient package for this.

npm install @apollo/client graphql

Recreating the Homepage

I want to produce a simplified version of the AlloyTech home page. I will render out the primary navigation along with the blocks from the main content area, this is analogous to the approach in the C# version of the page.

The page has no knowledge of where the data comes from this is handed via another step. It just uses the object send in the ‘props’.

type PageProps = {
  page: any;
  navigation: any;
};

function Home(props: PageProps) {
  const { page, navigation } = props;
  return (
    <>
      <Head>
        <title>{page.MetaTitle}</title>
        <meta name="description" content={page.MetaDescription} />
        <meta name="viewport" content="width=device-width, initial-scale=1" />
        <link rel="icon" href="/favicon.ico" />
      </Head>

      <MainNavigation navigation={navigation}/>
      
      <main className={styles.main}>
        <ContentAreaRenderer items={page.MainContentArea} />
      </main>
    </>
  )
}

Each Next.js page can include a ‘getStaticProps‘ function which is used during the build process to return the props used in the render. This is where we query the Content Graph to get the data for the home page (and navigation).

Note: ‘getStaticProps’ is just used for static site generation, a different method is called for server-side rendering.

export const getStaticProps: GetStaticProps = async (context) => {

  const httpLink = new HttpLink({ uri: process.env.GRAPHQL_HOST });

  const client = new ApolloClient({
    link: httpLink,
    cache: new InMemoryCache(),
    ssrMode: true
  });
 
  var { data } = await client.query({
    query: StartPageQuery
  })

  var startPage = data.StartPage.items[0];

  var { data } = await client.query({
    query: NavigationQuery
  })
  
  var navigation = data.StartPage.items[0];

  console.log(navigation)

  return {
    props: {
      page: startPage,
      navigation: navigation
    },
  }
}

GraphQL query to get the home page.

import { gql } from '@apollo/client';

const StartPageQuery = gql`
query MyQuery {
  StartPage(locale: en) {
    items {
      Name
      TeaserText
      RouteSegment
      MetaTitle
      MetaKeywords
      MetaDescription
      MainContentArea {
        DisplayOption
        Tag
        ContentLink {
          Id
          Expanded {
            Name
            ContentType
            ... on JumbotronBlock {
              Name
              Heading
              Image {
                Url
              }
              ButtonText
              ContentType
              SubHeading
            }
            ... on TeaserBlock {
              _score
              Name
              Image {
                Url
              }
              Heading
              Text
            }
          }
        }
      }
    }
  }
}`
export default StartPageQuery

Product Pages

The home page is a simple example, but what happens when you have lots of content that use the same template. In AlloyTech there are 3 product pages accessed as child pages of the home page.

Routing

The naming convention of ‘[product-slug].tsx‘ signifies that the page is a dynamic route. The name within the square brackets ‘[]‘ is not important.

Next.js goes into more detail here: https://nextjs.org/docs/routing/introduction.

Generating the Routes

Much like the ‘getStaticProps‘ function, Next.js has an approach for generating the routes, ‘getStaticPaths‘. This is also called at build time.

export const getStaticPaths: GetStaticPaths = async () => {
    const httpLink = new HttpLink({ uri: process.env.GRAPHQL_HOST });

    const client = new ApolloClient({
      link: httpLink,
      cache: new InMemoryCache(),
      ssrMode: true
    });
   
    var { data } = await client.query({
      query: gql`query ProductPagesQuery {
        ProductPage(locale: en) {
          items {
            Name
            RouteSegment
          }
        }
      }`
    })
    var pages = data.ProductPage.items;

    const paths = pages.map((page: any) => ({
      params: { slug: page.RouteSegment}, locale: 'en',
    }));
  
    return { paths, fallback: false };
  };

Generating the page

‘getStaticPaths’ is responsible for building all the routes, each route will then be used to generate a single page, with the route data being passed to ‘getStaticProps‘.

export const getStaticProps: GetStaticProps = async ({params}) => {

  if (!params || !params.slug) {
    return { props: {} };
  }

  const httpLink = new HttpLink({ uri: process.env.GRAPHQL_HOST });

  const client = new ApolloClient({
    link: httpLink,
    cache: new InMemoryCache(),
    ssrMode: true
  });
 
  var { data } = await client.query({
    query: ProductPageQuery,
    variables: {
      segment: params.slug
    }
  })

  var page = data.ProductPage.items[0];

  var { data } = await client.query({
    query: NavigationQuery
  })
  
  var navigation = data.StartPage.items[0];
  return {
    props: {
      page: page,
      navigation: navigation
    },
  }
}

The following GraphQL query gets the specific page matching the route

import { gql } from '@apollo/client';

const ProductPageQuery = gql`
query ProductPageQuery($segment: String) {
  ProductPage(locale: en, where: {RouteSegment: {eq: $segment}}) {
    items {
      Name
      MetaTitle
      MetaKeywords
      MetaDescription
      MainBody
      TeaserText
      RelativePath
      PageImage {
        Url
      }
      RouteSegment
    }
  }
}
`
export default ProductPageQuery

Content Areas / Blocks

For this POC I created my own Content Area Render as this is an Optimizely concept which requires custom development within your Next.js site.

The approach is very simple, the content area render iterates over each item, and uses a factory to determine the component to render. This factory also gets the display option, giving the ability for the blocks to be rendered at different sizes.

function ContentAreaRenderer(props :any) {

    let items :any[] = props.items;

    var factory = new componentFactory()

    return(
        <div className={styles.container}>

        {items?.map(i => {

            const ContentAreaItem = factory.resolve(i);
            const Component = ContentAreaItem.Component;
            
            if (Component != null)
                return (
                <div className={ContentAreaItem.ItemClasses} key={i.ContentLink.Id}>
                    <Component item={i}  />
                </div>)
            else
                return null
        })}

        </div>
    )
}

The ‘componentFactory‘ gets the correct component to render, and also gets the correct display option.

class ContentAreaItem {
    ItemClasses: string;
    Component: any;

    constructor () {
        this.ItemClasses = "fullwidth"
    }
}
interface Dictionary<T> {
    [Key: string]: T;
}

class componentFactory {
  
    components: Dictionary<any> = {};

    constructor(){
        this.components["JumbotronBlock"] = JumbotronBlock;
        this.components["TeaserBlock"] = TeaserBlock;
    } 

    getType(item: any) : string {
        var contentTypes = item.ContentLink.Expanded.ContentType;
        return contentTypes[contentTypes.length - 1]; 
    }

    getDisplayOption(item: any) : string {
        return item.DisplayOption === "" ? "fullwidth" : item.DisplayOption; 
    }

    resolve(item: any): ContentAreaItem {
        var contentType: string = this.getType(item);

        var i = new ContentAreaItem();

        i.Component = this.components[contentType];
        i.ItemClasses = this.getDisplayOption(item);

        return i;
    }
}

Step 3 – Hosting the site with Vercel

Vercel is a platform for sites built using frontend frameworks, when your site is hosted with Vercel you will automatically gain performance benefits due to the edge caching.

Deployment

Deploying your site with Vercel is extremely straightforward.

Create a new project and connect it to the GitHub repo and configure the source location and the build pipeline. Every time the branch is updated the code will be built and the site automatically deployed.

This approach has some real benefits:

  1. Previewing all changes is simple. Each push to the repository will trigger a build and generate a unique URL that can be shared.
  2. It is possible to promote a previous version to ‘Production’ meaning rolling back simply as clicking on a button.

Step 4 – Handling Content Changes

Static sites may deliver blistering performance, but produce challenges when content is modified; the changes are not reflected.

There are several strategies you can adopt to help solve this problem.

  1. Per Request Revalidation – It is possible to regenerate a page when a request comes in, but throttled so that an X number of seconds must have elapsed before the page can be regenerated.
  2. On-Demand Revalidation – You can expose an API endpoint, that when called will regenerate the specific resource.

The problem with Per Request Revalidation is we are moving from static generation to dynamic generation.

import type { NextApiRequest, NextApiResponse } from 'next'

type ErrorData = {
  message: string
}

type SuccessData = {
    revalidated: boolean,
    message: string
  }

export default async function handler(
    req: NextApiRequest,
    res: NextApiResponse<ErrorData | SuccessData>
) {
    if (req.query.secret !== process.env.REVALIDATE_TOKEN) {
      return res.status(401).json({ message: 'Invalid token' })
    }
  
    const { revalidatePath } = req.body;

    try {
      await res.revalidate(revalidatePath)
      return res.json({ message: revalidatePath, revalidated: true })
    } catch (err) {
      return res.status(500).send({ message: 'Error revalidating :' + revalidatePath })
    }
  }

In the example above I have exposed an API endpoint. The request body contains the path of the resource that needs to be invalidated. The ‘revalidate‘ function then triggers regeneration of the page.

public void Configure(IApplicationBuilder app, IWebHostEnvironment env, IContentEvents contentEvents)
{
        contentEvents.PublishedContent += ContentEvents_PublishedContent;
}

private void ContentEvents_PublishedContent(object sender, ContentEventArgs e)
{
        if (e.Content is IRoutable routableContent)
        {
            var url = UrlResolver.Current.GetUrl(e.ContentLink);

            Task.Run(() =>
            {
                var request = new RevalidateRequest { RevalidatePath = url };

                Task.Delay(10000);  // wait 10 seconds

                var r = client.PostJsonAsync<RevalidateRequest>("/api/revalidate/?secret=...", request);

                Task.WaitAll(new[] { r });
            });
        }
}

The C# code above demonstrates how the Optimizely website triggers the revalidation in the static site. I have built in a 10 second delay as you need to allow for the content to be synced with the content graph.

Closing Thoughts

Whilst you are unlikely to reproduce model your content as you would in a normal Optimizley website, this POC does demonstrate the core concepts with using Optimizely as a headless CMS.

Performance Benifits

Whilst not the most scientific of comparisons, the two lighthouse reports below demonstrate the performance improvements you can gain when moving to a statically generated approach.

Next Article

In the next article I will be looking into using Optimizely in a more headless mode and will also demonstrate other features such as searching, content listing etc.

Examples

You can access the POC source code at my GitHub Account, and the static site at https://graph-ql-three.vercel.app/.