twofoldtech-dakota

frontend-modern

Modern frontend patterns for headless Optimizely CMS (React, Next.js)

twofoldtech-dakota 1 1 Updated 4mo ago
GitHub

Install

npx skillscat add twofoldtech-dakota/claude-marketplace/frontend-modern

Install via the SkillsCat registry.

SKILL.md

Modern Frontend Patterns

Overview

This skill covers modern frontend patterns for headless Optimizely CMS implementations using React, Next.js, and the Content Delivery API.

React Components

Content Component

import { useState, useEffect } from 'react';
import { ContentReference, IContent } from '@/types/optimizely';
import { contentApi } from '@/lib/content-api';

interface ArticleProps {
  contentLink: ContentReference;
}

export function Article({ contentLink }: ArticleProps) {
  const [article, setArticle] = useState<ArticleContent | null>(null);
  const [loading, setLoading] = useState(true);
  const [error, setError] = useState<Error | null>(null);

  useEffect(() => {
    async function fetchArticle() {
      try {
        const data = await contentApi.get<ArticleContent>(contentLink);
        setArticle(data);
      } catch (err) {
        setError(err as Error);
      } finally {
        setLoading(false);
      }
    }

    fetchArticle();
  }, [contentLink]);

  if (loading) return <ArticleSkeleton />;
  if (error) return <ErrorMessage error={error} />;
  if (!article) return null;

  return (
    <article className="article">
      <h1>{article.heading}</h1>
      <div dangerouslySetInnerHTML={{ __html: article.mainBody }} />
    </article>
  );
}

Block Renderer

import dynamic from 'next/dynamic';
import { BlockContent } from '@/types/optimizely';

const blockComponents: Record<string, React.ComponentType<any>> = {
  HeroBlock: dynamic(() => import('./blocks/HeroBlock')),
  TeaserBlock: dynamic(() => import('./blocks/TeaserBlock')),
  TextBlock: dynamic(() => import('./blocks/TextBlock')),
  ImageBlock: dynamic(() => import('./blocks/ImageBlock')),
};

interface BlockRendererProps {
  block: BlockContent;
}

export function BlockRenderer({ block }: BlockRendererProps) {
  const Component = blockComponents[block.contentType];

  if (!Component) {
    console.warn(`Unknown block type: ${block.contentType}`);
    return null;
  }

  return <Component {...block} />;
}

Content Area Component

import { ContentArea as ContentAreaType } from '@/types/optimizely';
import { BlockRenderer } from './BlockRenderer';

interface ContentAreaProps {
  area: ContentAreaType | null;
  className?: string;
}

export function ContentArea({ area, className }: ContentAreaProps) {
  if (!area?.items?.length) return null;

  return (
    <div className={className}>
      {area.items.map((item, index) => (
        <BlockRenderer key={item.contentLink.id || index} block={item} />
      ))}
    </div>
  );
}

Next.js Integration

Page Component (App Router)

// app/[...slug]/page.tsx
import { notFound } from 'next/navigation';
import { contentApi } from '@/lib/content-api';
import { PageRenderer } from '@/components/PageRenderer';

interface PageProps {
  params: { slug: string[] };
}

export async function generateMetadata({ params }: PageProps) {
  const path = '/' + (params.slug?.join('/') || '');
  const page = await contentApi.getByUrl(path);

  if (!page) return {};

  return {
    title: page.metaTitle || page.name,
    description: page.metaDescription,
  };
}

export default async function Page({ params }: PageProps) {
  const path = '/' + (params.slug?.join('/') || '');
  const page = await contentApi.getByUrl(path);

  if (!page) {
    notFound();
  }

  return <PageRenderer page={page} />;
}

Content API Client

// lib/content-api.ts
const API_BASE = process.env.OPTIMIZELY_API_URL;
const API_KEY = process.env.OPTIMIZELY_API_KEY;

class ContentApiClient {
  private async fetch<T>(endpoint: string): Promise<T> {
    const response = await fetch(`${API_BASE}${endpoint}`, {
      headers: {
        'Authorization': `Bearer ${API_KEY}`,
        'Accept': 'application/json',
      },
      next: { revalidate: 60 }, // ISR: revalidate every 60 seconds
    });

    if (!response.ok) {
      throw new Error(`API error: ${response.status}`);
    }

    return response.json();
  }

  async get<T>(contentLink: ContentReference): Promise<T> {
    return this.fetch<T>(`/content/${contentLink.id}`);
  }

  async getByUrl<T>(url: string): Promise<T | null> {
    try {
      return await this.fetch<T>(`/content?url=${encodeURIComponent(url)}`);
    } catch {
      return null;
    }
  }

  async getChildren<T>(parentLink: ContentReference): Promise<T[]> {
    return this.fetch<T[]>(`/content/${parentLink.id}/children`);
  }
}

export const contentApi = new ContentApiClient();

GraphQL Integration

Apollo Client Setup

// lib/apollo-client.ts
import { ApolloClient, InMemoryCache, createHttpLink } from '@apollo/client';

const httpLink = createHttpLink({
  uri: process.env.OPTIMIZELY_GRAPHQL_URL,
  headers: {
    'Authorization': `Bearer ${process.env.OPTIMIZELY_API_KEY}`,
  },
});

export const apolloClient = new ApolloClient({
  link: httpLink,
  cache: new InMemoryCache(),
});

GraphQL Query Hook

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

const GET_ARTICLE = gql`
  query GetArticle($id: Int!) {
    ArticlePage(id: $id) {
      name
      heading
      mainBody {
        html
      }
      heroImage {
        url
      }
      publishedDate
    }
  }
`;

export function useArticle(id: number) {
  return useQuery(GET_ARTICLE, {
    variables: { id },
  });
}

TypeScript Types

Content Types

// types/optimizely.ts
export interface ContentReference {
  id: number;
  workId?: number;
  guidValue?: string;
}

export interface IContent {
  contentLink: ContentReference;
  name: string;
  contentType: string[];
}

export interface PageData extends IContent {
  url: string;
  parentLink: ContentReference;
  metaTitle?: string;
  metaDescription?: string;
}

export interface ArticleContent extends PageData {
  heading: string;
  mainBody: string;
  publishedDate?: string;
  heroImage?: ImageReference;
  mainContentArea?: ContentAreaItem[];
}

export interface BlockData extends IContent {
  // Block-specific properties
}

export interface ContentAreaItem {
  contentLink: ContentReference;
  displayOption?: string;
}

export interface ImageReference {
  url: string;
  alt?: string;
  width?: number;
  height?: number;
}

State Management

Content Store (Zustand)

import { create } from 'zustand';
import { contentApi } from '@/lib/content-api';

interface ContentStore {
  pages: Map<number, PageData>;
  loading: boolean;
  error: Error | null;
  fetchPage: (id: number) => Promise<void>;
}

export const useContentStore = create<ContentStore>((set, get) => ({
  pages: new Map(),
  loading: false,
  error: null,

  fetchPage: async (id: number) => {
    if (get().pages.has(id)) return;

    set({ loading: true, error: null });

    try {
      const page = await contentApi.get<PageData>({ id });
      set((state) => ({
        pages: new Map(state.pages).set(id, page),
        loading: false,
      }));
    } catch (error) {
      set({ error: error as Error, loading: false });
    }
  },
}));

Best Practices

  1. Use TypeScript for type safety with CMS content
  2. Implement ISR for optimal performance
  3. Handle loading and error states gracefully
  4. Use dynamic imports for block components
  5. Cache API responses appropriately
  6. Validate content before rendering