Building a Modern eCommerce Platform with Optimizely PaaS and Next.js SaaS Frontend – Part 2: Frontend Implementation

Introduction & Setup

In Part 1, we explored the high-level architecture of our headless eCommerce platform—why we chose Optimizely PaaS for the backend and Next.js for the frontend, and how we structured the overall system for scalability and developer experience.

Now we’ll dive into the frontend implementation—the code, patterns, and architectural decisions that bring this Optimizely headless eCommerce platform to life.

Our frontend is built on Next.js 14 (App Router), TypeScript 5.7.2, and URQL 4.2.1 for GraphQL integration with Optimizely Content Graph. We’re using Tailwind CSS for styling and React Hook Form (with Zod) for form management and validation. This stack is designed for type safety, performance, and developer experience.

The project structure follows a clean separation of concerns:

frontend/
├── app/                    # Next.js App Router pages and layouts
├── components/             # Reusable React components
├── lib/                    # Utilities, GraphQL client, and business logic
├── types/                  # TypeScript type definitions
├── hooks/                  # Custom React hooks
├── services/               # API services and external integrations
└── stories/                # Storybook stories (for testing)

This organization enforces modularity, making it easy to scale features like product search, checkout, and localization.

The heart of this architecture is the tight coupling between our Next.js frontend and Optimizely’s Content Graph API—augmented by custom REST endpoints for business logic that extends beyond CMS-managed content (like carts, authentication, and checkout).


Optimizely Integration Layer

Why GraphQL for Content

Optimizely’s Content Graph provides a flexible, type-safe way to query structured content. Using GraphQL lets us fetch exactly the data we need, support localization, and combine multiple content types in a single request—improving performance and reducing overfetching.

GraphQL Integration with Content Graph

We configure URQL to integrate with Optimizely’s Content Graph, ensuring efficient caching and type safety across all content queries.

// lib/graphql/client.ts
import { createClient, cacheExchange, fetchExchange } from 'urql';

export const contentGraphClient = createClient({
  url: process.env.NEXT_PUBLIC_CONTENT_GRAPH_URL!,
  exchanges: [
    cacheExchange({
      keys: {
        ContentArea: () => null, // don't normalize nested content areas
        XhtmlString: () => null,  // don't normalize large HTML blobs
      },
    }),
    fetchExchange,
  ],
  fetchOptions: () => ({
    headers: {
      Authorization: `Bearer ${process.env.NEXT_PUBLIC_CONTENT_GRAPH_TOKEN}`,
      'Content-Type': 'application/json',
    },
  }),
});

We use GraphQL Code Generator to automatically generate TypeScript types and URQL helpers from our schema. This gives us compile-time safety and auto-generated hooks for every operation.

// codegen.ts
import type { CodegenConfig } from '@graphql-codegen/cli';

const config: CodegenConfig = {
  schema: process.env.CONTENT_GRAPH_SCHEMA_URL,
  documents: ['src/**/*.graphql', 'src/**/*.tsx'],
  generates: {
    './src/gql/generated.ts': {
      plugins: ['typescript', 'typescript-operations', 'typescript-urql'],
      config: { withHooks: true, withComponent: false },
    },
  },
};

export default config;

Server components fetch content on the server for SEO and performance; client components layer on interactivity.

// app/products/[slug]/page.tsx (Server Component)
import { contentGraphClient } from '@/lib/graphql/client';
import { GetProductDocument } from '@/gql/generated';
import { notFound } from 'next/navigation';
import ProductDetails from '@/components/organisms/ProductDetails';
import AddToCartButton from '@/components/atoms/AddToCartButton';

export default async function ProductPage({ params }: { params: { slug: string } }) {
  const result = await contentGraphClient
    .query(GetProductDocument, { slug: params.slug, locale: 'en' })
    .toPromise();

  if (result.error) {
    throw new Error(`Failed to fetch product: ${result.error.message}`);
  }
  const product = result.data?.product;
  if (!product) {
    notFound();
  }

  return (
    <div>
      <h1 className="text-3xl font-bold">{product.name}</h1>
      <ProductDetails product={product} />
      {/* Client component for interactivity */}
      <AddToCartButton productId={product.id} />
    </div>
  );
}

When to Use REST

While GraphQL handles structured content and catalog data, we use REST APIs for operations involving transactions or user state—such as authentication, cart management, and payment processing. These endpoints are protected with JWTs or session tokens.

// lib/api/client.ts
class ApiClient {
  private baseUrl = process.env.NEXT_PUBLIC_API_URL!;
  private token: string | null = null;

  setAuthToken(token: string) {
    this.token = token;
  }

  async request<T>(endpoint: string, options: RequestInit = {}): Promise<T> {
    const url = `${this.baseUrl}${endpoint}`;
    const headers: HeadersInit = {
      'Content-Type': 'application/json',
      ...(options.headers || {}),
      ...(this.token ? { Authorization: `Bearer ${this.token}` } : {}),
    };

    const res = await fetch(url, { ...options, headers });
    if (!res.ok) {
      const msg = await res.text().catch(() => res.statusText);
      throw new Error(`API request failed: ${res.status} ${msg}`);
    }
    return res.json() as Promise<T>;
  }
}

export const apiClient = new ApiClient();
// app/api/cart/route.ts
import { NextRequest, NextResponse } from 'next/server';
import { apiClient } from '@/lib/api/client';

export async function GET(request: NextRequest) {
  try {
    const raw = request.headers.get('authorization') || '';
    const token = raw.replace(/^Bearer\s+/i, '');
    if (!token) {
      return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
    }

    apiClient.setAuthToken(token);
    const cart = await apiClient.request('/cart');
    return NextResponse.json(cart);
  } catch {
    return NextResponse.json({ error: 'Failed to fetch cart' }, { status: 500 });
  }
}

CMS 12 Compatibility & Progressive Enhancement

Optimizely projects often span multiple CMS versions. To ensure forward compatibility, we detect features via environment flags and progressively enhance when CMS 12+ features are available.

// lib/cms/feature-detection.ts
const cmsVersion = parseInt(process.env.NEXT_PUBLIC_CMS_VERSION || '12', 10);

export const cmsFeatures = {
  isCms12: cmsVersion >= 12,
  supportsContentGraph: cmsVersion >= 12,
  supportsBlocks: cmsVersion >= 12,
};

Remkoj Packages

To accelerate development, we leverage Remkoj community packages that extend Optimizely functionality for headless/composable scenarios:

  • Remkoj.ContentGraph — Enhanced GraphQL queries and mutations
  • Remkoj.Blocks — Advanced block rendering and composition
  • Remkoj.Commerce — Extended commerce functionality
  • Remkoj.Localization — Improved multilingual content handling
// lib/cms/blocks.tsx
import { BlockRenderer } from 'remkoj.blocks';
import TextBlockComponent from '@/components/blocks/TextBlock';
import ImageBlockComponent from '@/components/blocks/ImageBlock';
import ProductBlockComponent from '@/components/blocks/ProductBlock';

export function renderContentArea(contentArea: any[]) {
  return contentArea?.map((block: any) => (
    <BlockRenderer
      key={block.id}
      block={block}
      renderers={{
        TextBlock: TextBlockComponent,
        ImageBlock: ImageBlockComponent,
        ProductBlock: ProductBlockComponent,
      }}
    />
  ));
}

Localization is handled seamlessly, with locale detection and content switching:

// lib/cms/localization.ts
export function getLocalizedContent<T extends { localizations?: any[] }>(
  content: T,
  locale: string
): T {
  if (!content?.localizations?.length) return content;
  const localized = content.localizations.find((loc: any) => loc.locale === locale);
  return (localized || content) as T;
}

Next.js App Router Structure

Migrating to the App Router in Next.js 14 gives us granular control over layouts, server-side rendering, and SEO—crucial for eCommerce performance and crawlability.

app/
├── (auth)/                 # Route group for authentication pages
│   ├── login/
│   └── register/
├── (main)/                 # Main application routes
│   ├── layout.tsx          # Main layout with navigation
│   ├── page.tsx            # Homepage
│   ├── products/
│   │   ├── [category]/     # Dynamic category pages
│   │   └── [slug]/         # Individual product pages
│   ├── cart/
│   └── checkout/
├── api/                    # API routes
│   ├── auth/
│   ├── cart/
│   └── webhooks/
└── globals.css

This mirrors user workflows (auth, catalog, checkout), improving maintainability and onboarding.

// app/(main)/layout.tsx
import { Navigation } from '@/components/Navigation';
import { CartProvider } from '@/contexts/CartContext';

export default function MainLayout({ children }: { children: React.ReactNode }) {
  return (
    <CartProvider>
      <div className="min-h-screen bg-gray-50">
        <Navigation />
        <main className="container mx-auto px-4 py-8">{children}</main>
      </div>
    </CartProvider>
  );
}

Parallel routes let us load related content (e.g., reviews, upsells) without blocking the main product page—improving UX and Core Web Vitals.

// app/(main)/products/[slug]/layout.tsx
export default function ProductLayout({
  children,
  related,
  reviews,
}: {
  children: React.ReactNode;
  related: React.ReactNode;
  reviews: React.ReactNode;
}) {
  return (
    <div className="grid grid-cols-1 lg:grid-cols-3 gap-8">
      <div className="lg:col-span-2">{children}</div>
      <div className="space-y-8">
        {related}
        {reviews}
      </div>
    </div>
  );
}

Server vs Client Components

We use React Server Components by default for SEO-critical content and Client Components for interactive UI features. This pattern minimizes client-side JavaScript and improves time-to-interactive while still supporting rich interactions.

// components/atoms/AddToCartButton.tsx (Client Component)
'use client';

import { useState } from 'react';
import { useCart } from '@/hooks/useCart';

export default function AddToCartButton({ productId }: { productId: string }) {
  const [isLoading, setIsLoading] = useState(false);
  const { addToCart } = useCart();

  const handleAddToCart = async () => {
    setIsLoading(true);
    try {
      await addToCart(productId, 1);
    } finally {
      setIsLoading(false);
    }
  };

  return (
    <button
      onClick={handleAddToCart}
      disabled={isLoading}
      className="bg-blue-600 text-white px-6 py-2 rounded hover:bg-blue-700 disabled:opacity-50"
      aria-busy={isLoading}
    >
      {isLoading ? 'Adding…' : 'Add to Cart'}
    </button>
  );
}

Middleware & Routing Logic

Next.js middleware enables edge-based routing for authentication, localization, and request preprocessing.

// middleware.ts
import { NextResponse } from 'next/server';
import type { NextRequest } from 'next/server';

export function middleware(request: NextRequest) {
  const token = request.cookies.get('auth-token')?.value;
  const { pathname, searchParams } = request.nextUrl;

  if (pathname.startsWith('/checkout') && !token) {
    return NextResponse.redirect(new URL('/login', request.url));
  }

  if (pathname.startsWith('/login') && token) {
    return NextResponse.redirect(new URL('/', request.url));
  }

  const locale = searchParams.get('locale') || 'en';
  const requestHeaders = new Headers(request.headers);
  requestHeaders.set('x-locale', locale);

  return NextResponse.next({ request: { headers: requestHeaders } });
}

export const config = {
  matcher: ['/((?!api|_next/static|_next/image|favicon.ico).*)'],
};

Dynamic Routing & Static Generation

Dynamic routes plus Incremental Static Regeneration (ISR) keep frequently visited pages like products and categories up to date without full rebuilds.

// app/products/[slug]/page.tsx (SSG + ISR)
export async function generateStaticParams() {
  const result = await contentGraphClient
    .query(GetAllProductSlugsDocument, {})
    .toPromise();
  return (
    result.data?.products?.map((p: any) => ({ slug: p.slug })) ?? []
  );
}

export const revalidate = 3600; // Revalidate every hour

Component Architecture

Component Organization

We apply atomic design principles to create a scalable, maintainable component system.

components/
├── atoms/
│   ├── Button/
│   ├── Input/
│   └── Typography/
├── molecules/
│   ├── SearchBox/
│   ├── ProductCard/
│   └── FormField/
├── organisms/
│   ├── Navigation/
│   ├── ProductGrid/
│   └── CheckoutForm/
└── templates/
    ├── ProductPage/
    └── CheckoutPage/

Each component is self-contained with its own TypeScript interfaces, styles, and tests:

// components/molecules/ProductCard/ProductCard.tsx
interface ProductCardProps {
  product: { id: string; name: string; price: number; image: string; slug: string };
  onAddToCart?: (productId: string) => void;
  className?: string;
}

export function ProductCard({ product, onAddToCart, className }: ProductCardProps) {
  return (
    <div className={`bg-white rounded-lg shadow-md overflow-hidden ${className ?? ''}`}>
      <img
        src={product.image}
        alt={`Product image of ${product.name}`}
        className="w-full h-48 object-cover"
        loading="lazy"
      />
      <div className="p-4">
        <h3 className="text-lg font-semibold mb-2">{product.name}</h3>
        <p className="text-2xl font-bold text-blue-600 mb-4">${product.price.toFixed(2)}</p>
        {onAddToCart && (
          <button onClick={() => onAddToCart(product.id)} className="w-full bg-blue-600 text-white py-2 rounded">
            Add to Cart
          </button>
        )}
      </div>
    </div>
  );
}

State Management

Our state management strategy layers local component state, global context, and data fetching for clarity and scalability.

// contexts/CartContext.tsx
'use client';

import { createContext, useContext, useReducer, ReactNode } from 'react';

interface CartItem {
  id: string;
  quantity: number;
  product: any;
}
interface CartState {
  items: CartItem[];
  total: number;
  isLoading: boolean;
}
type CartAction =
  | { type: 'ADD_ITEM'; payload: { product: any; quantity: number } }
  | { type: 'REMOVE_ITEM'; payload: { productId: string } }
  | { type: 'UPDATE_QUANTITY'; payload: { productId: string; quantity: number } }
  | { type: 'SET_LOADING'; payload: boolean };

const CartContext = createContext<{ state: CartState; dispatch: React.Dispatch<CartAction> } | null>(null);

function cartReducer(state: CartState, action: CartAction): CartState {
  switch (action.type) {
    case 'ADD_ITEM': {
      const existing = state.items.find((i) => i.id === action.payload.product.id);
      if (existing) {
        return {
          ...state,
          items: state.items.map((i) =>
            i.id === action.payload.product.id ? { ...i, quantity: i.quantity + action.payload.quantity } : i
          ),
        };
      }
      return {
        ...state,
        items: [...state.items, { id: action.payload.product.id, quantity: action.payload.quantity, product: action.payload.product }],
      };
    }
    // ... other cases
    default:
      return state;
  }
}

export function CartProvider({ children }: { children: ReactNode }) {
  const [state, dispatch] = useReducer(cartReducer, { items: [], total: 0, isLoading: false });
  return <CartContext.Provider value={{ state, dispatch }}>{children}</CartContext.Provider>;
}

export function useCart() {
  const ctx = useContext(CartContext);
  if (!ctx) throw new Error('useCart must be used within a CartProvider');
  return ctx;
}

Hooks and Form Management

Custom Hooks

// hooks/useProductSearch.ts
'use client';

import { useState, useCallback } from 'react';
import { useQuery } from 'urql';
import { SearchProductsDocument } from '@/gql/generated';

export function useProductSearch() {
  const [query, setQuery] = useState('');
  const [filters, setFilters] = useState<Record<string, unknown>>({});

  const [result] = useQuery({
    query: SearchProductsDocument,
    variables: { query, filters },
    pause: !query,
  });

  const search = useCallback((q: string, f: Record<string, unknown> = {}) => {
    setQuery(q);
    setFilters(f);
  }, []);

  return {
    search,
    results: result.data?.searchProducts || [],
    loading: result.fetching,
    error: result.error,
  };
}

React Hook Form

For checkout, we use React Hook Form with Zod to manage complex forms with client-side validation and accessibility.

// components/organisms/CheckoutForm/CheckoutForm.tsx
'use client';

import { useForm } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod';
import { z } from 'zod';

const checkoutSchema = z.object({
  email: z.string().email('Invalid email address'),
  firstName: z.string().min(2, 'First name must be at least 2 characters'),
  lastName: z.string().min(2, 'Last name must be at least 2 characters'),
  address: z.object({
    street: z.string().min(5, 'Street address is required'),
    city: z.string().min(2, 'City is required'),
    state: z.string().min(2, 'State is required'),
    zipCode: z.string().min(5, 'ZIP code is required'),
  }),
});
type CheckoutFormData = z.infer<typeof checkoutSchema>;

export function CheckoutForm({ onSubmit }: { onSubmit: (data: CheckoutFormData) => void }) {
  const { register, handleSubmit, formState: { errors, isSubmitting } } = useForm<CheckoutFormData>({
    resolver: zodResolver(checkoutSchema),
  });

  return (
    <form onSubmit={handleSubmit(onSubmit)} className="space-y-6">
      <div>
        <label htmlFor="email" className="block text-sm font-medium text-gray-700">Email</label>
        <input {...register('email')} type="email" id="email"
          className="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-blue-500 focus:ring-blue-500" />
        {errors.email && <p className="mt-1 text-sm text-red-600">{errors.email.message}</p>}
      </div>
      {/* ...other fields... */}
      <button type="submit" disabled={isSubmitting} className="w-full bg-blue-600 text-white py-2 px-4 rounded-md hover:bg-blue-700 disabled:opacity-50">
        {isSubmitting ? 'Processing…' : 'Complete Order'}
      </button>
    </form>
  );
}

Content Rendering

Dynamic component mapping lets us render Optimizely block types as React components, preserving CMS flexibility with modern UI code.

// lib/cms/component-mapping.tsx
import { TextBlock } from '@/components/blocks/TextBlock';
import { ImageBlock } from '@/components/blocks/ImageBlock';
import { ProductBlock } from '@/components/blocks/ProductBlock';

const componentMap: Record<string, React.ComponentType<any>> = {
  TextBlock,
  ImageBlock,
  ProductBlock,
  // ... other types
};

export function renderContentBlock(block: any) {
  const Component = componentMap[block.type];
  if (!Component) {
    if (process.env.NODE_ENV !== 'production') console.warn(`Unknown block type: ${block.type}`);
    return null;
  }
  return <Component key={block.id} {...block} />;
}

export function ContentArea({ contentArea }: { contentArea: any[] }) {
  return <div className="space-y-8">{contentArea?.map(renderContentBlock)}</div>;
}

Image handling integrates Optimizely media with Next.js Image optimization. Ensure every image includes descriptive alt text for accessibility and SEO.

// components/atoms/OptimizelyImage/OptimizelyImage.tsx
import Image from 'next/image';

interface OptimizelyImageProps {
  src: string;
  alt: string;
  width?: number;
  height?: number;
  className?: string;
}

export function OptimizelyImage({ src, alt, width, height, className }: OptimizelyImageProps) {
  const optimizedSrc = src.replace(/\/cms\/media\//, '/cms/media/');
  return (
    <Image
      src={optimizedSrc}
      alt={alt}
      width={width}
      height={height}
      className={className}
      sizes="(max-width: 768px) 100vw, (max-width: 1200px) 50vw, 33vw"
    />
  );
}

Performance & Optimization

Performance is essential for conversion and SEO. We combine SSG, ISR, and multi-layer caching to ensure a fast, resilient storefront.

// app/categories/[slug]/page.tsx
export const revalidate = 1800; // 30 minutes

export default async function CategoryPage({ params }: { params: { slug: string } }) {
  const result = await contentGraphClient.query(GetCategoryDocument, { slug: params.slug }).toPromise();
  return <CategoryContent category={result.data?.category} />;
}

Multi-layer caching:

  • URQL cache for normalized GraphQL responses
  • Next.js cache for server components
  • CDN cache at the edge (e.g., Cloudflare)
  • Browser cache for images and fonts

Bundle optimization reduces payload:

// next.config.mjs
const nextConfig = {
  experimental: {
    optimizePackageImports: ['@headlessui/react', 'lucide-react'],
  },
  webpack: (config) => {
    config.optimization.splitChunks = {
      chunks: 'all',
      cacheGroups: {
        vendor: { test: /[\\/]node_modules[\\/]/, name: 'vendors', chunks: 'all' },
      },
    };
    return config;
  },
};

export default nextConfig;

Together, these techniques ensure a sub-second experience and consistent Core Web Vitals across geographies.


FAQ

Can I use Apollo Client instead of URQL?
Yes—but URQL’s smaller bundle and straightforward API pair nicely with App Router projects focused on performance.

Why GraphQL for content but REST for carts/checkout?
GraphQL excels at shape-exact reads across content types; REST keeps transactional flows simple, cacheable, and secure with standard middleware.

How do you handle localization?
We pass the locale via middleware headers and resolve localized content via Content Graph relationships, with safe fallbacks.


Conclusion & Next Steps

This implementation shows how Next.js App Router, Optimizely Content Graph, and TypeScript combine to deliver a modern, composable eCommerce frontend. By pairing Server Components with Client Components, using GraphQL for content and REST for commerce logic, we achieve both editorial flexibility and developer velocity.

Key takeaways:

  • Type safety across the stack improves reliability and onboarding
  • Server-side rendering boosts SEO and performance
  • Modular component architecture supports future growth and maintainability
  • Strategic caching and ISR ensure consistently fast user experiences

In Part 3: Testing, Deployment & Monitoring, we’ll cover:

  • Component testing with Storybook
  • E2E testing with Playwright
  • CI/CD pipelines with GitHub Actions
  • Performance monitoring with Application Insights and Vercel Analytics

Stay tuned for the final piece of the puzzle.