1: Build a UI with BigDesign

This exercise runs entirely in MOCK mode — DATA_MODE=MOCK in .env.local, the setting your project already uses from the initial setup. No real API requests are made, and the mock data you need ships with the boilerplate, so you don’t need BigCommerce credentials or a store yet.

By the end of this exercise, your app renders a working gift certificates list and detail page, styled with BigDesign, BigCommerce’s React design system.

Install the BigDesign packages

BigDesign isn’t part of the project boilerplate, so your first task is to add it.

Run the following command in your project directory to install BigDesign and styled-components.

Install BigDesign
pnpm install @bigcommerce/big-design@latest @bigcommerce/big-design-icons@latest @bigcommerce/big-design-theme@latest styled-components@6

@bigcommerce/big-design provides the components themselves, -theme supplies the design tokens the components read, and -icons is BigDesign’s icon set. styled-components is the CSS-in-JS library BigDesign is built on, so it’s a direct dependency of your app rather than just a transitive one.

styled-components v6 ships its own TypeScript types, so no separate @types/styled-components package is needed.

Example code

Wire BigDesign into the root layout

BigDesign is styled-components based, so getting it working in a Next.js App Router project takes a few pieces of setup. In this section, you’ll make the layout modifications that wire BigDesign into the UI.

1

Enable the styled-components compiler transform

Update next.config.ts to enable the styled-components compiler transform.

next.config.ts
const nextConfig: NextConfig = {
compiler: {
styledComponents: true,
},
experimental: {
...
},
};

Without this setting, styled-components generates class names at runtime in an order that can differ between the server’s render and the client’s first hydration pass.

2

Create the BigDesign provider

Update src/components/ui/big-design-provider.tsx.

big-design-provider.tsx
"use client";
import { GlobalStyles } from "@bigcommerce/big-design";
import { theme } from "@bigcommerce/big-design-theme";
import { ThemeProvider } from "styled-components";
export function BigDesignProvider({ children }: { children: React.ReactNode }) {
return (
<ThemeProvider theme={theme}>
<GlobalStyles />
{children}
</ThemeProvider>
);
}

BigDesign components are Client Components

Note the "use client" directive at the top of this file. BigDesign’s components are Client Components: they rely on browser APIs, React state, and styled-components’ runtime, none of which exist during a server render.

In the Next.js App Router, every component is a Server Component by default — rendered on the server, never shipped to the browser, and unable to use hooks or event handlers. "use client" marks the boundary where that changes. A file with the directive, and everything it imports, is bundled for the browser and hydrated there.

BigDesign declares this boundary itself, so you can import its components directly from @bigcommerce/big-design into a Server Component and render them without adding "use client" to your own file. You only need the directive when your component needs client-side behavior of its own — state, effects, or event handlers — as this provider does.

3

Create the styled-components registry

Update src/components/ui/styled-components-registry.tsx.

styled-components-registry.tsx
"use client";
import { useState } from "react";
import { useServerInsertedHTML } from "next/navigation";
import { ServerStyleSheet, StyleSheetManager } from "styled-components";
export function StyledComponentsRegistry({ children }: { children: React.ReactNode }) {
const [sheet] = useState(() => new ServerStyleSheet());
useServerInsertedHTML(() => {
const styles = sheet.getStyleElement();
sheet.instance.clearTag();
return <>{styles}</>;
});
if (typeof window !== "undefined") return <>{children}</>;
return <StyleSheetManager sheet={sheet.instance}>{children}</StyleSheetManager>;
}
4

Add registry/providers in the root layout

Update src/app/layout.tsx to remove font-related imports and constants.

app/layout.tsx
// Remove these lines
// import { Geist, Geist_Mono } from "next/font/google";
// import "./globals.css";
// const geistSans = Geist({
// ...
// });
// const geistMono = Geist_Mono({
// ...
// });

Update the same file to wire in the styled-components registry, the proper font, the BigDesign provider, and the alerts manager around your app’s children.

app/layout.tsx
import type { Metadata } from "next";
import { Source_Sans_3 } from "next/font/google";
import { BigDesignProvider } from "@/components/ui/big-design-provider";
import { StyledComponentsRegistry } from "@/components/ui/styled-components-registry";
import { ActionAlertsManager } from "@/components/ui/action-alerts";
const sourceSans3 = Source_Sans_3({
subsets: ["latin"],
});
export const metadata: Metadata = {
title: "Gift Certificates Manager",
description: "A BigCommerce single-click app for managing gift certificates.",
};
export default function RootLayout({
...
}>) {
return (
<html lang="en" className={sourceSans3.className}>
<body>
<StyledComponentsRegistry>
<BigDesignProvider>
{children}
<ActionAlertsManager />
</BigDesignProvider>
</StyledComponentsRegistry>
</body>
</html>
);
}

StyledComponentsRegistry collects styled-components’ server-rendered styles so they ship with the SSR payload instead of flashing unstyled, and BigDesignProvider supplies the styled-components ThemeProvider (with BigDesign’s theme) plus GlobalStyles. ActionAlertsManager is a single, app-wide manager that renders whatever alert a server action queues from anywhere in the tree.

5

Delete unneeded CSS file

Delete src/app/globals.css.

With these pieces in place, restart your dev server and confirm the app still loads without hydration warnings in the console. Nothing visually changes yet; this section only wires up the machinery the rest of the exercise depends on.

Example code

Convert main layout components to BigDesign

A few of the UI primitives already in place in the project’s boilerplate should be updated to use BigDesign components before we proceed with building out the gift certificate pages.

1

Rebuild the app shell

Open src/components/gift-certs-manager/app-shell.tsx and remove existing imports for UI components.

app-shell.tsx
// Remove these imports
// import { Box } from "@/components/ui/box";
// import { Flex, FlexItem } from "@/components/ui/flex";

Update the same file to rebuild it with BigDesign’s layout primitives.

app-shell.tsx
import { Box, Flex, FlexItem } from "@bigcommerce/big-design";
import { DataModeBanner } from "@/components/layout/data-mode-banner";
import { DeveloperInfoPanel } from "@/components/layout/developer-info-panel";
...
export function AppShell({ children }: { children: React.ReactNode }) {
return (
<Box>
<DataModeBanner />
<Flex
flexDirection={{ mobile: "column", wide: "row" }}
padding="large"
flexGap="1rem"
alignItems={{ mobile: "stretch", wide: "flex-start" }}
>
<FlexItem flexGrow={1} flexShrink={1} flexBasis={{ mobile: "auto", wide: "0" }}>
<Box>{children}</Box>
</FlexItem>
...
</Flex>
</Box>
);
}
2

Rebuild the alerts manager

Update src/components/ui/action-alerts.tsx to remove the useSyncExternalStore import and all logic before showSuccessAlert.

action-alerts.tsx
// Remove this import
// import { useSyncExternalStore } from "react";
...
// Remove all this
// interface Alert {
// ...
// }
// let alerts: Alert[] = [];
// let nextId = 1;
// const listeners = new Set<() => void>();
// function notify(): void {
// ...
// }
// function subscribe(listener: () => void): () => void {
// ...
// }
// function getSnapshot(): Alert[] {
// ...
// }
// function dismiss(id: number): void {
// ...
// }
// function addAlert(type: Alert["type"], text: string, autoDismiss: boolean): void {
// ...
// }

Update the same file as shown below.

action-alerts.tsx
"use client";
import { createAlertsManager } from "@bigcommerce/big-design";
import { AlertsManager } from "@bigcommerce/big-design";
import { ActionResult } from "@/lib/actions/action-result";
const alertsManager = createAlertsManager();
export function showSuccessAlert(message: string): void {
alertsManager.add({
autoDismiss: true,
messages: [{ text: message }],
type: "success",
});
}
export function showErrorAlert(message: string): void {
alertsManager.add({
autoDismiss: false,
messages: [{ text: message }],
type: "error",
});
}
...
export function ActionAlertsManager() {
return <AlertsManager manager={alertsManager} />;
}

This component manages alerts for the whole app. The updates replace the custom implementation with a simple render of the existing BigDesign AlertsManager, as well as implementing the appropriate manager add calls.

4

Rebuild the content fallback

Update the imports in src/components/layout/content-fallback.tsx. This is an important “loading” fallback state when caching and <Suspense> are in use. Nothing about the component implementation needs to change; Flex and ProgressCircle simply get replaced with BigDesign-native components instead of custom primitives.

content-fallback.tsx
import { Flex, ProgressCircle } from "@bigcommerce/big-design";
5

Rebuild the data mode banner

Update the imports in src/components/layout/data-mode-banner.tsx. This banner reminds users when the app is running in a non-production mode.

data-mode-banner.tsx
import { Box, InlineMessage } from "@bigcommerce/big-design";
import { getDataMode } from "@/lib/bc-api-client/data-mode";
6

Rebuild the developer info panel

Update the imports in src/components/layout/developer-info-panel.tsx. This side panel identifies the app developer information.

developer-info-panel.tsx
import Image from "next/image";
import { Box, Flex, H4, Link, Panel, Small, Text } from "@bigcommerce/big-design";
import { BaselineHelpIcon } from "@bigcommerce/big-design-icons";
7

Rebuild the pending overlay

Update the imports in src/components/ui/pending-overlay.tsx, which dims already-rendered content and overlays a spinner during a pending client-side action.

pending-overlay.tsx
import { Box, Flex, ProgressCircle } from "@bigcommerce/big-design";
8

Delete unneeded UI components

Now that they’ve been replaced with BigDesign components, delete the following files:

  • src/components/ui/inline-message.tsx
  • src/components/ui/link.tsx
  • src/components/ui/progress-circle.tsx

Example code

Build the gift certificates list page

You can finally start building out new UI for your gift certificates list page.

You’ll use a component pattern common to all page routes in the example app: an outer route component where an authorization and <Suspense> boundary will eventually live, a page component where dynamic values are resolved, and a cacheable view component where data fetching is handled and the main UI actually begins.

1

Create the gift certificate table

The table that drives the core gift certificate list UI is its own component - a React client component, due to the involvement of browser interactivity.

Note that the client nature of the component necessitates its separation from the view component, which handles data fetching server-side.

Update src/components/gift-certs-manager/gift-certificates/list/gift-certificate-table.tsx with the table implementation.

gift-certificate-table.tsx
"use client";
import { useMemo, useState } from "react";
import { usePathname, useRouter } from "next/navigation";
import { Badge, Table, TableColumn } from "@bigcommerce/big-design";
import { AppLink } from "@/components/ui/app-link";
import { PendingOverlay } from "@/components/ui/pending-overlay";
import { buildGiftCertificatesSearchParams } from "@/lib/gift-certs-manager/gift-certificates/query";
import { GIFT_CERTIFICATE_STATUS_BADGE_VARIANT, GIFT_CERTIFICATE_STATUS_LABEL } from "@/lib/gift-certs-manager/gift-certificates/status";
import { GiftCertificate, GiftCertificatesQuery } from "@/lib/gift-certs-manager/gift-certificates/types";
import { getAppUrl } from "@/lib/routing/app-url";
const ITEMS_PER_PAGE_OPTIONS = [10, 20, 50];
const currencyFormatter = new Intl.NumberFormat("en-US", { style: "currency", currency: "USD" });
const dateFormatter = new Intl.DateTimeFormat("en-US", { dateStyle: "medium" });
function getColumns(storeHash: string | undefined): Array<TableColumn<GiftCertificate>> {
return [
{
header: "Certificate #",
hash: "id",
render: ({ id, code }: GiftCertificate) => (
<AppLink href={getAppUrl(storeHash, `/gift-certs/${id}`)}>{code}</AppLink>
),
isSortable: true,
},
{
header: "Status",
hash: "status",
render: ({ status }: GiftCertificate) => (
<Badge label={GIFT_CERTIFICATE_STATUS_LABEL[status]} variant={GIFT_CERTIFICATE_STATUS_BADGE_VARIANT[status]} />
),
},
{
header: "Original Value",
hash: "amount",
render: ({ amount }: GiftCertificate) => currencyFormatter.format(amount),
align: "right",
},
{
header: "Current Balance",
hash: "balance",
render: ({ balance }: GiftCertificate) => currencyFormatter.format(balance),
align: "right",
},
{
header: "Recipient",
hash: "to_name",
render: ({ to_name }: GiftCertificate) => to_name,
},
{
header: "Recipient Email",
hash: "to_email",
render: ({ to_email }: GiftCertificate) => to_email,
},
{
header: "Purchase Date",
hash: "purchase_date",
render: ({ purchase_date }: GiftCertificate) =>
dateFormatter.format(new Date(Number(purchase_date) * 1000)),
},
];
}
interface GiftCertificateTableProps {
giftCertificates: GiftCertificate[];
hasNextPage: boolean;
query: GiftCertificatesQuery;
storeHash: string | undefined;
}
export function GiftCertificateTable({ giftCertificates, hasNextPage, query, storeHash }: GiftCertificateTableProps) {
const router = useRouter();
const pathname = usePathname();
const columns = useMemo(() => getColumns(storeHash), [storeHash]);
const [isPending, setIsPending] = useState(false);
const [lastQuery, setLastQuery] = useState(query);
if (query !== lastQuery) {
setLastQuery(query);
setIsPending(false);
}
const navigate = (nextQuery: GiftCertificatesQuery) => {
const params = buildGiftCertificatesSearchParams(nextQuery);
const queryString = params.toString();
setIsPending(true);
router.push(queryString ? `${pathname}?${queryString}` : pathname);
};
return (
<PendingOverlay isPending={isPending}>
<Table
columns={columns}
items={giftCertificates}
keyField="id"
sortable={{
columnHash: "id",
direction: query.direction,
onSort: (_columnHash, direction) => navigate({ ...query, direction }),
}}
pagination={{
itemsPerPage: query.limit,
itemsPerPageOptions: ITEMS_PER_PAGE_OPTIONS,
onPrevious: query.page > 1 ? () => navigate({ ...query, page: query.page - 1 }) : undefined,
onNext: hasNextPage ? () => navigate({ ...query, page: query.page + 1 }) : undefined,
onItemsPerPageChange: (limit) => navigate({ ...query, limit, page: 1 }),
}}
/>
</PendingOverlay>
);
}

This component is purely presentational: it renders the page of items the server already fetched, and every search, sort, or pagination interaction navigates to a new URL with router.push rather than holding its own state or fetching data. The view component you’ll build next reads the resulting querystring with searchParams and re-fetches server-side, and PendingOverlay shows the in-between state.

Note that the BigDesign Table component is doing all the heavy lifting of the UI, requiring only props controlling the displayed columns, items, and nagivation behavior.

2

Implement the view component

This view component handles data fetching and then hands off rendering to the table component you built in the previous step. The component lives separately from the resolution of dynamic values like querystring params, receiving only serializable props so it remains cacheable.

Update src/components/gift-certs-manager/gift-certificates/list/gift-certificate-list-view.tsx.

gift-certificate-list-view.tsx
import { Panel } from "@bigcommerce/big-design";
import { GiftCertificateTable } from "@/components/gift-certs-manager/gift-certificates/list/gift-certificate-table";
import { fetchGiftCertificates } from "@/lib/gift-certs-manager/gift-certificates/gift-certificates-api";
import { parseGiftCertificatesQuery } from "@/lib/gift-certs-manager/gift-certificates/query";
export async function GiftCertificateListView({
searchParams,
storeHash,
}: {
searchParams: Record<string, string | string[] | undefined>;
storeHash: string | undefined;
}) {
const query = parseGiftCertificatesQuery(searchParams);
const { items, hasNextPage } = await fetchGiftCertificates(query, storeHash);
return (
<Panel header="Gift Certificates">
<GiftCertificateTable
giftCertificates={items}
hasNextPage={hasNextPage}
query={query}
storeHash={storeHash}
/>
</Panel>
);
}

Note that the component accepts a storeHash prop. While this isn’t currently used, it’s critical to the eventual single-click app implementation, where all app URLs will include a /store/<store-hash> segment identifying the store context.

The data fetching is done with the fetchGiftCertificates function that already exists in the project boilerplate. In this exercise, we’re concerned only with UI and not the data access layer. fetchGiftCertificates currently returns mock data. parseGiftCertificatesQuery turns the raw querystring params into a normalized query object; open and examine the import file if you want to see the details.

3

Complete the page component

Proceeding up the component tree, the page component is a quite thin layer, responsible for resolving params and searchParams and passing dynamic values to the view component as serializable props.

Update src/components/gift-certs-manager/gift-certificates/list/gift-certificates-page.tsx.

gift-certificates-page.tsx
import { GiftCertificateListView } from "@/components/gift-certs-manager/gift-certificates/list/gift-certificate-list-view";
export async function GiftCertificatesPage({
params,
searchParams,
}: {
params: Promise<Record<string, string | string[] | undefined>>;
searchParams: Promise<Record<string, string | string[] | undefined>>;
}) {
const resolvedParams = await params;
const resolvedSearchParams = await searchParams;
const storeHash = resolvedParams.storeHash;
const storeHashString = Array.isArray(storeHash) ? storeHash[0] : storeHash;
return <GiftCertificateListView searchParams={resolvedSearchParams} storeHash={storeHashString} />;
}
4

Complete the route component

At the top of the hierarchy is the route component itself. Right now this does nothing but render the page component you built in the previous step. In future exercises, you’ll see that the route will become the home of an authorization check.

Update src/app/store/[storeHash]/gift-certs/page.tsx.

gift-certs/page.tsx
import { GiftCertificatesPage } from "@/components/gift-certs-manager/gift-certificates/list/gift-certificates-page";
export default function Page(props: React.ComponentProps<typeof GiftCertificatesPage>) {
return <GiftCertificatesPage {...props} />;
}

Restart your dev server if needed and browse to /gift-certs to confirm the table renders with mock data, including working sort and pagination.

Gift certificates list with mock data

Example code

Replace the store home page with the gift certificates list

1

Update the home page export

Replace the contents of src/app/store/[storeHash]/page.tsx with a pass-through export to the gift certificates list page you just built.

[storeHash]/page.tsx
export { default } from "@/app/store/[storeHash]/gift-certs/page";

This step is a convenience, adopting the gift certificate list page as the content of the base route. This convention keeps the example app cleanly separate and easy to swap out for different home page content.

Example code

Build the gift certificate detail page

The core component hierarchy (route, page, and view components) will apply to the gift certificate detail page as well. However, the UI will be broken into several smaller components in this case, due to the composition of multiple tabs and panels.

1

Implement the status panel

The first and smallest piece of UI for the detail page is a “status” panel with basic information about the gift certificate.

Replace the contents of src/components/gift-certs-manager/gift-certificates/detail/gift-certificate-status-panel.tsx with the following contents.

gift-certificate-status-panel.tsx
import { Box, Panel, Select, Small, Text } from "@bigcommerce/big-design";
import { GIFT_CERTIFICATE_STATUSES, GIFT_CERTIFICATE_STATUS_LABEL } from "@/lib/gift-certs-manager/gift-certificates/status";
import { GiftCertificate, GiftCertificateStatus } from "@/lib/gift-certs-manager/gift-certificates/types";
const currencyFormatter = new Intl.NumberFormat("en-US", { style: "currency", currency: "USD" });
const dateFormatter = new Intl.DateTimeFormat("en-US", { dateStyle: "medium" });
const STATUS_OPTIONS = GIFT_CERTIFICATE_STATUSES.map((status) => ({
value: status,
content: GIFT_CERTIFICATE_STATUS_LABEL[status],
}));
function DetailField({ label, children }: { label: string; children: React.ReactNode }) {
return (
<Box marginBottom="medium">
<Small marginBottom="none">{label}</Small>
<Text marginBottom="none">{children}</Text>
</Box>
);
}
interface GiftCertificateStatusPanelProps {
giftCertificate: GiftCertificate;
status: GiftCertificateStatus;
onStatusChange(status: GiftCertificateStatus): void;
}
export function GiftCertificateStatusPanel({ giftCertificate, status, onStatusChange }: GiftCertificateStatusPanelProps) {
return (
<Panel header={giftCertificate.code}>
<DetailField label="Purchase Date">
{dateFormatter.format(new Date(Number(giftCertificate.purchase_date) * 1000))}
</DetailField>
<DetailField label="Email Template">{giftCertificate.template}</DetailField>
<DetailField label="Original Value">{currencyFormatter.format(giftCertificate.amount)}</DetailField>
<Box marginBottom="none">
<Select
label="Status"
onOptionChange={(value) => value && onStatusChange(value)}
options={STATUS_OPTIONS}
value={status}
/>
</Box>
</Panel>
);
}

Note that the component expects an onStatusChange handler controlled by the ancestor.

2

Implement the party panel

A “party” will refer to either the sender or recipient of a gift certificate. This panel will be used to show details of both.

Replace the contents of src/components/gift-certs-manager/gift-certificates/detail/gift-certificate-party-panel.tsx.

gift-certificate-party-panel.tsx
import { Box, Panel, Small, Text } from "@bigcommerce/big-design";
function DetailField({ label, children }: { label: string; children: React.ReactNode }) {
return (
<Box marginBottom="medium">
<Small marginBottom="none">{label}</Small>
<Text marginBottom="none">{children}</Text>
</Box>
);
}
export function GiftCertificatePartyPanel({
header,
name,
email,
}: {
header: string;
name: string;
email: string;
}) {
return (
<Panel header={header}>
<DetailField label="Name on Certificate">{name}</DetailField>
<DetailField label="Email">{email}</DetailField>
</Panel>
);
}
3

Implement the details tab

With both panels implemented, you can now build out the “Details” tab, which displays basic gift certificate, sender, and recipient information.

Replace the contents of src/components/gift-certs-manager/gift-certificates/detail/gift-certificate-details-tab.tsx.

gift-certificate-details-tab.tsx
"use client";
import { useState } from "react";
import { Flex, FlexItem } from "@bigcommerce/big-design";
import { GiftCertificatePartyPanel } from "@/components/gift-certs-manager/gift-certificates/detail/gift-certificate-party-panel";
import { GiftCertificateStatusPanel } from "@/components/gift-certs-manager/gift-certificates/detail/gift-certificate-status-panel";
import { GiftCertificate, GiftCertificateStatus } from "@/lib/gift-certs-manager/gift-certificates/types";
export function GiftCertificateDetailsTab({
giftCertificate,
storeHash,
}: {
giftCertificate: GiftCertificate;
storeHash: string | undefined;
}) {
const [status, setStatus] = useState<GiftCertificateStatus>(giftCertificate.status);
return (
<Flex flexDirection="column" flexGap="1rem">
<FlexItem>
<GiftCertificateStatusPanel giftCertificate={giftCertificate} onStatusChange={setStatus} status={status} />
</FlexItem>
<FlexItem>
<GiftCertificatePartyPanel header="Sender" name={giftCertificate.from_name} email={giftCertificate.from_email} />
</FlexItem>
<FlexItem>
<GiftCertificatePartyPanel header="Recipient" name={giftCertificate.to_name} email={giftCertificate.to_email} />
</FlexItem>
</Flex>
);
}
4

Implement the balance tab

The gift certificate’s balance will be displayed in a tab of its own, which will eventually be dedicated to several actions capable of updating the balance.

Replace the contents of src/components/gift-certs-manager/gift-certificates/detail/gift-certificate-balance-tab.tsx.

gift-certificate-balance-tab.tsx
"use client";
import { Box, Panel, Small, Text } from "@bigcommerce/big-design";
import { GiftCertificate } from "@/lib/gift-certs-manager/gift-certificates/types";
const currencyFormatter = new Intl.NumberFormat("en-US", { style: "currency", currency: "USD" });
function DetailField({ label, children }: { label: string; children: React.ReactNode }) {
return (
<Box marginBottom="medium">
<Small marginBottom="none">{label}</Small>
<Text marginBottom="none">{children}</Text>
</Box>
);
}
export function GiftCertificateBalanceTab({
giftCertificate,
storeHash,
}: {
giftCertificate: GiftCertificate;
storeHash: string | undefined;
}) {
return (
<Panel header={giftCertificate.code}>
<DetailField label="Original Value">{currencyFormatter.format(giftCertificate.amount)}</DetailField>
<DetailField label="Current Balance">{currencyFormatter.format(giftCertificate.balance)}</DetailField>
</Panel>
);
}
5

Implement the tabs container

This thin client component implements the tabs interface itself, using the two tab components you’ve built.

Replace the contents of src/components/gift-certs-manager/gift-certificates/detail/gift-certificate-tabs.tsx.

gift-certificate-tabs.tsx
"use client";
import { useState } from "react";
import { Box, Tabs } from "@bigcommerce/big-design";
import { GiftCertificateBalanceTab } from "@/components/gift-certs-manager/gift-certificates/detail/gift-certificate-balance-tab";
import { GiftCertificateDetailsTab } from "@/components/gift-certs-manager/gift-certificates/detail/gift-certificate-details-tab";
import { GiftCertificate } from "@/lib/gift-certs-manager/gift-certificates/types";
const TAB_ITEMS = [
{ id: "details", title: "Details", ariaControls: "details-content" },
{ id: "balance", title: "Balance", ariaControls: "balance-content" },
];
export function GiftCertificateTabs({
giftCertificate,
storeHash,
}: {
giftCertificate: GiftCertificate;
storeHash: string | undefined;
}) {
const [activeTab, setActiveTab] = useState("details");
return (
<>
<Tabs activeTab={activeTab} items={TAB_ITEMS} onTabClick={setActiveTab} />
{activeTab === "details" ? (
<Box id="details-content">
<GiftCertificateDetailsTab
giftCertificate={giftCertificate}
key={`${giftCertificate.id}-${giftCertificate.status}`}
storeHash={storeHash}
/>
</Box>
) : (
<Box id="balance-content">
<GiftCertificateBalanceTab
giftCertificate={giftCertificate}
key={`${giftCertificate.id}-${giftCertificate.balance}`}
storeHash={storeHash}
/>
</Box>
)}
</>
);
}

As with the table on the gift certificate list page, you’re letting the BigDesign Tabs component do the heavy lifting, using a piece of React state (activeTab) to track the currently selected tab.

6

Complete the view component

From here, the component hierarchy mirrors the list page. The view component performs data fetching and hands off to GiftCertificateTabs for the main UI.

Update src/components/gift-certs-manager/gift-certificates/detail/gift-certificate-view.tsx.

gift-certificate-view.tsx
import { notFound } from "next/navigation";
import { Box, Flex } from "@bigcommerce/big-design";
import { ArrowBackIcon } from "@bigcommerce/big-design-icons";
import { AppLink } from "@/components/ui/app-link";
import { GiftCertificateTabs } from "@/components/gift-certs-manager/gift-certificates/detail/gift-certificate-tabs";
import { fetchGiftCertificate } from "@/lib/gift-certs-manager/gift-certificates/gift-certificates-api";
import { getAppUrl } from "@/lib/routing/app-url";
import { AppError } from "@/lib/errors/app-error";
export async function GiftCertificateView({
id,
storeHash,
}: {
id: string;
storeHash: string | undefined;
}) {
let giftCertificate;
try {
giftCertificate = await fetchGiftCertificate(id, storeHash);
} catch (error) {
if (error instanceof AppError && error.status === 404) {
notFound();
}
throw error;
}
return (
<Box>
<Box marginBottom="medium">
<AppLink href={getAppUrl(storeHash, "/gift-certs")}>
<Flex alignItems="center" flexGap="0.25rem">
<ArrowBackIcon size="small" />
Back to Gift Certificates
</Flex>
</AppLink>
</Box>
<GiftCertificateTabs giftCertificate={giftCertificate} storeHash={storeHash} />
</Box>
);
}

As with the list page, fetchGiftCertificate is an existing data access layer function that currently returns mock data.

7

Complete the page component

The page component is also familiar, resolving dynamic params and passing them as props to the view component.

Update src/components/gift-certs-manager/gift-certificates/detail/gift-certificate-detail-page.tsx.

gift-certificate-detail-page.tsx
import { GiftCertificateView } from "@/components/gift-certs-manager/gift-certificates/detail/gift-certificate-view";
export async function GiftCertificateDetailPage({
params,
searchParams,
}: {
params: Promise<Record<string, string | string[] | undefined>>;
searchParams: Promise<Record<string, string | string[] | undefined>>;
}) {
const resolvedParams = await params;
await searchParams;
const id = resolvedParams.id;
const idString = Array.isArray(id) ? id[0] : id;
const storeHash = resolvedParams.storeHash;
const storeHashString = Array.isArray(storeHash) ? storeHash[0] : storeHash;
return <GiftCertificateView id={idString ?? ""} storeHash={storeHashString} />;
}

Note that both id and storeHash are resolved from params.

8

Complete the route component

Update src/app/store/[storeHash]/gift-certs/[id]/page.tsx.

gift-certs/[id]/page.tsx
import { GiftCertificateDetailPage } from "@/components/gift-certs-manager/gift-certificates/detail/gift-certificate-detail-page";
export default function Page(props: React.ComponentProps<typeof GiftCertificateDetailPage>) {
return <GiftCertificateDetailPage {...props} />;
}

The props that the route passes directly to GiftCertificateDetailPage contain the params where that component looks for id and storeHash. These are resolved from the [id] and [storeHash] route segments in the file path of the route.

Browse to a gift certificate from the list you built earlier in this exercise, and confirm both tabs render.

Gift certificate detail page

Example code

Status update and balance refill actions

This step will give you a chance to incorporate BigDesign components and patterns for user actions. The actions we’ll implement for the scope of this tutorial include updating a gift certificate’s status and refilling its balance.

1

Implement the server actions

The client components that drive user interactivity on the gift certificate detail page directly call server actions - functions that execute on the server.

Update src/app/store/[storeHash]/gift-certs/[id]/actions.ts.

gift-certs/[id]/actions.ts
"use server";
import { ActionResult } from "@/lib/actions/action-result";
import {
fetchGiftCertificate,
refillGiftCertificateBalance as refillGiftCertificateBalanceRequest,
updateGiftCertificateStatus as updateGiftCertificateStatusRequest,
} from "@/lib/gift-certs-manager/gift-certificates/gift-certificates-api";
import { GiftCertificateStatus } from "@/lib/gift-certs-manager/gift-certificates/types";
import { isNotFoundError, toSafeMessage } from "@/lib/errors/app-error";
import { logError } from "@/lib/errors/logger";
import { revalidatePath } from "next/cache";
const GIFT_CERTIFICATE_NOT_FOUND_MESSAGE =
"That gift certificate no longer exists. It may have been deleted — reload the page to see the current list.";
export async function updateGiftCertificateStatus(
id: number | string,
status: GiftCertificateStatus,
storeHash: string | undefined,
): Promise<ActionResult> {
try {
const giftCertificate = await fetchGiftCertificate(id, storeHash);
await updateGiftCertificateStatusRequest(giftCertificate, status, storeHash);
} catch (error) {
logError(`updateGiftCertificateStatus: certificate ${id}`, error);
if (isNotFoundError(error)) {
return { success: false, message: GIFT_CERTIFICATE_NOT_FOUND_MESSAGE };
}
return { success: false, message: toSafeMessage(error, "Failed to update the gift certificate status.") };
}
revalidatePath("/store/[storeHash]/gift-certs/[id]", "page");
revalidatePath("/store/[storeHash]/gift-certs", "page");
return { success: true, message: "Gift certificate status updated." };
}
export async function refillGiftCertificateBalance(
id: number | string,
newBalance: number,
storeHash: string | undefined,
): Promise<ActionResult> {
try {
const giftCertificate = await fetchGiftCertificate(id, storeHash);
if (giftCertificate.status !== "active" && giftCertificate.status !== "expired") {
return { success: false, message: "Only active or expired gift certificates can be refilled." };
}
if (!Number.isFinite(newBalance) || newBalance < 0) {
return { success: false, message: "Refill balance must be a non-negative number." };
}
if (newBalance <= giftCertificate.balance) {
return { success: false, message: "Refill balance must be greater than the current gift certificate balance." };
}
if (newBalance > giftCertificate.amount) {
return { success: false, message: "Refill balance cannot exceed the original gift certificate amount." };
}
await refillGiftCertificateBalanceRequest(giftCertificate, newBalance, storeHash);
} catch (error) {
logError(`refillGiftCertificateBalance: certificate ${id}`, error);
if (isNotFoundError(error)) {
return { success: false, message: GIFT_CERTIFICATE_NOT_FOUND_MESSAGE };
}
return { success: false, message: toSafeMessage(error, "Failed to refill the gift certificate balance.") };
}
revalidatePath("/store/[storeHash]/gift-certs/[id]", "page");
revalidatePath("/store/[storeHash]/gift-certs", "page");
return { success: true, message: "Gift certificate balance refilled." };
}

Note that the two server action functions accept simple arguments and return simple JSON results; Next.js takes care of wrapping the function execution in a web request from the client components.

Both actions re-fetch the certificate server-side before acting on it, trusting only the id the client supplied. Once again, you’re relying on data access layer functions (like refillGiftCertificateBalanceRequest) that already exist; you’re not yet concerned with the API call details.

On success, each action calls revalidatePath for both the detail and list routes. Without this, the mutation would succeed on the server but the user would keep seeing the stale values Next.js already rendered, making the action look broken. Invalidating both routes means the certificate’s new status or balance is reflected whether the user stays on the detail page or navigates back to the list.

2

Add refill behavior to the balance tab

Modify src/components/gift-certs-manager/gift-certificates/detail/gift-certificate-balance-tab.tsx to add the refill action, a confirmation modal, and a pending state.

gift-certificate-balance-tab.tsx
"use client";
import { useState, useTransition } from "react";
import { Box, Button, Flex, Input, Modal, Panel, Small, Text } from "@bigcommerce/big-design";
import {
refillGiftCertificateBalance,
} from "@/app/store/[storeHash]/gift-certs/[id]/actions";
import { runServerAction } from "@/components/ui/action-alerts";
import { canRefill } from "@/lib/gift-certs-manager/gift-certificates/status";
import { GiftCertificate } from "@/lib/gift-certs-manager/gift-certificates/types";
type BalanceAction = "refill";
const ACTION_LABEL: Record<BalanceAction, string> = {
refill: "Refill",
};
const currencyFormatter = new Intl.NumberFormat("en-US", { style: "currency", currency: "USD" });
...
function getConfirmationMessage(action: BalanceAction, amount: number): string {
switch (action) {
case "refill":
return `Refill balance to ${currencyFormatter.format(amount)}?`;
}
}
export function GiftCertificateBalanceTab({
giftCertificate,
storeHash,
}: {
giftCertificate: GiftCertificate;
storeHash: string | undefined;
}) {
const [selectedAction, setSelectedAction] = useState<BalanceAction | null>(null);
const [pendingAction, setPendingAction] = useState<BalanceAction | null>(null);
const [refillAmount, setRefillAmount] = useState(String(giftCertificate.amount));
const [isPending, startTransition] = useTransition();
const toggleAction = (action: BalanceAction) => {
setSelectedAction((current) => (current === action ? null : action));
};
const closeConfirmModal = () => setPendingAction(null);
const dismissConfirmModal = () => {
setPendingAction(null);
setSelectedAction(null);
setRefillAmount(String(giftCertificate.amount));
};
const handleConfirm = () => {
const action = pendingAction;
closeConfirmModal();
startTransition(async () => {
switch (action) {
case "refill":
await runServerAction(() =>
refillGiftCertificateBalance(giftCertificate.id, Number(refillAmount), storeHash),
);
break;
}
});
};
const pendingAmount = refillAmount;
const canSubmitRefill = refillAmount !== "" && Number(refillAmount) > giftCertificate.balance;
return (
<Panel header={giftCertificate.code}>
<DetailField label="Original Value">{currencyFormatter.format(giftCertificate.amount)}</DetailField>
<DetailField label="Current Balance">{currencyFormatter.format(giftCertificate.balance)}</DetailField>
<Flex flexGap="0.5rem" marginBottom="medium">
<Button
disabled={!canRefill(giftCertificate)}
onClick={() => toggleAction("refill")}
variant={selectedAction === "refill" ? "primary" : "secondary"}
>
Refill
</Button>
</Flex>
{selectedAction === "refill" && (
<Box>
<Input
label="Refill to new balance"
onChange={(event) => setRefillAmount(event.target.value)}
type="number"
value={refillAmount}
/>
<Text>
This will set the total active balance to this amount — more than the current balance of{" "}
<strong>{currencyFormatter.format(giftCertificate.balance)}</strong>, up to{" "}
<strong>{currencyFormatter.format(giftCertificate.amount)}</strong>.
</Text>
<Button disabled={!canSubmitRefill} onClick={() => setPendingAction("refill")} variant="primary">
Refill
</Button>
</Box>
)}
{pendingAction && (
<Modal
actions={[
{ text: "Cancel", variant: "subtle", onClick: dismissConfirmModal },
{
text: ACTION_LABEL[pendingAction],
variant: "primary",
isLoading: isPending,
onClick: handleConfirm,
},
]}
closeOnEscKey
header={ACTION_LABEL[pendingAction]}
isOpen
onClose={dismissConfirmModal}
>
<Text marginBottom="none">{getConfirmationMessage(pendingAction, Number(pendingAmount))}</Text>
</Modal>
)}
</Panel>
);
}

canRefill is a pre-existing helper in the boilerplate’s gift-certificates/status.ts. It centralizes the conditions under which a refill is offered at all — currently, that the certificate’s status is one that can still be refilled. Using it here rather than an inline status check keeps the button’s enabled state and the server action’s own validation from drifting apart. The separate canSubmitRefill check then gates the submit button on the entered amount actually exceeding the current balance, so the action can’t be sent in a state the server would just reject.

3

Add status update behavior to the details tab

Modify src/components/gift-certs-manager/gift-certificates/detail/gift-certificate-details-tab.tsx to add cancel and update-status controls.

gift-certificate-details-tab.tsx
"use client";
import { useState, useTransition } from "react";
import { Button, Flex, FlexItem, Modal, Text } from "@bigcommerce/big-design";
import { updateGiftCertificateStatus } from "@/app/store/[storeHash]/gift-certs/[id]/actions";
import { GiftCertificatePartyPanel } from "@/components/gift-certs-manager/gift-certificates/detail/gift-certificate-party-panel";
import { GiftCertificateStatusPanel } from "@/components/gift-certs-manager/gift-certificates/detail/gift-certificate-status-panel";
import { runServerAction } from "@/components/ui/action-alerts";
import { GIFT_CERTIFICATE_STATUS_LABEL } from "@/lib/gift-certs-manager/gift-certificates/status";
import { GiftCertificate, GiftCertificateStatus } from "@/lib/gift-certs-manager/gift-certificates/types";
export function GiftCertificateDetailsTab({
giftCertificate,
storeHash,
}: {
giftCertificate: GiftCertificate;
storeHash: string | undefined;
}) {
const [status, setStatus] = useState<GiftCertificateStatus>(giftCertificate.status);
const [isPending, startTransition] = useTransition();
const [isUpdateModalOpen, setIsUpdateModalOpen] = useState(false);
const isDirty = status !== giftCertificate.status;
const resetStatus = () => setStatus(giftCertificate.status);
const dismissUpdateModal = () => {
setIsUpdateModalOpen(false);
resetStatus();
};
const closeUpdateModal = () => setIsUpdateModalOpen(false);
const handleUpdate = () => {
startTransition(async () => {
await runServerAction(() => updateGiftCertificateStatus(giftCertificate.id, status, storeHash));
closeUpdateModal();
});
};
return (
<Flex flexDirection="column" flexGap="1rem">
...
<FlexItem>
<GiftCertificateStatusPanel ... />
</FlexItem>
<FlexItem>
<GiftCertificatePartyPanel ... />
</FlexItem>
<FlexItem>
<GiftCertificatePartyPanel ... />
</FlexItem>
<FlexItem>
<Flex flexGap="0.5rem">
<Button disabled={!isDirty || isPending} onClick={resetStatus} variant="subtle">
Cancel
</Button>
<Button
disabled={!isDirty || isPending}
onClick={() => setIsUpdateModalOpen(true)}
variant="primary"
>
Update Status
</Button>
</Flex>
</FlexItem>
<Modal
actions={[
{ text: "Cancel", variant: "subtle", onClick: dismissUpdateModal },
{ text: "Update Status", variant: "primary", isLoading: isPending, onClick: handleUpdate },
]}
closeOnEscKey
header="Update Status"
isOpen={isUpdateModalOpen}
onClose={dismissUpdateModal}
>
<Text marginBottom="none">
Update status from {GIFT_CERTIFICATE_STATUS_LABEL[giftCertificate.status]} to{" "}
{GIFT_CERTIFICATE_STATUS_LABEL[status]}?
</Text>
</Modal>
</Flex>
);
}

On a gift certificate detail page, you should now be able to observe the UI interactions for status updates and balance refills. NOTE, however, that updates will currently fail. The static mock data you’re currently relying on can’t be mutated! You’ll be revisiting the updating logic in the next exercise.

Balance refill action UI

Our basic BigDesign-based UI for managing gift certificates is now complete!

Example code

Convert remaining components to BigDesign

The initial project boilerplate still contains a few components built with custom UI not yet converted to BigDesign. These mostly consist of error pages. After converting these, several custom UI components can finally be deleted.

We won’t walk through these final conversions explicitly, but see the example code link below if you want to clean up these final components.

Example code

Full step code

Full diff

Next: Integrate BigCommerce data