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:
- Previewing all changes is simple. Each push to the repository will trigger a build and generate a unique URL that can be shared.
- 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.
- 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.
- 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/.
Thanks for sharing!
Great blog post!
Would it be easier for you to revalidate the endpoint if we would add subscription support to Content Graph?