Skip to content

Add a feature package

A feature package is one storefront feature, ready to use: shopping lists, quotes, returns, invoices. This page adds feature-shopping-list to a project that does not have it yet. Every other feature-* package follows the same steps.

Each feature has the same shape. You import each part from its own subpath:

Subpath What it is
. Types, cache keys and helpers. Safe to import in a Server Component.
./queries queryOptions factories and the GraphQL documents. Also server-safe.
./hooks Client hooks for reads and mutations.
./provider, ./context The provider that gives the feature its session, and the hooks that read it.
./<component> One subpath per component, for example ./shopping-list-detail. The smaller parts of a component come from the same subpath.
./styles.css Registers the package’s source with Tailwind.
./messages/* Translations in en, nl, de, fr and es, under one namespace (ShoppingList).

Not every feature has every subpath: feature-auth has no ./queries, and feature-content-blocks has no provider. The package page in the reference lists what yours has.

The packages ship TypeScript source, not a build. Your app compiles them, which is why some of the steps below exist.

  • Your project is a copy of a reference site (evolve-reference-b2c or evolve-reference-b2b), with the Evolve registry configured. See Installation.
  • Read the package’s README. Its GraphQL contract section says which operations the feature calls.

A feature only calls GraphQL. Some backend module has to answer those calls. Open the feature’s domain in the GraphQL reference and read Served by. For shopping lists that is Shopping list GraphQL: the ShoppingListModule of commercetools, Medusa and SFCC.

Each operation page shows what every engine does with it. On SFCC, the shopping list reads resolve, but the writes are stubs that answer Not implemented.

Add the module to the subgraph that owns the customer’s data. In the reference sites that is backend/purchase, and the module goes in its CompositeModule:

import { ShoppingListModule } from "@evolve-framework/commercetools";
export const createModule = (payments?: PaymentRegistry): CompositeModule<ContextValue> => {
return new CompositeModule<ContextValue>([
// ...the modules you already have
new ShoppingListModule(),
]);
};

On Medusa, the module also needs the @evolve-framework/medusa-plugin-wishlist plugin in your Medusa server. Without it, every shopping list call fails.

  1. Install it in frontend/site:

    pnpm add @evolve-storefront/feature-shopping-list

    The peers (react, @tanstack/react-query, @labdigital/graphql-fetcher, graphql, next-intl, sonner and next) are already in a reference-site copy. The reference sites keep every version in the pnpm catalog in pnpm-workspace.yaml, so the entry in package.json reads "catalog:".

  2. Let Next compile it. Add the package to transpilePackages:

    transpilePackages: [
    "@evolve-storefront/ui",
    "@evolve-storefront/core",
    "@evolve-storefront/errors",
    "@evolve-storefront/feature-shopping-list",
    // ...
    ],

    The package depends on ui, core and errors. They are in the list already if you have any other feature.

  3. Import its styles in your brand stylesheet, after ui:

    @import "tailwindcss";
    @import "@evolve-storefront/ui/styles.css";
    @import "@evolve-storefront/feature-shopping-list/styles.css";

    This line adds @source "./src" for the package, so Tailwind generates the classes its components use. If you forget it, you get no error. The components only look a little broken.

  4. Render the provider in src/app/[locale]/providers.tsx. It must sit inside NextIntlClientProvider, ClientGqlFetcherProvider and QueryClientProvider. The reference sites wrap it in a small component that reads the app’s own auth hooks:

    import { ShoppingListProvider } from "@evolve-storefront/feature-shopping-list/provider";
    import { useEnsureSession } from "#hooks/auth/use-ensure-session.ts";
    import { useAuth } from "#hooks/use-auth.ts";
    function ShoppingListSessionProvider({ children }: { children: ReactNode }) {
    const { isAuthenticated, hasToken, validateLocalToken, values } = useAuth();
    const ensureSession = useEnsureSession();
    return (
    <ShoppingListProvider
    isAuthenticated={isAuthenticated}
    hasToken={hasToken}
    businessUnitId={values?.values.businessContext?.businessUnit}
    ensureSession={ensureSession}
    onTokenChange={validateLocalToken}
    >
    {children}
    </ShoppingListProvider>
    );
    }

    Then nest <ShoppingListSessionProvider> with the other feature providers, around children.

    businessUnitId sets the mode. Without it, a shopper has one list behind a heart toggle. With it, an associate has the company’s named lists behind a picker. Without the provider, the feature acts as if every visitor is an anonymous guest.

  5. Generate the messages and types. From the repository root:

    pnpm codegen

    This builds the backend schema and the supergraph, then runs the site’s codegen:types and codegen:messages.

    codegen:messages runs evolve messages build. It reads every @evolve-storefront/* package in the site’s package.json and adds the ShoppingList keys to each file in src/messages/. A key you already have keeps your value.

    codegen:types checks the feature’s GraphQL documents against your supergraph, and adds them to the persisted-document manifest.

If codegen fails on a field like anonymousShoppingList, your supergraph does not serve an operation the feature ships. You have two choices:

  • Add the module that serves it (step 1).
  • If no session in your shop can reach that operation, drop it in frontend/site/codegen/feature-documents.cjs. Add the operation name to unreachableOperations, and any fragment that only it uses to unreachableFragments.

The reference sites already drop GetAnonymousShoppingList and AnonymousShoppingListCreate, because no engine serves anonymousShoppingList. That is the shared, read-only list. The share button and shoppingListDetailQueryOptions({ shareable: true }) depend on it, so do not use them.

The feature’s views never fetch. Your page runs the query and gives the view the data. You also write the markup for each line item, so the line items look like the rest of your site.

The package ships two buttons and no wrapper that picks between them, because the mode is your app’s decision. The reference sites decide in one component and use it on the product card, the product page and the carousel card:

"use client";
import { useShoppingListContext } from "@evolve-storefront/feature-shopping-list/context";
import { ShoppingListAddButton } from "@evolve-storefront/feature-shopping-list/shopping-list-add-button";
import { ShoppingListPickerButton } from "@evolve-storefront/feature-shopping-list/shopping-list-picker-button";
import type { ReactNode } from "react";
type Props = { sku: string; productName?: string; className?: string };
export function ShoppingListButton({ sku, productName, className }: Props): ReactNode {
const { businessUnitId } = useShoppingListContext();
if (businessUnitId) {
return <ShoppingListPickerButton sku={sku} productName={productName} className={className} />;
}
return <ShoppingListAddButton sku={sku} productName={productName} className={className} />;
}

productName appears in the toast after an add. Toasts need a <Toaster /> in your layout. The reference sites render the one from sonner in src/app/[locale]/(main)/layout.tsx.

For a shopper with one list, find the list, then load it and render it with ShoppingListDetail:

"use client";
import { useShoppingListScope } from "@evolve-storefront/feature-shopping-list/context";
import {
shoppingListDetailQueryOptions,
shoppingListsQueryOptions,
} from "@evolve-storefront/feature-shopping-list/queries";
import {
ShoppingListDetail,
ShoppingListDetailEmpty,
ShoppingListDetailSkeleton,
} from "@evolve-storefront/feature-shopping-list/shopping-list-detail";
import { useClientGqlFetcher } from "@labdigital/graphql-fetcher/react";
import { useQuery } from "@tanstack/react-query";
import type { ReactNode } from "react";
export function ShoppingList(): ReactNode {
const gqlClient = useClientGqlFetcher();
const scope = useShoppingListScope();
const lists = useQuery(shoppingListsQueryOptions({ gqlClient, scope, enabled: true }));
if (lists.isLoading) return <ShoppingListDetailSkeleton />;
const listId = lists.data?.items[0]?.id;
return listId ? <ListContents id={listId} /> : <ShoppingListDetailEmpty />;
}
function ListContents({ id }: { id: string }): ReactNode {
const gqlClient = useClientGqlFetcher();
const scope = useShoppingListScope();
const detail = useQuery(shoppingListDetailQueryOptions({ gqlClient, scope, shoppingListId: id }));
if (detail.isPending) return <ShoppingListDetailSkeleton />;
if (!detail.data) return <ShoppingListDetailEmpty />;
return (
<ShoppingListDetail
list={detail.data}
renderItem={(item, api) => (
<div>
<p>{item.variant.name}</p>
<button type="button" onClick={api.remove} disabled={api.isRemoving}>
Remove
</button>
</div>
)}
/>
);
}

Without children, ShoppingListDetail renders its standard layout: the title, the product count, the items with pagination, and the empty state. In renderItem you write the markup, and api does the work: remove, setQuantity, isRemoving and isUpdatingQuantity. The reference sites’ line item also has a quantity stepper and the cart’s CartAddProductButton.

For a different layout, pass children (ShoppingListDetailHeader, ShoppingListDetailActions, ShoppingListDetailItems, …). ShoppingListDetailItems then needs shoppingListId, items and renderItem. For an associate’s named lists, use ShoppingListOverview. The reference B2C site’s shopping-lists/page.tsx shows both modes on one route.

  1. pnpm codegen finishes without errors. This means your supergraph serves every document the feature sends.

  2. The messages are complete. Run this in frontend/site:

    pnpm evolve --package @evolve-storefront/commands messages build --check

    This writes nothing. It fails when the files would change, or when your source locale still has [placeholder] values.

  3. pnpm tsc passes in frontend/site.

  4. Start the site and click the heart on a product card. A toast says the product was added, and the network panel shows ShoppingListLineItemsAdd. A new visitor has no list yet, so the backend creates one with that product in it.

  5. Open the list page. The product is there, and Remove takes it out.