2: Integrate BigCommerce data

The previous exercise built the gift certificates UI entirely in “MOCK” mode, so nothing you saw came from a real BigCommerce store. In this exercise, you’ll switch to “STATIC” mode: using data from a real store using a real API token. This will allow you to focus on the details of API requests to BigCommerce while still not yet worrying about single-click install flow, session handling, or storage.

Create a store-level API account

Create a store-level API account in your store’s control panel at Settings > API > Store-level API accounts, with the following scopes:

  • Modify: Marketing (gift certificates), Customers
  • Read-only: Channel Settings, Channel Listings, Information & Settings

For background on API accounts, OAuth scopes, and how BigCommerce authenticates API requests, see API accounts.

In .env.local in your project (and .env.example), switch DATA_MODE and add your store hash and access token, as well as a config value to enable API request logging:

1DATA_MODE=STATIC
2STATIC_STORE_HASH=<your-store-hash>
3STATIC_STORE_TOKEN=<your-access-token>
4LOG_API_REQUESTS=TRUE

Restart the dev server after changing .env.local.

Introduce the real REST client

The project boilerplate already contains a basic BcRestApiClient interface and a getRestApiClient controller function (currently only equipped to return a mock client). In this first step, you’ll create a basic API client used whenever the data mode is not “MOCK”.

1

Build the REST client

This is the most verbose step of this exercise. Build a REST API client that implements BcRestApiClient and supports GET, POST, PUT, and DELETE requests.

Replace the contents of src/lib/bc-api-client/rest-client/rest-client.ts.

rest-client/rest-client.ts
import { ApiMutationOptions, ApiRequestOptions, ApiResponse, BcRestApiClient } from "@/lib/bc-api-client/rest-client/types";
import { StoreApiCredentials } from "@/lib/bc-api-client/types";
import { AppError } from "@/lib/errors/app-error";
const API_BASE_URL = "https://api.bigcommerce.com";
function buildUrl(storeHash: string, path: string, params: ApiRequestOptions["params"]): string {
const url = new URL(`${API_BASE_URL}/stores/${storeHash}${path}`);
for (const [key, value] of Object.entries(params ?? {})) {
if (value !== undefined && value !== "") {
url.searchParams.set(key, String(value));
}
}
return url.toString();
}
function toLoggableUrl(url: string): string {
const parsed = new URL(url);
return `${parsed.origin}${parsed.pathname}${
[...parsed.searchParams.keys()].length ? `?${[...parsed.searchParams.keys()].map((key) => `${key}=<redacted>`).join("&")}` : ""
}`;
}
async function parseJsonResponse<TResponse>(response: Response, path: string): Promise<TResponse> {
const responseText = await response.text();
try {
return JSON.parse(responseText) as TResponse;
} catch (error) {
throw new AppError("UPSTREAM_API", "A BigCommerce API request failed.", {
cause: `Response to "${path}" was not valid JSON: ${responseText.slice(0, 500)} (${error})`,
});
}
}
function isApiRequestLoggingEnabled(): boolean {
return process.env.LOG_API_REQUESTS?.toLowerCase() === "true";
}
function logApiRequest(method: string, url: string, status: number, durationMs: number): void {
if (!isApiRequestLoggingEnabled()) {
return;
}
console.log(`[BigCommerce API] ${method} ${toLoggableUrl(url)} -> ${status} (${durationMs.toFixed(0)}ms)`);
}
export class RestApiClient implements BcRestApiClient {
constructor(private readonly credentials: StoreApiCredentials) {}
private getCredentialsOrThrow(): { storeHash: string; apiToken: string } {
const { storeHash, apiToken } = this.credentials;
if (!storeHash || !apiToken) {
throw new AppError("VALIDATION", "A store hash and API token are required to make a request.");
}
return { storeHash, apiToken };
}
}

This is the basic shell of the client and several useful utility functions. Note that the BigCommerce base REST URL is stored in the constant API_BASE_URL. Also note the import of several pre-existing types from the project boilerplate, defining the shape of data like credentials (a store hash and token) and basic request/response shapes. These are fairly generic, not representing the shape of any particular API endpoint, and you can feel free to examine the interfaces.

The client itself expects credentials to be passed into the constructor when first instantiated.

Now add a get method to the client:

rest-client/rest-client.ts
export class RestApiClient implements BcRestApiClient {
...
async get<TResponse>(path: string, options: ApiRequestOptions = {}): Promise<ApiResponse<TResponse>> {
const { storeHash, apiToken } = this.getCredentialsOrThrow();
const url = buildUrl(storeHash, path, options.params);
const startedAt = performance.now();
let response: Response;
try {
response = await fetch(url, {
headers: {
"X-Auth-Token": apiToken,
Accept: "application/json",
},
});
} catch (error) {
throw new AppError("UPSTREAM_API", "Could not reach BigCommerce.", { cause: error });
}
logApiRequest("GET", url, response.status, performance.now() - startedAt);
if (!response.ok) {
throw new AppError("UPSTREAM_API", `A BigCommerce API request failed.`, {
cause: `GET "${path}" failed with status ${response.status}.`,
status: response.status,
});
}
const data = response.status === 204 ? undefined : await parseJsonResponse<TResponse>(response, path);
return { data: data as TResponse, headers: response.headers };
}
}

This method wraps a simple fetch, executed with the appropriate endpoint URL, querystring params, and authentication header.

Note that get defines a TypeScript generic (TResponse), allowing the caller to specify an expected response shape specific to the endpoint being called.

Finally, add the mutating methods. Since POST, PUT, and DELETE all use the same pattern (a fetch with the appropriate HTTP method and optionally a body) the public methods wrap the same private mutate method.

rest-client/rest-client.ts
export class RestApiClient implements BcRestApiClient {
...
private async mutate<TResponse>(
method: "POST" | "PUT" | "DELETE",
path: string,
options: ApiMutationOptions = {},
): Promise<ApiResponse<TResponse>> {
const { storeHash, apiToken } = this.getCredentialsOrThrow();
const url = buildUrl(storeHash, path, undefined);
const startedAt = performance.now();
let response: Response;
try {
response = await fetch(url, {
method,
headers: {
"X-Auth-Token": apiToken,
Accept: "application/json",
...(options.body !== undefined ? { "Content-Type": "application/json" } : {}),
},
body: options.body !== undefined ? JSON.stringify(options.body) : undefined,
});
} catch (error) {
throw new AppError("UPSTREAM_API", "Could not reach BigCommerce.", { cause: error });
}
logApiRequest(method, url, response.status, performance.now() - startedAt);
if (!response.ok) {
throw new AppError("UPSTREAM_API", `A BigCommerce API request failed.`, {
cause: `${method} "${path}" failed with status ${response.status}.`,
status: response.status,
});
}
const data = response.status === 204 ? undefined : await parseJsonResponse<TResponse>(response, path);
return { data: data as TResponse, headers: response.headers };
}
async post<TResponse>(path: string, options: ApiMutationOptions = {}): Promise<ApiResponse<TResponse>> {
return this.mutate<TResponse>("POST", path, options);
}
async put<TResponse>(path: string, options: ApiMutationOptions = {}): Promise<ApiResponse<TResponse>> {
return this.mutate<TResponse>("PUT", path, options);
}
async delete<TResponse>(path: string, options: ApiMutationOptions = {}): Promise<ApiResponse<TResponse>> {
return this.mutate<TResponse>("DELETE", path, options);
}
}

A few details worth calling out:

  • Query strings can carry PII. This app’s endpoints support searching gift certificates by recipient email, so the request logger only logs a path’s parameter names, never their values.
  • No retry or timeout logic yet. You’ll have the opportunity after the tutorial to examine an implementation of these features.
2

Resolve the store hash and API token

Before you can implement the central controller, it’s necessary to flesh out the logic the application uses to resolve the store hash and token for API requests. Remember, the app supports multiple data modes, and you’re starting with “STATIC” for your first requests.

  • In “MOCK” mode, there is no store hash and no token to resolve, regardless of any store hash in the URL path.
  • In “STATIC” mode, both store hash and token are resolved from static environment variables, also regardless of any store hash in the URL path.
  • In “MULTITENANT” mode (full single-click app context), the store hash is detected from a URL segment, and the token matching that store will be looked up from storage.

Update src/lib/bc-api-client/resolve-store-credentials.ts to flesh out both functions.

resolve-store-credentials.ts
import { getDataMode } from "@/lib/bc-api-client/data-mode";
export { getDataMode } from "@/lib/bc-api-client/data-mode";
export type { DataMode } from "@/lib/bc-api-client/data-mode";
export function resolveStoreHash(storeHash: string | undefined): string | undefined {
switch (getDataMode()) {
case "MOCK":
return undefined;
case "STATIC":
return process.env.STATIC_STORE_HASH;
case "MULTITENANT":
if (!storeHash) {
throw new Error("A store hash is required when DATA_MODE is MULTITENANT.");
}
return storeHash;
}
}
export async function resolveApiToken(storeHash: string | undefined): Promise<string | undefined> {
if (getDataMode() === "STATIC") {
return process.env.STATIC_STORE_TOKEN;
}
throw new Error("Not implemented yet.");
}

The storeHash value passed into both functions will come from the corresponding URL segment in the app’s routes. You’re not currently using any such URLs as you’re testing your app, but store-scoped URL paths will be central to the workflow once you’re in a single-click context in the BigCommerce control panel.

3

Select the client based on data mode

It’s time to update getRestApiClient to support the real API client.

Update src/lib/bc-api-client/get-rest-api-client.ts.

get-rest-api-client.ts
import { MockRestApiClient } from "@/lib/bc-api-client/rest-client/mock-rest-client/mock-rest-client";
import { getDataMode, resolveApiToken, resolveStoreHash } from "@/lib/bc-api-client/resolve-store-credentials";
import { RestApiClient } from "@/lib/bc-api-client/rest-client/rest-client";
import { BcRestApiClient } from "@/lib/bc-api-client/rest-client/types";
async function getConfiguredRestApiClient(resolvedStoreHash: string | undefined): Promise<BcRestApiClient> {
return new RestApiClient({ storeHash: resolvedStoreHash, apiToken: await resolveApiToken(resolvedStoreHash) });
}
export async function getRestApiClient(storeHash: string | undefined): Promise<BcRestApiClient> {
if (getDataMode() === "MOCK") {
return new MockRestApiClient();
}
return getConfiguredRestApiClient(resolveStoreHash(storeHash));
}

Note the controller function’s support for a “mock” REST API client. This will ensure that “MOCK” mode continues to function as expected after you’ve updated the data access layer to make use of the API client surface.

Example code

Fetch gift certificates through the real REST client

Your API client implementation is now prepared to take care of authentication, response handling, and data mode switching. Now it’s time to use this layer for the specific API requests that drive the gift certificate manager.

Remember that the UI you built in the previous exercises makes use of existing data access layer functions like fetchGiftCertificates. You weren’t previously concerned with the details of these functions. In the project boilerplate, they directly use the handlers that return mock data. It’s time to update this layer to make real API requests.

1

Fetch the certificates list from the real client

Update src/lib/gift-certs-manager/gift-certificates/gift-certificates-api.ts to remove the mock list handler import:

gift-certificates-api.ts
// Remove this import
// import { handleGiftCertificatesListRequest } from "@/lib/gift-certs-manager/gift-certificates/mock/gift-certificates-list-handler";

Update the same file to implement fetchGiftCertificatesPage.

gift-certificates-api.ts
import { getDataMode } from "@/lib/bc-api-client/data-mode";
import { getRestApiClient } from "@/lib/bc-api-client/get-rest-api-client";
import { handleGiftCertificateDetailRequest } from "@/lib/gift-certs-manager/gift-certificates/mock/gift-certificate-detail-handler";
import {
GIFT_CERTIFICATES_PATH,
GiftCertificate,
GiftCertificatesQuery,
GiftCertificatesResult,
GiftCertificateStatus,
} from "@/lib/gift-certs-manager/gift-certificates/types";
...
async function fetchGiftCertificatesPage(
query: GiftCertificatesQuery,
storeHash: string | undefined,
): Promise<GiftCertificateWireRecord[]> {
const apiClient = await getRestApiClient(storeHash);
const { data: items } = await apiClient.get<GiftCertificateWireRecord[]>(GIFT_CERTIFICATES_PATH, {
params: {
sort: "id",
direction: query.direction.toLowerCase(),
page: query.page,
limit: query.limit,
},
});
return items ?? [];
}

You should also delete the import statement for handleGiftCertificatesListRequest from the mock/gift-certificates-list-handler file.

fetchGiftCertificatesPage handles the heavy lifting of fetching a single page of gift certificate results from the appropriate API endpoint. Examine the details of the GiftCertificatesQuery type to see the expected shape of the querystring parameters that specify the page size and page number.

Examine the details of the GiftCertificateWireRecord type to see the expected shape of each item in the response.

The function fetchGiftCertificates in the same file remains untouched. This function uses fetchGiftCertificatesPage to fetch the results set but also performs a “look-ahead” request for the next page of results. v2 endpoints like those used for gift certificates don’t return meta information about total results, so the look-ahead provides information the UI needs to determine whether to allow a “next page” navigation.

Browse to the gift certificates list page. You should now see real gift certificates from your store, paginated using the real endpoint’s page/limit/sort/direction params.

Example code

Fetch and update a single gift certificate through the real REST client

You’ve implemented a real API request for the data fetch on the list page. All that’s left is the requests on the detail page.

1

Fetch and update a single certificate

Update src/lib/gift-certs-manager/gift-certificates/gift-certificates-api.ts to remove the remaining mock-related imports:

gift-certificates-api.ts
// Remove these imports
// import { getDataMode } from "@/lib/bc-api-client/data-mode";
...
// import { handleGiftCertificateDetailRequest } from "@/lib/gift-certs-manager/gift-certificates/mock/gift-certificate-detail-handler";

Update the same file and implement the fetchGiftCertificate function.

gift-certificates-api.ts
import { getRestApiClient } from "@/lib/bc-api-client/get-rest-api-client";
import {
GIFT_CERTIFICATES_PATH,
GiftCertificate,
GiftCertificatesQuery,
GiftCertificatesResult,
GiftCertificateStatus,
getGiftCertificatePath,
} from "@/lib/gift-certs-manager/gift-certificates/types";
...
export async function fetchGiftCertificate(
id: number | string,
storeHash: string | undefined,
): Promise<GiftCertificate> {
const apiClient = await getRestApiClient(storeHash);
const { data: record } = await apiClient.get<GiftCertificateWireRecord>(getGiftCertificatePath(id));
return parseGiftCertificate(record);
}
...

Now update the same file to implement the updateGiftCertificate function.

gift-certificates-api.ts
...
async function updateGiftCertificate(
giftCertificate: GiftCertificate,
fields: Partial<Omit<GiftCertificateWireRecord, "id">>,
storeHash: string | undefined,
): Promise<GiftCertificate> {
const apiClient = await getRestApiClient(storeHash);
const { data: record } = await apiClient.put<GiftCertificateWireRecord>(getGiftCertificatePath(giftCertificate.id), {
body: { ...getRequiredFields(giftCertificate), ...fields },
});
return parseGiftCertificate(record);
}

You should also delete the imports for getDataMode and handleGiftCertificateDetailRequest. Switching based on data mode is handled by the client, and the handler is specific to mock data.

updateGiftCertificate is shared by every gift certificate mutation — status updates and balance refills alike. Callers pass only the field or fields they’re actually changing, and this function fills in whatever else BigCommerce’s PUT endpoint requires from the certificate’s current state. There’s deliberately no mock-mode support for this path: mock mode exists for building and demoing UI, not for round-tripping writes against in-memory data.

Browse to a gift certificate’s detail page and try a status update or balance refill action. You should see the change persist against your real store, since these actions now call updateGiftCertificate against the real REST client.

Example code

Note that the example code also updates the mock-related functions in their source files to avoid exporting them.

Full step code

Full diff

Your app is now fully functional as a standalone experience, reading and updating your real BigCommerce store data. Critically, it’s currently using a hard-coded API token, with no user authentication whatsoever. In the next exercise, you’ll implement the necessary workflow to embed the app in the BigCommerce control panel and support the platform-initiated authentication.

Next: Single-click authentication