← All articles >
magento 2magento developmentphpe-commerceadobe-commercetutorialmodule-development

Magento 2 GraphQL API: Complete Developer Guide (2026)

Share: LinkedIn X Facebook

Magento 2’s GraphQL API is the backbone of modern headless storefronts, PWAs, and mobile apps. Unlike REST — which often requires multiple round-trips — GraphQL lets a client request exactly the fields it needs in a single call. This guide covers the built-in schema, authentication, cart and checkout mutations, custom resolver development, and the performance patterns that matter in production.

In short: GraphQL in Magento 2 exposes products, categories, cart, checkout, and customer data through a typed schema at /graphql. Use queries for reads, mutations for writes, and extend the schema with custom resolvers when built-in fields are not enough.

Table of Contents

  1. Why GraphQL for Magento 2?
  2. Endpoint, Headers, and First Query
  3. Core Schema: Products, Categories, Search
  4. Cart and Checkout Mutations
  5. Customer Authentication
  6. Building a Custom Query Resolver
  7. Building a Custom Mutation
  8. Performance and Caching
  9. GraphQL vs REST: When to Use What
  10. Technical FAQ

Why GraphQL for Magento 2?

Adobe has invested heavily in GraphQL as the primary API for storefront-facing operations. REST remains available for Admin integrations and legacy systems, but new frontend work — Hyvä React Checkout, PWA Studio, custom Next.js storefronts — runs on GraphQL.

AdvantageDetail
Single requestFetch product name, price, image, and stock in one call
No over-fetchingClient selects only the fields it renders
Typed schemaSelf-documenting via introspection
Cart statecart_id token model fits stateless frontends
Version stabilitySchema evolves with backward-compatible deprecations

Endpoint:

POST https://your-store.com/graphql
Content-Type: application/json

Endpoint, Headers, and First Query

Basic product query

{
  products(filter: { sku: { eq: "24-MB01" } }) {
    items {
      sku
      name
      price_range {
        minimum_price {
          regular_price {
            value
            currency
          }
        }
      }
      image {
        url
        label
      }
    }
  }
}

Send it with curl:

curl -X POST https://your-store.com/graphql \
  -H "Content-Type: application/json" \
  -d '{"query": "{ products(filter: { sku: { eq: \"24-MB01\" } }) { items { sku name } } }"}'

Store header (multi-store setups)

When running multiple store views, pass the Store header:

-H "Store: default"

Without it, Magento may return data from the wrong store view — wrong prices, wrong language, wrong currency.


Category listing with filters

{
  categoryList(filters: { url_key: { eq: "gear" } }) {
    id
    name
    products(pageSize: 12, currentPage: 1) {
      total_count
      items {
        sku
        name
        small_image { url }
        price_range {
          minimum_price {
            final_price { value currency }
          }
        }
      }
      page_info {
        current_page
        total_pages
      }
    }
  }
}

Full-text search

{
  products(search: "backpack", pageSize: 10) {
    total_count
    items {
      sku
      name
      url_key
    }
  }
}

Search relies on the catalogsearch_fulltext indexer and Elasticsearch/OpenSearch. If search returns empty results, reindex first:

bin/magento indexer:reindex catalogsearch_fulltext

Configurable product options

{
  products(filter: { sku: { eq: "MH01" } }) {
    items {
      sku
      name
      ... on ConfigurableProduct {
        configurable_options {
          attribute_code
          label
          values {
            value_index
            label
          }
        }
        variants {
          product {
            sku
            name
            price_range {
              minimum_price {
                final_price { value }
              }
            }
          }
        }
      }
    }
  }
}

Use inline fragments (... on ConfigurableProduct) to access type-specific fields — a core GraphQL pattern in Magento.


Cart and Checkout Mutations

Guest carts use a cart_id (masked quote ID). Customer carts use the authenticated customer token.

Create a guest cart

mutation {
  createEmptyCart
}

Response:

{ "data": { "createEmptyCart": "abc123xyz" } }

Add a product to cart

mutation {
  addProductsToCart(
    cartId: "abc123xyz"
    cartItems: [{ sku: "24-MB01", quantity: 1 }]
  ) {
    cart {
      items {
        quantity
        product {
          name
          sku
        }
      }
      prices {
        grand_total {
          value
          currency
        }
      }
    }
  }
}

Merge guest cart after login

mutation {
  mergeCarts(
    source_cart_id: "abc123xyz"
    destination_cart_id: "customer-cart-id"
  ) {
    items { quantity product { sku } }
  }
}

Set shipping address and method

mutation {
  setShippingAddressesOnCart(
    input: {
      cart_id: "abc123xyz"
      shipping_addresses: [{
        address: {
          firstname: "John"
          lastname: "Doe"
          street: ["123 Main St"]
          city: "Paris"
          postcode: "75001"
          country_code: FR
          telephone: "0600000000"
        }
      }]
    }
  ) {
    cart {
      shipping_addresses {
        available_shipping_methods {
          carrier_code
          method_code
          amount { value currency }
        }
      }
    }
  }
}

Headless checkout tip: Always request available_shipping_methods and available_payment_methods after setting the address — rates depend on the cart contents and destination.


Customer Authentication

Generate a customer token

mutation {
  generateCustomerToken(
    email: "[email protected]"
    password: "Password123!"
  ) {
    token
  }
}

Use the token on subsequent requests:

-H "Authorization: Bearer <token>"

Tokens expire based on Admin configuration (Stores → Configuration → Services → OAuth → Customer Token Lifetime). Default is 1 hour — plan refresh logic in your frontend.

Introspection query (dev only)

Disable introspection in production for security. In development, explore the schema:

{
  __schema {
    types {
      name
      kind
    }
  }
}

Or use GraphQL Playground / Altair / Postman with introspection enabled.


Building a Custom Query Resolver

When the built-in schema is not enough, extend it with a custom module.

Directory structure

app/code/MagentoMastery/GraphQlDemo/
├── registration.php
├── etc/
│   ├── module.xml
│   └── schema.graphqls
└── Model/
    └── Resolver/
        └── HelloWorld.php

Query schema.graphqls

type Query {
    helloWorld(name: String): String
      @resolver(class: "MagentoMastery\\GraphQlDemo\\Model\\Resolver\\HelloWorld")
      @doc(description: "Returns a greeting string")
}

Resolver class

<?php
declare(strict_types=1);

namespace MagentoMastery\GraphQlDemo\Model\Resolver;

use Magento\Framework\GraphQl\Config\Element\Field;
use Magento\Framework\GraphQl\Query\ResolverInterface;
use Magento\Framework\GraphQl\Schema\Type\ResolveInfo;

class HelloWorld implements ResolverInterface
{
    public function resolve(
        Field $field,
        $context,
        ResolveInfo $info,
        ?array $value = null,
        ?array $args = null
    ): string {
        $name = $args['name'] ?? 'World';
        return "Hello, {$name}!";
    }
}

Test the custom query

{ helloWorld(name: "Magento") }

After deploying:

bin/magento setup:upgrade
bin/magento cache:flush

GraphQL schema changes require cache flush — Magento caches the compiled schema.


Building a Custom Mutation

Mutations follow the same pattern but implement ResolverInterface on a Mutation type field.

Mutation schema.graphqls

type Mutation {
    subscribeNewsletter(email: String!): NewsletterOutput
      @resolver(class: "MagentoMastery\\GraphQlDemo\\Model\\Resolver\\SubscribeNewsletter")
}

type NewsletterOutput {
    success: Boolean!
    message: String
}

Resolver with validation

<?php
declare(strict_types=1);

namespace MagentoMastery\GraphQlDemo\Model\Resolver;

use Magento\Framework\Exception\LocalizedException;
use Magento\Framework\GraphQl\Config\Element\Field;
use Magento\Framework\GraphQl\Exception\GraphQlInputException;
use Magento\Framework\GraphQl\Query\ResolverInterface;
use Magento\Framework\GraphQl\Schema\Type\ResolveInfo;

class SubscribeNewsletter implements ResolverInterface
{
    public function resolve(
        Field $field,
        $context,
        ResolveInfo $info,
        ?array $value = null,
        ?array $args = null
    ): array {
        $email = $args['email'] ?? '';

        if (!filter_var($email, FILTER_VALIDATE_EMAIL)) {
            throw new GraphQlInputException(__('Invalid email address.'));
        }

        // Subscribe logic here...

        return [
            'success' => true,
            'message' => 'Subscribed successfully.',
        ];
    }
}

Throw GraphQlInputException for client errors (400-level) and GraphQlAuthorizationException for auth failures — Magento maps these to proper GraphQL error responses.


Performance and Caching

GraphQL can be slower than expected if clients request too many nested fields. Apply these rules in production:

1. Limit query depth and complexity

Use a reverse proxy or Magento’s built-in query complexity limits (Adobe Commerce) to block abusive queries.

2. Cache at the CDN for anonymous queries

Only cache queries that do not include customer tokens or cart IDs. Product listing queries are good CDN candidates when keyed by store + query hash.

3. Avoid N+1 in custom resolvers

When resolving lists, use batch resolvers or data loaders. A naive resolver that loads a product model per item will destroy performance on category pages.

4. Request only what you render

items { sku name description { html } }

items { sku name small_image { url } price_range { minimum_price { final_price { value } } } }

5. Keep indexers healthy

GraphQL reads from indexed flat tables and Elasticsearch. Stale indexers = stale API responses. See the indexers guide for maintenance.


GraphQL vs REST: When to Use What

Use CaseRecommended API
Storefront / PWA / mobile appGraphQL
Admin integrations (orders, catalog import)REST (Async Bulk API)
Third-party ERP syncREST
Real-time cart/checkoutGraphQL
Legacy integrationsREST

Adobe’s direction is clear: GraphQL for customer-facing, REST for operational/back-office bulk operations.


Technical FAQ

Where is the GraphQL schema defined?

Core schema files live in each module’s etc/schema.graphqls. Magento merges them at runtime. Custom modules add their own etc/schema.graphqls.

Can I use GraphQL in Admin?

GraphQL is designed for storefront operations. Admin functionality uses REST or the Admin UI — do not expose Admin operations via custom GraphQL without strict ACL checks.

How do I debug GraphQL errors?

Enable developer mode and check var/log/exception.log. GraphQL returns errors in the errors array of the JSON response with message and category fields.

Does GraphQL replace Knockout.js checkout?

Not automatically. The Luma checkout is still Knockout-based. Headless checkout requires a frontend that consumes GraphQL mutations (React, Vue, Hyvä Checkout, etc.). See the checkout customization guide for working with Knockout.js or building a headless alternative.

Is GraphQL available in Magento Open Source?

Yes. GraphQL has been part of Magento Open Source since 2.3.x. Some advanced features (query complexity limits, staging previews) are Adobe Commerce only.


Conclusion

GraphQL is the standard API layer for modern Magento 2 storefronts. Master the built-in product and cart schema first, then extend with custom resolvers when business logic demands it. Keep queries lean, indexers current, and authentication tokens fresh — and your headless storefront will stay fast and reliable.

Building a headless Magento 2 storefront or need custom GraphQL endpoints? Contact me for architecture reviews, custom modules , and performance optimization.