Next.js E-commerce App with Strapi and Chakra UI

Melih Ekinci - Feb 16 '22 - - Dev Community

refine e-commerce examples
In this article, we will create the e-commerce app of our Strapi-Multitenancy admin panel that we have done before.

It is now used headless with the refine 3 version. You can use any UI library you want with the headless feature.

We will use Strapi and Chakra-UI together with Next.js in our E-commerce client example application.

Refine Project Setup

Let's start by creating our refine project. You can use the superplate to create a refine project.

npx superplate-cli -p refine-nextjs refine-ecommerce-example
Enter fullscreen mode Exit fullscreen mode
✔ What will be the name of your app · refine-ecommerce-example
✔ Package manager: · npm
✔ Do you want to using UI Framework? > No(headless)
✔ Data Provider: Strapi
✔ i18n - Internationalization: · no
Enter fullscreen mode Exit fullscreen mode

superplate will quickly create our refine project according to the features we choose. Let's continue by install the refine Strapi-v4 Data Provider and Chakra-UI packages that we will use later.

Installation

cd refine-ecommerce-example

npm i @pankod/refine-strapi-v4
npm i @chakra-ui/react @emotion/react@^11 @emotion/styled@^11 framer-motion@^6
Enter fullscreen mode Exit fullscreen mode

Our refine project and installations are now ready! Let's start using it.

Usage

Configure Refine for Strapi-v4​

pages/index.tsx:

import React from "react";
import { AppProps } from "next/app";
import Head from "next/head";
import { Refine } from "@pankod/refine-core";
import routerProvider from "@pankod/refine-nextjs-router";
import { DataProvider } from "@pankod/refine-strapi-v4";

const API_URL = "https://api.strapi-multi-tenant.refine.dev/api";

function MyApp({ Component, pageProps }: AppProps): JSX.Element {
    const dataProvider = DataProvider(API_URL);

    return (
        <Refine
            routerProvider={routerProvider}
            dataProvider={dataProvider}
        >
            <Component {...pageProps} />
        </Refine>
    );
}
Enter fullscreen mode Exit fullscreen mode

Chakra-UI Provider Setup​

pages/index.tsx:

import React from "react";
import { AppProps } from "next/app";
import Head from "next/head";
import { Refine } from "@pankod/refine-core";
import routerProvider from "@pankod/refine-nextjs-router";
import { DataProvider } from "@pankod/refine-strapi-v4";

import { ChakraProvider } from "@chakra-ui/react";

const API_URL = "https://api.strapi-multi-tenant.refine.dev/api";

function MyApp({ Component, pageProps }: AppProps): JSX.Element {
    const dataProvider = DataProvider(API_URL);

    return (
        <Refine routerProvider={routerProvider} dataProvider={dataProvider}>
            <ChakraProvider>
                <Component {...pageProps} />
            </ChakraProvider>
        </Refine>
    );
}
Enter fullscreen mode Exit fullscreen mode

Create Strapi Collections

We created three collections on Strapi as store, product and order and added a relation between them. For detailed information on how to create a collection, you can check here.

We created our collections in the previous Strapi Multitenancy guide. Now we will use the same collections.

Refer to the Project Collections for detailed information. →

Create Refine Layout

refine headless is not affiliated with any UI. It is entirely up to you to customize your UI. Let's create a simple layout for this example.

The Layout we've created now will only show the refine logo. In the following steps, we will edit our Layout.

components/Layout.tsx:

import { Box, Container, Flex, Image } from "@chakra-ui/react";

export const Layout: React.FC = ({ children }) => {
    return (
        <Box
            display={"flex"}
            flexDirection={"column"}
            backgroundColor={"#eeeeee"}
            minH={"100vh"}
        >
            <Container maxW={"container.lg"}>
                <Flex justify={"space-between"} mt={4} alignSelf={"center"}>
                    <a href="https://refine.dev">
                        <Image alt="Refine Logo" src={"./refine_logo.png"} />
                    </a>
                </Flex>
                {children}
            </Container>
        </Box>
    );
};
Enter fullscreen mode Exit fullscreen mode

pages/_app.tsx:

import React from "react";
import { AppProps } from "next/app";
import Head from "next/head";
import { Refine } from "@pankod/refine-core";
import routerProvider from "@pankod/refine-nextjs-router";
import { DataProvider } from "@pankod/refine-strapi-v4";

import { ChakraProvider } from "@chakra-ui/react";
import { Layout } from "src/components";

const API_URL = "https://api.strapi-multi-tenant.refine.dev/api";

function MyApp({ Component, pageProps }: AppProps): JSX.Element {
    const dataProvider = DataProvider(API_URL);

    return (
        <Refine
            routerProvider={routerProvider}
            dataProvider={dataProvider}
            Layout={Layout}
        >
            <ChakraProvider>
                <Component {...pageProps} />
            </ChakraProvider>
        </Refine>
    );
}
Enter fullscreen mode Exit fullscreen mode

Layout

Product Card Design with Chakra-UI

Let's design our product cards with Chakra-UI.

src/components/ProductCard.tsx

import React from "react";
import { Box, Image, Badge, Button } from "@chakra-ui/react";

export type ProductProps = {
    id: string;
    title: string;
    description: string;
    cardImage: string;
};

export const ProductCard: React.FC<ProductProps> = ({
    id,
    title,
    description,
    cardImage,
}) => {
    return (
        <Box maxH={"sm"} borderWidth="1px" borderRadius="lg" overflow="hidden">
            <Image w={"100%"} h={200} src={cardImage} />
            <Box p="6" bgColor={"gray.600"}>
                <Box display="flex" alignItems="baseline" mb={2} ml={-2}>
                    <Badge borderRadius="full" px="2" colorScheme="teal">
                        New Product
                    </Badge>
                </Box>

                <Box
                    mt="1"
                    fontWeight="semibold"
                    as="h4"
                    lineHeight="tight"
                    isTruncated
                    color={"white"}
                >
                    {title}
                </Box>

                <Box color={"white"}>{}</Box>
                <Box
                    color="white"
                    fontSize="sm"
                    display={"flex"}
                    mt={4}
                    justifyContent={"flex-end"}
                ></Box>
            </Box>
        </Box>
    );
};
Enter fullscreen mode Exit fullscreen mode

Product Card

We created our Product Card component. Now let's move on to the process of fetch and showing our products from the Strapi.

Fetch Products with SSR

First, let's fetch our products with the nextjs getServerSideProps function.

GetServerSideProps

pages/index.tsx:

import { GetServerSideProps } from "next";
import { DataProvider } from "@pankod/refine-strapi-v4";

import { IProduct } from "interfaces";

const API_URL = "https://api.strapi-multi-tenant.refine.dev/api";

export const getServerSideProps: GetServerSideProps = async (context) => {
    const data = await DataProvider(API_URL).getList<IProduct>({
        resource: "products",
        metaData: { populate: ["image"] },
    });

    return {
        props: { products: data },
    };
};
Enter fullscreen mode Exit fullscreen mode

Create Product List with Refine

Let's process the data we fetch above using refine's useTable hook. Then let's put our data in our ProductCard component.

pages/index.tsx:

import { GetServerSideProps } from "next";
import { LayoutWrapper, GetListResponse, useTable } from "@pankod/refine-core";
import { DataProvider } from "@pankod/refine-strapi-v4";

import { IProduct } from "interfaces";
import { SimpleGrid } from "@chakra-ui/react";
import { ProductCard } from "src/components";

const API_URL = "https://api.strapi-multi-tenant.refine.dev/api";

type ItemProps = {
    products: GetListResponse<IProduct>;
};

export const ProductList: React.FC<ItemProps> = ({ products }) => {
    const { tableQueryResult } = useTable<IProduct>({
        resource: "products",
        queryOptions: {
            initialData: products,
        },
        metaData: { populate: ["image"] },
    });

    return (
        <LayoutWrapper>
            <SimpleGrid columns={[1, 2, 3]} mt={6} spacing={3}>
                {tableQueryResult.data?.data.map((item) => (
                    <ProductCard
                        id={item.id}
                        title={item.title}
                        description={item.description}
                        cardImage={
                            item.image
                                ? API_URL + item.image.url
                                : "./error.png"
                        }
                    />
                ))}
            </SimpleGrid>
        </LayoutWrapper>
    );
};

export default ProductList;

export const getServerSideProps: GetServerSideProps = async (context) => {
    const data = await DataProvider(API_URL).getList<IProduct>({
        resource: "products",
        metaData: { populate: ["image"] },
    });

    return {
        props: { products: data },
    };
};
Enter fullscreen mode Exit fullscreen mode

Products List

Adding Store-based Filtering

We have fetch all the products above. Now, let's fetch the stores and list the store-specific products separately.

First, let's fetch our stores by using the refine useMany hook within the getServerSideProps function. Next we will create buttons for the stores. When these buttons are clicked, a store is selected, we will do a filtering with useTable setFilters and list the products specific to that store.

pages/index.tsx:

export const getServerSideProps: GetServerSideProps = async (context) => {
    const data = await DataProvider(API_URL).getList<IProduct>({
        resource: "products",
        metaData: { populate: ["image"] },
        pagination: { current: 1, pageSize: 9 },
    });

    const { data: storesData } = await DataProvider(API_URL).getMany({
        resource: "stores",
        ids: ["1", "2", "3"],
    });

    return {
        props: {
            products: data,
            stores: storesData,
        },
    };
};
Enter fullscreen mode Exit fullscreen mode

pages/index.tsx:

import { GetServerSideProps } from "next";
import { LayoutWrapper, GetListResponse, useTable } from "@pankod/refine-core";
import { DataProvider } from "@pankod/refine-strapi-v4";

import { IProduct, IStore } from "interfaces";
import { Button, SimpleGrid, Flex, Text } from "@chakra-ui/react";
import { ProductCard, FilterButton } from "src/components";

const API_URL = "https://api.strapi-multi-tenant.refine.dev/api";

type ItemProps = {
    products: GetListResponse<IProduct>;
    stores: IStore[];
};

export const ProductList: React.FC<ItemProps> = ({ products, stores }) => {
    const { tableQueryResult, setFilters } = useTable<IProduct>({
        resource: "products",
        queryOptions: {
            initialData: products,
        },
        metaData: { populate: ["image"] },
    });

    return (
        <LayoutWrapper>
            <Flex mt={6} gap={2}>
                <FilterButton
                    setFilters={() =>
                        setFilters([
                            {
                                field: "stores][id]",
                                operator: "eq",
                                value: undefined,
                            },
                        ])
                    }
                >
                    <Text fontSize={{ base: "12px", md: "14px", lg: "14px" }}>
                        All Products
                    </Text>
                </FilterButton>
                {stores?.map((item) => {
                    return (
                        <FilterButton
                            setFilters={() =>
                                setFilters([
                                    {
                                        field: "stores][id]",
                                        operator: "eq",
                                        value: item.id,
                                    },
                                ])
                            }
                        >
                            <Text
                                fontSize={{
                                    base: "12px",
                                    md: "14px",
                                    lg: "14px",
                                }}
                            >
                                {item.title}
                            </Text>
                        </FilterButton>
                    );
                })}
            </Flex>
            <SimpleGrid columns={[1, 2, 3]} mt={6} spacing={3}>
                {tableQueryResult.data?.data.map((item) => (
                    <ProductCard
                        id={item.id}
                        title={item.title}
                        description={item.description}
                        cardImage={
                            item.image
                                ? API_URL + item.image.url
                                : "./error.png"
                        }
                    />
                ))}
            </SimpleGrid>
        </LayoutWrapper>
    );
};

export default ProductList;

export const getServerSideProps: GetServerSideProps = async (context) => {
    const data = await DataProvider(API_URL).getList<IProduct>({
        resource: "products",
        metaData: { populate: ["image"] },
        pagination: { current: 1, pageSize: 9 },
    });

    const { data: storesData } = await DataProvider(API_URL).getMany({
        resource: "stores",
        ids: ["1", "2", "3"],
    });

    return {
        props: {
            products: data,
            stores: storesData,
        },
    };
};
Enter fullscreen mode Exit fullscreen mode

Store Pagination

Adding Pagination

We list all products on our All Products page. Let's add pagination to this page and divide the products into pages. We will perform pagination using the pageSize, current and setCurrent properties from the useTable hook.

Refer to the useTable documentation for detailed information. →

pages/index.tsx:

import { GetServerSideProps } from "next";
import { LayoutWrapper, GetListResponse, useTable } from "@pankod/refine-core";
import { DataProvider } from "@pankod/refine-strapi-v4";

import { IProduct, IStore } from "interfaces";
import { Button, SimpleGrid, Flex, Text } from "@chakra-ui/react";
import { ProductCard, FilterButton } from "src/components";

const API_URL = "https://api.strapi-multi-tenant.refine.dev/api";

type ItemProps = {
    products: GetListResponse<IProduct>;
    stores: IStore[];
};

export const ProductList: React.FC<ItemProps> = ({ products, stores }) => {
    const { tableQueryResult, setFilters, current, setCurrent, pageSize } =
        useTable<IProduct>({
            resource: "products",
            queryOptions: {
                initialData: products,
            },
            initialPageSize: 9,
            metaData: { populate: ["image"] },
        });

    const totalPageCount = Math.ceil(tableQueryResult.data?.total!! / pageSize);

    return (
        <LayoutWrapper>
            <Flex mt={6} gap={2}>
                <FilterButton
                    setFilters={() =>
                        setFilters([
                            {
                                field: "stores][id]",
                                operator: "eq",
                                value: undefined,
                            },
                        ])
                    }
                >
                    <Text fontSize={{ base: "12px", md: "14px", lg: "14px" }}>
                        All Products
                    </Text>
                </FilterButton>
                {stores?.map((item) => {
                    return (
                        <FilterButton
                            setFilters={() =>
                                setFilters([
                                    {
                                        field: "stores][id]",
                                        operator: "eq",
                                        value: item.id,
                                    },
                                ])
                            }
                        >
                            <Text
                                fontSize={{
                                    base: "12px",
                                    md: "14px",
                                    lg: "14px",
                                }}
                            >
                                {item.title}
                            </Text>
                        </FilterButton>
                    );
                })}
            </Flex>
            <SimpleGrid columns={[1, 2, 3]} mt={6} spacing={3}>
                {tableQueryResult.data?.data.map((item) => (
                    <ProductCard
                        id={item.id}
                        title={item.title}
                        description={item.description}
                        cardImage={
                            item.image
                                ? API_URL + item.image.url
                                : "./error.png"
                        }
                    />
                ))}
            </SimpleGrid>
            <Flex justify={"flex-end"} mt={4} mb={4} gap={2}>
                {Array.from(Array(totalPageCount), (e, i) => {
                    if (current > totalPageCount) {
                        setCurrent(i);
                    }
                    return (
                        <Button
                            colorScheme={"teal"}
                            onClick={() => setCurrent(i + 1)}
                        >
                            {"Page: " + (i + 1)}
                        </Button>
                    );
                })}
            </Flex>
        </LayoutWrapper>
    );
};

export default ProductList;

export const getServerSideProps: GetServerSideProps = async (context) => {
    const data = await DataProvider(API_URL).getList<IProduct>({
        resource: "products",
        metaData: { populate: ["image"] },
        pagination: { current: 1, pageSize: 9 },
    });

    const { data: storesData } = await DataProvider(API_URL).getMany({
        resource: "stores",
        ids: ["1", "2", "3"],
    });

    return {
        props: { products: data, stores: storesData },
    };
};
Enter fullscreen mode Exit fullscreen mode

Pagination

Adding Basket and Payment features with Snipcart

One of the steps that should be in an E-commerce application is the cart and payment transactions. In our example, we will use Snipcart for this process.

Refer to the Snipcart documentation for detailed information. →

Installation Snipcart Widget

pages/_app.tsx:

function MyApp({ Component, pageProps }: AppProps): JSX.Element {
    const dataProvider = DataProvider(API_URL);

    return (
        <>
            <Head>
                <link rel="preconnect" href="https://app.snipcart.com" />
                <link
                    rel="stylesheet"
                    href="https://cdn.snipcart.com/themes/v3.0.16/default/snipcart.css"
                />
                <script
                    async
                    src="https://cdn.snipcart.com/themes/v3.0.16/default/snipcart.js"
                />
            </Head>
            <Refine
                routerProvider={routerProvider}
                dataProvider={dataProvider}
                resources={[{ name: "products" }]}
                Layout={Layout}
            >
                <ChakraProvider>
                    <Component {...pageProps} />
                </ChakraProvider>
            </Refine>
            <div hidden id="snipcart" data-api-key="YOUR_SNIPCART_TEST_KEY" />
        </>
    );
}
Enter fullscreen mode Exit fullscreen mode

Adding "Add to Basket" Button on ProductCard component

src/components/ProductCard.tsx:

import React from "react";
import { Box, Image, Badge, Button } from "@chakra-ui/react";

export type ProductProps = {
    id: string;
    title: string;
    description: string;
    cardImage: string;
};

export const ProductCard: React.FC<ProductProps> = ({
    id,
    title,
    description,
    cardImage,
}) => {
    return (
        <Box
            maxH={"sm"}
            maxW="sm"
            borderWidth="1px"
            borderRadius="lg"
            overflow="hidden"
        >
            <Image w={"100%"} h={200} src={cardImage} />
            <Box p="6" bgColor={"gray.600"}>
                <Box display="flex" alignItems="baseline" mb={2} ml={-2}>
                    <Badge borderRadius="full" px="2" colorScheme="teal">
                        New Product
                    </Badge>
                </Box>

                <Box
                    mt="1"
                    fontWeight="semibold"
                    as="h4"
                    lineHeight="tight"
                    isTruncated
                    color={"white"}
                >
                    {title}
                </Box>
                <Box
                    color="white"
                    fontSize="sm"
                    display={"flex"}
                    mt={4}
                    justifyContent={"flex-end"}
                >
                    <Button
                        className="buy-button snipcart-add-item"
                        bgColor={"green.400"}
                        data-item-id={id}
                        data-item-price="5"
                        data-item-url="/"
                        data-item-name={title}
                        data-item-description={description}
                        data-item-image={cardImage}
                    >
                        Add to Basket
                    </Button>
                </Box>
            </Box>
        </Box>
    );
};
Enter fullscreen mode Exit fullscreen mode

Basket

Conclusion

One of the biggest features that distinguishes refine from other frameworks is that it is customizable. Combined with refine headless, it now provides more customization options. This provides a great deal of convenience in the project you will develop.

As you can see in this article, we have developed the Client part of our Admin Panel, which we have done before, with refine. refine offers the opportunity to develop B2B and B2C applications without any restrictions and in a fully customizable manner.

Refer to the Admin side of the project →

Source Code

Live CodeSandbox Example

Check out for detailed information about refine. →

. . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . .