Lab - Build Customer Registration

Plan: Composable Developer

Lesson 25 of 27 · 45 min

Introduction

The final essential workflow to implement in your basic Next.js storefront is customer login. While you’ll track a customer’s authenticated state within your own application, BigCommerce will be used for the authentication itself.

In this lab, you will:

  • Add a customer login page.
  • Use a customer’s email and password to authenticate them with BigCommerce.
  • Track a customer’s ID in a secure JWT and include it as context in all relevant GraphQL requests.

Prerequisites

This exercise builds on the basic Next.js application begun in previous labs and has the same technical requirements.

Customer account registration will not be part of this lab, so you will need a pre-existing customer in your store.

To observe the full functionality of your customer session logic, your store should contain a customer group matching the following:

  • Is NOT the default customer group. (The point of this customer group is to demonstrate differences from an anonymous user.)
  • Has custom pricing. (Edit the customer group and set a Storewide Discount.)
  • Has restricted category access. (Edit the customer group, un-check “Customers in this group can see products in all categories across all channels” and select a limited sub-set of your headless channel’s top-level categories.)

Then make sure your store contains a customer matching the following:

  • Has “Origin channel” set to your headless channel
  • Has “Customer group” set to the customer group above
  • Has a password set

Setup

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

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. In your terminal, run the following, replacing the path to the working directory with your own path:
corepack enable pnpm && pnpm dlx create-next-app@latest -e https://github.com/bigcommerce-edu/lab-nextjs-storefront/tree/bcu-lab-cust-start /path/to/working/directory
  1. Copy .env.example as .env.local and modify .env.local with configuration details for your store.
  2. Start the dev server process:
cd /path/to/working/directory
pnpm run dev
  1. Browse to the local URL printed in the pnpm output and verify your catalog pages and cart load successfully.

Exercise Reference Copy

You may choose to download or clone the completed version of this lab in a separate location to serve as a reference for the final state.

Customer Registration Lab

Configure JWT Support

You’ll be encoding an authenticated customer’s information in a JWT to securely track their session. The library jsonwebtoken is pre-installed in the codebase and is used to manage JWTs. This library requires a secret value in order to sign JWTs.

  1. Run the following in your terminal to generate a random secret that will be used to sign JWTs.
openssl rand -hex 32
  1. Add the secret you generated above to your .env.local file.
JWT_SECRET="{your secret}"

Create the Registration Page

  1. Open the file app/register/page.tsx, which defines the register page component. Update the component as follows.
export default async function RegisterPage() {
return (
<>
<PageHeading>Register</PageHeading>
<div className="w-1/3">
<RegisterForm />
</div>
</>
);
}

We’re not doing much in this page component. The reason is that basically the entire contents of the page are contained within a form, and as you’ve previously seen with the Add to Cart button, interacction requires a client component!

RegisterForm is currently empty. Let’s fill it in next.

  1. Open the file components/account/register-form.tsx and modify the component as follows.
const RegisterForm = () => {
const [email, setEmail] = useState("");
const [firstName, setFirstName] = useState("");
const [lastName, setLastName] = useState("");
const [password, setPassword] = useState("");
const [loading, setLoading] = useState(false);
const [errorMsg, setErrorMsg] = useState<string | null>(null);
const submitRegister = async () => {
}
return (
<form onSubmit={(e) => {e.preventDefault(); submitRegister()}}>
{errorMsg && (
<div className="bg-rose-400 p-2 font-bold text-sm text-center">
{errorMsg}
</div>
)}
<div className="my-8 p-4">
<label className="font-bold text-lg block">Email</label>
<input className="w-full border border-neutral-300 p-2
hover:border-neutral-700"
type="text" value={email} onChange={(e) => setEmail(e.target.value)} />
</div>
<div className="my-8 p-4">
<label className="font-bold text-lg block">First Name</label>
<input className="w-full border border-neutral-300 p-2
hover:border-neutral-700"
type="text" value={firstName} onChange={(e) => setFirstName(e.target.value)} />
</div>
<div className="my-8 p-4">
<label className="font-bold text-lg block">Last Name</label>
<input className="w-full border border-neutral-300 p-2
hover:border-neutral-700"
type="text" value={lastName} onChange={(e) => setLastName(e.target.value)} />
</div>
<div className="my-8 p-4">
<label className="font-bold text-lg block">Password</label>
<input className="w-full border border-neutral-300 p-2
hover:border-neutral-700"
type="password" value={password} onChange={(e) => setPassword(e.target.value)} />
</div>
<div className="my-8 p-4 text-center">
<button disabled={loading} className="p-2 rounded-md text-lg w-44 cursor-pointer
bg-neutral-700 text-white hover:bg-neutral-500 disabled:bg-neutral-500">
{loading ? (
<span>...</span>
) : (
<span>
Register
</span>
)}
</button>
</div>
</form>
);
}

We’re using some simple state here to capture the form values, as well as tracking a “loading” state when the form is submitted.

  1. Fill in the submitRegister function with the request logic.
const RegisterForm = () => {
...
const submitRegister = async () => {
setLoading(true);
setErrorMsg(null);
const registerResp = await registerCustomer({
email,
firstName,
lastName,
password,
});
setLoading(false);
if (!registerResp.success) {
setErrorMsg(registerResp.error ?? "An unexpected error occurred");
}
}
return ...
}

registerCustomer is another server function, which doesn’t currently accept the right parameters.

  1. Open the file components/account/_actions/register-customer.ts and update the function to accept the proper parameters and return a generic error for the time being.
export const registerCustomer = async ({
email,
firstName,
lastName,
password,
}: {
email: string,
firstName: string,
lastName: string,
password: string,
}) => {
return Promise.resolve(
{ success: false, error: "Registration not implemented." }
);
};
  1. Since there’s not yet a navigation link to the registration page, browse directly to http://localhost:3000/register (or the correct port for your own local server). You can try submitting the form for a successful HTTP response, but you’ll currently receive the “Registration. is not implemented” error.

Example Code

Add the Registration Logic

  1. Open the file components/account/_actions/register-customer.ts and add the GraphQL and type definitions related to the registration mutation.
import ...
const registerQuery = `
mutation Register(
$email: String!,
$firstName: String!,
$lastName: String!,
$password: String!
) {
customer {
registerCustomer(
input: {
email: $email,
firstName: $firstName,
lastName: $lastName,
password: $password
}
) {
customer {
entityId
}
errors {
... on EmailAlreadyInUseError {
message
}
... on AccountCreationDisabledError {
message
}
... on CustomerRegistrationError {
message
}
... on ValidationError {
message
}
}
}
}
}
`;
interface RegisterVars {
email: string;
firstName: string;
lastName: string;
password: string;
}
interface RegisterResp {
data: {
customer: {
registerCustomer: {
customer: {
entityId: number;
};
errors?: {
message: string;
}[]
}
}
}
}
/**
* Register a new customer
*/
export const registerCustomer = async ({
...
  1. Update the registerCustomer function to perform the mutation.
export const registerCustomer = async ({
...
}: {
...
}) => {
try {
const customerResp = await bcGqlFetch<RegisterResp, RegisterVars>(
registerQuery,
{
email,
firstName,
lastName,
password,
}
);
const customer = customerResp.data.customer.registerCustomer.customer;
const errors = customerResp.data.customer.registerCustomer.errors;
if (!customer) {
let errorMsg = "Customer registration failed";
if (errors) {
errorMsg = errors.map(error => error.message).join("; ")
}
return { success: false, error: errorMsg };
}
} catch(err) {
const error = (err instanceof Error) ? err.message : String(err);
return { success: false, error };
}
redirect("/login");
};
  1. Browse once again to http://localhost:3000/register (or the correct port for your own local server).
  2. Test the register form with an email address that doesn’t match any existing customer account. A successful registration should result in being redirected to /login (which is currently a blank page).
  3. Verify in your store control panel that the new customer exists.
  4. Browse to /register again and try out a registration with the same email address you used before. Verify that you receive appropriate error feedback.

Example Code

Create the Login Page

The focus of the next sections will be to provide a login form, perform authentication with BigCommerce, and store a JWT in a cookie to represent the customer’s session.

  1. Open the file app/login/page.tsx, which defines the login page component. Update the component as follows.
export default function LoginPage() {
return (
<>
<PageHeading>Log In</PageHeading>
<div className="w-1/3">
<LoginForm />
</div>
</>
);
}

Similar to the registration page, most of what’s going on here is delegated to a client form component.

  1. Open the file components/account/login-form.tsx and modify the component as follows.
const LoginForm = () => {
const [email, setEmail] = useState("");
const [password, setPassword] = useState("");
const [loading, setLoading] = useState(false);
const [errorMsg, setErrorMsg] = useState<string | null>(null);
const submitLogin = async () => {
}
return (
<form onSubmit={(e) => {e.preventDefault(); submitLogin()}}
className="">
{errorMsg && (
<div className="bg-rose-400 p-2 font-bold text-sm text-center">
{errorMsg}
</div>
)}
<div className="my-8 p-4">
<label className="font-bold text-lg block">Email</label>
<input className="w-full border border-neutral-300 p-2
hover:border-neutral-700"
type="text" value={email} onChange={(e) => setEmail(e.target.value)} />
</div>
<div className="my-8 p-4">
<label className="font-bold text-lg block">Password</label>
<input className="w-full border border-neutral-300 p-2
hover:border-neutral-700"
type="password" value={password} onChange={(e) => setPassword(e.target.value)} />
</div>
<div className="my-8 p-4 text-center">
<button disabled={loading} className="p-2 rounded-md text-lg w-44 cursor-pointer
bg-neutral-700 text-white hover:bg-neutral-500 disabled:bg-neutral-500">
{loading ? (
<span>...</span>
) : (
<span>
Log In
</span>
)}
</button>
</div>
</form>
);
};
  1. Fill in the submitLogin function with the request logic.
const LoginForm = () => {
...
const submitLogin = async () => {
setLoading(true);
setErrorMsg(null);
const loginResp = await loginCustomer({
email,
password,
});
setLoading(false);
if (!loginResp.success) {
setErrorMsg(loginResp.error ?? "An unexpected error occurred");
}
}
return ...
};

Once again, we’re relying on a server function that must be modified to accept the right parameters.

  1. Open the file components/account/_actions/login-customer.ts and modify the function as follows.
export const loginCustomer = async ({
email,
password,
}: {
email: string,
password: string,
}) => {
return Promise.resolve(
{ success: false, error: 'Login not implemented' }
);
};
  1. Since there’s not yet a navigation link to the login page, browse directly to http://localhost:3000/login (or the correct port for your own local server). You can try submitting the form for a successful HTTP response. You’ll currently see the “not implemented” message.

Example Code

Add the Login Logic

  1. Open the file components/account/_actions/login-customer.ts and add the GraphQL and type definitions related to the login mutation.
import ...
const loginQuery = `
mutation Login(
$email: String!,
$password: String!
) {
login(
email: $email,
password: $password
) {
customer {
entityId
}
customerAccessToken {
value
}
}
}
`;
interface LoginVars {
email: string;
password: string;
}
interface LoginResp {
data: {
login: {
customer: {
entityId: number;
}
customerAccessToken: {
value: string;
}
}
}
}
/**
* Perform login
*/
export const loginCustomer = async ({
...
  1. Update the loginCustomer function to perform the mutation.
export const loginCustomer = async ({
email,
password,
}: {
email: string,
password: string,
}) => {
const { JWT_SECRET } = process.env;
if (!JWT_SECRET) {
return { success: false, error: "Could not perform login" };
}
try {
const customerResp = await bcGqlFetch<LoginResp, LoginVars>(
loginQuery,
{
email,
password,
}
);
const customer = customerResp.data.login.customer;
const token = customerResp.data.login.customerAccessToken.value;
if (!customer || !token) {
return { success: false, error: "Customer login failed"};
}
const customerJwt = jwt.sign({
sub: JSON.stringify({
entityId: customer.entityId,
token,
}),
}, JWT_SECRET);
const cookieStore = await cookies();
const secure = await isSecure();
const cookieName = getCookieName({ name: "customer", secure });
cookieStore.set({
name: cookieName,
value: customerJwt,
httpOnly: true,
secure,
});
} catch(err) {
const error = (err instanceof Error) ? err.message : String(err);
return { success: false, error };
}
redirect("/");
};
  1. Browse once again to http://localhost:3000/login (or the correct port for your own local server).
  2. Test the login form with an incorrect email or password to observe the resulting error message.
  3. Test the login form with credentials for an existing customer. While there won’t yet be any visible feedback, you can use your browser dev tools to observe the customer cookie being set.

Example Code

Your next step will be to add contextual login/logout links in the store header, which will require awareness of a customer’s logged-in status.

  1. Open the file lib/getCurrentCustomer.ts. The helper function here will be used by any component that needs to retrieve the details tracked in the “customer” cookie. Fill in the function as follows.
export const getCurrentCustomer = async () => {
const cookieStore = await cookies();
const secure = await isSecure();
const cookieName = getCookieName({ name: "customer", secure });
const customerJwt = cookieStore.get(cookieName)?.value;
if (!customerJwt) {
return null;
}
const JWT_SECRET = process.env.JWT_SECRET ?? '';
let customerResult;
try {
const customerClaim = jwt.verify(customerJwt, JWT_SECRET);
const customer = JSON.parse(customerClaim.sub?.toString() ?? '');
customerResult = {
entityId: parseInt(customer.entityId ?? ''),
token: customer.token ?? '',
}
} catch (err) {
customerResult = null;
cookieStore.delete(cookieName);
}
return customerResult;
};
  1. Open the file components/account-links/index.tsx. Update the component as follows.
const AccountLinks = async () => {
const currentCustomer = await getCurrentCustomer();
return (
<>
{currentCustomer?.entityId ? (
<LogoutButton />
) : (
<>
<Link href="/register" className="mx-4 font-bold hover:underline">Register</Link>
|
<Link href="/login" className="mx-4 font-bold hover:underline">Log in</Link>
</>
)}
</>
);
};

Notice that while “Register” and “Log in” are simple links, while a separate component is included for logging out. The logout link, rather than simply linking the user to a destination, is a dynamic interaction, and so it calls for yet another client component. Let’s fill out that component’s initial content now.

  1. Open the file components/account-links/logout-button.tsx and modify the component as follows.
const LogoutButton = () => {
const [loading, setLoading] = useState(false);
return (
<button
disabled={loading}
className="mx-4 font-bold cursor-pointer hover:underline"
>
{loading ? (
<span>...</span>
) : (
<span>Log out</span>
)}
</button>
);
};

This button doesn’t do anything yet but already has a simple loading state ready to go.

  1. Modify components/header/index.tsx to place AccountLinks.
const Header = async () => {
...
return (
<header ...>
<div ...>
...
<div className="flex">
<AccountLinks />
<MiniCart />
</div>
</div>
...
</header>
)
}
  1. Browse to your local site and observe the new account link.

If you retry a valid login on the login page, you should be able to observe the links in the header immediately update accordingly.

Example Code

Add Logout Logic

  1. Open the file components/account-links/logout-button.tsx and modify the component to implement a submit event calling a server function.
const LogoutButton = () => {
const [loading, setLoading] = useState(false);
const submitLogout = async () => {
setLoading(true);
await logout();
setLoading(false);
}
return (
<button
...
onClick={submitLogout}
>
...
</button>
);
}
  1. Open the file components/account-links/_actions/logout.ts and add the appropriate query and response types.
import ...
const logoutQuery = `
mutation Logout {
logout {
result
}
}
`;
interface LogoutResp {
data: {
logout: {
result: string;
}
}
}
/**
* Perform logout
*/
export const logout = async () => {
  1. Update logout to perform the appropriate mutation and destroy the customer cookie.
export const logout = async () => {
const currentCustomer = await getCurrentCustomer();
if (currentCustomer?.token) {
await bcGqlFetch<LogoutResp>(
logoutQuery,
currentCustomer?.token
);
}
const cookieStore = await cookies();
const secure = await isSecure();
const cookieName = getCookieName({ name: "customer", secure });
cookieStore.delete(cookieName);
};
  1. Browse to your local site and make sure you’re logged in with a valid customer account. Try out the “Log Out” link in the header.

Example Code

Send Customer Token in GraphQL

Your storefront will now handle authenticating and tracking a customer, but this has little actual effect except to then give the customer the chance to log out again. Let’s put the customer context to use in one of the most important ways: Passing this context in the storefront’s various GraphQL queries, which will affect the data returned.

Support for a customerToken is already built into the bcGqlFetch function, which passes this parameter in the X-Bc-Customer-Access-Token header. Each of the catalog queries you’ve built also supports a customerToken parameter. All that’s needed is to update the contexts where these queries are used to obtain and pass in the current customer’s token.

In a truly complete storefront, cart-related queries and mutations should be affected as well, but we’re not tackling the extra complexity (such as reassigning an existing guest cart to a logged-in customer) in this exercise.

  1. Modify components/header/index.tsx to get the current customer using the helper function you created and pass the token into the GraphQL function.
const Header = async () => {
const currentCustomer = await getCurrentCustomer();
const { settings, navCategories } = await getHeaderSettings({
customerToken: currentCustomer?.token,
});
...
return ...
}
  1. Modify app/category/[...catPath]/page.tsx to perform the same customer context logic.
export default async function CategoryPage({
...
}: {
...
}) {
...
const currentCustomer = await getCurrentCustomer();
let category;
try {
category = await getCategoryWithProducts({
path,
mainImgSize,
thumbnailSize,
page: {
...
},
customerToken: currentCustomer?.token,
});
} catch(err) {
...
}
...
return ...
}
  1. Modify app/product/[...productPath]/page.tsx to perform the same customer context logic.
export default async function ProductPage({
...
}: {
...
}) {
...
const currentCustomer = await getCurrentCustomer();
let product;
try {
product = await getProduct({
path,
imgSize,
customerToken: currentCustomer?.token,
});
} catch (err) {
...
}
...
return ...
}
  1. Choose a customer belonging to a group with custom pricing and without access to some top-level categories. If you don’t have such a customer, register a new customer and make the appropriate change to their customer group.
  2. Try out both a logged-in and logged-out state on your storefront. Make sure when logging in to use the customer account subject to price and catalog differences. You should be able to observe the differences in the contents of the main nav menu and the pricing on catalog pages.

If you try adding a product to the cart while logged in as a customer with pricing discounts, you will NOT see the same price difference in the cart. This is because the cart queries and mutations, including the initial createCart request, still lack the customer context, so cart pricing is not affected by it.

Example Code

Full Lab Code

Full Lab Code

Taking It Further

Provide the token from the current customer for cart requests as well, and verify that a logged-in customer session is maintained when redirecting to checkout.