> ## Documentation Index
> Fetch the complete documentation index at: https://docs.hiveku.com/llms.txt
> Use this file to discover all available pages before exploring further.

# Build Product Pages with Checkout

> Create e-commerce product pages and integrate with Stripe for payments

Product pages tie together four moving parts: your database schema, a listing page, per-product detail pages, and a checkout flow. This guide walks through a simple one-click purchase setup, plus the advanced patterns you'll layer on once the basics work.

<Info>
  Before you start: make sure your [database is set up](/how-tos/setup-database) and [Stripe is configured](/how-tos/add-stripe).
</Info>

## Three Paths

<Tabs>
  <Tab title="AI Chat (easiest)">
    In your project's AI chat, describe what you want:

    ```
    Build a product catalog with a listing page at /shop and 
    detail pages at /shop/[slug]. Use a Stripe checkout flow 
    for purchases.
    ```

    The AI will scaffold the schema, pages, and checkout endpoint. Review the result and ask for refinements (pricing layout, image sizes, cart vs one-click, etc.).
  </Tab>

  <Tab title="From a template">
    In your project templates, pick an **E-commerce starter**. It comes with products, cart, checkout, and order confirmation pre-wired. Customize from there.
  </Tab>

  <Tab title="Build from scratch">
    Follow the schema + pages + API pattern below. Best if you want full control or you're adding e-commerce to an existing site with its own design system.
  </Tab>
</Tabs>

## The Products Table

```sql theme={null}
CREATE TABLE products (
  id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
  slug text UNIQUE NOT NULL,
  name text NOT NULL,
  description text,
  price_cents integer NOT NULL,
  currency text DEFAULT 'usd',
  stripe_price_id text,
  image_url text,
  stock_quantity integer DEFAULT 0,
  active boolean DEFAULT true,
  created_at timestamptz DEFAULT now()
);
```

Key decisions:

* `price_cents` (integer) avoids floating-point rounding issues with money
* `slug` (unique text) is your URL path segment
* `stripe_price_id` links the DB row to the Stripe Price object
* `active` lets you hide products without deleting them

## Create Products in Stripe

For each product you want to sell:

<Steps>
  <Step title="Create the Product in Stripe">
    Stripe Dashboard > Products > Add Product. Name, description, image.
  </Step>

  <Step title="Add a Price">
    Stripe lets you have multiple prices per product. For a one-time purchase, pick **One-time** and enter the amount.
  </Step>

  <Step title="Copy the Price ID">
    Starts with `price_`. Save it to your DB row's `stripe_price_id`.
  </Step>
</Steps>

## The Listing Page: `/shop`

Fetch all `active` products and render them as a grid:

```tsx theme={null}
export default async function ShopPage() {
  const products = await db.query(
    'SELECT * FROM products WHERE active = true ORDER BY created_at DESC'
  );

  return (
    <div className="grid grid-cols-3 gap-6">
      {products.map(p => (
        <a key={p.id} href={`/shop/${p.slug}`} className="card">
          <img src={p.image_url} alt={p.name} />
          <h3>{p.name}</h3>
          <p>${(p.price_cents / 100).toFixed(2)}</p>
          <span>View details</span>
        </a>
      ))}
    </div>
  );
}
```

## The Detail Page: `/shop/[slug]`

```tsx theme={null}
export default async function ProductPage({ params }) {
  const product = await db.queryOne(
    'SELECT * FROM products WHERE slug = $1 AND active = true',
    [params.slug]
  );

  if (!product) return <NotFound />;

  return (
    <div>
      <img src={product.image_url} alt={product.name} />
      <h1>{product.name}</h1>
      <p>{product.description}</p>
      <p className="price">${(product.price_cents / 100).toFixed(2)}</p>
      <BuyButton priceId={product.stripe_price_id} slug={product.slug} />
    </div>
  );
}
```

## The Checkout Endpoint

Create `api/checkout.ts`:

```typescript theme={null}
import Stripe from 'stripe';

const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!);

export default async function handler(req: Request) {
  const { priceId, slug } = await req.json();
  const origin = new URL(req.url).origin;

  const session = await stripe.checkout.sessions.create({
    mode: 'payment',
    line_items: [{ price: priceId, quantity: 1 }],
    success_url: `${origin}/order-success?session_id={CHECKOUT_SESSION_ID}`,
    cancel_url: `${origin}/shop/${slug}`,
  });

  return Response.json({ url: session.url });
}
```

The `Buy Now` button POSTs to this endpoint and redirects the browser to `session.url` (Stripe's hosted checkout page).

## Order Confirmation Page: `/order-success`

Reads `session_id` from the URL, fetches the session from Stripe, and displays a thank-you message:

```tsx theme={null}
export default async function OrderSuccess({ searchParams }) {
  const session = await stripe.checkout.sessions.retrieve(searchParams.session_id);
  return (
    <div>
      <h1>Thanks for your order!</h1>
      <p>Email receipt sent to {session.customer_details.email}</p>
    </div>
  );
}
```

## The Stripe Webhook

The webhook is where the real work happens — recording orders, decrementing inventory, sending confirmation emails. Stripe calls your endpoint when the payment completes:

<Steps>
  <Step title="Create the webhook endpoint">
    `api/stripe-webhook.ts`. Verify the signing secret, then handle `checkout.session.completed`.
  </Step>

  <Step title="Create an orders row">
    Record amount, customer email, line items, Stripe session ID.
  </Step>

  <Step title="Decrement inventory">
    ```sql theme={null}
    UPDATE products
    SET stock_quantity = stock_quantity - 1
    WHERE id = $1
    ```

    Pair this with your [low-stock alert workflow](/how-tos/workflow-low-stock).
  </Step>

  <Step title="Send a confirmation email">
    Use your email provider to send the order details. See [Send Emails](/how-tos/send-emails).
  </Step>
</Steps>

## Advanced Patterns

Layer these in once the basic single-item checkout works:

* **Shopping cart** — multi-item checkout with a cart state stored in localStorage or a `carts` table
* **Subscriptions** — use Stripe's subscription mode for recurring plans
* **Variants** — size/color options with per-variant SKU and stock
* **Discount codes** — Stripe Coupons applied at checkout
* **Abandoned cart recovery** — see [abandoned cart workflow](/how-tos/workflow-abandoned-cart)
* **Out-of-stock handling** — see [low-stock alerts](/how-tos/workflow-low-stock)

<Tip>
  Start simple — single-product, one-click checkout. Add cart, subscriptions, variants, and discounts only when specific customer requests or data show you need them. Most early e-commerce sites over-build and under-sell.
</Tip>

## Verify It Worked

<Steps>
  <Step title="Use a test Stripe key">
    Confirm your project is using a `sk_test_...` key, not the live one.
  </Step>

  <Step title="Complete a test checkout">
    Click Buy Now on a product. On the Stripe checkout page, use test card `4242 4242 4242 4242` with any future expiry and any 3-digit CVC.
  </Step>

  <Step title="Confirm order recorded">
    Check your `orders` table — the new row should exist. Stock should have decremented. Confirmation email should be in your inbox.
  </Step>

  <Step title="Switch to live mode and buy your own product">
    Before going fully live, set your test Stripe keys to live keys, and actually buy a real product from your own site. Refund yourself. This catches anything test mode skipped.
  </Step>
</Steps>

## Troubleshooting

<AccordionGroup>
  <Accordion title="'No such price' error">
    You're mixing test and live Stripe keys. Test-mode price IDs don't work with live-mode keys and vice versa. Keep them consistent — either everything test or everything live.
  </Accordion>

  <Accordion title="Checkout loads but won't submit">
    Usually a missing or incorrect `STRIPE_PUBLISHABLE_KEY` env var on the client side, or a mismatch between the publishable key and the secret key's mode. Check the browser console for Stripe errors.
  </Accordion>

  <Accordion title="Webhook isn't firing">
    In the Stripe Dashboard, go to Developers > Webhooks. Confirm the endpoint URL matches your deployed site. Confirm the signing secret in your env vars matches the one shown in Stripe. Click the webhook's "Send test event" button to trigger a test delivery and check the response.
  </Accordion>

  <Accordion title="Inventory not decrementing after purchase">
    The webhook handler is erroring before it reaches the UPDATE. Check the Stripe webhook attempts log — you'll see the failed responses with error messages. Then check your server logs via [View Logs](/how-tos/view-logs).
  </Accordion>

  <Accordion title="Orders going through but no confirmation email">
    Either your email service isn't configured or the webhook fails after the order insert but before the email send. Use a try/catch around the email call so a mail failure doesn't break the rest of the order processing.
  </Accordion>
</AccordionGroup>

## What's Next?

<CardGroup cols={2}>
  <Card title="Low-Stock Alerts" icon="bell" href="/how-tos/workflow-low-stock">
    Get notified before you run out of inventory
  </Card>

  <Card title="Abandoned Cart Recovery" icon="cart-shopping" href="/how-tos/workflow-abandoned-cart">
    Win back customers who didn't complete checkout
  </Card>
</CardGroup>
