Table of Contents
- Setting Up WordPress for Your Headless eCommerce Application
- Creating and Setting Up Your eCommerce App for development
- Building Shop Listing Pages With WooGraphQL
- Creating User Login and Navigation
- Creating the Product page and Cart Options
Welcome to the second part of our tutorial series on creating a headless eCommerce application! This next chapter is to set up the e-commerce app to interface with your store. We'll leverage Next.js for the front-end and take advantage of several other tools like shadcn/ui for components, and GraphQL Code Generator for handling GraphQL operations.
Creating and setting up our e-commerce app for development
After setting up your WooCommerce store with WooGraphQL, you should be left with a GraphQL endpoint at https://site-name/graphql
, If you're using the bundled development setup from the last chapter this is http://localhost:8080/wp/graphql
. With that said, let's get started.
Create Next.js application
Let's start by creating our Next.js application and setting up some necessary configurations
1. First, generate a new Next.js application by running the following command in your terminal: npx create-next-app@latest
and follow the prompts and navigate to the newly created project directory.
2. Then, install the dotenv-flow
and deepmerge
npm packages by running the following command in your terminal: npm i dotenv-flow deepmerge
3. Create a new .env.local
file in the root directory of your project and fill it with the following details:
GRAPHQL_ENDPOINT=[GRAPHQL_ENDPOINT]
FRONTEND_URL=[NEXT_APP_URL]
BACKEND_URL=[WP_URL]
SITE_NAME=[SITE_NAME]
SITE_DESCRIPTION=[SITE_DESCRIPTION]
Don't forget to replace the bracketed values with your respective equivalents.
4. Create a new next.config.js
file in the root directory of your project and add the following code:
/**
* @type {import('next').NextConfig}
*/
const nextConfig = {
reactStrictMode: true,
swcMinify: true,
images: {
dangerouslyAllowSVG: true,
formats: ['image/avif', 'image/webp'],
domains: ['localhost'],
minimumCacheTTL: 60,
disableStaticImages: true,
},
env: {
GRAPHQL_ENDPOINT: process.env.GRAPHQL_ENDPOINT,
FRONTEND_URL: process.env.FRONTEND_URL,
BACKEND_URL: process.env.BACKEND_URL,
SITE_NAME: process.env.SITE_NAME,
SITE_DESCRIPTION: process.env.SITE_DESCRIPTION,
},
}
module.exports = nextConfig;
5. Update the content
property in your tailwind.config.js
file to the following:
...
content: [
'./ui/**/*.{ts,tsx}',
'./server/**/*.{ts,tsx}',
'./client/**/*.{ts,tsx}',
'./app/**/*.{ts,tsx}',
]
...
Install shadcn/ui
components
We'll be using the shadcn/ui
library for our application's UI components. Here's how to install and configure it.
1. Run the shadcn-ui init command: npx shadcn-ui@latest init
Then, follow the prompts.
2. Open the components.json
file that was generated and replace its contents with the following:
{
"$schema": "https://ui.shadcn.com/schema.json",
"style": "new-york",
"rsc": true,
"tailwind": {
"config": "tailwind.config.js",
"css": "app/globals.css",
"baseColor": "slate",
"cssVariables": true
},
"aliases": {
"components": "/",
"utils": "@/utils/ui"
}
}
3. Rename the lib/utils.ts
file to /utils/ui.ts
and create a /ui
directory.
4. Add the necessary components to your project by running the following commands in your terminal:
npx shadcn-ui@latest add aspect-ratio
npx shadcn-ui@latest add badge
npx shadcn-ui@latest add button
npx shadcn-ui@latest add card
npx shadcn-ui@latest add form
npx shadcn-ui@latest add input
npx shadcn-ui@latest add label
npx shadcn-ui@latest add radio-group
npx shadcn-ui@latest add select
npx shadcn-ui@latest add sheet
npx shadcn-ui@latest add slider
npx shadcn-ui@latest add tabs
npx shadcn-ui@latest add toast
Create other UI components
We'll create some additional components for our application.
1. Create a new ui/Image/index.ts
file and add the following code:
'use client';
export * from './Image';
2. Create a new ui/Image/Image.tsx
file and add the following code:
import { useState } from 'react';
import NextImage from 'next/image';
import { cn } from '@/utils/ui';
import { LoadingSpinner } from '@/ui/LoadingSpinner';
import { AspectRatio } from "@/ui/aspect-ratio"
export type ImageProps = {
className?: string;
src: string;
sizes?: string;
width?: number;
height?: number;
ratio?: number;
alt: string;
style?: JSX.IntrinsicElements['img']['style']
fill?: boolean;
priority?: boolean;
}
export function Image(props: ImageProps) {
const [isLoading, setLoading] = useState(true);
const {
className = '',
src,
alt,
sizes,
width,
height,
ratio,
style,
fill = true,
priority,
} = props;
return (
<div
className={cn(
'overflow-hidden group relative',
className && className,
)}
style={style}
>
<AspectRatio ratio={ratio}>
{isLoading && (
<LoadingSpinner className="position absolute left-1/2 top-1/2 -translate-x-1/2 -translate-y-1/2" />
)}
<NextImage
src={src}
alt={alt}
width={width as number}
height={height as number}
sizes={sizes}
className={cn(
'group-hover:opacity-75 object-cover',
'duration-700 ease-in-out',
isLoading
? 'grayscale blur-2xl scale-110'
: 'grayscale-0 blur-0 scale-100',
)}
fill={fill}
onLoadingComplete={() => setLoading(false)}
priority={priority}
/>
</AspectRatio>
</div>
);
}
3. Create a new ui/LoadingSpinner/index.ts
file and add the following code:
export * from './LoadingSpinner';
4. Create a new ui/LoadingSpinner/LoadingSpinner.tsx
file and add the following code:
export interface LoadingSpinnerProps {
className?: string;
noText?: boolean;
color?: string;
}
export function LoadingSpinner({ className = '', noText = false, color = 'amber-400' }: LoadingSpinnerProps) {
return (
<div aria-label="Loading..." role="status" className={`relative flex items-center justify-center space-x-2 ${className}`}>
<svg className="h-6 w-6 animate-spin stroke-amber-600" viewBox="0 0 256 256">
<line x1="128" y1="32" x2="128" y2="64" strokeLinecap="round" strokeLinejoin="round" strokeWidth="24" />
<line
x1="195.9"
y1="60.1"
x2="173.3"
y2="82.7"
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth="24"
/>
<line x1="224" y1="128" x2="192" y2="128" strokeLinecap="round" strokeLinejoin="round" strokeWidth="24" />
<line
x1="195.9"
y1="195.9"
x2="173.3"
y2="173.3"
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth="24"
/>
<line x1="128" y1="224" x2="128" y2="192" strokeLinecap="round" strokeLinejoin="round" strokeWidth="24" />
<line
x1="60.1"
y1="195.9"
x2="82.7"
y2="173.3"
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth="24"
/>
<line x1="32" y1="128" x2="64" y2="128" strokeLinecap="round" strokeLinejoin="round" strokeWidth="24" />
<line
x1="60.1"
y1="60.1"
x2="82.7"
y2="82.7"
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth="24"
/>
</svg>
{!noText && <span className="text-xs font-medium text-gray-500">Loading...</span>}
</div>
);
}
5. Create a new ui/NavLink/index.ts
file and add the following code:
export * from './NavLink';
6. Create a new ui/NavLink/NavLink.tsx
file and add the following code:
import { PropsWithChildren } from 'react';
import Link from 'next/link';
import { cn } from '@woographql/utils/ui';
export const linkClassName = 'transition-colors group-hover:text-blue-400';
export interface NavLinkProps {
href: string;
className?: string;
shallow?: boolean;
prefetch?: boolean;
}
export function NavLink(props: PropsWithChildren<NavLinkProps>) {
const {
children,
href,
className,
shallow,
prefetch,
} = props;
return (
<Link
className={cn(
className,
linkClassName,
)}
href={href}
shallow={shallow}
prefetch={prefetch}
>
{children}
</Link>
);
}
Install and configure GraphQL Code Generator
Finally, we need to set up GraphQL Code Generator (Codegen) for generating typed GraphQL operations. Codegen is real time-saver and a must if working with lots of components or a large GraphQL schema. Based upon how it is configured it will generate code related to the provided GraphQL endpoint. In our application it will be used to generate TypeScript types for GraphQL endpoint, TS types for the GraphQL operations we'll write, and neat wrapper that will make running said operations a breeze using the graphql-request
library.
1. Install GraphQL Code Generator along with some required plugins by running the following command in your terminal:
npm i -D npm-run-all @graphql-codegen/cli @graphql-codegen/typescript @graphql-codegen/typescript-operations @graphql-codegen/typescript-graphql-request
2. Create a new codegen.ts
file in the root directory of your project and add the following code:
import type { CodegenConfig } from '@graphql-codegen/cli';
import dotenv from 'dotenv-flow';
dotenv.config({ silent: true });
const config: CodegenConfig = {
schema: process.env.GRAPHQL_ENDPOINT,
documents: ['graphql/**/*.graphql'],
verbose: true,
overwrite: true,
generates: {
'graphql/generated.ts': {
plugins: [
'typescript',
'typescript-operations',
'typescript-graphql-request',
],
config: {
namingConvention: 'keep',
}
}
}
}
export default config;
3. Update the scripts
property in your package.json
file to include scripts for generating the GraphQL operations and wrappers:
{
...
"scripts": {
"next:dev": "next dev",
"next:build": "next build",
"start": "next start",
"lint": "next lint",
"codegen:dev": "graphql-codegen --config codegen.ts --watch",
"codegen:build": "graphql-codegen --config codegen.ts",
"dev": "run-p codegen:dev next:dev",
"build": "NODE_ENV=production run-s codegen:build next:build",
},
...
}
4. Create a new graphql/index.ts
file and add the following code:
export * from './generated';
export * from './client';
5. Create a new graphql/main.graphql
file and add the following code:
fragment MenuItemContent on MenuItem {
id
uri
title
label
cssClasses
}
fragment MenuItemRecursive on MenuItem {
...MenuItemContent
childItems {
nodes {
...MenuItemContent
}
}
}
fragment MenuContent on Menu {
id
name
locations
slug
menuItems(first: 20, where: {parentId: 0}) {
nodes {
...MenuItemRecursive
}
}
}
fragment CustomerContent on Customer {
id
sessionToken
firstName
shipping {
postcode
state
city
country
}
}
fragment ProductContentSlice on Product {
id
databaseId
name
slug
type
image {
id
sourceUrl(size:WOOCOMMERCE_THUMBNAIL)
altText
}
... on SimpleProduct {
price
regularPrice
soldIndividually
}
... on VariableProduct {
price
regularPrice
soldIndividually
}
}
fragment ProductVariationContentSlice on ProductVariation {
id
databaseId
name
slug
image {
id
sourceUrl(size:WOOCOMMERCE_THUMBNAIL)
altText
}
price
regularPrice
}
fragment ProductContentSmall on Product {
id
databaseId
slug
name
type
shortDescription(format: RAW)
image {
id
sourceUrl(size:WOOCOMMERCE_THUMBNAIL)
altText
}
productCategories(first: 20) {
nodes {
id
slug
name
}
}
productTags(first: 20) {
nodes {
id
slug
name
}
}
allPaColor(first: 100) {
nodes {
id
slug
name
}
}
... on SimpleProduct {
onSale
stockStatus
price
rawPrice: price(format: RAW)
regularPrice
salePrice
soldIndividually
}
... on VariableProduct {
onSale
stockStatus
price
rawPrice: price(format: RAW)
regularPrice
salePrice
soldIndividually
}
}
fragment ProductContentFull on Product {
id
databaseId
slug
name
type
description
shortDescription(format: RAW)
image {
id
sourceUrl
altText
}
galleryImages {
nodes {
id
sourceUrl(size:WOOCOMMERCE_THUMBNAIL)
altText
}
}
productTags(first: 20) {
nodes {
id
slug
name
}
}
attributes {
nodes {
id
attributeId
... on LocalProductAttribute {
name
label
options
variation
}
... on GlobalProductAttribute {
name
label
options
variation
slug
terms(first: 100) {
nodes {
id
name
slug
}
}
}
}
}
... on SimpleProduct {
onSale
stockStatus
price
rawPrice: price(format: RAW)
regularPrice
salePrice
stockStatus
stockQuantity
soldIndividually
defaultAttributes(first: 100) {
nodes {
id
attributeId
name
value
label
}
}
}
... on VariableProduct {
onSale
price
rawPrice: price(format: RAW)
regularPrice
salePrice
stockStatus
stockQuantity
soldIndividually
defaultAttributes(first: 100) {
nodes {
id
attributeId
label
name
value
}
}
variations(first: 50) {
nodes {
id
databaseId
name
price
rawPrice: price(format: RAW)
regularPrice
salePrice
onSale
attributes {
nodes {
name
label
value
}
}
image {
id
sourceUrl
altText
}
}
}
}
}
fragment VariationContent on ProductVariation {
id
name
slug
price
regularPrice
salePrice
stockStatus
stockQuantity
onSale
image {
id
sourceUrl
altText
}
}
fragment CartItemContent on CartItem {
key
product {
node {
...ProductContentSlice
}
}
variation {
attributes {
id
label
name
value
}
node {
...ProductVariationContentSlice
}
}
quantity
total
subtotal
subtotalTax
extraData {
key
value
}
}
fragment CartContent on Cart {
contents(first: 100) {
itemCount
nodes {
...CartItemContent
}
}
appliedCoupons {
code
discountAmount
discountTax
}
needsShippingAddress
availableShippingMethods {
packageDetails
supportsShippingCalculator
rates {
id
instanceId
methodId
label
cost
}
}
subtotal
subtotalTax
shippingTax
shippingTotal
total
totalTax
feeTax
feeTotal
discountTax
discountTotal
}
fragment CustomerFields on Customer {
id
databaseId
firstName
lastName
displayName
email
sessionToken
metaData {
key
value
}
}
query GetTopNav {
menu(id: "primary", idType: LOCATION) {
...MenuContent
}
}
query GetProducts($first: Int, $after: String, $where: RootQueryToProductConnectionWhereArgs) {
products(first: $first, after: $after, where: $where) {
pageInfo {
endCursor
hasNextPage
}
edges {
cursor
node {
...ProductContentSmall
}
}
nodes {
...ProductContentSmall
}
}
}
query GetProduct($id: ID!, $idType: ProductIdTypeEnum) {
product(id: $id, idType: $idType) {
...ProductContentFull
}
}
query GetShopCategories($first: Int, $after: String, $where: RootQueryToProductCategoryConnectionWhereArgs) {
productCategories(first: $first, after: $after, where: $where) {
pageInfo {
hasNextPage
endCursor
}
edges {
node {
id
name
slug
}
}
nodes {
id
name
slug
}
}
}
query GetShopTags($first: Int, $after: String, $where: RootQueryToProductTagConnectionWhereArgs) {
productTags(first: $first, after: $after, where: $where) {
pageInfo {
hasNextPage
endCursor
}
edges {
node {
id
name
slug
}
}
nodes {
id
name
slug
}
}
}
query GetShopColors($first: Int, $after: String, $where: RootQueryToPaColorConnectionWhereArgs) {
allPaColor(first: $first, after: $after, where: $where) {
pageInfo {
hasNextPage
endCursor
}
edges {
node {
id
name
slug
}
}
nodes {
id
name
slug
}
}
}
query GetSession {
cart {
...CartContent
}
customer {
...CustomerContent
}
}
mutation AddToCart(
$productId: Int!,
$variationId: Int,
$quantity: Int,
$variation: [ProductAttributeInput],
$extraData: String
) {
addToCart(
input: {
productId: $productId,
variationId: $variationId,
quantity: $quantity,
variation: $variation,
extraData: $extraData
}
) {
cart {
...CartContent
}
cartItem {
...CartItemContent
}
}
}
mutation UpdateCartItemQuantities($items: [CartItemQuantityInput]) {
updateItemQuantities(input: {items: $items}) {
cart {
...CartContent
}
items {
...CartItemContent
}
}
}
mutation RemoveItemsFromCart($keys: [ID], $all: Boolean) {
removeItemsFromCart(input: {keys: $keys, all: $all}) {
cart {
...CartContent
}
cartItems {
...CartItemContent
}
}
}
mutation Login($username: String!, $password: String!) {
login(input: { username: $username, password: $password }) {
authToken
refreshToken
customer {
...CustomerFields
}
}
}
mutation RefreshAuthToken($refreshToken: String!) {
refreshJwtAuthToken(input: { jwtRefreshToken: $refreshToken }) {
authToken
}
}
mutation UpdateSession($input: UpdateSessionInput!) {
updateSession(input: $input) {
session {
id
key
value
}
}
}
6. Create a new graphql/client.ts
file and add the following code:
import { GraphQLClient } from 'graphql-request';
import deepmerge from 'deepmerge';
import {
RootQueryToProductConnectionWhereArgs,
Product,
ProductCategory,
PaColor,
getSdk,
RootQueryToProductCategoryConnectionWhereArgs,
RootQueryToPaColorConnectionWhereArgs,
ProductIdTypeEnum,
} from './generated';
let client: GraphQLClient;
export function getClient() {
const endpoint = process.env.GRAPHQL_ENDPOINT
if (!endpoint) {
throw new Error('GRAPHQL_ENDPOINT is not defined')
}
if (!client) {
client = new GraphQLClient(endpoint);
}
return client;
}
export function getClientWithSdk() {
return getSdk(getClient());
}
const initialConnectionResults = {
pageInfo: {
hasNextPage: true,
endCursor: null,
},
edges: [],
nodes: [],
};
export async function fetchProducts(
pageSize: number,
pageLimit = 0,
where?: RootQueryToProductConnectionWhereArgs
) {
try {
const client = getClientWithSdk();
let data = { products: initialConnectionResults }
let after = '';
let count = 0
while(data.products.pageInfo.hasNextPage && (pageLimit === 0 || count < pageLimit)) {
const next = await client.GetProducts({
first: pageSize,
after,
where,
});
data = deepmerge(data, next);
after = next.products?.pageInfo.endCursor || '';
count++;
}
return (data.products.nodes) as Product[];
} catch (err) {
console.error(err || 'Failed to fetch product listing!!!');
}
}
export async function fetchCategories(
pageSize: number,
pageLimit = 0,
where?: RootQueryToProductCategoryConnectionWhereArgs
) {
try {
const client = getClientWithSdk();
let data = { productCategories: initialConnectionResults }
let after = '';
let count = 0
while(data.productCategories.pageInfo.hasNextPage && (pageLimit === 0 || count < pageLimit)) {
const next = await client.GetShopCategories({
first: pageSize,
after,
where,
});
data = deepmerge(data, next);
after = next.productCategories?.pageInfo.endCursor || '';
count++;
}
return (data.productCategories.nodes) as ProductCategory[];
} catch (err) {
console.error(err || 'Failed to fetch product categories!!!');
}
}
export async function fetchColors(
pageSize: number,
pageLimit = 0,
where?: RootQueryToPaColorConnectionWhereArgs
) {
try {
const client = getClientWithSdk();
let data = { allPaColor: initialConnectionResults }
let after = '';
let count = 0
while(data.allPaColor.pageInfo.hasNextPage && (pageLimit === 0 || count < pageLimit)) {
const next = await client.GetShopColors({
first: pageSize,
after,
where
});
data = deepmerge(data, next);
after = next.allPaColor?.pageInfo.endCursor || '';
count++;
}
return (data.allPaColor.nodes) as PaColor[];
} catch (err) {
console.error(err || 'Failed to fetch product color attributes!!!');
}
}
export async function fetchProductBy(slug: string, idType: ProductIdTypeEnum) {
try {
const client = getClientWithSdk();
const data = await client.GetProduct({
id: slug,
idType: idType,
});
if (!data.product) {
throw new Error('Product not found!!!');
}
return data.product as Product;
} catch (err) {
console.error(err || 'Failed to fetch product data!!!');
}
}
Conclusion
Congratulations! You've successfully set up a Next.js application for e-commerce development using the GraphQL endpoint from your WooCommerce store. You've installed and configured shadcn/ui
components, created other UI components, and installed and configured the GraphQL Code Generator. Your application is now ready for further development and integration with your WooCommerce store using GraphQL.