Skip to content

GraphQL Query Patterns

This guide shows you how to implement GraphQL operations, including type generation, query organization, data transformations, and best practices.

For component-level data fetching patterns, see Data Fetching Patterns.

Our GraphQL implementation uses a structured three-layer approach for type safety and maintainability:

  1. Generated Types - Auto-generated from Contentful GraphQL schema
  2. Transform Types - Custom interfaces for transformed data structures
  3. Transform Functions - Convert raw GraphQL data to UI-ready formats

GraphQL Codegen automatically generates TypeScript types from the Contentful GraphQL schema:

Terminal window
# Generate types from schema and queries
npm run codegen

Configuration: src/graphql/codegen.ts

  • Connects to Contentful GraphQL API using environment variables
  • Scans for .cms.graphql query files
  • Generates types in src/graphql/generated/contentful/schema.ts

Query Files: Use .cms.graphql extension for Contentful queries processed by codegen:

src/pages/_homepage/graphql/pageQuery.cms.graphql
query Homepage {
internalPageCollection(where: { slug: "homepage" }) {
items {
title
hero {
# ... query fields
}
}
}
}

Create custom interfaces representing the final shape of data consumed by UI components:

src/pages/_homepage/graphql/transform.types.ts
export interface TransformedData {
title: string;
hero: HeroData;
contentBlocks: ContentBlockItem[];
adTargeting: AdTargeting;
metatags?: Metadata[];
}

Generated Types:

import type { HomepageQuery } from "#graphql/generated/contentful/schema";

Transform Types:

import type { TransformedData } from "./_graphql/transform.types";

Extracting Specific Types from Generated Queries:

// Extract nested types from generated query responses
export type PromotionFromQuery = NonNullable<
NonNullable<
NonNullable<HomepageQuery["internalPageCollection"]>["items"][0]
>["hero"]
>["introduction"]["promotion"];
// Extract union type variants
export type ShopPromotion = Extract<PromotionFromQuery, { __typename: "Shop" }>;
export type InternalPagePromotion = Extract<
PromotionFromQuery,
{ __typename: "InternalPage" }
>;

Transform functions convert raw GraphQL responses to UI-ready data:

src/pages/_homepage/graphql/transform.ts
import type { HomepageQuery } from "#graphql/generated/contentful/schema";
import type { TransformedData } from "./transform.types";
export function transformHomepageData(data: HomepageQuery): TransformedData {
const page = data.internalPageCollection?.items[0];
return {
title: page?.title ?? "",
hero: transformHero(page?.hero),
contentBlocks: transformContentBlocks(page?.componentsCollection),
adTargeting: { url: "/homepage" },
};
}

We organize GraphQL operations following a feature-based structure. For more details on component organization, see Component Architecture.

src/
graphql/
fragments/ # Shared fragments
imageProps.fragment.graphql
articleListItem.fragment.graphql
pages/
articles/[slug]/
_graphql/ # Page-specific queries
page.query.graphql # Main query (shell and above-fold)
validate.query.graphql # Validation query
api.ts # API functions
transform.ts # Data transformations
_components/ # Page components
relatedArticles/ # Leaf node component
query.graphql # Component-specific query
api.ts # Component API
transform.ts # Component transforms

🤔 Why This Pattern?

  • Clear separation of concerns
  • Reusable fragments
  • Colocated queries with components
  • Support for HTML streaming
  • Progressive loading

We follow a leaf node architecture pattern where components beyond the page shell and above-the-fold content fetch their own data. This pattern works in conjunction with our Component Architecture and Data Fetching Patterns.

pages/articles/[slug]/index.astro
---
import { getArticleBySlug } from "./_graphql/api";
import RelatedArticles from "./_components/relatedArticles/RelatedArticles.astro";
// Shell and above-fold data
const article = await getArticleBySlug(Astro.params.slug);
if (!article) {
return Astro.rewrite("/404");
}
---
<Layout title={article.title}>
{/* Above-fold content */}
<ArticleHero data={article.hero} />
<ArticleContent content={article.content} />
{/* Leaf node components with colocated queries */}
<RelatedArticles />
{/* No need to pass slug - available via Astro.params */}
<PopularArticles />
{/* Can access URL params directly */}
</Layout>
_components/relatedArticles/RelatedArticles.astro
---
import { getRelatedArticles } from "./api";
// Component has access to the same request context
const { slug } = Astro.params;
const articles = await getRelatedArticles(slug);
---
<section>
{articles.map(article => <ArticleCard article={article} />)}
</section>

🤔 Why This Pattern?

  • Supports HTML streaming or server islands (astro) using the server:defer attribute
  • Shared request context across components
  • Reduced prop drilling
  • Clear data boundaries
  • Better performance
  • Simpler maintenance

Leaf node components maintain their own GraphQL files and can access request data directly:

_components/relatedArticles/api.ts
import { graphqlApi } from "#graphql/graphqlClient";
import RELATED_ARTICLES_QUERY from "./query.graphql";
import { transformArticles } from "./transform";
export async function getRelatedArticles(slug: string) {
const { data } = await graphqlApi({
query: RELATED_ARTICLES_QUERY,
variables: { slug }
});
return transformArticles(data.relatedArticles);
}
// Usage in component:
---
const { slug } = Astro.params; // Access URL params directly
const { url } = Astro; // Access full URL
const { cookies } = Astro; // Access cookies
const articles = await getRelatedArticles(slug);
---

🤔 Why This Pattern?

  • Colocated queries with components
  • Direct access to request context
  • Simplified component props
  • Clear data boundaries
  • Easier testing
  1. Data Independence

    • Each leaf component manages its own data
    • Queries are colocated with components
    • Direct access to request context
    • Clear transformation boundaries
    • Independent error handling
  2. Performance

    • Leverage HTML streaming
    • Use server:defer for lower priorityasync content
    • Progressive component loading
    • Efficient data fetching
    • Appropriate caching
  3. Organization

    • Feature-based directory structure
    • Access request data directly when needed
    • Clear component boundaries
    • Maintainable transforms
  4. Props vs Context

    • Use request context for URL/params/cookies
    • Pass explicit props for component-specific data
    • Avoid unnecessary prop drilling
    • Keep components self-contained

We follow consistent naming patterns for GraphQL files:

# ✅ Do: Descriptive, namespaced queries (these will show up in logs and error messages)
query Homepage {
landingPage: page(slug: "homepage") {
title
meta {
metaTags
}
}
}
# ❌ Don't: Generic or ambiguous names
query GetData {
# wrong
page {
title
}
}

We use fragments to share common field selections:

graphql/fragments/articleListItem.fragment.graphql
#import "#graphql/fragments/imageProps.fragment.graphql"
fragment ArticleListItem on Article {
title
esid
date
featuredImage {
...ImageProperties
}
readTime
authors {
firstName
lastName
slug
image {
...ImageProperties
}
}
slug
excerpt
tags {
slug
title
}
meta {
type {
slug
title
}
coreContentTags {
slug
title
}
}
}
# Usage in page query
query Articles {
articles(page: { size: 15 }) {
items {
...ArticleListItem
}
}
}

🤔 Why This Pattern?

  • DRY field selections
  • Consistent data shapes
  • Type safety
  • Easy maintenance

Use explicit imports for fragments:

# ✅ Do: Explicit imports
#import "#graphql/fragments/imageProps.fragment.graphql"
# ❌ Don't: Relative paths
#import "../../../fragments/imageProps.fragment.graphql" # wrong

Transform GraphQL responses into component-friendly shapes:

pages/articles/[slug]/_graphql/transform.ts
import type { ArticleQuery } from "./types";
interface TransformedArticle {
title: string;
content: string;
meta: {
tags: string[];
readTime: number;
};
}
export function transformArticleData(data: ArticleQuery): TransformedArticle {
return {
title: data.article.title,
content: data.article.content,
meta: {
tags: data.article.tags.map(tag => tag.title),
readTime: data.article.readTime,
},
};
}

🤔 Why This Pattern?

  • Clean data shapes
  • Type safety
  • Separation of concerns
  • Consistent transformations

Encapsulate GraphQL operations in typed functions:

pages/articles/[slug]/_graphql/api.ts
import { graphqlApi } from "#graphql/graphqlClient";
import ARTICLE_QUERY from "./page.query.graphql";
import type { ArticleQuery, ArticleQueryVariables } from "./types";
import { transformArticleData } from "./transform";
export async function getArticleBySlug(slug: string) {
try {
const { data } = await graphqlApi<ArticleQuery, ArticleQueryVariables>({
query: ARTICLE_QUERY,
variables: { slug },
});
// Validate minimum required data
if (!data?.article?.content) {
console.error("Missing required article data", {
component: "getArticleBySlug",
slug,
});
return null;
}
return transformArticleData(data);
} catch (err) {
console.error("Failed to fetch article", {
error: err.message,
component: "getArticleBySlug",
slug,
});
return null;
}
}

🤔 Why This Pattern?

  • Type-safe operations
  • Consistent error handling
  • Data validation
  • Clean API surface
  1. Query Organization

    • Colocate queries with features
    • Use fragments for shared fields
    • Follow naming conventions
    • Keep queries focused
  2. Type Safety

    • Generate types from schemas
    • Use TypeScript interfaces
    • Validate responses
    • Transform consistently
  3. Error Handling

    • Validate required fields
    • Log structured errors
    • Return clean nulls
    • Include context
  4. Performance

    • Request only needed fields
    • Use pagination
    • Cache appropriately
    • Monitor query times

Don’t: Inline fragments

# Bad: Repeated field selections
query Articles {
articles {
items {
title
date
# Duplicated fields
}
}
}

Do: Use shared fragments

# Good: Reusable fragments
query Articles {
articles {
items {
...ArticleListItem
}
}
}

Don’t: Skip data validation

// Bad: Assuming data exists
const { title, content } = data.article;

Do: Validate required fields

// Good: Checking required fields
if (!data?.article?.content) {
return null;
}

When implementing GraphQL operations, ask:

  1. Query Structure

    • Is it colocated with feature?
    • Are fragments used appropriately?
    • Is the naming consistent?
    • Are types generated?
  2. Data Requirements

    • Are fields minimal?
    • Is pagination needed?
    • Are transforms defined?
    • Is caching considered?
  3. Error Handling

    • Are required fields validated?
    • Is error logging in place?
    • Are nulls handled?
    • Is context included?
  4. Performance

    • Are queries optimized?
    • Is caching configured?
    • Are transforms efficient?
    • Is monitoring in place?

For testing GraphQL operations, refer to our Testing Patterns guide, which covers:

  • Unit testing transforms
  • Integration testing queries
  • Mocking GraphQL responses
  • Testing error cases

When implementing GraphQL operations that affect UI state, ensure you follow our Accessibility Requirements for:

  • Loading states
  • Error messages
  • Status updates
  • Focus management