Stop Writing Manual GraphQL Calls: Migrate to Generated SDK in 30 Minutes

Transform repetitive shopifyFetch calls into type-safe SDK methods with full compile-time validation

·Matija Žiberna·
Stop Writing Manual GraphQL Calls: Migrate to Generated SDK in 30 Minutes

Last month, I was working on a client's e-commerce project when I realized something frustrating: we had this powerful GraphQL Code Generation setup that automatically creates TypeScript types and SDK functions, but we were still making API calls the old way - manually typing everything and dealing with nested response structures.

After setting up automatic TypeScript type generation for Shopify Storefront queries, I discovered we had this beautiful getSdk function that creates fully-typed methods for every GraphQL query. Yet somehow, we were ignoring it and sticking with the manual shopifyFetch approach.

This guide shows you exactly how to migrate from those manual API calls to the generated SDK, eliminating boilerplate code and getting compile-time validation for every GraphQL operation. Note: You'll need to complete the GraphQL codegen setup first - the linked article above walks through that entire process.

The Problem: Ignoring Our Own Generated SDK

Here's what our code looked like before the migration:

// The manual approach we were stuck in
const { body }: { body: ShopifyProductHandlesOperation } = 
  await shopifyFetch<ShopifyProductHandlesOperation>({
    query: getProductHandlesQuery,
    variables: { first: 250, after: after || undefined }
  });

const products = removeEdgesAndNodes(body.data.products) as ShopifyProductHandle[];

The irony? Our GraphQL codegen setup was already generating a getSdk function that creates perfectly typed methods for every query. We just weren't using it.

After completing the automatic TypeScript generation setup, we had everything needed for type-safe operations - we just needed to actually use the generated SDK instead of the manual approach.

The Solution: Direct SDK Usage

The fix was surprisingly simple: use the generated SDK functions directly instead of the manual approach. Here's the transformation:

// Before: Manual with lots of boilerplate
const { body } = await shopifyFetch<ShopifyProductHandlesOperation>({
  query: getProductHandlesQuery,
  variables: { first: 250 }
});
const products = body.data.products;

// After: Clean SDK call
const result = await storefrontSdk.getProductHandles({ first: 250 });
const products = result.products; // Direct access!

This approach eliminates all the manual type specifications, string-based queries, and nested data access while providing full compile-time validation.

Implementation Steps

Step 1: Set Up the SDK Client

First, install the GraphQL client library and create your SDK wrapper:

pnpm add graphql-request

Create the SDK client:

// File: lib/shopify/utils/storefront-sdk.ts
import { GraphQLClient } from 'graphql-request';
import { getSdk } from '../generated/storefront';
import { ensureStartsWith } from 'lib/utils';
import { SHOPIFY_GRAPHQL_API_ENDPOINT } from 'lib/constants';

const domain = process.env.NEXT_PUBLIC_SHOPIFY_STORE_DOMAIN
  ? ensureStartsWith(process.env.NEXT_PUBLIC_SHOPIFY_STORE_DOMAIN, 'https://')
  : '';

const endpoint = `${domain}${SHOPIFY_GRAPHQL_API_ENDPOINT}`;

const client = new GraphQLClient(endpoint, {
  headers: {
    'X-Shopify-Storefront-Access-Token': process.env.SHOPIFY_STOREFRONT_ACCESS_TOKEN!,
  },
});

export const storefrontSdk = getSdk(client);
export type StorefrontSdk = ReturnType<typeof getSdk>;

The key here is getSdk(client) - this function was automatically generated by GraphQL Code Generation and creates typed methods for every query in your schema. Each query becomes a method on the SDK with full TypeScript support and compile-time validation.

Step 2: The Migration Pattern

Here's the step-by-step transformation pattern. Let's convert a real function that fetches product handles with pagination:

Before - Manual approach:

// File: lib/shopify/fetchers/storefront/products.ts
import { shopifyFetch } from "../../utils/clients";
import { getProductHandlesQuery } from "../../queries/product";

const getAllProductHandles = async (): Promise<string[]> => {
  // ... pagination setup ...
  
  while (hasNextPage) {
    const { body }: { body: ShopifyProductHandlesOperation } =
      await shopifyFetch<ShopifyProductHandlesOperation>({
        query: getProductHandlesQuery, // String-based query
        variables: {
          first: 250,
          after: after || undefined,
          query: `-tag:${HIDDEN_PRODUCT_TAG}`,
        },
      });

    const products = removeEdgesAndNodes(
      body.data.products, // Nested access
    ) as ShopifyProductHandle[];

    // ... handle results ...
    hasNextPage = body.data.products.pageInfo.hasNextPage;
    after = body.data.products.pageInfo.endCursor;
  }
};

After - SDK approach:

// File: lib/shopify/fetchers/storefront/products.ts
import { storefrontSdk } from "../../utils/storefront-sdk";

const getAllProductHandles = async (): Promise<string[]> => {
  // ... pagination setup ...
  
  while (hasNextPage) {
    const result = await storefrontSdk.getProductHandles({
      first: 250,
      after: after, // SDK handles null/undefined automatically
      query: `-tag:${HIDDEN_PRODUCT_TAG}`,
    });

    const products = removeEdgesAndNodes(
      result.products, // Direct access - no body.data!
    ) as ShopifyProductHandle[];

    // ... handle results ...
    hasNextPage = result.products.pageInfo.hasNextPage;
    after = result.products.pageInfo.endCursor;
  }
};

The transformation eliminates three key pain points:

  1. Import changes: Replace shopifyFetch and string query imports with just storefrontSdk
  2. Function calls: shopifyFetch<Type>({ query, variables }) becomes storefrontSdk.functionName(variables) - no more manual type specifications
  3. Data access: body.data.products becomes result.products - direct access to your data

The SDK automatically validates parameter names at compile-time and infers return types, catching errors before they reach production.

Step 3: Clean Up Imports

After migration, remove the imports you no longer need:

// ❌ Remove these - no longer needed
import { getProductHandlesQuery } from "../../queries/product"; 
import { ShopifyProductHandlesOperation } from "../../types";

// ✅ Keep these - still needed for data transformation  
import { ShopifyProductHandle } from "../../types";

The SDK has the queries and operation types baked in, but you'll still need types for data transformation after receiving the results.

Step 4: Complex Example - Collection Products

Here's a more complex function showing the same pattern with filters and pagination:

Before - Manual approach:

const getProductsInCollection_original = async ({
productFilters = [],
reverse,
sortKey,
collection,
first,
last,
after,
before,
}: CollectionProductsParams): Promise<CollectionProductsResult> => {

    const queryVariables = {
      handle: collection,
      filters: productFilters.length > 0 ? productFilters : undefined,
      reverse,
      sortKey: mapSortKeyForContext(sortKey || "COLLECTION_DEFAULT", true),
      first,
      last,
      after,
      before,
    };

    const res = await shopifyFetch<ShopifyCollectionProductsOperation>({
      query: getCollectionProductsQuery,
      variables: queryVariables,
    });

    if (!res.body.data.collection) {
      return { products: [], pageInfo: {...}, filters: [], totalCount: 0 };
    }

    const products = reshapeProducts(
      res.body.data.collection.products.edges.map((edge) => edge.node),
    );

    return {
      products,
      pageInfo: res.body.data.collection.products.pageInfo,
      filters: res.body.data.collection.products.filters,
      totalCount: calculateTotalFromFilters(res.body.data.collection.products.filters),
    };

};

After - SDK approach:

const getProductsInCollection_original = async ({
productFilters = [],
reverse,
sortKey,
collection,
first,
last,
after,
before,
}: CollectionProductsParams): Promise<CollectionProductsResult> => {

    const result = await storefrontSdk.getCollectionProducts({
      handle: collection,
      filters: productFilters.length > 0 ? productFilters : undefined,
      reverse,
      sortKey: mapSortKeyForContext(sortKey || "COLLECTION_DEFAULT", true),
      first,
      last,
      after,
      before,
    });

    if (!result.collection) {
      return { products: [], pageInfo: {...}, filters: [], totalCount: 0 };
    }

    const products = reshapeProducts(
      result.collection.products.edges.map((edge) => edge.node),
    );

    return {
      products,
      pageInfo: result.collection.products.pageInfo,
      filters: result.collection.products.filters,
      totalCount: calculateTotalFromFilters(result.collection.products.filters),
    };

};

Key improvements:

  • Cleaner variable passing: No need for intermediate queryVariables object
  • Direct result access: result.collection instead of res.body.data.collection
  • Same business logic: All the data transformation and error handling logic remains unchanged
  • Better IntelliSense: Your IDE now shows available fields as you type

Best Practice: Keep your existing business logic (data transformation, error handling, caching) unchanged. Only replace the data fetching layer.

Pitfalls & Debugging

Common Migration Mistakes

1. Forgetting to update data access patterns

// ❌ Wrong - still using old pattern
const result = await storefrontSdk.getProduct({ handle });
return result.body.data.product; // body.data doesn't exist!

// ✅ Correct - direct access
const result = await storefrontSdk.getProduct({ handle });
return result.product;

2. Not removing unused imports

// ❌ This will cause build warnings/errors
import { getProductQuery } from "../../queries/product"; // Not needed anymore
import { ShopifyProductOperation } from "../../types"; // Not needed anymore

// ✅ Clean imports
import { Product } from "../../types"; // Only what you actually use

3. Assuming error handling is the same The SDK has its own error handling mechanisms. If you had custom error wrapping, you might need to adjust:

// Old error handling
try {
  const res = await shopifyFetch<Type>({ query, variables });
} catch (e) {
  if (isShopifyError(e)) {
    throw { cause: e.cause, status: e.status, message: e.message, query: 'functionName' };
  }
}

// New - let SDK handle errors naturally or wrap if needed
try {
  const result = await storefrontSdk.functionName(variables);
} catch (e) {
  // SDK already provides good error information
  console.error('GraphQL Error:', e);
  throw e;
}

Debugging Tips

When builds fail after migration:

  1. Check that all handleSdkRequest calls are removed
  2. Verify imports are cleaned up
  3. Make sure data access patterns are updated (result.field not result.body.data.field)

When TypeScript complains:

  1. The SDK validates variables at compile time - check parameter names match your GraphQL schema
  2. If you get "Property doesn't exist" errors, check the GraphQL schema for the correct field names

When runtime errors occur:

  1. Use browser dev tools to inspect the actual GraphQL response
  2. Check that environment variables (API tokens, endpoints) are still correctly configured

⚠️ Common Bug: If you see "Cannot read property 'X' of undefined", you're likely still trying to access body.data.something instead of just something.

Final Working Version

Here's a complete before/after comparison of a migrated file:

Before (Manual approach):

// lib/shopify/fetchers/storefront/products.ts
import { shopifyFetch } from "../../utils/clients";
import { getProductQuery, getProductRecommendationsQuery } from "../../queries/product";
import { ShopifyProductOperation, ShopifyProductRecommendationsOperation } from "../../types";

export async function getProduct(handle: string): Promise<Product | undefined> {
  const res = await shopifyFetch<ShopifyProductOperation>({
    query: getProductQuery,
    variables: { handle },
  });

  return reshapeProducts([res.body.data.product])[0];
}

export async function getProductRecommendations(productId: string): Promise<Product[]> {
  const res = await shopifyFetch<ShopifyProductRecommendationsOperation>({
    query: getProductRecommendationsQuery,
    variables: { productId },
  });

  return reshapeProducts(res.body.data.productRecommendations);
}

After (SDK approach):

// lib/shopify/fetchers/storefront/products.ts
import { storefrontSdk } from "../../utils/storefront-sdk";

export async function getProduct(handle: string): Promise<Product | undefined> {
  const result = await storefrontSdk.getProduct({ handle });

  return reshapeProducts([result.product])[0];
}

export async function getProductRecommendations(productId: string): Promise<Product[]> {
  const result = await storefrontSdk.getProductRecommendations({ productId });

  return reshapeProducts(result.productRecommendations);
}

The transformation:

  • 50% less code - eliminated boilerplate
  • 100% type safe - compile-time validation of all parameters
  • Zero manual types - no more <ShopifyProductOperation>
  • Direct data access - no more body.data nesting
  • Auto-completion - IDE knows exactly what fields are available

Results and Benefits

Developer Experience Improvements

Before migration:

// Lots of manual work, easy to make mistakes
const res = await shopifyFetch<ShopifyProductOperation>({
  query: getProductQuery, // Could be wrong string
  variables: {
    handle, // Could misspell parameter name
    first: 10, // Might not be valid for this query
  },
});

const product = res.body.data.product; // Deep nesting

After migration:

// Clean, validated, auto-completed
const result = await storefrontSdk.getProduct({
  handle, // TypeScript validates this exists
  // first: 10 // Would error - getProduct doesn't accept 'first'
});

const product = result.product; // Direct access

Performance Benefits

  • Smaller bundle size: No need to ship query strings to the client
  • Better tree shaking: Only used SDK functions are included in the bundle
  • Fewer runtime errors: Type validation catches issues at compile time

Maintenance Benefits

  • Schema changes: When GraphQL schema updates, regenerate types and get compile errors for breaking changes
  • Refactoring: Renaming fields in GraphQL automatically updates TypeScript types
  • Documentation: SDK functions include JSDoc comments from GraphQL schema

Migration Progress Tracking

We successfully migrated several key files:

✅ Completed Migrations

  • lib/shopify/fetchers/storefront/products.ts - 6+ functions
  • lib/shopify/fetchers/storefront/cart.ts - 5 functions
  • lib/shopify/fetchers/storefront/collections.ts - 4 functions

🔄 Remaining Files (Optional)

  • lib/shopify/variantOptions.ts - 2 functions
  • lib/shopify/fetchers/storefront/pages.ts - 2 functions
  • lib/shopify/fetchers/storefront/menu.ts - 1 function
  • lib/shopify/fetchers/storefront/shop.ts - 2 functions
  • lib/shopify/fetchers/storefront/files.ts - 1 function
  • lib/shopify/fetchers/storefront/sitemap.ts - 2 functions

Migration time per file: 10-30 minutes each, following the same pattern shown above.

Common Migration Pitfalls

The biggest mistake: Forgetting to update data access patterns.

// ❌ Wrong - still using old nested pattern
const result = await storefrontSdk.getProduct({ handle });
return result.body.data.product; // body.data doesn't exist!

// ✅ Correct - direct access  
const result = await storefrontSdk.getProduct({ handle });
return result.product;

Other common issues:

  • Not removing unused imports (causes build warnings)
  • Assuming error handling works the same way (SDK has built-in error handling)
  • Trying to access fields that don't exist in the GraphQL schema

Results: From 50+ Lines to 15

After migrating all our Shopify API calls, we eliminated over 50% of our GraphQL-related code while gaining complete compile-time type safety. The transformation from shopifyFetch<Type>({ query, variables }) to storefrontSdk.methodName(variables) made our codebase cleaner and more maintainable.

The real win isn't just shorter code - it's the developer experience. Your IDE now shows exactly which parameters each query accepts, what fields are available in responses, and catches typos before they become runtime errors.

If you've set up GraphQL Code Generation but are still using manual API calls, this migration will pay dividends immediately. The pattern is consistent across all query types, making it straightforward to apply across your entire codebase.

Let me know in the comments if you have questions about migrating your own GraphQL calls, and subscribe for more practical development guides.

Thanks, Matija

0

Frequently Asked Questions

Comments

Enjoyed this article?
Subscribe to my newsletter for more insights and tutorials.
Matija Žiberna
Matija Žiberna
Full-stack developer, co-founder

I'm Matija Žiberna, a self-taught full-stack developer and co-founder passionate about building products, writing clean code, and figuring out how to turn ideas into businesses. I write about web development with Next.js, lessons from entrepreneurship, and the journey of learning by doing. My goal is to provide value through code—whether it's through tools, content, or real-world software.

You might be interested in