The API Client

Plan: Composable Developer

Lesson 8 of 19 · 45 min

Interacting with BigCommerce via the GraphQL Storefront API is a critical part of the Catalyst architecture. The @bigcommerce/catalyst-client package facilitates easy GraphQL interaction with a client wrapping the native fetch API.

Initializing the Client

The Catalyst API client must be initialized with the appropriate store-specific configuration details (such as the store hash and GraphQL token). With this config info, the client can then automatically take care of handling the HTTP request details for all GraphQL calls, including authentication.

You can see in client/index.ts that this initialization is done automatically by the main Catalyst project codebase.

export const client = createClient({
storefrontToken: process.env.BIGCOMMERCE_STOREFRONT_TOKEN ?? '',
storeHash: process.env.BIGCOMMERCE_STORE_HASH ?? '',
channelId: process.env.BIGCOMMERCE_CHANNEL_ID,
backendUserAgentExtensions: backendUserAgent,
logger:
(process.env.NODE_ENV !== 'production' && process.env.CLIENT_LOGGER !== 'false') ||
process.env.CLIENT_LOGGER === 'true',
...
});

You should recognize the initialization values as those defined in the environment configuration of the Catalyst storefront application.

To make GraphQL requests, simply import the client instance from this module.

With the API client imported, any particular GraphQL query or mutation needs to be concerned only with passing in the query and variable details, as well as any additional options. Here’s a typical example from the Catalyst core (from app/[locale]/(default)/cart/page-data.ts):

const { data } = await client.fetch({
document: CartPageQuery,
variables,
customerAccessToken,
fetchOptions: {
cache: 'no-store',
next: {
tags: [TAGS.cart, TAGS.checkout],
},
},
});

We’ll talk about each of the pieces of information being passed to fetch in turn.

Common Patterns

In the default storefront implementation, GraphQL queries and mutations are typically co-located closely with the routes and components they relate to. The cart page example above appears directly in the route file that controls the cart page content.

GraphQL fragments are often used to define reusable query structure and to locate this structure closely to the components that actually use it. For example, components/header/fragment.ts owns the specific fields necessary for querying the product information relevant to the site header. This is the case even though the settings query itself isn’t performed within this component.

Some reusable query code is centrally located in the client directory, which has the following subdirectories:

  • queries - Contains GraphQL queries
  • fragments - Contains fragments defining sets of fields reused in multiple queries or mutations.

Since all of this, whether in the client location or elsewhere, is located in your own project code, this makes you the full owner of the GraphQL that powers your storefront. The queries and mutations that come “out-of-the-box” can be freely modified to suit your requirements, and new queries and mutations can be created in the same directory structure using the same patterns. There are no additional hoops to jump through in order to tailor the GraphQL your application needs.

Let’s look in more detail at the cart page example:

...
const CartPageQuery = graphql(
`
query CartPageQuery($cartId: String, $currencyCode: currencyCode) {
site {
...
cart(entityId: $cartId) {
entityId
version
currencyCode
discountedAmount {
...MoneyFieldsFragment
}
lineItems {
physicalItems {
...PhysicalItemFragment
}
digitalItems {
...DigitalItemFragment
}
giftCertificates {
...CartGiftCertificateFragment
}
totalQuantity
}
}
checkout(entityId: $cartId) {
...
}
}
}
`,
[
PhysicalItemFragment,
DigitalItemFragment,
MoneyFieldsFragment,
ShippingInfoFragment,
GeographyFragment,
CartGiftCertificateFragment,
],
);
...
export const getCart = async (variables: Variables) => {
...
const { data } = await client.fetch({
document: CartPageQuery,
variables,
...
});
return data;
};

This common pattern performs several tasks:

  • Defines a GraphQL string with the query syntax itself
  • Passes this GraphQL string to the function _graphql (Notice that GraphQL fragments are similarly passed to the same function, the results being passed as a second dependencies argument for any queries that use them)
  • Passes the GraphQL document returned from graphql, along with any relevant variables and options, to the client
  • Extracts data from the expected structure of the GraphQL response

When architecting your own queries and mutations, it’s best to stick to this same pattern.

Type Safety

Catalyst utilizes Typescript to improve the predictability, stability, and maintainability of its JS code.

The dynamic nature of GraphQL makes it cumbersome to manually define types expressing the expected format of input variables and GraphQL responses.

type peopleVars = {
ids: number[];
limit: number;
}
type peopleResp = {
data: Array<{
id: number;
firstName: string;
lastName: string;
}>;
}
const resp = await fetch(graphQlUrl, {
...,
body: JSON.stringify({
query: ...,
variables: {
ids: [1, 2],
limit: 10
} satisfies peopleVars,
}),
}).then(res => res.json()) satisfies peopleResp;

Manual type definitions also aren’t reliable, in and of themselves, for ensuring that the real GraphQL schema lines up with them.

Catalyst utilizes third-party tools that automate GraphQL type safety by using the authoritative source — the actual remote GraphQL schema — to generate the types needed for all queries.

Let’s look again at the cart page example:

...
const CartPageQuery = graphql(
`
...
`,
[
PhysicalItemFragment,
DigitalItemFragment,
MoneyFieldsFragment,
ShippingInfoFragment,
GeographyFragment,
CartGiftCertificateFragment,
],
);
type Variables = VariablesOf<typeof CartPageQuery>;
export const getCart = async (variables: Variables) => {
...
const { data } = await client.fetch({
document: CartPageQuery,
variables,
...
});
return data;
};

This code contains no type definitions, no type assertions, and no type variables being passed to fetch. Nevertheless, Typescript is fully aware of whether the query’s variables input is valid and what properties can be accessed on the resulting data object.

The document object returned from the graphql function contains the necessary inferred type information - types dynamically defined by a combination of the query string itself (including the variables that appear in it) and an introspection of BigCommerce’s GraphQL schema.

By using the same pattern for your own queries and mutations, you can have a high degree of confidence that type checkers in your editor and build tools will catch any consistencies in the way your code interacts with GraphQL.

This dynamic type safety involves the auto-generated files bigcommerce-graphql.d.ts and bigcommerce.graphql. These files are generated whenever the dev or build npm commands are run.

The names of these generated files are provider-specific, and it’s fairly easy to add other generated schema/type files for other GraphQL sources to get the same type safety benefits. See the following key locations for insight into the steps required to add other providers:

  • In tsconfig.json, the schemas list for the @0no-co/graphqlsp plugin under plugins
  • The generation script in scripts/generate.cjs (a generateSchema call is needed for each source)
  • The initialization logic in client/graphql.ts (a unique version of this should be created for each source)

Customer Context

The GraphQL Storefront API supports providing the context of a registered BigCommerce customer in any given request, which can affect the data returned. This comes into play if the user has logged in with valid BigCommerce customer credentials. For example, if a customer belongs to a customer group with access to only certain categories or with custom pricing, this will be reflected in the product information returned by GraphQL. Server-side requests like those in Catalyst establish this context by including the customer access token originally obtained during login in a specialized header.

The Catalyst API client supports passing in a customerAccessToken config parameter and will take care of including the context in the HTTP request if it’s provided. The storefront’s authentication implementation also exposes a simple function for retrieving the access token from a logged-in user’s session.

Once more, from the cart example:

import { getSessionCustomerAccessToken } from '~/auth';
...
export const getCart = async (variables: Variables) => {
const customerAccessToken = await getSessionCustomerAccessToken();
const { data } = await client.fetch({
document: CartPageQuery,
variables,
customerAccessToken,
...
});
...
};

You can easily use these tools to provide the current customer’s context to your own queries. Since this is an optional parameter of the API client, you can be selective about which queries and mutations pass in a customer context and which ones don’t. If you’re confident that a certain query will not be affected by customer context, making the request anonymous is typically the best option for performance.

How is Customer Session Tracked?

See auth/index.ts for the Catalyst logic surrounding customer authentication, including GraphQL mutations handling login as well as session handling with Auth.js.

Note that the necessary functionality for persistent cart — that is, restoring a customer’s in-progress cart when they log in on any device, as well as merging a guest cart with the existing cart — is built into the accepted arguments for the authentication providers in this location.

Error Handling

The client supports an errorPolicy parameter defining how errors in the GraphQL response should be handled.

const response = await client.fetch({
document: MyQuery,
...
errorPolicy: 'auth',
});

Possible values of errorPolicy include:

  • none: An Error inheriting from type BigCommerceGQLError will be thrown with the text of any errors in the response. Any onError callback defined when the client was initialized will also be called.
  • all: The reverse of “none”; the full GraphQL response will be returned, including any error messages. This is the default errorPolicy if none is provided.
  • auth: Operates like “none”, except that the client’s onError callback will only be executed if the thrown error inherits from BigCommerceAuthError, indicating an issue with customer access token authentication. By default, Catalyst’s onError callback automatically signs the user out of their customer session when this error type occurs.
  • ignore: No errors will be thrown or returned. The GraphQL response will be returned with error messages excluded.

The following is an example of handling errors thrown with a “none” error policy:

import { BigCommerceGQLError, BigCommerceAuthError } from '@bigcommerce/catalyst-client';
try {
const response = await client.fetch({
document: MyQuery,
...
errorPolicy: 'none',
});
} catch (error) {
if (error instanceof BigCommerceAuthError) {
// ...
} else if (error instanceof BigCommerceGQLError) {
// ...
} else {
// ...
}
}

Caching and Streaming

External data fetching is one of the biggest potential bottlenecks in your frontend application. A page inundated with a large number of network requests to external systems can suffer a significant impact to load times.

This makes it particularly helpful to understand the caching techniques and other mechanisms Catalyst and Next.js use to reduce unnecessary requests or improve page load performance.

Data Cache

The Next.js Data Cache will cache responses for fetch requests across multiple page renders, according to the configuration parameters for that request and if certain conditions are met. If a response is cached, a subsequent identical fetch will receive this cached data; another network request will not be required. By default, Catalyst utilizes this caching behavior for most anonymous GraphQL requests (that is, those occurring when the user is not logged in).

A cache option is passed to fetchOptions to control whether a particular response is cached, and the next option can contain other config values like the time after which the request should revalidate. A standardized value for revalidation time in Catalyst is implemented in client/revalidate-target.ts and can be set with the environment variable DEFAULT_REVALIDATE_TARGET.

See this example of the use of these fetch options in app/[locale]/(default)/product/[slug]/page-data.tsx:

...
import { revalidate } from '~/client/revalidate-target';
...
export const getStreamableProduct = cache(
async (...) => {
const { data } = await client.fetch({
...
fetchOptions: customerAccessToken ? { cache: 'no-store' } : { next: { revalidate } },
});
...
},
);

default-cache

By default, the Data Cache mentioned above is not used for any fetch requests that occur after dynamic functions are used (such as accessing cookies or headers). This is regardless of whether such requests use dynamic information or not. Out of the box, Catalyst changes this default.

See this line in app/[locale]/layout.tsx:

export const fetchCache = 'default-cache';

This sets configuration for all page routes to ensure that any fetch requests are cacheable.

Memoization

Next.js memoizes the results of fetch calls within the same page render. For some reusable queries, Catalyst takes this a step further by memoizing entire query functions.

Note this opening line of the getRegisterCustomerQuery function in app/[locale]/(default)/(auth)/register/page-data.ts:

export const getRegisterCustomerQuery = cache(async ({ ... }: Props) => {

The entire function is “wrapped” in a React cache call, meaning the full results are memoized. If multiple identical calls to getRegisterCustomerQuery occur within the same page render, this memoization will avoid executing the function again.

If your storefront pages don’t reflect the most up-to-date data in your store, you may need to clear cached data to ensure fresh GraphQL requests are made to BigCommerce. In a local development environment, you can clear the contents of .next/cache. You may need to restart the dev server process as well.

Suspense and Streaming

Often, some content on a page is not as critical as other content. When this is the case, a good strategy for optimal page performance is to allow the data fetching and rendering for less important content to be done asynchronously, allowing the most important to be delivered as soon as its own data is available.

The Next.js App Router makes this easy with the use of React Suspense components to create boundaries dictating which content is loaded asynchronously.

The following is an example of a Suspense component wrapping another component:

<Suspense fallback={<FallbackComponent />}>
<ProductInfo productId={...} />
</Suspense>

If the ProductInfo might take significant time to render, such as if it’s performing unique data fetching, the provided fallback will be rendered initially, and the full content of ProductInfo will be streamed to the browser when it’s ready.

In Catalyst, the concept of Suspense is expanded with the Stream component pattern. We’ll examine this when we look at the component library.

Logging

If the environment variable CLIENT_LOGGER is TRUE, the output of the dev server will include logging of all requests using the API client.

Image

Calls to the API client fetch method are logged, regardless of whether an actual web request goes out or if a cache hit from the Next.js Data Cache is returned. This logging is an easy way to get insight into which queries are being performed in a given page render, and which queries exhibit the highest loading time.