> This page is for Developer.

> For clean Markdown of any page, append .md to the page URL.
> For a complete documentation index, see https://docs.bigcommerce.com/llms.txt.
> For AI client integration (Claude Code, Cursor, etc.), connect to the MCP server at https://docs.bigcommerce.com/_mcp/server.

# Lab - Server Actions and Built-in Components

Plan: Composable Developer

Lesson 13 of 19 · 45 min

After the steps in the previous exercise, you should have a basic presentation of product FAQs displayed on your product detail page. Next, you'll improve this with enhanced presentation using the core component library and interactivity.

**In this lab, you will:**

* Update the product FAQs presentation to use the Accordion component from the core component library
* Implement a "Load More" feature, using a server action to fetch additional pages of results
* Add loading states and error handling

## Setup

This exercise continues where you left off previously in your Catalyst project. Simply start the dev server from the project root if it's not currently running:

```bash showLineNumbers={false}
pnpm run dev
```

If you need a fresh start, you can follow the instructions below to set up a new project complete with previous exercise code.

1. **Install** `degit`.

```bash showLineNumbers={false}
npm install -g degit
```

2. In your terminal, **run** the following, replacing the path to the working directory with your own path:

```bash showLineNumbers={false}
degit https://github.com/bigcommerce-edu/lab-catalyst-makeswift-faqs#e-faqs-enh-start /path/to/working/directory
```

3. **Navigate** to the new project working directory and **initialize** a Git repository for your project.

```bash showLineNumbers={false}
cd /path/to/working/directory
git init
git add .
git commit -m "Initial project files"
```

4. **Install** the project dependencies.

```bash showLineNumbers={false}
corepack enable pnpm && pnpm install
```

5. **Copy** `.env.example` as `.env.local` and **modify** `.env.local` with configuration details for your store.
6. **Start** the dev server process:

```bash showLineNumbers={false}
cd /path/to/working/directory
pnpm run dev
```

### Exercise Reference Copy

If it's helpful to compare your own code as you go, you can clone or download the completed version of this lab as a reference.

[Product FAQs Enhancement Lab Snapshot](https://github.com/bigcommerce-edu/lab-catalyst-makeswift-faqs/tree/e-faqs-enh-complete)

## Step 1: Implement an Accordion

1. **Edit** the file `components/custom/product-faqs/faqs-list.tsx` and **add** an import among the other imports at the top of the file.

```js showLineNumbers={false}
import { Accordion, AccordionItem } from '@/vibes/soul/primitives/accordion';
```

2. **Modify** the original JSX code returned from the component as shown.

```jsx {5-14} showLineNumbers={false}
export function FaqsList({
  faqs,
}: FaqsListProps) {
  return faqs.length <= 0 ? '' : (
    <div className="mx-auto md:w-2/3">
      <Accordion
        type="multiple"
      >
        {faqs.map(faq => (
          <AccordionItem key={faq.key} title={faq.question} value={faq.key}>
            {faq.answer}
          </AccordionItem>
        ))}
      </Accordion>
    </div>
  );
}
```

**Browse** to your FAQ-enabled product on your storefront, and you should now see the original presentation replaced with a cleaner, compact interface allowing FAQs to be expanded individually.

![Product FAQs with accordions](/_fern-files/bigcommerce.docs.buildwithfern.com/0285bb2ee7abe6acf6fe381f0b86dece351ee8ed92b1258ffbbccf4f45ca7506/assets/learn/catalyst-core/accordion-faqs.png)

As a brief exercise to demonstrate how common components can be themed using CSS variables, let's also override one of the `Accordion` component's color styles.

3. **Modify** the `Accordion` placement with a `style` property to set `--accordion-light-title-text-hover` in this context.

```html {2-5} showLineNumbers={false}
<Accordion
  // eslint-disable-next-line @typescript-eslint/consistent-type-assertions
  style={{
    '--accordion-light-title-text-hover': 'hsl(var(--info))'
  } as React.CSSProperties}

  type="multiple"
>
  ...
</Accordion>
```

In this case, we're making use of one of the primitive color values defined in the site-wide palette (`info`), utilizing the matching CSS variable (`--info`). Since Makeswift is integrated in this project, this CSS variable is defined in `lib/makeswift/components/site-theme/base-colors.tsx`. **Browse** to the product page again and verify that the corresponding color value is used when hovering over each accordion title.

> **Info**
>
> In a Makeswift-enabled storefront, it's considered best practice to avoid the direct use of primitive color values as we've done here with `--info`. Instead, the color palette defined within the Makeswift site should be authoritative, with the use of Site Theme properties to allow for mapping them to practical CSS variables for components.
>
> See Makeswift Core for more on this technique.

[Example Code](https://github.com/bigcommerce-edu/lab-catalyst-makeswift-faqs/compare/e-faqs-enh-01a-pre...e-faqs-enh-01b-post?diff=split)

## Step 2: Implement a Load More Component

Time to implement the ability to load more product FAQs.

1. **Edit** the file `messages/en.json` to add one more string in the "FAQ" section: A "Load More" label.

```json {4} showLineNumbers={false}
    ...
    "FAQ": {
      "heading": "Frequently Asked Questions",
      "loadMore": "Load more"
    }
    ...
```

2. **Open** the file `components/custom/product-faqs/load-more-faqs.tsx`. This component handles the interactivity involved with the "Load More" action and renders any additional "pages" of FAQs that are loaded.

Note the `use client` directive at the top of the component file.  As it's responsible for interactivity in the browser, this is a client component.

3. **Add** an import for the `Button` component from the core library, with the other imports at the top of the file.

```js showLineNumbers={false}
import { Button } from '@/vibes/soul/primitives/button';
```

4. **Modify** the props interface and destructuring expression to accept the appropriate props.

```js {2-4,8-10} showLineNumbers={false}
interface LoadMoreFaqsProps {
  productId: number;
  limit: number;
  initialEndCursor: string | null;
}

export function LoadMoreFaqs({
  productId,
  limit,
  initialEndCursor,
}: LoadMoreFaqsProps) {
  ...
}
```

5. **Modify** the component to create state values to track the "more FAQs" that are loaded by the component, as well as the current end cursor. This modification will also create a full list of FAQs to display by concatenating the original passed FAQs with any further FAQs that are loaded, as well as defining a function for loading the next set based on locale.

```js {4-23} showLineNumbers={false}
export function LoadMoreFaqs({
  ...
}: LoadMoreFaqsProps) {
  const locale = useLocale();
  const t = useTranslations('Product.FAQ');

  const [faqs, setFaqs] = useState<Faq[]>([]);
  const [endCursor, setEndCursor] = useState<string | null>(initialEndCursor);

  const getNextFaqs = async () => {
    if (!productId) {
      return;
    }

    try {
      const nextFaqData = await getNextProductFaqs({ productId, locale, limit, after: endCursor });

      setEndCursor(nextFaqData.endCursor);
      setFaqs(faqs.concat(nextFaqData.faqs));
    } catch (err) {
      // Handle error
    }
  };

  return (
    ...
  );
}
```

6. **Modify** the returned JSX to render any additional loaded FAQs, using the same `FaqsList` component already designed for this.

```jsx {7-11} showLineNumbers={false}
export function LoadMoreFaqs({
  ...
}: LoadMoreFaqsProps) {
  ...

  return (
    <>
      {faqs.length > 0 && (
        <FaqsList faqs={faqs} />
      )}
    </>
  );
}
```

7. **Modify** the returned JSX code to include a "Load More" button if there is an `endCursor` value.

```jsx {12-21} showLineNumbers={false}
export function LoadMoreFaqs({
  ...
}: LoadMoreFaqsProps) {
  ...

  return (
    <>
      {faqs.length > 0 && (
        <FaqsList faqs={faqs} />
      )}

      {(endCursor !== null) && (
        <div className="mx-auto md:w-2/3 lg:w-1/3 text-center py-4">
          <Button
            onClick={getNextFaqs}
            variant="secondary"
          >
            {t('loadMore')}
          </Button>
        </div>
      )}
    </>
  );
}
```

8. **Open** the file `components/custom/product-faqs/index.tsx` and **add** the boolean prop `showLoadMore`. This isn't currently needed but will allow for conditional inclusion of the "Load More" interactivity in the future.

```js {3,8} showLineNumbers={false}
interface ProductFaqsProps {
  ...
  showLoadMore?: boolean;
}

export function ProductFaqs({
  ...
  showLoadMore = true,
}: ProductFaqsProps) {
  ...
}
```

9. **Add** the `LoadMoreFaqs` component, with the rendering conditional on `showLoadMore` and the presence of an `endCursor`.

```jsx {11-17} showLineNumbers={false}
export function ProductFaqs({
  ...
}: ProductFaqsProps) {
  return (
    <section ...>
    <div ...>
      ...

      <FaqsList faqs={faqsCollection.faqs} />

      {showLoadMore && (faqsCollection.endCursor !== null) && (
        <LoadMoreFaqs
          initialEndCursor={faqsCollection.endCursor}
          limit={limit}
          productId={productId}
        />
      )}
    </div>
    </section>
  );
}
```

**Browse** to your product page once again. Presuming the product contains more FAQ metafields than the two initially loaded on the page, you should have a "Load More" button. Since the server action this will call is already wired up, you can see a corresponding request in the Network tab of your browser's dev tools when you activate the button, although there won't be any result until the server action is working.

[Example Code](https://github.com/bigcommerce-edu/lab-catalyst-makeswift-faqs/compare/e-faqs-enh-02-pre...e-faqs-enh-02-post?diff=split)

## Step 3: Complete the Server Action

1. **Open** the file `components/custom/product-faqs/_actions/get-next-product-faqs.ts`. This defines the server action that the "Load More" interaction in the client component activates.
2. **Modify** the component to run the metafields query.

```js {2} showLineNumbers={false}
export const getNextProductFaqs = async(variables: ProductFaqVariables) => {
  return await getProductFaqMetafields(variables);
}
```

You can see that not much is required here, as this function is essentially serving simply as a "pass-through" to the query function. Having the wrapper here allows this specific context to be defined as a server action, as well as any future logic that is specific to this response to our "load more" button.

**Browse** to your product page once again. Presuming the product contains more FAQ metafields than the two initially loaded on the page, you should have a working "Load More" button that will successfully fetch and display the next (up to) two FAQs.

[Example Code](https://github.com/bigcommerce-edu/lab-catalyst-makeswift-faqs/compare/e-faqs-enh-03-pre...e-faqs-enh-03-post?diff=split)

## Step 4: Load Initial FAQs Asynchronously

In the next step, we'll use the `<Stream>` component pattern to ensure the initial loading of FAQs doesn't impact the rendering of the basic page content.

1. **Edit** the file `components/custom/product-faqs/index.tsx` and fill out a definition for the `ProductFaqsSkeleton` component. This defines a visual loading state appropriate for the FAQs content.

```jsx {2-8} showLineNumbers={false}
export function ProductFaqsSkeleton() {
  return (
    <div className="animate-pulse mx-auto md:w-2/3 p-4 items-center">
      <div className="my-3 h-12 w-full rounded-md bg-contrast-100 @md:my-4" />
      <div className="my-3 h-12 w-full rounded-md bg-contrast-100 @md:my-4" />
      <div className="my-3 h-12 w-full rounded-md bg-contrast-100 @md:my-4" />
    </div>
  );
}
```

2. **Add** an import at the top of the file for the appropriate streaming resources.

```js showLineNumbers={false}
import { Stream, Streamable } from '@/vibes/soul/lib/streamable';
```

3. **Update** the props interface to accept a "streamable" version of the `faqsCollection`, which can be resolved as a promise or not, as well as establishing an alias for the prop in the component signature.

```jsx {3,9} showLineNumbers={false}
interface ProductFaqsProps {
  ...
  faqsCollection: Streamable<FaqsCollection>;
  ...
}

export function ProductFaqs({
  ...
  faqsCollection: streamableFaqsCollection,
  ...
}: ProductFaqsProps) {
  ...
}
```

4. **Modify** the current content of `ProductFaqs` to wrap it in `<Stream>` with an appropriate fallback, as well as a callback that will render when the streamable value is resolved.

```jsx {6-7,13-14} showLineNumbers={false}
export function ProductFaqs({
  ...
}: ProductFaqsProps) {

  return (
    <Stream fallback={<ProductFaqsSkeleton />} value={streamableFaqsCollection}>
    {(faqsCollection) => (

    <section ...>
    ...
    </section>

    )}
    </Stream>
  );
}
```

5. **Edit** the file `app/[locale]/(default)/product/[slug]/page.tsx` to wrap the FAQs fetch in `Streamable.from`, ensuring we have a promise that doesn't block page rendering and will only be executed when it is used.

```js {7-9} showLineNumbers={false}
export default async function Product({ params, searchParams }: Props) {
  ...

  const tFaqs = await getTranslations('Product.FAQ');
  const faqsHeading = tFaqs('heading');
  const faqsLimit = 2;
  const streamableFaqsCollection = Streamable.from(async () => {
    return await getProductFaqMetafields({ productId, locale, limit: faqsLimit });
  });

  return (
    ...
  );
}
```

6. **Update** the `faqsCollection` prop of `ProductFaqs` to receive the streamable value.

```jsx {2} showLineNumbers={false}
<ProductFaqs
  faqsCollection={streamableFaqsCollection}
  heading={faqsHeading}
  limit={faqsLimit}
  productId={productId}
/>
```

7. **Edit** the file `components/custom/product-faqs/_data/component-data.ts`. To make the new loading state obvious, you'll artificially extend the time it takes to perform the query. **Add** the following at the top of the `getProductFaqMetafields` function.

```js {3} showLineNumbers={false}
const getProductFaqMetafields = cache(
  async (variables: Variables) => {
    await new Promise((resolve) => setTimeout(resolve, 5000));

    ...
  }
);
```

**Browse** to or **fully refresh** your product page to see the loading state in action.

[Example Code](https://github.com/bigcommerce-edu/lab-catalyst-makeswift-faqs/compare/e-faqs-enh-04-pre...e-faqs-enh-04-post?diff=split)

## Step 5: Additional Loading State

The "Load More" button works, but currently the user receives no feedback while the network request is occurring to let them know that something is happening. Let's improve that.

The `Button` component you've already implemented supports a built-in loading state. All that's needed is to pass it a state variable that tracks when the button is in the `loading` state.

1. **Open** `components/custom/product-faqs/load-more-faqs.tsx` and **add** a `pending` state var and pass it on to `Button`.

```jsx {8,15,23,33} showLineNumbers={false}
export function LoadMoreFaqs({
  ...
}: LoadMoreFaqsProps) {
  ...

  const [faqs, setFaqs] = useState<Faq[]>([]);
  const [endCursor, setEndCursor] = useState<string | null>(initialEndCursor);
  const [pending, setPending] = useState(false);

  const getNextFaqs = async () => {
    if (!productId) {
      return;
    }

    setPending(true);

    try {
      ...
    } catch (err) {
      ...
    }

    setPending(false);
  };

  return (
    <>
      ...

      {(endCursor !== null) && (
        <div className="mx-auto md:w-2/3 lg:w-1/3 text-center py-4">
          <Button
            loading={pending}
            onClick={getNextFaqs}
            variant="secondary"
          >
            ...
          </Button>
        </div>
      )}
    </>
  );
}
```

**Browse** to your product page again and use the "Load More" button to observe the new loading state.

![Loading state for button](/_fern-files/bigcommerce.docs.buildwithfern.com/895dc6667e00276548b902f30740b2bcd18bafca4439c780dfb97c6bb06f562d/assets/learn/catalyst-core/more-faq-loading.png)

[Example Code](https://github.com/bigcommerce-edu/lab-catalyst-makeswift-faqs/compare/e-faqs-enh-05-pre...e-faqs-enh-05-post?diff=split)

## Step 6: Add Error Handling

Let's add a proper user notification in the event that our "load more" button results in an error.

1. **Edit** the file `components/custom/product-faqs/load-more-faqs.tsx` and **add** the following import after the other imports.

```js showLineNumbers={false}
import { toast } from '@/vibes/soul/primitives/toaster';
```

2. **Modify** the `catch` block in `getNextFaqs` to trigger an error notification.

```js {12-14} showLineNumbers={false}
export function LoadMoreFaqs({
  ...
}: LoadMoreFaqsProps) {
  ...

  const getNextFaqs = async () => {
    ...

    try {
      ...
    } catch (err) {
      const error = err instanceof Error ? err.message : String(err);

      toast.error(error);
    }

    ...
  };

  ...
}
```

3. If you want to test this error notification, **temporarily modify** `components/custom/product-faqs/_actions/get-next-product-faqs.ts` and **add** an exception.

```js {6} showLineNumbers={false}
...

const getNextProductFaqs = async (
  ...
) => {
  throw new Error("Something went wrong");

  ...
```

Now when you use the "Load More" button, you should be able to observe the simple error notification in the top right corner of the viewport.

![Error message](/_fern-img/b06927f669b71f848a36820dd0dc170dcf0d3252584e8a944ec7b673b0288d9d.webp)

[Example Code](https://github.com/bigcommerce-edu/lab-catalyst-makeswift-faqs/compare/e-faqs-enh-06-pre...e-faqs-enh-06-post?diff=split)

## Full Exercise Code

[Full Exercise Code](https://github.com/bigcommerce-edu/lab-catalyst-makeswift-faqs/compare/e-faqs-enh-start...e-faqs-enh-complete?diff=split)