Build a Secure Stripe Storefront

Build a Next.js storefront with server-validated Stripe sandbox checkout.

Introduction

30 Second Summary

Online shoppers expect prices to stay consistent from the product shelf to the payment screen. A beautiful store loses trust the moment its totals can be changed before checkout.

In this project, you will build Lather & Aura as a responsive Next.js storefront powered by React. You will send product IDs plus quantities to the server, which rebuilds each order before Stripe Checkout runs a simulation where no funds move.

What You'll Build

You will watch a mixed cosmetics cart move from Lather & Aura to a Stripe-hosted sandbox payment page before returning to a branded confirmation screen.

By the end of this project, you'll have:

  • A filterable catalog where choosing Baby Soap or Adult Perfume shows the matching fictional products.
  • A live mixed cart where repeated product selections increase the quantity up to 10 while the order total updates instantly.
  • A secure sandbox checkout flow that replaces an intentional browser-price block with server validation before returning from Stripe to a branded confirmation screen.
  • Secret Mission: Add explicit increase and decrease controls that preserve the server's quantity limits.

Do I need any experience before starting?

You need experienced web development fundamentals. The project assumes a Windows computer with Visual Studio Code, Node.js, and Microsoft Edge.

Before We Start

This checkpoint locks in what Lather & Aura is meant to prove before any hands-on work begins. You will define the shopper experience plus the sandbox-only boundary that keeps this prototype focused.

Set Up the Pinned Local Sandbox

Your storefront needs a repeatable local base before any catalog code arrives. A pinned Node.js setup keeps Next.js and React behavior consistent.

The Stripe sandbox secret must stay in the server environment. You will build the starter in Visual Studio Code with that credential excluded from source control.

In this step, get ready to:
  • Verify that Node.js meets the required version.
  • Create the pinned Next.js starter project.
  • Store the Stripe sandbox secret safely.
Verify Node.js and open the project folder

Next.js requires Node.js 20.9.0 or newer. Checking first prevents an older runtime from causing confusing installation errors.

  • Press the Windows key to open Windows search.
  • Type PowerShell into the search field.
  • Press Enter to open PowerShell.
  • Check your installed Node.js version by running this command:
node --version

What does this command check?

The command prints the Node.js version available to PowerShell. The result determines whether your current installation can run this pinned Next.js project.

✔️ I see the required version

If the result is 20.9.0 or newer, your runtime meets the requirement. That is the first compatibility check complete.

ⓧ I see an older version

Your installed Node.js version is below the minimum required by this project. Upgrade to the current v24.21.0 LTS release before continuing.

  • Visit the official Node.js download page.
  • Download the Windows installer for Node.js v24.21.0 LTS.
  • Run the downloaded installer.
  • Follow the installer prompts to complete the upgrade.
  • Close PowerShell after the installation finishes.
  • Open a fresh PowerShell window through Windows search.
  • Repeat the version check from above.

ⓧ Command not found

PowerShell cannot find a Node.js installation. Install the current LTS release before continuing.

  • Visit the official Node.js download page.
  • Download the Windows installer for Node.js v24.21.0 LTS.
  • Run the downloaded installer.
  • Follow the installer prompts to complete the installation.
  • Close PowerShell after the installation finishes.
  • Open a fresh PowerShell window through Windows search.
  • Repeat the version check from above.

With the runtime confirmed, the next visible anchor is a dedicated folder on your Desktop. Visual Studio Code treats that folder as the workspace for every file you create.

  • Press the Windows key to open Windows search.
  • Type Visual Studio Code into the search field.
  • Press Enter to open Visual Studio Code.
  • Select File from the top menu.
  • Select Open Folder.
  • Choose your Desktop in the folder picker.
  • Select New Folder.
  • Enter lather-and-aura-store as the folder name.
  • Select the new lather-and-aura-store folder.
  • Select Select Folder to open it as your workspace.
  • Approve Workspace Trust if Visual Studio Code asks whether you trust the folder you created.

You will see lather-and-aura-store at the top of the Explorer sidebar. Your runtime and workspace are now ready for the pinned starter.

Folder missing from the Explorer?

Check that the Explorer heading shows lather-and-aura-store. Reopen that folder from the File menu if Visual Studio Code opened a different location.

Ask for help with the workspace path: help me open my lather-and-aura-store Desktop folder in Visual Studio Code.

Create the pinned starter files

The package.json file pins every dependency to a known version. This keeps the project reproducible across installations.

  • Click the new-file control in Visual Studio Code's Explorer sidebar.
  • Enter package.json as the file name.
  • Paste this pinned package configuration into package.json:
{
  "name": "lather-and-aura-store",
  "version": "1.0.0",
  "private": true,
  "scripts": {
    "dev": "next dev",
    "build": "next build",
    "start": "next start"
  },
  "dependencies": {
    "next": "16.3.5",
    "react": "19.3.0",
    "react-dom": "19.3.0",
    "stripe": "22.6.2"
  }
}

What does this configuration control?

  • The scripts provide commands for development, production builds, and production startup.
  • Next.js is pinned to 16.3.5.
  • React and React DOM are pinned to 19.3.0.
  • The Stripe Node.js SDK is pinned to 22.6.2.
  • Save package.json by pressing Ctrl+S.
  • Confirm that package.json remains listed in the Explorer sidebar.

The first dependency installation can take a minute while npm downloads the pinned packages. A steady stream of terminal output means the installation is progressing.

  • Open Visual Studio Code's integrated terminal by pressing Ctrl+`.
  • Install the dependencies from package.json by running this command:
npm install

What did npm install?

The command reads package.json and installs its dependencies into a local node_modules folder. It also records the resolved dependency tree for repeatable future installations.

You will see an installation summary when the command completes. The Explorer sidebar will also show new dependency files and folders.

Dependency installation failed?

A JSON syntax error usually means a quote or comma in package.json does not match the code above. A network-related failure means the packages could not be downloaded.

Ask for help with the exact terminal output: help me diagnose my npm install failure.

Source control should ignore generated dependencies and local credentials. The .gitignore file defines those exclusions.

  • Click the new-file control in the Explorer sidebar.
  • Enter .gitignore as the file name.
  • Paste these exclusions into .gitignore:
node_modules
.next
.env.local

Why ignore these paths?

The node_modules and .next folders contain generated files. The .env.local entry prevents your local Stripe secret from being included in source control.

  • Save .gitignore by pressing Ctrl+S.
  • Confirm that all three exclusion lines remain visible in the saved file.

Next.js uses the app folder for its App Router files. The root layout supplies the document shell for every page.

  • Click the new-folder control in the Explorer sidebar.
  • Enter app as the folder name.
  • Click the new-file control in the Explorer sidebar.
  • Enter app/layout.js as the file path.
  • Paste this root layout into app/layout.js:
import "./globals.css";

export default function RootLayout({ children }) {
  return (
    <html lang="en">
      <body>{children}</body>
    </html>
  );
}

What does the root layout do?

  • The stylesheet import applies global presentation to every page.
  • The children value represents the page rendered inside the layout.
  • The HTML structure gives Next.js the document shell required by the App Router.
  • Save app/layout.js by pressing Ctrl+S.
  • Confirm that layout.js appears inside the app folder in the Explorer sidebar.

The first page provides a small branded shell that proves the app can render. Later steps expand this same page into the two-collection storefront.

  • Click the new-file control in the Explorer sidebar.
  • Enter app/page.js as the file path.
  • Paste this starter page into app/page.js:
export default function Storefront() {
  return (
    <main>
      <header className="site-header">
        <a className="brand" href="#top">
          Lather & Aura
        </a>
      </header>

      <section className="hero" id="top">
        <p className="eyebrow">Everyday care. Evening character.</p>
        <h1>Baby-care simplicity meets expressive adult fragrance.</h1>
        <p>
          Explore a fictional demo collection built to showcase a secure
          ecommerce flow.
        </p>
      </section>
    </main>
  );
}

What does the starter page render?

The Storefront component renders the Lather & Aura brand header and hero message. The class names give the global stylesheet stable styling targets.

  • Save app/page.js by pressing Ctrl+S.
  • Confirm that page.js appears inside the app folder in the Explorer sidebar.

The initial stylesheet establishes the storefront's warm background and text colors. It also gives the header and hero their first branded layout.

  • Click the new-file control in the Explorer sidebar.
  • Enter app/globals.css as the file path.
  • Paste this global foundation into app/globals.css:
:root {
  color-scheme: light;
  font-family: Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont,
    "Segoe UI", sans-serif;
  background: #f7f2eb;
  color: #211d1a;
}

* {
  box-sizing: border-box;
}

html {
  scroll-behavior: smooth;
}

body {
  margin: 0;
  min-height: 100vh;
  background:
    radial-gradient(circle at top left, #fffaf2 0, transparent 34rem),
    #f7f2eb;
}

What does the global foundation do?

  • The root values define the default typeface and color palette.
  • The universal selector keeps element dimensions predictable.
  • The body background creates the warm canvas behind the storefront.
  • Save app/globals.css by pressing Ctrl+S.
  • Confirm that globals.css appears inside the app folder in the Explorer sidebar.
  • Add the header and hero styles below the existing body rule:
.site-header {
  align-items: center;
  display: flex;
  justify-content: space-between;
  padding: 1.25rem clamp(1rem, 5vw, 5rem);
}

.brand {
  color: #211d1a;
  font-family: Georgia, serif;
  font-size: 1.35rem;
  font-weight: 700;
  text-decoration: none;
}

.hero {
  margin: 2rem auto 4rem;
  max-width: 850px;
  padding: 0 1.25rem;
  text-align: center;
}

How does this shape the starter shell?

The header spacing keeps the brand clear of the browser edges. The hero rule centers the opening message within a readable width.

  • Save app/globals.css by pressing Ctrl+S.
  • Confirm that Visual Studio Code no longer shows an unsaved indicator on the globals.css tab.

Seeing red underlines in the starter files?

Check that each file path matches the Explorer structure. Make sure the braces and closing tags match the supplied code.

Ask for help comparing the starter files: help me find the syntax mismatch in my Next.js starter.

Store the sandbox key and start the server

An environment variable lets server code read a credential without placing it in browser code. The local secret file remains outside source control because .gitignore already excludes it.

  • Press the Windows key to open Windows search.
  • Type Microsoft Edge into the search field.
  • Press Enter to open Microsoft Edge.
  • Visit the Stripe sandbox API keys page.
  • Create a Stripe account if the page asks you to register.
  • Complete the account prompts with your own details.
  • Return to the linked sandbox API keys page after signing in.
  • Confirm that the Dashboard is showing sandbox data.

Your sandbox secret key can authorize test API requests, so treat it like a real credential. It stays only in your local .env.local file throughout this project.

  • Use the key page's reveal control to display the sandbox secret key.
  • Copy the displayed sandbox secret key.
  • Return to Visual Studio Code.
  • Click the new-file control in the Explorer sidebar.
  • Enter .env.local.example as the file name.
  • Paste this safe template into .env.local.example:
STRIPE_SECRET_KEY=your-stripe-test-secret-key-here

Why create an example file?

The example records the required variable name without containing a working credential. It shows future developers how to configure the project safely.

  • Save .env.local.example by pressing Ctrl+S.
  • Confirm that .env.local.example appears in the Explorer sidebar.
  • Click the new-file control in the Explorer sidebar.
  • Enter .env.local as the file name.
  • Paste this configuration line into .env.local:
STRIPE_SECRET_KEY=your-stripe-test-secret-key-here

How will Next.js use this file?

Next.js loads STRIPE_SECRET_KEY from .env.local into the server environment. A later Route Handler uses it to create Stripe Checkout Sessions.

  • Replace your-stripe-test-secret-key-here with the sandbox secret key you copied.
  • Save .env.local by pressing Ctrl+S.
  • Close the .env.local editor tab to keep the credential off-screen.
  • Return to .gitignore in the Explorer sidebar.
  • Confirm that .env.local remains listed as an exclusion.

Cannot find the sandbox secret key?

Confirm that you are signed into the Stripe Dashboard. Check that the Dashboard is showing sandbox data before returning to the API keys page.

Ask for help locating the sandbox credential safely: help me find my Stripe sandbox secret key without exposing it.

Before you start the server, what do you expect to see when the pinned files and dependencies are all valid?

  • Switch back to the integrated terminal in Visual Studio Code.
  • Start the Next.js development server by running this command:
npm run dev

What does the development script do?

The development script starts the local Next.js server defined in package.json. It watches the project files so saved changes can appear during development.

You will see the terminal report that the local server is ready. Keep this terminal running while you view the storefront.

Development server did not start?

  • Check that the terminal path ends with lather-and-aura-store.
  • Check that package.json contains the pinned dependencies and scripts shown above.
  • Check that layout.js, page.js, and globals.css are inside the app folder.

Ask for help with the terminal error: help me debug why the Lather & Aura Next.js server will not start.

  • Switch back to Microsoft Edge from earlier.
  • Enter http://localhost:3000 in the address bar.
  • Press Enter to load the local storefront.

You will see the Lather & Aura name above the message Baby-care simplicity meets expressive adult fragrance. The warm page background confirms that the global stylesheet is loading.

That is your local sandbox running: the pinned Next.js app now renders its branded starter shell in Microsoft Edge.

✔️ Awesome, I've got everything!

Great. Double check that every file is saved and keep the development server running.

ⓧ I'd like to double check the full code

Compare each non-secret project file below. Your local .env.local must contain the real sandbox secret after STRIPE_SECRET_KEY=, so its value is intentionally omitted here.

{
  "name": "lather-and-aura-store",
  "version": "1.0.0",
  "private": true,
  "scripts": {
    "dev": "next dev",
    "build": "next build",
    "start": "next start"
  },
  "dependencies": {
    "next": "16.3.5",
    "react": "19.3.0",
    "react-dom": "19.3.0",
    "stripe": "22.6.2"
  }
}
node_modules
.next
.env.local
STRIPE_SECRET_KEY=your-stripe-test-secret-key-here
import "./globals.css";

export default function RootLayout({ children }) {
  return (
    <html lang="en">
      <body>{children}</body>
    </html>
  );
}
export default function Storefront() {
  return (
    <main>
      <header className="site-header">
        <a className="brand" href="#top">
          Lather & Aura
        </a>
      </header>

      <section className="hero" id="top">
        <p className="eyebrow">Everyday care. Evening character.</p>
        <h1>Baby-care simplicity meets expressive adult fragrance.</h1>
        <p>
          Explore a fictional demo collection built to showcase a secure
          ecommerce flow.
        </p>
      </section>
    </main>
  );
}
:root {
  color-scheme: light;
  font-family: Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont,
    "Segoe UI", sans-serif;
  background: #f7f2eb;
  color: #211d1a;
}

* {
  box-sizing: border-box;
}

html {
  scroll-behavior: smooth;
}

body {
  margin: 0;
  min-height: 100vh;
  background:
    radial-gradient(circle at top left, #fffaf2 0, transparent 34rem),
    #f7f2eb;
}

.site-header {
  align-items: center;
  display: flex;
  justify-content: space-between;
  padding: 1.25rem clamp(1rem, 5vw, 5rem);
}

.brand {
  color: #211d1a;
  font-family: Georgia, serif;
  font-size: 1.35rem;
  font-weight: 700;
  text-decoration: none;
}

.hero {
  margin: 2rem auto 4rem;
  max-width: 850px;
  padding: 0 1.25rem;
  text-align: center;
}

Your pinned sandbox is ready for feature work. Next up, you will turn this starter shell into a filterable Baby Soap and Adult Perfume storefront.

Build the Two-Collection Storefront

Your starter shell already proves that the local Next.js app renders in Microsoft Edge. The next goal is to turn that shell into a storefront that shoppers can browse.

This step establishes the Lather & Aura brand before cart logic or payment complexity enters the project. You will build one catalog with separate baby soap and adult perfume collections.

In this step, get ready to:
  • Create a shared catalog containing four fictional products.
  • Render the branded hero with collection filters and product cards.
  • Apply the responsive Lather & Aura visual system.
Create the shared product catalog

A shared catalog gives the browser and the future checkout server one consistent product list. Stable IDs will let later code identify products without accepting names or prices from the browser.

  • Create a folder named lib inside the lather-and-aura-store folder using Visual Studio Code's left file sidebar.
  • Create products.js inside the new lib folder.
  • Add the first two catalog records by pasting this first part into lib/products.js:
export const PRODUCTS = [
  {
    id: "oat-baby-bar",
    name: "Gentle Oat Baby Bar",
    category: "Baby Soap",
    description: "An unscented demo soap with an oat-inspired presentation.",
    price: 1200,
    visual: "OAT",
    tone: "oat",
  },
  {
    id: "cloud-baby-bar",
    name: "Cloud Soft Baby Bar",
    category: "Baby Soap",
    description: "A soft, minimal demo concept for a baby-care collection.",
    price: 1400,
    visual: "SOFT",
    tone: "cloud",
  },

What does this catalog hold?

  • Each record keeps a stable ID beside the product details that shoppers see.
  • Each price uses an integer number of cents to avoid decimal rounding inside the storefront.
  • Complete the PRODUCTS array by pasting this second part directly below the first part:
  {
    id: "amber-noir",
    name: "Amber Noir Eau de Parfum",
    category: "Adult Perfume",
    description: "A warm amber, cedar, and vanilla fragrance concept.",
    price: 5800,
    visual: "AMBER",
    tone: "amber",
  },
  {
    id: "rose-velvet",
    name: "Rose Velvet Eau de Parfum",
    category: "Adult Perfume",
    description: "A floral rose and soft musk fragrance concept.",
    price: 6400,
    visual: "ROSE",
    tone: "rose",
  },
];

Why include visuals and tones?

  • The visual value gives each card a short display label.
  • The tone value connects each product to a distinct CSS treatment without relying on external images.
  • Save lib/products.js.
  • Confirm that products.js is listed inside the lib folder in Visual Studio Code's left file sidebar.

Your project now has one catalog containing two baby soaps and two adult perfumes.

Is the catalog file showing a syntax problem?

Check that the first part ends after the second record's comma. Confirm that the second part ends with the array's closing bracket.

Ask for help checking the catalog structure.

✔️ Awesome, I've got everything!

Great. Your shared catalog is saved with all four stable product IDs.

ⓧ I'd like to double check the full code

export const PRODUCTS = [
  {
    id: "oat-baby-bar",
    name: "Gentle Oat Baby Bar",
    category: "Baby Soap",
    description: "An unscented demo soap with an oat-inspired presentation.",
    price: 1200,
    visual: "OAT",
    tone: "oat",
  },
  {
    id: "cloud-baby-bar",
    name: "Cloud Soft Baby Bar",
    category: "Baby Soap",
    description: "A soft, minimal demo concept for a baby-care collection.",
    price: 1400,
    visual: "SOFT",
    tone: "cloud",
  },
  {
    id: "amber-noir",
    name: "Amber Noir Eau de Parfum",
    category: "Adult Perfume",
    description: "A warm amber, cedar, and vanilla fragrance concept.",
    price: 5800,
    visual: "AMBER",
    tone: "amber",
  },
  {
    id: "rose-velvet",
    name: "Rose Velvet Eau de Parfum",
    category: "Adult Perfume",
    description: "A floral rose and soft musk fragrance concept.",
    price: 6400,
    visual: "ROSE",
    tone: "rose",
  },
];
Build the filterable storefront

Filtering changes what the shopper sees, so the page needs interactive state. A Client Component can use React state to remember the selected collection.

Why use a Client Component?

The filter buttons update the page immediately after a shopper selects a collection. The client boundary gives this component access to useState for that interaction.

You will replace the starter page in three consecutive parts. Together, the parts form one complete component.

  • Switch to app/page.js in Visual Studio Code.
  • Select all of the existing starter page code.
  • Replace the selected code with this first part:
"use client";

import { useState } from "react";
import { PRODUCTS } from "../lib/products";

const FILTERS = ["All", "Baby Soap", "Adult Perfume"];
const money = (cents) => `$${(cents / 100).toFixed(2)}`;

export default function Storefront() {
  const [filter, setFilter] = useState("All");

  const visibleProducts =
    filter === "All"
      ? PRODUCTS
      : PRODUCTS.filter((product) => product.category === filter);

What does the storefront state control?

  • The filter state starts at All so every product appears on the first render.
  • The visibleProducts value returns the entire catalog or the records matching the selected category.
  • The money helper turns integer cent prices into shopper-facing dollar values.
  • Continue app/page.js by pasting this second part directly below the first part:
  return (
    <main>
      <header className="site-header">
        <a className="brand" href="#top">
          Lather & Aura
        </a>
      </header>

      <section className="hero" id="top">
        <p className="eyebrow">Everyday care. Evening character.</p>
        <h1>Baby-care simplicity meets expressive adult fragrance.</h1>
        <p>
          Explore a fictional demo collection built to showcase a secure
          ecommerce flow.
        </p>
      </section>

      <section className="shop-layout">
        <div className="catalog">
          <div className="filter-row" aria-label="Product collections">
            {FILTERS.map((option) => (
              <button
                className={filter === option ? "filter active" : "filter"}
                key={option}
                onClick={() => setFilter(option)}
                type="button"
              >
                {option}
              </button>
            ))}
          </div>

How do the filters work?

  • The page creates one button for every value in FILTERS.
  • Selecting a button passes its collection name to setFilter.
  • The active collection receives the additional active class for visible feedback.
  • Complete app/page.js by pasting this final part directly below the filter row:
          <div className="product-grid">
            {visibleProducts.map((product) => (
              <article className="product-card" key={product.id}>
                <div className={`product-visual ${product.tone}`}>
                  {product.visual}
                </div>
                <p className="category">{product.category}</p>
                <h2>{product.name}</h2>
                <p className="description">{product.description}</p>
                <div className="product-footer">
                  <strong>{money(product.price)}</strong>
                  <button type="button">Add to cart</button>
                </div>
              </article>
            ))}
          </div>
        </div>
      </section>
    </main>
  );
}

How are the product cards created?

  • The grid maps each record in visibleProducts to one product card.
  • The tone value becomes a CSS class that gives every product family a distinct visual treatment.
  • The product button is present for the next cart step.

Before you save, which collection do you expect to appear first?

  • Save app/page.js.
  • Switch back to Microsoft Edge from earlier.

You should see the Lather & Aura hero followed by three collection filters and four product cards.

Do you see a page error?

Confirm that products.js is inside lib. Check that the import path in app/page.js is ../lib/products.

Ask for help checking the component structure.

✔️ Awesome, I've got everything!

Your page now reads the shared catalog and responds to collection selections.

ⓧ I'd like to double check the full code

"use client";

import { useState } from "react";
import { PRODUCTS } from "../lib/products";

const FILTERS = ["All", "Baby Soap", "Adult Perfume"];
const money = (cents) => `$${(cents / 100).toFixed(2)}`;

export default function Storefront() {
  const [filter, setFilter] = useState("All");

  const visibleProducts =
    filter === "All"
      ? PRODUCTS
      : PRODUCTS.filter((product) => product.category === filter);

  return (
    <main>
      <header className="site-header">
        <a className="brand" href="#top">
          Lather & Aura
        </a>
      </header>

      <section className="hero" id="top">
        <p className="eyebrow">Everyday care. Evening character.</p>
        <h1>Baby-care simplicity meets expressive adult fragrance.</h1>
        <p>
          Explore a fictional demo collection built to showcase a secure
          ecommerce flow.
        </p>
      </section>

      <section className="shop-layout">
        <div className="catalog">
          <div className="filter-row" aria-label="Product collections">
            {FILTERS.map((option) => (
              <button
                className={filter === option ? "filter active" : "filter"}
                key={option}
                onClick={() => setFilter(option)}
                type="button"
              >
                {option}
              </button>
            ))}
          </div>

          <div className="product-grid">
            {visibleProducts.map((product) => (
              <article className="product-card" key={product.id}>
                <div className={`product-visual ${product.tone}`}>
                  {product.visual}
                </div>
                <p className="category">{product.category}</p>
                <h2>{product.name}</h2>
                <p className="description">{product.description}</p>
                <div className="product-footer">
                  <strong>{money(product.price)}</strong>
                  <button type="button">Add to cart</button>
                </div>
              </article>
            ))}
          </div>
        </div>
      </section>
    </main>
  );
}
Style the responsive storefront

The product structure now works, but the brand still depends on a visual system. The stylesheet will turn the catalog into a responsive storefront with distinct oat, cloud, amber, and rose treatments.

  • Switch back to app/globals.css in Visual Studio Code.
  • Select all of the existing starter styles.
  • Replace the selected styles with the page foundation below:
:root {
  color-scheme: light;
  font-family: Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont,
    "Segoe UI", sans-serif;
  background: #f7f2eb;
  color: #211d1a;
}

* {
  box-sizing: border-box;
}

html {
  scroll-behavior: smooth;
}

body {
  margin: 0;
  min-height: 100vh;
  background:
    radial-gradient(circle at top left, #fffaf2 0, transparent 34rem),
    #f7f2eb;
}

What does the foundation style?

  • The root rules establish the warm color palette and shared font stack.
  • The body background adds a soft highlight behind the storefront.
  • Save app/globals.css.
  • Switch back to Microsoft Edge.

The page should now use a warm cream background with dark text.

  • Switch back to app/globals.css.
  • Append the shared controls and header styles below the existing rules:
button,
a {
  font: inherit;
}

button {
  cursor: pointer;
}

button:disabled {
  cursor: not-allowed;
  opacity: 0.55;
}

.site-header {
  align-items: center;
  display: flex;
  justify-content: space-between;
  padding: 1.25rem clamp(1rem, 5vw, 5rem);
}

.brand {
  color: #211d1a;
  font-family: Georgia, serif;
  font-size: 1.35rem;
  font-weight: 700;
  text-decoration: none;
}

What changes in the header?

  • The header aligns its content across the available width.
  • The brand link receives a serif treatment that separates it from the product copy.
  • Save app/globals.css.
  • Switch back to Microsoft Edge.

Lather & Aura should now appear as a styled brand at the top of the page.

  • Switch back to app/globals.css.
  • Append the label and hero typography styles:
.cart-count,
.category,
.eyebrow,
.sandbox-note {
  color: #78685f;
  font-size: 0.76rem;
  font-weight: 700;
  letter-spacing: 0.11em;
  text-transform: uppercase;
}

.hero {
  margin: 2rem auto 4rem;
  max-width: 850px;
  padding: 0 1.25rem;
  text-align: center;
}

.hero h1,
.success-card h1 {
  font-family: Georgia, serif;
  font-size: clamp(2.5rem, 7vw, 5rem);
  font-weight: 500;
  line-height: 1;
  margin: 0.75rem 0 1.25rem;
}

How does the hero establish the brand?

  • Uppercase labels create a consistent visual cue for categories and supporting notes.
  • The responsive heading size gives the hero impact without overflowing narrow screens.
  • Save app/globals.css.
  • Switch back to Microsoft Edge.

The hero should now be centered with a large serif heading and a compact uppercase eyebrow.

  • Switch back to app/globals.css.
  • Append the shop layout and filter spacing styles:
.hero > p:last-child {
  color: #645b55;
  font-size: 1.05rem;
}

.shop-layout {
  display: grid;
  gap: 2rem;
  grid-template-columns: minmax(0, 1fr) minmax(280px, 360px);
  margin: 0 auto;
  max-width: 1280px;
  padding: 0 1.25rem 5rem;
}

.filter-row {
  display: flex;
  flex-wrap: wrap;
  gap: 0.6rem;
  margin-bottom: 1.25rem;
}

What does the shop layout prepare?

  • The main grid reserves space for the catalog and the cart panel added in the next step.
  • The filter row wraps its controls when the available width becomes narrow.
  • Save app/globals.css.
  • Switch back to Microsoft Edge.

The filters should now sit in a tidy row above the catalog area.

  • Switch back to app/globals.css.
  • Append the button and product grid styles:
.filter,
.product-footer button,
.checkout-button,
.return-link {
  border: 0;
  border-radius: 999px;
  padding: 0.75rem 1rem;
}

.filter {
  background: #e9e0d7;
  color: #514740;
}

.filter.active {
  background: #211d1a;
  color: #ffffff;
}

.product-grid {
  display: grid;
  gap: 1rem;
  grid-template-columns: repeat(2, minmax(0, 1fr));
}

How does the selected filter stand out?

  • Every filter uses the same rounded shape.
  • The selected filter changes to a dark background through the active class.
  • The product grid displays two cards per row when enough width is available.
  • Save app/globals.css.
  • Switch back to Microsoft Edge.

The active filter should now appear dark while the other filters remain light.

  • Switch back to app/globals.css.
  • Append the card surfaces and product visual foundation:
.product-card,
.cart-panel,
.success-card {
  background: rgba(255, 255, 255, 0.82);
  border: 1px solid rgba(74, 61, 53, 0.1);
  border-radius: 24px;
  box-shadow: 0 18px 50px rgba(69, 51, 40, 0.08);
}

.product-card {
  padding: 1rem;
}

.product-visual {
  align-items: center;
  border-radius: 18px;
  color: rgba(33, 29, 26, 0.75);
  display: flex;
  font-family: Georgia, serif;
  font-size: 1.4rem;
  height: 190px;
  justify-content: center;
  letter-spacing: 0.2em;
}

What gives the products their card shape?

  • Each product receives a translucent surface with a soft border and shadow.
  • The visual area centers the short catalog label inside a fixed-height panel.
  • Save app/globals.css.
  • Switch back to Microsoft Edge.

Each product should now sit inside a rounded white card with a large visual panel.

  • Switch back to app/globals.css.
  • Append the four product tone styles and card typography:
.product-visual.oat {
  background: linear-gradient(145deg, #f4e7c9, #d7c39a);
}

.product-visual.cloud {
  background: linear-gradient(145deg, #eef4f5, #cddde0);
}

.product-visual.amber {
  background: linear-gradient(145deg, #d9a35f, #653920);
  color: #fff8ec;
}

.product-visual.rose {
  background: linear-gradient(145deg, #edc7ca, #9d5b68);
  color: #fff7f7;
}

.category {
  margin: 1rem 0 0.4rem;
}

.product-card h2 {
  font-family: Georgia, serif;
  font-size: 1.35rem;
  margin: 0;
}

How are the collections distinguished?

  • The oat and cloud products use pale gradients associated with the baby-care collection.
  • The amber and rose products use richer gradients associated with the adult fragrance collection.
  • Save app/globals.css.
  • Switch back to Microsoft Edge.

You should now see four distinct product visuals labeled OAT, SOFT, AMBER, and ROSE.

  • Switch back to app/globals.css.
  • Append the product footer and future cart panel styles:
.description {
  color: #6e625a;
  min-height: 3rem;
}

.product-footer,
.total-row {
  align-items: center;
  display: flex;
  justify-content: space-between;
}

.product-footer button,
.checkout-button,
.return-link {
  background: #4d2834;
  color: #ffffff;
  text-decoration: none;
}

.cart-panel {
  align-self: start;
  padding: 1.4rem;
  position: sticky;
  top: 1rem;
}

What completes each product card?

  • A minimum description height keeps the card footers aligned across each row.
  • The footer places the price opposite the product action.
  • The cart panel rules prepare the layout for the cart added in the next step.
  • Save app/globals.css.
  • Switch back to Microsoft Edge.

The product prices and Add to cart buttons should now align along the bottom of their cards.

  • Switch back to app/globals.css.
  • Append the cart content placeholders:
.cart-panel h2 {
  font-family: Georgia, serif;
  font-size: 1.8rem;
  margin: 0.25rem 0 1.25rem;
}

.empty {
  color: #756960;
}

.cart-lines {
  display: grid;
  gap: 0.9rem;
}

.cart-line {
  align-items: start;
  border-bottom: 1px solid #e9dfd7;
  display: flex;
  gap: 1rem;
  justify-content: space-between;
  padding-bottom: 0.9rem;
}

Why include cart placeholders now?

These selectors complete the base visual system that the cart will use next. Keeping them in the shared stylesheet prevents the later cart work from interrupting the storefront layout.

  • Save app/globals.css.
  • Append the cart totals and checkout feedback styles:
.cart-line span {
  color: #746a63;
  display: block;
  font-size: 0.88rem;
  margin-top: 0.2rem;
}

.text-button {
  background: transparent;
  border: 0;
  color: #844457;
  padding: 0;
}

.total-row {
  border-top: 2px solid #211d1a;
  font-size: 1.08rem;
  margin-top: 1.2rem;
  padding-top: 1rem;
}

.checkout-button {
  margin-top: 1rem;
  padding: 0.95rem 1rem;
  width: 100%;
}

What do these checkout styles prepare?

  • Cart lines gain secondary quantity text and a compact removal action.
  • The order total receives a strong divider before the full-width checkout button.
  • Save app/globals.css.
  • Append the message and success page surfaces:
.message {
  background: #fbe5df;
  border-radius: 12px;
  color: #7f2d21;
  padding: 0.8rem;
}

.sandbox-note {
  line-height: 1.5;
  margin-bottom: 0;
  text-align: center;
}

.success-page {
  align-items: center;
  display: flex;
  justify-content: center;
  min-height: 100vh;
  padding: 1.25rem;
}

.success-card {
  max-width: 720px;
  padding: clamp(2rem, 7vw, 5rem);
  text-align: center;
}

What do these surfaces support?

  • Checkout feedback receives a distinct error-colored message surface.
  • The success page rules prepare a centered confirmation card for the completed sandbox flow.
  • Save app/globals.css.
  • Append the success content and quantity control layout:
.success-card h1 {
  font-size: clamp(2.5rem, 6vw, 4.5rem);
}

.success-card p:not(.eyebrow) {
  color: #665b54;
  line-height: 1.7;
  margin-bottom: 2rem;
}

.return-link {
  display: inline-block;
}

.quantity-controls {
  align-items: center;
  display: flex;
  gap: 0.4rem;
  margin-top: 0.35rem;
}

What completes the reusable visual system?

  • The success content receives readable spacing and responsive heading sizing.
  • The quantity controls gain a compact horizontal layout for the optional extension.
  • Save app/globals.css.
  • Append the quantity buttons and responsive breakpoints:
.quantity-controls button {
  align-items: center;
  background: #eee5de;
  border: 0;
  border-radius: 50%;
  display: inline-flex;
  height: 1.8rem;
  justify-content: center;
  width: 1.8rem;
}

@media (max-width: 850px) {
  .shop-layout {
    grid-template-columns: 1fr;
  }

  .cart-panel {
    position: static;
  }
}

@media (max-width: 560px) {
  .product-grid {
    grid-template-columns: 1fr;
  }
}

How does the storefront adapt to smaller screens?

  • The shop layout collapses to one column on narrower screens.
  • The product grid changes from two cards per row to one card per row on small screens.

Before you test the filters, how many cards do you expect each collection to show?

  • Save app/globals.css.
  • Switch back to Microsoft Edge.
  • Select Baby Soap in the filter row.

You should see only Gentle Oat Baby Bar and Cloud Soft Baby Bar.

  • Select Adult Perfume in the filter row.

You should see only Amber Noir Eau de Parfum and Rose Velvet Eau de Parfum. That is the browsing experience working across both collections.

Are the filters showing the wrong products?

Check that each category in lib/products.js exactly matches either Baby Soap or Adult Perfume. Confirm that each filter passes its own option value to setFilter.

Ask for help debugging the collection filter.

✔️ Awesome, I've got everything!

Your storefront is styled and both collection filters show the correct products.

ⓧ I'd like to double check the full code

:root {
  color-scheme: light;
  font-family: Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont,
    "Segoe UI", sans-serif;
  background: #f7f2eb;
  color: #211d1a;
}

* {
  box-sizing: border-box;
}

html {
  scroll-behavior: smooth;
}

body {
  margin: 0;
  min-height: 100vh;
  background:
    radial-gradient(circle at top left, #fffaf2 0, transparent 34rem),
    #f7f2eb;
}

button,
a {
  font: inherit;
}

button {
  cursor: pointer;
}

button:disabled {
  cursor: not-allowed;
  opacity: 0.55;
}

.site-header {
  align-items: center;
  display: flex;
  justify-content: space-between;
  padding: 1.25rem clamp(1rem, 5vw, 5rem);
}

.brand {
  color: #211d1a;
  font-family: Georgia, serif;
  font-size: 1.35rem;
  font-weight: 700;
  text-decoration: none;
}

.cart-count,
.category,
.eyebrow,
.sandbox-note {
  color: #78685f;
  font-size: 0.76rem;
  font-weight: 700;
  letter-spacing: 0.11em;
  text-transform: uppercase;
}

.hero {
  margin: 2rem auto 4rem;
  max-width: 850px;
  padding: 0 1.25rem;
  text-align: center;
}

.hero h1,
.success-card h1 {
  font-family: Georgia, serif;
  font-size: clamp(2.5rem, 7vw, 5rem);
  font-weight: 500;
  line-height: 1;
  margin: 0.75rem 0 1.25rem;
}

.hero > p:last-child {
  color: #645b55;
  font-size: 1.05rem;
}

.shop-layout {
  display: grid;
  gap: 2rem;
  grid-template-columns: minmax(0, 1fr) minmax(280px, 360px);
  margin: 0 auto;
  max-width: 1280px;
  padding: 0 1.25rem 5rem;
}

.filter-row {
  display: flex;
  flex-wrap: wrap;
  gap: 0.6rem;
  margin-bottom: 1.25rem;
}

.filter,
.product-footer button,
.checkout-button,
.return-link {
  border: 0;
  border-radius: 999px;
  padding: 0.75rem 1rem;
}

.filter {
  background: #e9e0d7;
  color: #514740;
}

.filter.active {
  background: #211d1a;
  color: #ffffff;
}

.product-grid {
  display: grid;
  gap: 1rem;
  grid-template-columns: repeat(2, minmax(0, 1fr));
}

.product-card,
.cart-panel,
.success-card {
  background: rgba(255, 255, 255, 0.82);
  border: 1px solid rgba(74, 61, 53, 0.1);
  border-radius: 24px;
  box-shadow: 0 18px 50px rgba(69, 51, 40, 0.08);
}

.product-card {
  padding: 1rem;
}

.product-visual {
  align-items: center;
  border-radius: 18px;
  color: rgba(33, 29, 26, 0.75);
  display: flex;
  font-family: Georgia, serif;
  font-size: 1.4rem;
  height: 190px;
  justify-content: center;
  letter-spacing: 0.2em;
}

.product-visual.oat {
  background: linear-gradient(145deg, #f4e7c9, #d7c39a);
}

.product-visual.cloud {
  background: linear-gradient(145deg, #eef4f5, #cddde0);
}

.product-visual.amber {
  background: linear-gradient(145deg, #d9a35f, #653920);
  color: #fff8ec;
}

.product-visual.rose {
  background: linear-gradient(145deg, #edc7ca, #9d5b68);
  color: #fff7f7;
}

.category {
  margin: 1rem 0 0.4rem;
}

.product-card h2 {
  font-family: Georgia, serif;
  font-size: 1.35rem;
  margin: 0;
}

.description {
  color: #6e625a;
  min-height: 3rem;
}

.product-footer,
.total-row {
  align-items: center;
  display: flex;
  justify-content: space-between;
}

.product-footer button,
.checkout-button,
.return-link {
  background: #4d2834;
  color: #ffffff;
  text-decoration: none;
}

.cart-panel {
  align-self: start;
  padding: 1.4rem;
  position: sticky;
  top: 1rem;
}

.cart-panel h2 {
  font-family: Georgia, serif;
  font-size: 1.8rem;
  margin: 0.25rem 0 1.25rem;
}

.empty {
  color: #756960;
}

.cart-lines {
  display: grid;
  gap: 0.9rem;
}

.cart-line {
  align-items: start;
  border-bottom: 1px solid #e9dfd7;
  display: flex;
  gap: 1rem;
  justify-content: space-between;
  padding-bottom: 0.9rem;
}

.cart-line span {
  color: #746a63;
  display: block;
  font-size: 0.88rem;
  margin-top: 0.2rem;
}

.text-button {
  background: transparent;
  border: 0;
  color: #844457;
  padding: 0;
}

.total-row {
  border-top: 2px solid #211d1a;
  font-size: 1.08rem;
  margin-top: 1.2rem;
  padding-top: 1rem;
}

.checkout-button {
  margin-top: 1rem;
  padding: 0.95rem 1rem;
  width: 100%;
}

.message {
  background: #fbe5df;
  border-radius: 12px;
  color: #7f2d21;
  padding: 0.8rem;
}

.sandbox-note {
  line-height: 1.5;
  margin-bottom: 0;
  text-align: center;
}

.success-page {
  align-items: center;
  display: flex;
  justify-content: center;
  min-height: 100vh;
  padding: 1.25rem;
}

.success-card {
  max-width: 720px;
  padding: clamp(2rem, 7vw, 5rem);
  text-align: center;
}

.success-card h1 {
  font-size: clamp(2.5rem, 6vw, 4.5rem);
}

.success-card p:not(.eyebrow) {
  color: #665b54;
  line-height: 1.7;
  margin-bottom: 2rem;
}

.return-link {
  display: inline-block;
}

.quantity-controls {
  align-items: center;
  display: flex;
  gap: 0.4rem;
  margin-top: 0.35rem;
}

.quantity-controls button {
  align-items: center;
  background: #eee5de;
  border: 0;
  border-radius: 50%;
  display: inline-flex;
  height: 1.8rem;
  justify-content: center;
  width: 1.8rem;
}

@media (max-width: 850px) {
  .shop-layout {
    grid-template-columns: 1fr;
  }

  .cart-panel {
    position: static;
  }
}

@media (max-width: 560px) {
  .product-grid {
    grid-template-columns: 1fr;
  }
}

Your branded storefront now gives shoppers a clear way to browse both collections. Next, you will add a mixed cart and discover why its browser-calculated total cannot authorize a payment.

Add a Cart and Expose the Trust Boundary

Your storefront already lets shoppers browse two distinct collections. Now the page needs to remember their selections while they move between those collections.

A client-side cart built with React state makes those interactions immediate. However, its browser-visible total sits on the shopper's side of the trust boundary. That total cannot authorize a payment.

In this step, get ready to:
  • Manage selected products with immutable cart state.
  • Render live cart quantities with an order total.
  • Stop checkout before any browser-calculated amount reaches Stripe.
Build immutable cart state

The cart stores only a product ID with its selected quantity. Each update returns a new array so React can detect the change and refresh the interface.

  • Switch back to Visual Studio Code.
  • Select app/page.js in the Explorer sidebar.
  • Find the existing filter state declaration.
  • Replace that declaration with these three state declarations:
  const [filter, setFilter] = useState("All");
  const [cart, setCart] = useState([]);
  const [message, setMessage] = useState("");

What does the new state hold?

  • The cart array holds each selected product ID with its quantity.
  • The message string gives checkout feedback a visible place in the interface.
  • Find the closing line of the visibleProducts calculation.
  • Add the cart update functions directly below that calculation:
  function addToCart(productId) {
    setMessage("");
    setCart((current) => {
      const existing = current.find((item) => item.id === productId);

      if (existing) {
        return current.map((item) =>
          item.id === productId
            ? { ...item, quantity: Math.min(item.quantity + 1, 10) }
            : item,
        );
      }

      return [...current, { id: productId, quantity: 1 }];
    });
  }

  function removeFromCart(productId) {
    setCart((current) => current.filter((item) => item.id !== productId));
  }

How Does the Immutable Update Work?

  • The existing lookup checks whether the selected product already has a cart line.
  • The map() update creates a new array with the matching quantity increased.
  • The Math.min() check caps each quantity at 10.
  • The filter() update creates a new array without the removed product.
  • Save app/page.js with Ctrl+S.
  • Check the running terminal for a successful recompilation.
  • Return to Microsoft Edge to confirm the storefront still renders.

Did the Storefront Stop Compiling?

Check that both functions remain inside Storefront(). Match every closing brace with the function shown above.

If the terminal identifies a line near setCart, compare the parentheses around map() and filter().

Help me repair the immutable cart functions.

The cart logic now needs controls that call it. A header count provides immediate feedback before the full cart summary arrives.

  • Find the closing tag for the brand link inside site-header.
  • Add this count directly below the link:
        <span className="cart-count">
          {cart.reduce((sum, item) => sum + item.quantity, 0)} items
        </span>

What Does the Header Count Measure?

The reduce() calculation adds every cart quantity. Repeated selections increase the item count without creating duplicate lines.

  • Find the existing product-footer block inside each product card.
  • Replace that block with this interactive version:
                <div className="product-footer">
                  <strong>{money(product.price)}</strong>
                  <button onClick={() => addToCart(product.id)} type="button">
                    Add to cart
                  </button>
                </div>

How Does Each Button Identify Its Product?

Each button passes its card's stable product.id into addToCart(). The cart can now track a product without copying its browser-visible name or price.

  • Save app/page.js.
  • Return to the storefront in Microsoft Edge.
  • Click Add to cart on the same product eleven times.

You will see the header count rise to 10 items. Further clicks leave that product at the maximum quantity.

That is the interaction layer working: the cart updates without mutating its previous array.

Does the Count Stay at Zero?

Confirm that the button calls addToCart(product.id). Check that the header count reads from cart.

Help me debug the cart count.

Render the cart summary

The cart stores compact records with IDs and quantities. The summary joins those IDs back to PRODUCTS so it can display catalog details.

  • Find the visibleProducts calculation near the top of Storefront().
  • Add these derived cart values below it:
  const detailedCart = cart.map((item) => ({
    ...item,
    product: PRODUCTS.find((product) => product.id === item.id),
  }));

  const total = detailedCart.reduce(
    (sum, item) => sum + item.product.price * item.quantity,
    0,
  );

What Do These Values Derive?

  • The detailedCart array attaches the matching catalog record to each compact cart item.
  • The total calculation multiplies each catalog price by its quantity for display.
  • Find the closing catalog container inside shop-layout.
  • Add this cart panel directly after that container:
        <aside className="cart-panel">
          <p className="eyebrow">Your selection</p>
          <h2>Shopping cart</h2>

          {detailedCart.length === 0 ? (
            <p className="empty">Your cart is ready for something special.</p>
          ) : (
            <div className="cart-lines">
              {detailedCart.map((item) => (
                <div className="cart-line" key={item.id}>
                  <div>
                    <strong>{item.product.name}</strong>
                    <span>
                      {item.quantity} x {money(item.product.price)}
                    </span>
                  </div>
                  <button
                    className="text-button"
                    onClick={() => removeFromCart(item.id)}
                    type="button"
                  >
                    Remove
                  </button>
                </div>
              ))}
            </div>
          )}
        </aside>

How Does the Summary Respond?

  • An empty detailedCart displays the cart's empty-state message.
  • A populated cart maps each record into a line with its catalog name.
  • Each line shows the selected quantity with the catalog unit price.
  • The Remove button sends the matching product ID to removeFromCart().
  • Save app/page.js.
  • Refresh the storefront in Microsoft Edge.
  • Click Add to cart on Gentle Oat Baby Bar.
  • Click Add to cart on Amber Noir Eau de Parfum.

You will see two cart lines. Each line shows a product name with 1 x its unit price.

  • Click Remove beside Amber Noir Eau de Parfum.

You will see the perfume line disappear. Gentle Oat Baby Bar remains in the cart.

Do the Cart Lines Show the Wrong Products?

Confirm that PRODUCTS.find() compares product.id with item.id. The stable IDs must match exactly.

Help me debug the cart-to-catalog join.

The individual lines explain what the shopper selected. An order total now gives the cart a single display amount.

  • Find the end of the conditional cart-line section inside cart-panel.
  • Add this total row directly below the conditional section:
          <div className="total-row">
            <span>Total</span>
            <strong>{money(total)}</strong>
          </div>

What Does This Total Represent?

The total comes from the browser-visible catalog data joined into detailedCart. It gives the shopper instant feedback about the current selection.

  • Save app/page.js.
  • Refresh the storefront in Microsoft Edge.
  • Click Add to cart on Gentle Oat Baby Bar once.
  • Click Add to cart on Amber Noir Eau de Parfum once.

You will see both cart lines. The displayed total will be $70.00.

The browsing experience now feels like a real storefront because every selection has an immediate cart result.

Expose the checkout trust boundary

The cart can calculate a useful display total in the browser. A shopper can also alter browser state, so this value needs to stop before it authorizes a payment.

  • Find removeFromCart() in app/page.js.
  • Add this temporary checkout function directly below it:
  function checkout() {
    setMessage("Checkout blocked: connect the server-trusted catalog first.");
  }

Why Does Checkout Stop Here?

The temporary function records the trust-boundary message in state. It does not send product prices or the displayed total to a payment provider.

  • Find the total-row block inside the cart panel.
  • Add the checkout controls directly below it:
          <button
            className="checkout-button"
            disabled={cart.length === 0}
            onClick={checkout}
            type="button"
          >
            Secure sandbox checkout
          </button>

          {message && <p className="message">{message}</p>}
          <p className="sandbox-note">Sandbox only. No real payment is taken.</p>

How Does the Temporary Checkout Behave?

  • The checkout button stays disabled while cart is empty.
  • Clicking an enabled button calls checkout().
  • The conditional message paragraph displays feedback only when the state contains text.
  • The sandbox note makes the prototype's payment scope visible to the shopper.
  • Save app/page.js.
  • Refresh the storefront in Microsoft Edge.

You will see the Secure sandbox checkout button in a disabled state while the cart is empty.

  • Add Gentle Oat Baby Bar to the cart.
  • Add Rose Velvet Eau de Parfum to the cart.

Before you click checkout, make a prediction about whether this browser-owned total can safely continue to a payment page.

  • Click Secure sandbox checkout.

You will remain on the storefront. The page will display Checkout blocked: connect the server-trusted catalog first.

That block is intentional. You have reached the exact point where responsive browser state must hand control to trusted server code.

Do You See the Blocked Checkout Message?

Confirm that the button uses onClick={checkout}. Check that the message paragraph sits inside cart-panel.

If the button remains disabled after adding a product, confirm that its disabled condition checks cart.length === 0.

Help me debug the temporary checkout block.

✔️ Awesome, I've got everything!

Great. Double check that app/page.js is saved before moving on.

ⓧ I'd like to double check the full code

Compare your complete app/page.js with this version. The temporary checkout function should stop at the trust boundary.

"use client";

import { useState } from "react";
import { PRODUCTS } from "../lib/products";

const FILTERS = ["All", "Baby Soap", "Adult Perfume"];
const money = (cents) => `$${(cents / 100).toFixed(2)}`;

export default function Storefront() {
  const [filter, setFilter] = useState("All");
  const [cart, setCart] = useState([]);
  const [message, setMessage] = useState("");

  const visibleProducts =
    filter === "All"
      ? PRODUCTS
      : PRODUCTS.filter((product) => product.category === filter);

  const detailedCart = cart.map((item) => ({
    ...item,
    product: PRODUCTS.find((product) => product.id === item.id),
  }));

  const total = detailedCart.reduce(
    (sum, item) => sum + item.product.price * item.quantity,
    0,
  );

  function addToCart(productId) {
    setMessage("");
    setCart((current) => {
      const existing = current.find((item) => item.id === productId);

      if (existing) {
        return current.map((item) =>
          item.id === productId
            ? { ...item, quantity: Math.min(item.quantity + 1, 10) }
            : item,
        );
      }

      return [...current, { id: productId, quantity: 1 }];
    });
  }

  function removeFromCart(productId) {
    setCart((current) => current.filter((item) => item.id !== productId));
  }

  function checkout() {
    setMessage("Checkout blocked: connect the server-trusted catalog first.");
  }

  return (
    <main>
      <header className="site-header">
        <a className="brand" href="#top">
          Lather & Aura
        </a>
        <span className="cart-count">
          {cart.reduce((sum, item) => sum + item.quantity, 0)} items
        </span>
      </header>

      <section className="hero" id="top">
        <p className="eyebrow">Everyday care. Evening character.</p>
        <h1>Baby-care simplicity meets expressive adult fragrance.</h1>
        <p>
          Explore a fictional demo collection built to showcase a secure
          ecommerce flow.
        </p>
      </section>

      <section className="shop-layout">
        <div className="catalog">
          <div className="filter-row" aria-label="Product collections">
            {FILTERS.map((option) => (
              <button
                className={filter === option ? "filter active" : "filter"}
                key={option}
                onClick={() => setFilter(option)}
                type="button"
              >
                {option}
              </button>
            ))}
          </div>

          <div className="product-grid">
            {visibleProducts.map((product) => (
              <article className="product-card" key={product.id}>
                <div className={`product-visual ${product.tone}`}>
                  {product.visual}
                </div>
                <p className="category">{product.category}</p>
                <h2>{product.name}</h2>
                <p className="description">{product.description}</p>
                <div className="product-footer">
                  <strong>{money(product.price)}</strong>
                  <button onClick={() => addToCart(product.id)} type="button">
                    Add to cart
                  </button>
                </div>
              </article>
            ))}
          </div>
        </div>

        <aside className="cart-panel">
          <p className="eyebrow">Your selection</p>
          <h2>Shopping cart</h2>

          {detailedCart.length === 0 ? (
            <p className="empty">Your cart is ready for something special.</p>
          ) : (
            <div className="cart-lines">
              {detailedCart.map((item) => (
                <div className="cart-line" key={item.id}>
                  <div>
                    <strong>{item.product.name}</strong>
                    <span>
                      {item.quantity} x {money(item.product.price)}
                    </span>
                  </div>
                  <button
                    className="text-button"
                    onClick={() => removeFromCart(item.id)}
                    type="button"
                  >
                    Remove
                  </button>
                </div>
              ))}
            </div>
          )}

          <div className="total-row">
            <span>Total</span>
            <strong>{money(total)}</strong>
          </div>

          <button
            className="checkout-button"
            disabled={cart.length === 0}
            onClick={checkout}
            type="button"
          >
            Secure sandbox checkout
          </button>

          {message && <p className="message">{message}</p>}
          <p className="sandbox-note">Sandbox only. No real payment is taken.</p>
        </aside>
      </section>
    </main>
  );
}

Your cart is interactive while checkout remains deliberately blocked. Next, you will rebuild every line item from the server-owned catalog before opening hosted sandbox checkout.

Create Server-Validated Stripe Checkout

Your cart already gives shoppers fast feedback. Its browser-visible total cannot safely authorize a payment across the trust boundary.

A Next.js Route Handler can accept product IDs plus quantities. It rebuilds every line from the server-owned catalog before creating a Stripe Checkout Session.

In this step, get ready to:
  • Validate product IDs plus quantities against the server-owned catalog.
  • Create a hosted sandbox Checkout Session from trusted product data.
  • Connect the cart to the checkout route without sending browser-calculated prices.
Validate cart input on the server

The shared PRODUCTS catalog gives the server an independent source for names plus prices. The request only needs each product ID plus its requested quantity.

Why rebuild the cart?

Browser state is useful for displaying a total. A shopper can still modify any value stored in the browser.

The server treats each incoming ID as a lookup request. Only catalog data becomes a payment line item.

The handler spans several consecutive sections. Keep route.js open while you assemble the complete function.

  • Create app/api/checkout/route.js inside the existing lather-and-aura-store folder using the VS Code Explorer sidebar.
  • Start the route with its imports plus request checks by adding this first section:
import Stripe from "stripe";
import { PRODUCTS } from "../../../lib/products";

export async function POST(request) {
  try {
    const secretKey = process.env.STRIPE_SECRET_KEY;

    if (!secretKey) {
      return Response.json(
        { error: "Missing STRIPE_SECRET_KEY in .env.local." },
        { status: 500 },
      );
    }

    const body = await request.json();
    const items = body.items;

    if (!Array.isArray(items) || items.length === 0) {
      return Response.json({ error: "Your cart is empty." }, { status: 400 });
    }

How does the request enter the route?

  • The server reads STRIPE_SECRET_KEY from its environment.
  • The route reads only body.items from the request.
  • The array check rejects missing carts plus empty carts before Stripe receives anything.
  • Continue directly below the empty-cart check with the trusted catalog mapping:
    const lineItems = items.map((item) => {
      const product = PRODUCTS.find((entry) => entry.id === item.id);
      const quantity = Number(item.quantity);

      if (
        !product ||
        !Number.isInteger(quantity) ||
        quantity < 1 ||
        quantity > 10
      ) {
        return null;
      }

      return {
        price_data: {
          currency: "usd",
          product_data: {
            name: product.name,
            description: product.description,
          },
          unit_amount: product.price,
        },
        quantity,
      };
    });

What does the mapping protect?

  • The product lookup accepts only IDs found in PRODUCTS.
  • The quantity check accepts integers from 1 through 10.
  • Each accepted line uses the catalog's name plus description.
  • Each accepted line uses the catalog's integer cent price.
  • Reject the complete request when any mapped cart line is invalid by adding this section:
    if (lineItems.some((item) => item === null)) {
      return Response.json(
        { error: "The cart contains an invalid product or quantity." },
        { status: 400 },
      );
    }

Why reject the whole cart?

A single invalid line makes the submitted order unreliable. The route returns a controlled response before creating a payment session.

Create the Stripe Checkout Session

Validated line items are ready for the payment provider. The server can now initialize the Stripe SDK with the secret from .env.local.

  • Complete POST(request) by adding this final section directly below the invalid-cart check:
    const stripe = new Stripe(secretKey);
    const origin = new URL(request.url).origin;
    const session = await stripe.checkout.sessions.create({
      line_items: lineItems,
      mode: "payment",
      success_url: `${origin}/success`,
      cancel_url: `${origin}/`,
    });

    if (!session.url) {
      return Response.json(
        { error: "Stripe did not return a checkout URL." },
        { status: 502 },
      );
    }

    return Response.json({ url: session.url });
  } catch (error) {
    console.error("Checkout Session creation failed", error);
    return Response.json(
      { error: "Checkout could not be created. Check the server terminal." },
      { status: 500 },
    );
  }
}

What does this section do?

  • The Stripe SDK receives the server-only secret key.
  • The Checkout Session receives validated lineItems.
  • The request origin supplies local success plus cancellation destinations.
  • The response exposes only the hosted session URL to the browser.
  • Save app/api/checkout/route.js.
  • Return to Microsoft Edge from earlier.
  • Refresh the storefront at http://localhost:3000.

You should still see the Lather & Aura storefront. The new server route now exists without changing the browsing experience.

Does the storefront fail to refresh?

Check the server terminal for a syntax error mentioning route.js. Compare every brace plus parenthesis with the complete file below.

Confirm the file sits at app/api/checkout/route.js. The nested folders determine the request path.

Help me check the checkout Route Handler structure.

✔️ Awesome, I've got everything!

Great. Your server route now validates the cart before asking Stripe to create a hosted Checkout Session.

ⓧ I'd like to double check the full code

import Stripe from "stripe";
import { PRODUCTS } from "../../../lib/products";

export async function POST(request) {
  try {
    const secretKey = process.env.STRIPE_SECRET_KEY;

    if (!secretKey) {
      return Response.json(
        { error: "Missing STRIPE_SECRET_KEY in .env.local." },
        { status: 500 },
      );
    }

    const body = await request.json();
    const items = body.items;

    if (!Array.isArray(items) || items.length === 0) {
      return Response.json({ error: "Your cart is empty." }, { status: 400 });
    }

    const lineItems = items.map((item) => {
      const product = PRODUCTS.find((entry) => entry.id === item.id);
      const quantity = Number(item.quantity);

      if (
        !product ||
        !Number.isInteger(quantity) ||
        quantity < 1 ||
        quantity > 10
      ) {
        return null;
      }

      return {
        price_data: {
          currency: "usd",
          product_data: {
            name: product.name,
            description: product.description,
          },
          unit_amount: product.price,
        },
        quantity,
      };
    });

    if (lineItems.some((item) => item === null)) {
      return Response.json(
        { error: "The cart contains an invalid product or quantity." },
        { status: 400 },
      );
    }

    const stripe = new Stripe(secretKey);
    const origin = new URL(request.url).origin;
    const session = await stripe.checkout.sessions.create({
      line_items: lineItems,
      mode: "payment",
      success_url: `${origin}/success`,
      cancel_url: `${origin}/`,
    });

    if (!session.url) {
      return Response.json(
        { error: "Stripe did not return a checkout URL." },
        { status: 502 },
      );
    }

    return Response.json({ url: session.url });
  } catch (error) {
    console.error("Checkout Session creation failed", error);
    return Response.json(
      { error: "Checkout could not be created. Check the server terminal." },
      { status: 500 },
    );
  }
}
Connect the cart to the route

The Client Component can now submit its minimal cart payload. A loading state also prevents repeated checkout requests while the redirect is being prepared.

  • Switch back to app/page.js in VS Code.
  • Find the three existing state declarations near the top of Storefront().
  • Use this current state group as your reference:
  const [filter, setFilter] = useState("All");
  const [cart, setCart] = useState([]);
  const [message, setMessage] = useState("");
  • Replace that state group with this version:
  const [filter, setFilter] = useState("All");
  const [cart, setCart] = useState([]);
  const [message, setMessage] = useState("");
  const [loading, setLoading] = useState(false);
  • Find the temporary checkout function containing Checkout blocked: connect the server-trusted catalog first..
  • Replace the complete temporary function with this asynchronous version:
  async function checkout() {
    setLoading(true);
    setMessage("");

    try {
      const response = await fetch("/api/checkout", {
        method: "POST",
        headers: { "Content-Type": "application/json" },
        body: JSON.stringify({ items: cart }),
      });
      const data = await response.json();

      if (!response.ok) {
        throw new Error(data.error || "Checkout could not be created.");
      }

      window.location.assign(data.url);
    } catch (error) {
      setMessage(error.message);
      setLoading(false);
    }
  }

What crosses the trust boundary?

  • The request body contains only items: cart.
  • Each cart entry contains a project-owned product ID plus a quantity.
  • The browser sends no product names plus descriptions.
  • The browser sends no currency plus prices.
  • Find the checkout button with className="checkout-button".
  • Replace the complete checkout button with this loading-aware version:
          <button
            className="checkout-button"
            disabled={cart.length === 0 || loading}
            onClick={checkout}
            type="button"
          >
            {loading ? "Opening checkout..." : "Secure sandbox checkout"}
          </button>

Why disable the button while loading?

The first click starts the request. Disabling the button blocks duplicate Session creation while the hosted URL is loading.

  • Save app/page.js.

Before you test the checkout, which system do you expect to determine the payment total? Make your prediction now.

  • Switch back to Microsoft Edge from earlier.
  • Add at least one product from the storefront to your cart.
  • Click Secure sandbox checkout in the cart panel.

You should leave the storefront. You should see a Stripe-hosted sandbox payment page with matching products, quantities, plus totals.

That closes the trust boundary. The payment page now reflects prices rebuilt from the server-owned catalog.

Does checkout stay on the storefront?

  • Read the controlled message beneath the checkout button.
  • Check the server terminal for the corresponding route error.
  • Confirm .env.local still contains your local STRIPE_SECRET_KEY value.
  • Confirm the cart payload still uses JSON.stringify({ items: cart }).

Help me debug the checkout request.

✔️ Awesome, I've got everything!

Excellent. Your cart now opens a Stripe-hosted sandbox Checkout page using server-validated product data.

ⓧ I'd like to double check the full code

"use client";

import { useState } from "react";
import { PRODUCTS } from "../lib/products";

const FILTERS = ["All", "Baby Soap", "Adult Perfume"];
const money = (cents) => `$${(cents / 100).toFixed(2)}`;

export default function Storefront() {
  const [filter, setFilter] = useState("All");
  const [cart, setCart] = useState([]);
  const [message, setMessage] = useState("");
  const [loading, setLoading] = useState(false);

  const visibleProducts =
    filter === "All"
      ? PRODUCTS
      : PRODUCTS.filter((product) => product.category === filter);

  const detailedCart = cart.map((item) => ({
    ...item,
    product: PRODUCTS.find((product) => product.id === item.id),
  }));

  const total = detailedCart.reduce(
    (sum, item) => sum + item.product.price * item.quantity,
    0,
  );

  function addToCart(productId) {
    setMessage("");
    setCart((current) => {
      const existing = current.find((item) => item.id === productId);

      if (existing) {
        return current.map((item) =>
          item.id === productId
            ? { ...item, quantity: Math.min(item.quantity + 1, 10) }
            : item,
        );
      }

      return [...current, { id: productId, quantity: 1 }];
    });
  }

  function removeFromCart(productId) {
    setCart((current) => current.filter((item) => item.id !== productId));
  }

  async function checkout() {
    setLoading(true);
    setMessage("");

    try {
      const response = await fetch("/api/checkout", {
        method: "POST",
        headers: { "Content-Type": "application/json" },
        body: JSON.stringify({ items: cart }),
      });
      const data = await response.json();

      if (!response.ok) {
        throw new Error(data.error || "Checkout could not be created.");
      }

      window.location.assign(data.url);
    } catch (error) {
      setMessage(error.message);
      setLoading(false);
    }
  }

  return (
    <main>
      <header className="site-header">
        <a className="brand" href="#top">
          Lather & Aura
        </a>
        <span className="cart-count">
          {cart.reduce((sum, item) => sum + item.quantity, 0)} items
        </span>
      </header>

      <section className="hero" id="top">
        <p className="eyebrow">Everyday care. Evening character.</p>
        <h1>Baby-care simplicity meets expressive adult fragrance.</h1>
        <p>
          Explore a fictional demo collection built to showcase a secure
          ecommerce flow.
        </p>
      </section>

      <section className="shop-layout">
        <div className="catalog">
          <div className="filter-row" aria-label="Product collections">
            {FILTERS.map((option) => (
              <button
                className={filter === option ? "filter active" : "filter"}
                key={option}
                onClick={() => setFilter(option)}
                type="button"
              >
                {option}
              </button>
            ))}
          </div>

          <div className="product-grid">
            {visibleProducts.map((product) => (
              <article className="product-card" key={product.id}>
                <div className={`product-visual ${product.tone}`}>
                  {product.visual}
                </div>
                <p className="category">{product.category}</p>
                <h2>{product.name}</h2>
                <p className="description">{product.description}</p>
                <div className="product-footer">
                  <strong>{money(product.price)}</strong>
                  <button onClick={() => addToCart(product.id)} type="button">
                    Add to cart
                  </button>
                </div>
              </article>
            ))}
          </div>
        </div>

        <aside className="cart-panel">
          <p className="eyebrow">Your selection</p>
          <h2>Shopping cart</h2>

          {detailedCart.length === 0 ? (
            <p className="empty">Your cart is ready for something special.</p>
          ) : (
            <div className="cart-lines">
              {detailedCart.map((item) => (
                <div className="cart-line" key={item.id}>
                  <div>
                    <strong>{item.product.name}</strong>
                    <span>
                      {item.quantity} x {money(item.product.price)}
                    </span>
                  </div>
                  <button
                    className="text-button"
                    onClick={() => removeFromCart(item.id)}
                    type="button"
                  >
                    Remove
                  </button>
                </div>
              ))}
            </div>
          )}

          <div className="total-row">
            <span>Total</span>
            <strong>{money(total)}</strong>
          </div>

          <button
            className="checkout-button"
            disabled={cart.length === 0 || loading}
            onClick={checkout}
            type="button"
          >
            {loading ? "Opening checkout..." : "Secure sandbox checkout"}
          </button>

          {message && <p className="message">{message}</p>}
          <p className="sandbox-note">Sandbox only. No real payment is taken.</p>
        </aside>
      </section>
    </main>
  );
}

Your storefront now hands a validated cart to hosted sandbox Checkout. Next, you will complete a simulated payment plus return to a branded confirmation screen.

Complete and Confirm a Sandbox Order

Your server-trusted cart now opens Stripe-hosted Checkout with products from the shared catalog. The displayed quantities also match the cart.

A checkout integration is complete only when payment returns the shopper to a clear confirmation screen. Its failure paths also need observable responses during development.

In this step, get ready to:
  • Create the branded confirmation page for successful sandbox orders.
  • Complete a simulated payment through Stripe-hosted Checkout.
  • Exercise the Route Handler's controlled failure paths before restoring valid checkout.
Create the branded success page

The App Router in Next.js turns app/success/page.js into the /success route. This route gives hosted Checkout a branded destination after a simulated payment.

  • In Visual Studio Code's Explorer sidebar, right-click the app folder.
  • Select New Folder.
  • Enter success as the folder name.
  • Right-click the new app/success folder.
  • Select New File.
  • Enter page.js as the file name.
  • Add the confirmation page by pasting this code into app/success/page.js:
export default function SuccessPage() {
  return (
    <main className="success-page">
      <section className="success-card">
        <p className="eyebrow">Sandbox order confirmed</p>
        <h1>Your checkout flow works.</h1>
        <p>
          Stripe accepted the simulated payment and returned the shopper to
          Lather & Aura. No real funds moved.
        </p>
        <a className="return-link" href="/">
          Return to storefront
        </a>
      </section>
    </main>
  );
}

What does this page do?

  • The success-page class places the confirmation card in the center of the viewport.
  • The message confirms that Stripe accepted a simulated payment.
  • The Return to storefront link sends the shopper back to /.
  • Save app/success/page.js by pressing Ctrl+S.
  • Switch back to Microsoft Edge from earlier.
  • Enter http://localhost:3000/success in the address bar.

You should see a centered confirmation card headed Your checkout flow works. The card also explains that no real funds moved.

Confirmation page not loading?

Check that the file is saved at app/success/page.js. Confirm that the development server from earlier is still running.

Ask for help with the success route.

✔️ Awesome, I've got everything!

Great. Double check that app/success/page.js is saved before completing the sandbox payment.

ⓧ I'd like to double check the full code

export default function SuccessPage() {
  return (
    <main className="success-page">
      <section className="success-card">
        <p className="eyebrow">Sandbox order confirmed</p>
        <h1>Your checkout flow works.</h1>
        <p>
          Stripe accepted the simulated payment and returned the shopper to
          Lather & Aura. No real funds moved.
        </p>
        <a className="return-link" href="/">
          Return to storefront
        </a>
      </section>
    </main>
  );
}
Complete the sandbox payment

Stripe's test card simulates a successful payment through the hosted form. Sandbox transactions do not move funds.

  • Click Return to storefront on the confirmation card.
  • Click Add to cart for Gentle Oat Baby Bar.
  • Select the Adult Perfume filter.
  • Click Add to cart for Amber Noir Eau de Parfum.
  • Click Secure sandbox checkout.
  • Compare the displayed product names with your storefront cart.
  • Compare the displayed quantities with your storefront cart.
  • Compare the displayed total with your storefront cart.

Before you submit the payment, where do you expect Stripe-hosted Checkout to return you?

  • Enter 4242424242424242 as the test card number.
  • Enter any future expiry date.
  • Enter any three-digit CVC.
  • Submit the sandbox payment.

Microsoft Edge returns to http://localhost:3000/success. The confirmation card states that the checkout flow works.

That closes the full loop. Your server-validated cart now reaches hosted payment before returning to Lather & Aura.

Verify the controlled failure paths

The Route Handler should fail predictably when its credential is unavailable. It should also reject cart data outside the server's accepted limits.

This temporary check only changes the sandbox key's local name, so the credential stays untouched in Stripe.

  • Switch back to the .env.local file from earlier.
  • Place your cursor before STRIPE_SECRET_KEY.
  • Type DISABLED_ before the key name.
  • Save .env.local.
  • Stop the development server by pressing Ctrl+C in the terminal.
  • Restart the local server by running:
npm run dev

Why restart the server?

Restarting makes Next.js reload the environment configuration. The Route Handler can now demonstrate its controlled response for a missing STRIPE_SECRET_KEY.

The terminal should report that the local Next.js server is available again.

  • Switch back to the storefront in Microsoft Edge.
  • Open DevTools by pressing Ctrl+Shift+I.
  • Select the Network tool.
  • Add one product to the empty cart.
  • Click Secure sandbox checkout.

The storefront displays Missing STRIPE_SECRET_KEY in .env.local. The page stays available instead of exposing an uncontrolled server failure.

This Failure Is Intentional

The missing-key message is the controlled response defined by your Route Handler. It proves that checkout stops before Stripe initialization when server configuration is incomplete.

Can't find the checkout request?

  • Keep the Network tool open.
  • Click Secure sandbox checkout again.
  • Look for the request whose URL ends in /api/checkout.

Help me find the checkout request in Microsoft Edge DevTools.

  • Switch back to .env.local in Visual Studio Code.
  • Remove the temporary DISABLED_ prefix.
  • Save .env.local.
  • Stop the development server by pressing Ctrl+C in the terminal.
  • Restart the server with the restored sandbox key by running:
npm run dev

What changed?

The restarted server can read the restored STRIPE_SECRET_KEY again. The captured request remains available for a separate invalid-cart check.

The terminal should report that the local server is available again.

  • Switch back to the Network tool in Microsoft Edge.
  • Right-click the request whose URL ends in /api/checkout.
  • Select Edit and Resend.
  • Change the request body's quantity from 1 to 11.
  • Click Send.

The response area shows The cart contains an invalid product or quantity. This proves that the server rejects a quantity above 10.

  • Refresh http://localhost:3000 in Microsoft Edge.
  • Add one product to the cart.

Before the final check, do you expect the restored server to accept this valid cart?

  • Click Secure sandbox checkout.

You should see the Stripe-hosted sandbox payment page with the selected product. That final redirect proves the valid secret is restored and the server trust boundary still works.

Secret mission

Add Explicit Quantity Controls

Add accessible decrease and increase controls to every cart line. Then prove the revised quantities stay within the existing limits and reach Stripe-hosted sandbox Checkout.

Clean Up Your Resources

Clean Up Your Resources

This project costs $0 because its local server plus Stripe sandbox do not move real funds. Decide whether to keep your resources running, pause them for later, or delete them entirely.

Resources you used:

  • The running local Next.js development server.
  • The lather-and-aura-store project folder with your completed storefront.
  • The installed dependencies in node_modules.
  • The generated development files in .next.
  • The local Stripe sandbox secret in .env.local.

Keep everything running

No action needed. Choose this if you want to continue testing the storefront or quantity controls.

  • Leave the local development server running for continued testing.
  • Keep the lather-and-aura-store folder in its current location.
  • Keep .env.local only on your machine.
  • Retain your Stripe sandbox data for future test checkouts.

Pause - I'll come back to this later

Shut down the local server to free up memory. Your source code stays on disk while the sandbox key leaves the project folder.

  • Switch back to the Visual Studio Code terminal from earlier.
  • Press Ctrl+C to stop the development server.
  • Verify that your Stripe sandbox key already exists in a secure credential manager.
  • Use the Visual Studio Code file sidebar to remove .env.local from lather-and-aura-store.
  • Confirm that .env.local no longer appears in the file sidebar.
  • Confirm that .env.local.example remains available as the safe template.

Delete - I don't want to use this again

Deleting the project folder is permanent. Your Stripe account remains available with its sandbox records.

  • Switch back to the Visual Studio Code terminal from earlier.
  • Press Ctrl+C to stop the development server.
  • Close the Visual Studio Code window from earlier.
  • Use the Windows file browser to locate the folder named lather-and-aura-store.
  • Delete the entire lather-and-aura-store folder.
  • Confirm that the folder no longer appears in its previous location.
  • Leave your Stripe account dormant if you may use its sandbox again.

Cleanup complete. The local project plus its sandbox secret are gone.

Nice Work!

Nice Work!

You did it! Lather & Aura now carries a server-validated cart through Stripe Checkout before returning the shopper to a branded confirmation page.

Here's what you learned:

  • Built a responsive two-collection storefront with live product filtering. Managed its mixed cart through immutable React state.
  • Protected checkout with a server-owned catalog that rebuilds trusted line items from product IDs plus quantities. The Route Handler independently rejects unknown products plus quantities outside the allowed range.
  • Completed a Stripe-hosted sandbox checkout using the server-validated cart. Confirmed that the success route returns shoppers to Lather & Aura without moving real funds.
  • Completed the optional Secret Mission with accessible quantity controls. Decreases can remove a line. Increases stop at 10. A final sandbox checkout confirmed that revised quantities still pass through the server contract.

Ready to quiz yourself?