Dev guideRecipesAPI ReferenceChangelog
Dev guideRecipesUser GuidesNuGetDev CommunityOptimizely AcademySubmit a ticketLog In
Dev guide

Dynamic frontend integration

Integrate Optimizely Graph with modern frontend frameworks like React, Vue, Angular, and Next.js for performant, reusable component-based architectures.

Dynamic frontend integration connects Optimizely Graph to modern JavaScript frameworks, enabling you to bind GraphQL data to UI components in a performant, reusable way.

This is the HOW-TO guide: complete code examples for integrating Graph with React, Vue, Angular, Next.js, iOS, Android, and React Native. For architectural decisions (WHEN to use SSG vs SSR vs CSR), see Headless architecture patterns.

What you'll find here:

  • ✅ Framework setup and configuration
  • ✅ Complete working code examples
  • ✅ Apollo Client, React Query, and native fetch implementations
  • ✅ Error handling, loading states, caching
  • ✅ Mobile platform integration (iOS, Android, React Native)

What you WON'T find here:

Framework selection

Choose based on your team's expertise and project requirements:

FrameworkBest forGraph integration
ReactComponent-based UIs, large ecosystemsApollo Client, React Query
Next.jsReact with SSR/SSG, production sitesNative fetch, Apollo Client
VueProgressive adoption, simpler learning curveApollo Client, Vue Query
AngularEnterprise apps, TypeScript-firstApollo Angular
SvelteMinimal bundle size, reactive by defaultGraphQL Request, Svelte Query

React integration

Setup with Apollo Client

Apollo Client is the most popular GraphQL client for React:

npm install @apollo/client graphql

Configuration:

⚠️

Security note

Only use NEXT_PUBLIC_* environment variables when you are using a public single key intended for read-only public content. For bearer/HMAC/admin credentials, keep secrets server-side (API routes/BFF) so they are never shipped to the browser.

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

const httpLink = new HttpLink({
  uri: process.env.NEXT_PUBLIC_GRAPH_ENDPOINT,
  headers: {
    Authorization: `epi-single ${process.env.NEXT_PUBLIC_GRAPH_SINGLE_KEY}`
  }
});

export const client = new ApolloClient({
  link: httpLink,
  cache: new InMemoryCache({
    typePolicies: {
      Query: {
        fields: {
          Content: {
            // Customize caching behavior
            keyArgs: ['where', 'orderBy'],
            merge(existing = { items: [] }, incoming) {
              return {
                ...incoming,
                items: [...existing.items, ...incoming.items]
              };
            }
          }
        }
      }
    }
  })
});

App wrapper:

// pages/_app.js
import { ApolloProvider } from '@apollo/client';
import { client } from '../lib/apollo-client';

function MyApp({ Component, pageProps }) {
  return (
    <ApolloProvider client={client}>
      <Component {...pageProps} />
    </ApolloProvider>
  );
}

export default MyApp;

Query data in components

// components/ArticleList.js
import { useQuery, gql } from '@apollo/client';

const GET_ARTICLES = gql`
  query GetArticles($limit: Int!, $skip: Int, $category: String) {
    Article(
      where: { category: { eq: $category } }
      orderBy: { publishedDate: DESC }
      limit: $limit
      skip: $skip
    ) {
      items {
        id
        title
        excerpt
        publishedDate
        author {
          name
          avatar
        }
        image {
          url
        }
      }
      total
    }
  }
`;

function ArticleList({ category, limit = 10 }) {
  const { data, loading, error, fetchMore } = useQuery(GET_ARTICLES, {
    variables: { category, limit, skip: 0 }
  });
  
  if (loading) return <LoadingSpinner />;
  if (error) return <ErrorMessage error={error} />;
  
  const articles = data.Article.items;
  
  return (
    <div className="article-list">
      {articles.map(article => (
        <ArticleCard key={article.id} article={article} />
      ))}
      
      {articles.length < data.Article.total && (
        <button onClick={() => fetchMore({
          variables: { 
            limit: 10,
            skip: articles.length // Skip what is already loaded
          }
        })}>
          Load More
        </button>
      )}
    </div>
  );
}

Using React Query (alternative)

React Query offers a simpler API for GraphQL:

npm install @tanstack/react-query
// hooks/useGraphQuery.js
import { useQuery } from '@tanstack/react-query';

export function useGraphQuery(query, variables) {
  return useQuery({
    queryKey: [query, variables],
    queryFn: async () => {
      const response = await fetch(process.env.NEXT_PUBLIC_GRAPH_ENDPOINT, {
        method: 'POST',
        headers: {
          'Content-Type': 'application/json',
          'Authorization': `epi-single ${process.env.NEXT_PUBLIC_GRAPH_SINGLE_KEY}`
        },
        body: JSON.stringify({ query, variables })
      });
      
      const { data, errors } = await response.json();
      
      if (errors) throw new Error(errors[0].message);
      
      return data;
    },
    staleTime: 60000, // Consider data fresh for 1 minute
    cacheTime: 300000 // Keep in cache for 5 minutes
  });
}

Usage:

function ProductList({ category }) {
  const { data, isLoading, error } = useGraphQuery(
    `query GetProducts($category: String!) {
      Product(where: { category: { eq: $category } }) {
        items { id, name, price, image { url } }
      }
    }`,
    { category }
  );
  
  if (isLoading) return <div>Loading...</div>;
  if (error) return <div>Error: {error.message}</div>;
  
  return (
    <div className="product-grid">
      {data.Product.items.map(product => (
        <ProductCard key={product.id} product={product} />
      ))}
    </div>
  );
}

Next.js integration

Next.js combines SSG, SSR, and CSR capabilities. For architectural guidance on when to use each pattern, see Headless architecture patterns. This section shows how to implement each pattern.

Pattern 1: Static site generation (SSG)

Use getStaticProps() to fetch content at build time and getStaticPaths() to generate dynamic routes.

A single unpaged query returns only the first page of results, so a site with more articles than the default page size silently loses routes. Page through the full set with skip and limit, advancing skip by the batch size until a batch comes back shorter than requested.

Skip and limit reach only the first 10,000 hits, and Graph enforces that ceiling by rejecting the request – it does not return a truncated result. Both skip and skip + limit are validated, so the loop must stop before skip + limit exceeds 10,000 rather than relying on the short-batch check to end it.

// pages/blog/[slug].js
const SLUG_BATCH_SIZE = 100;

// Graph rejects a request when skip exceeds 10,000 or when skip + limit does,
// so the loop stops at the ceiling instead of requesting past it
const MAX_SKIP_LIMIT = 10000;

async function fetchAllArticleSlugs() {
  const slugs = [];
  let skip = 0;
  let exhausted = false;

  while (skip + SLUG_BATCH_SIZE <= MAX_SKIP_LIMIT) {
    const response = await fetch(process.env.GRAPH_ENDPOINT, {
      method: 'POST',
      headers: {
        'Content-Type': 'application/json',
        'Authorization': `epi-single ${process.env.GRAPH_SINGLE_KEY}`
      },
      body: JSON.stringify({
        query: `
          query GetArticleSlugs($skip: Int!, $limit: Int!) {
            Article(
              skip: $skip
              limit: $limit
              # Stable ordering so items do not shift between batches
              orderBy: { publishedDate: DESC }
            ) {
              items { slug }
              total
            }
          }
        `,
        variables: { skip, limit: SLUG_BATCH_SIZE }
      })
    });

    // An out-of-range skip or limit fails the request itself, so check the status
    if (!response.ok) {
      throw new Error(`Graph returned HTTP ${response.status} for skip ${skip}`);
    }

    const { data, errors } = await response.json();

    if (errors) throw new Error(errors[0].message);

    const batch = data.Article;
    slugs.push(...batch.items.map(article => article.slug));

    // A short batch means the result set is exhausted
    if (batch.items.length < SLUG_BATCH_SIZE) {
      exhausted = true;
      break;
    }

    skip += SLUG_BATCH_SIZE;
  }

  // Reaching the ceiling with full batches means articles remain out of reach
  if (!exhausted) {
    throw new Error(
      `More than ${MAX_SKIP_LIMIT} articles: skip and limit cannot enumerate the rest. ` +
      'Prerender a subset or switch to cursor-based pagination.'
    );
  }

  return slugs;
}

export async function getStaticPaths() {
  const slugs = await fetchAllArticleSlugs();

  return {
    paths: slugs.map(slug => ({ params: { slug } })),
    fallback: 'blocking'
  };
}

export async function getStaticProps({ params, locale }) {
  const response = await fetch(process.env.GRAPH_ENDPOINT, {
    method: 'POST',
    headers: {
      'Content-Type': 'application/json',
      'Authorization': `epi-single ${process.env.GRAPH_SINGLE_KEY}`
    },
    body: JSON.stringify({
      query: `
        query GetArticle($slug: String!, $locale: [Locales]) {
          Article(
            where: { slug: { eq: $slug } }
            locale: $locale
          ) {
            item {
              title
              body
              publishedDate
              author { name }
            }
          }
        }
      `,
      variables: { slug: params.slug, locale: [locale] }
    })
  });
  
  const { data } = await response.json();
  
  return {
    props: { article: data.Article.item },
    revalidate: 60 // ISR: regenerate every 60 seconds
  };
}

function ArticlePage({ article }) {
  return (
    <article>
      <h1>{article.title}</h1>
      <div dangerouslySetInnerHTML={{ __html: article.body }} />
    </article>
  );
}

Keep the following in mind when paging through paths at build time:

  • Skip and limit reach only the first 10,000 hits, and requests past that boundary are rejected rather than served slowly. Graph validates skip and skip + limit separately, so a batch size of 100 fails at skip: 9950 – that request is within the skip maximum but its skip + limit of 10,050 is not. Guard the loop against the ceiling, as shown above, and either prerender a subset or switch to cursor-based pagination for sites with more articles than that.
  • Always pair skip with a stable orderBy. Without a tiebreaker such as _id, items that share a sort value can shift between batches, which duplicates some slugs and drops others.
  • Project only the fields the paths need. Fetching slug alone keeps each batch small and the build fast.
  • For very large sites, skip the full enumeration and prerender only the pages that matter most – for example the most recently published articles – letting fallback: 'blocking' render the remainder on first request:
export async function getStaticPaths() {
  // Prerender the 500 newest articles; generate the rest on demand
  const response = await fetch(process.env.GRAPH_ENDPOINT, {
    method: 'POST',
    headers: {
      'Content-Type': 'application/json',
      'Authorization': `epi-single ${process.env.GRAPH_SINGLE_KEY}`
    },
    body: JSON.stringify({
      query: `
        query GetRecentArticleSlugs {
          Article(limit: 500, orderBy: { publishedDate: DESC }) {
            items { slug }
          }
        }
      `
    })
  });

  const { data } = await response.json();

  return {
    paths: data.Article.items.map(article => ({
      params: { slug: article.slug }
    })),
    fallback: 'blocking'
  };
}

With Incremental Static Regeneration (ISR):

// pages/blog/[slug].js
export async function getStaticProps({ params, locale }) {
  const response = await fetch(process.env.GRAPH_ENDPOINT, {
    method: 'POST',
    headers: {
      'Content-Type': 'application/json',
      'Authorization': `epi-single ${process.env.GRAPH_SINGLE_KEY}`
    },
    body: JSON.stringify({
      query: `
        query GetArticle($slug: String!, $locale: [Locales]) {
          Article(
            where: { slug: { eq: $slug } }
            locale: $locale
          ) {
            item {
              title
              body
              publishedDate
              author { name, avatar }
            }
          }
        }
      `,
      variables: { slug: params.slug, locale: [locale] }
    })
  });
  
  const { data } = await response.json();
  
  if (!data?.Article?.item) {
    return { notFound: true };
  }
  
  return {
    props: { article: data.Article.item },
    revalidate: 60 // Regenerate page every 60 seconds (ISR)
  };
}

function ArticlePage({ article }) {
  return (
    <article>
      <h1>{article.title}</h1>
      <div dangerouslySetInnerHTML={{ __html: article.body }} />
    </article>
  );
}

export default ArticlePage;

Pattern 2: Server-side rendering (SSR)

Use getServerSideProps() to fetch content on every request.

// pages/products/[key].js
export async function getServerSideProps({ params, req, locale }) {
  // Query Graph on each request for fresh data
  const response = await fetch(process.env.GRAPH_ENDPOINT, {
    method: 'POST',
    headers: {
      'Content-Type': 'application/json',
      'Authorization': `epi-single ${process.env.GRAPH_SINGLE_KEY}`
    },
    body: JSON.stringify({
      query: `
        query GetProduct($key: String!, $locale: [Locales]) {
          Product(
            where: { _metadata: { key: { eq: $key } } }
            locale: $locale
          ) {
            item {
              name
              price
              inventory
              description
              images { url }
            }
          }
        }
      `,
      variables: { key: params.key, locale: [locale] }
    })
  });
  
  const { data } = await response.json();
  
  if (!data?.Product?.item) {
    return { notFound: true };
  }
  
  return {
    props: { 
      product: data.Product.item,
      timestamp: new Date().toISOString() // Shows this was rendered fresh
    }
  };
}

function ProductPage({ product, timestamp }) {
  return (
    <div>
      <h1>{product.name}</h1>
      <p>{product.description}</p>
      <p>Price: ${product.price}</p>
      <p>In Stock: {product.inventory}</p>
      <small>Rendered at: {timestamp}</small>
    </div>
  );
}

export default ProductPage;

Pattern 3: Client-side rendering (CSR)

Fetch data in the browser using React hooks or Apollo Client. See the React integration section for examples.

Vue integration

Setup with Apollo Client

npm install @vue/apollo-composable @apollo/client graphql

Configuration:

// plugins/apollo.js
import { ApolloClient, InMemoryCache, HttpLink } from '@apollo/client/core';
import { DefaultApolloClient } from '@vue/apollo-composable';

const httpLink = new HttpLink({
  uri: import.meta.env.VITE_GRAPH_ENDPOINT,
  headers: {
    Authorization: `epi-single ${import.meta.env.VITE_GRAPH_SINGLE_KEY}`
  }
});

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

export default {
  install(app) {
    app.provide(DefaultApolloClient, apolloClient);
  }
};

Component usage:

<!-- components/ArticleList.vue -->
<template>
  <div class="article-list">
    <div v-if="loading">Loading...</div>
    <div v-else-if="error">Error: {{ error.message }}</div>
    <div v-else>
      <article-card 
        v-for="article in articles" 
        :key="article.id"
        :article="article"
      />
    </div>
  </div>
</template>

<script setup>
import { useQuery } from '@vue/apollo-composable';
import { gql } from '@apollo/client/core';

const props = defineProps({
  category: String,
  limit: { type: Number, default: 10 }
});

const GET_ARTICLES = gql`
  query GetArticles($category: String, $limit: Int!) {
    Article(
      where: { category: { eq: $category } }
      limit: $limit
    ) {
      items {
        id
        title
        excerpt
        image { url }
      }
    }
  }
`;

const { result, loading, error } = useQuery(GET_ARTICLES, {
  category: props.category,
  limit: props.limit
});

const articles = computed(() => result.value?.Article?.items ?? []);
</script>

Nuxt 3 integration

// composables/useGraph.js
export const useGraph = () => {
  const config = useRuntimeConfig();
  
  const query = async (graphqlQuery, variables = {}) => {
    const { data, error } = await useFetch(config.public.graphEndpoint, {
      method: 'POST',
      headers: {
        'Content-Type': 'application/json',
        'Authorization': `epi-single ${config.public.graphSingleKey}`
      },
      body: {
        query: graphqlQuery,
        variables
      }
    });
    
    if (error.value) throw error.value;
    
    return data.value.data;
  };
  
  return { query };
};

Usage in Nuxt component:

<script setup>
const { query } = useGraph();

const { data: articles } = await useAsyncData('articles', () => 
  query(`
    query {
      Article(limit: 10) {
        items { id, title, excerpt }
      }
    }
  `)
);
</script>

<template>
  <div>
    <article-card 
      v-for="article in articles.Article.items"
      :key="article.id"
      :article="article"
    />
  </div>
</template>

Native mobile integration

iOS (Swift)

Integrate Graph with native iOS applications:

// GraphQLClient.swift
import Foundation

struct GraphQLClient {
    private let endpoint = URL(string: "https://cg.optimizely.com/content/v2")!
    private let singleKey: String
    
    init(singleKey: String) {
        self.singleKey = singleKey
    }
    
    func fetchProducts(limit: Int) async throws -> [Product] {
        let query = """
        query GetProducts($limit: Int!) {
          Product(limit: $limit, orderBy: { featured: DESC }) {
            items {
              id
              name
              price
              image {
                mobileUrl
              }
            }
          }
        }
        """
        
        var request = URLRequest(url: endpoint)
        request.httpMethod = "POST"
        request.setValue("application/json", forHTTPHeaderField: "Content-Type")
        request.setValue("epi-single \(singleKey)", forHTTPHeaderField: "Authorization")
        
        let body = ["query": query, "variables": ["limit": limit]]
        request.httpBody = try JSONSerialization.data(withJSONObject: body)
        
        let (data, _) = try await URLSession.shared.data(for: request)
        let response = try JSONDecoder().decode(GraphQLResponse.self, from: data)
        
        return response.data.Product.items
    }
}

// Models
struct GraphQLResponse: Codable {
    let data: ProductData
}

struct ProductData: Codable {
    let Product: ProductList
}

struct ProductList: Codable {
    let items: [Product]
}

struct Product: Codable, Identifiable {
    let id: String
    let name: String
    let price: Double
    let image: ProductImage
}

struct ProductImage: Codable {
    let mobileUrl: String
}

SwiftUI integration:

// ProductListView.swift
import SwiftUI

struct ProductListView: View {
    @StateObject private var viewModel = ProductViewModel()
    
    var body: some View {
        List(viewModel.products) { product in
            ProductRow(product: product)
        }
        .task {
            await viewModel.loadProducts()
        }
    }
}

class ProductViewModel: ObservableObject {
    @Published var products: [Product] = []
    private let client = GraphQLClient(singleKey: "YOUR_SINGLE_KEY")
    
    func loadProducts() async {
        do {
            products = try await client.fetchProducts(limit: 20)
        } catch {
            print("Failed to load products: \(error)")
        }
    }
}

Android (Kotlin)

Integrate Graph with native Android applications:

// GraphQLClient.kt
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import okhttp3.OkHttpClient
import okhttp3.Request
import okhttp3.RequestBody.Companion.toRequestBody
import okhttp3.MediaType.Companion.toMediaType
import org.json.JSONObject

class GraphQLClient(private val singleKey: String) {
    private val client = OkHttpClient()
    private val endpoint = "https://cg.optimizely.com/content/v2"
    
    suspend fun fetchProducts(limit: Int): List<Product> = withContext(Dispatchers.IO) {
        val query = """
            query GetProducts(${'$'}limit: Int!) {
              Product(limit: ${'$'}limit, orderBy: { featured: DESC }) {
                items {
                  id
                  name
                  price
                  image {
                    mobileUrl
                  }
                }
              }
            }
        """
        
        val json = JSONObject().apply {
            put("query", query)
            put("variables", JSONObject().put("limit", limit))
        }
        
        val request = Request.Builder()
            .url(endpoint)
            .addHeader("Content-Type", "application/json")
            .addHeader("Authorization", "epi-single $singleKey")
            .post(json.toString().toRequestBody("application/json".toMediaType()))
            .build()
        
        val response = client.newCall(request).execute()
        val responseBody = response.body?.string() ?: throw Exception("Empty response")
        
        parseProducts(responseBody)
    }
    
    private fun parseProducts(json: String): List<Product> {
        val jsonObject = JSONObject(json)
        val items = jsonObject.getJSONObject("data")
            .getJSONObject("Product")
            .getJSONArray("items")
        
        return (0 until items.length()).map { index ->
            val item = items.getJSONObject(index)
            Product(
                id = item.getString("id"),
                name = item.getString("name"),
                price = item.getDouble("price"),
                imageUrl = item.getJSONObject("image").getString("mobileUrl")
            )
        }
    }
}

// Models
data class Product(
    val id: String,
    val name: String,
    val price: Double,
    val imageUrl: String
)

Jetpack Compose integration:

// ProductListScreen.kt
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.runtime.*
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import kotlinx.coroutines.launch

@Composable
fun ProductListScreen(viewModel: ProductViewModel = viewModel()) {
    val products by viewModel.products.collectAsState()
    
    LazyColumn {
        items(products) { product ->
            ProductCard(product)
        }
    }
    
    LaunchedEffect(Unit) {
        viewModel.loadProducts()
    }
}

class ProductViewModel : ViewModel() {
    private val client = GraphQLClient(singleKey = "YOUR_SINGLE_KEY")
    private val _products = MutableStateFlow<List<Product>>(emptyList())
    val products: StateFlow<List<Product>> = _products
    
    fun loadProducts() {
        viewModelScope.launch {
            try {
                _products.value = client.fetchProducts(limit = 20)
            } catch (e: Exception) {
                // Handle error
            }
        }
    }
}

React Native

For cross-platform mobile development:

// hooks/useGraphQuery.js
import { useQuery, gql } from '@apollo/client';

const GET_PRODUCTS = gql`
  query GetProducts($limit: Int!, $skip: Int) {
    Product(
      limit: $limit
      skip: $skip
      orderBy: { featured: DESC }
    ) {
      items {
        id
        name
        price
        image {
          mobileUrl
        }
      }
      total
    }
  }
`;

export function useProducts(limit = 20) {
  return useQuery(GET_PRODUCTS, {
    variables: { limit, skip: 0 },
    fetchPolicy: 'cache-and-network', // Mobile-optimized caching
  });
}

Component usage:

// screens/ProductListScreen.js
import React from 'react';
import { FlatList, ActivityIndicator } from 'react-native';
import { useProducts } from '../hooks/useGraphQuery';
import ProductCard from '../components/ProductCard';

export default function ProductListScreen() {
  const { data, loading, fetchMore } = useProducts(20);
  
  const loadMore = () => {
    const loaded = data?.Product?.items?.length ?? 0;
    
    // Stop requesting once every product is loaded
    if (loaded < (data?.Product?.total ?? 0)) {
      fetchMore({
        variables: { skip: loaded }
      });
    }
  };
  
  if (loading && !data) {
    return <ActivityIndicator size="large" />;
  }
  
  return (
    <FlatList
      data={data?.Product?.items || []}
      renderItem={({ item }) => <ProductCard product={item} />}
      keyExtractor={item => item.id}
      onEndReached={loadMore}
      onEndReachedThreshold={0.5}
    />
  );
}

Offline caching:

// apollo-client.js
import { ApolloClient, InMemoryCache } from '@apollo/client';
import { persistCache } from 'apollo3-cache-persist';
import AsyncStorage from '@react-native-async-storage/async-storage';

const cache = new InMemoryCache();

// Persist cache for offline support
await persistCache({
  cache,
  storage: AsyncStorage,
  maxSize: 10485760, // 10 MB
});

const client = new ApolloClient({
  uri: 'https://cg.optimizely.com/content/v2',
  cache,
  headers: {
    Authorization: `epi-single ${process.env.GRAPH_SINGLE_KEY}`
  }
});

Angular integration

Setup with Apollo Angular

npm install apollo-angular @apollo/client graphql

Configuration:

// app.config.ts
import { ApplicationConfig } from '@angular/core';
import { provideHttpClient } from '@angular/common/http';
import { Apollo, APOLLO_OPTIONS } from 'apollo-angular';
import { HttpLink } from 'apollo-angular/http';
import { InMemoryCache } from '@apollo/client/core';

export const appConfig: ApplicationConfig = {
  providers: [
    provideHttpClient(),
    {
      provide: APOLLO_OPTIONS,
      useFactory(httpLink: HttpLink) {
        return {
          cache: new InMemoryCache(),
          link: httpLink.create({
            uri: 'https://cg.optimizely.com/content/v2',
            headers: {
              Authorization: `epi-single ${environment.graphSingleKey}`
            }
          })
        };
      },
      deps: [HttpLink]
    }
  ]
};

Service:

// services/article.service.ts
import { Injectable } from '@angular/core';
import { Apollo, gql } from 'apollo-angular';
import { Observable } from 'rxjs';
import { map } from 'rxjs/operators';

const GET_ARTICLES = gql`
  query GetArticles($limit: Int!) {
    Article(limit: $limit) {
      items {
        id
        title
        excerpt
        image { url }
      }
    }
  }
`;

@Injectable({ providedIn: 'root' })
export class ArticleService {
  constructor(private apollo: Apollo) {}
  
  getArticles(limit: number = 10): Observable<Article[]> {
    return this.apollo
      .watchQuery({
        query: GET_ARTICLES,
        variables: { limit }
      })
      .valueChanges
      .pipe(
        map(result => result.data.Article.items)
      );
  }
}

Component:

// components/article-list.component.ts
import { Component, OnInit } from '@angular/core';
import { ArticleService } from '../services/article.service';

@Component({
  selector: 'app-article-list',
  template: `
    <div class="article-list">
      <app-article-card 
        *ngFor="let article of articles$ | async"
        [article]="article">
      </app-article-card>
    </div>
  `
})
export class ArticleListComponent implements OnInit {
  articles$: Observable<Article[]>;
  
  constructor(private articleService: ArticleService) {}
  
  ngOnInit() {
    this.articles$ = this.articleService.getArticles();
  }
}

Best practices

1. Fragment reuse

Define reusable fragments for common field sets:

// fragments/article.js
import { gql } from '@apollo/client';

export const ARTICLE_CARD_FRAGMENT = gql`
  fragment ArticleCardFields on Article {
    id
    title
    excerpt
    publishedDate
    author {
      name
      avatar
    }
    image {
      url
    }
  }
`;

// Usage
const GET_ARTICLES = gql`
  ${ARTICLE_CARD_FRAGMENT}
  
  query GetArticles($limit: Int!) {
    Article(limit: $limit) {
      items {
        ...ArticleCardFields
      }
    }
  }
`;

2. Loading and error states

Always handle loading and error states:

function ContentComponent() {
  const { data, loading, error } = useQuery(GET_CONTENT);
  
  if (loading) {
    return <Skeleton count={5} />;
  }
  
  if (error) {
    return (
      <ErrorBoundary>
        <ErrorMessage 
          message="Failed to load content"
          retry={() => refetch()}
        />
      </ErrorBoundary>
    );
  }
  
  return <Content data={data} />;
}

3. Type safety with TypeScript

Generate types from your Graph schema:

npm install -D @graphql-codegen/cli @graphql-codegen/typescript
# codegen.yml
schema: https://cg.optimizely.com/content/v2
documents: ./src/**/*.graphql
generates:
  ./src/types/graph.ts:
    plugins:
      - typescript
      - typescript-operations
      - typescript-react-apollo

4. Performance monitoring

Track query performance:

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

const performanceLink = new ApolloLink((operation, forward) => {
  const startTime = performance.now();
  
  return forward(operation).map(response => {
    const endTime = performance.now();
    console.log(`Query ${operation.operationName} took ${endTime - startTime}ms`);
    
    analytics.track('graphql_query', {
      operation: operation.operationName,
      duration: endTime - startTime
    });
    
    return response;
  });
});

Platform-specific considerations

Mobile (iOS, Android, React Native)

For mobile platform-specific query strategies and optimization, see Omnichannel content delivery.

Key optimizations:

  • Select the mobile image URL field (for example, mobileUrl) instead of the desktop rendition
  • Request summaries instead of full content
  • Implement aggressive caching for offline support
  • Use pagination/infinite scroll
  • Optimize for cellular networks

Kiosks and IoT

For kiosk and IoT device integration patterns, see Omnichannel content delivery.

Next steps


Did this page help you?