Skip to main content
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.
Before you start: make sure your database is set up and Stripe is configured.

Three Paths

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.).

The Products Table

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:
1

Create the Product in Stripe

Stripe Dashboard > Products > Add Product. Name, description, image.
2

Add a Price

Stripe lets you have multiple prices per product. For a one-time purchase, pick One-time and enter the amount.
3

Copy the Price ID

Starts with price_. Save it to your DB row’s stripe_price_id.

The Listing Page: /shop

Fetch all active products and render them as a grid:
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]

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:
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:
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:
1

Create the webhook endpoint

api/stripe-webhook.ts. Verify the signing secret, then handle checkout.session.completed.
2

Create an orders row

Record amount, customer email, line items, Stripe session ID.
3

Decrement inventory

UPDATE products
SET stock_quantity = stock_quantity - 1
WHERE id = $1
Pair this with your low-stock alert workflow.
4

Send a confirmation email

Use your email provider to send the order details. See Send Emails.

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
  • Out-of-stock handling — see low-stock alerts
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.

Verify It Worked

1

Use a test Stripe key

Confirm your project is using a sk_test_... key, not the live one.
2

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.
3

Confirm order recorded

Check your orders table — the new row should exist. Stock should have decremented. Confirmation email should be in your inbox.
4

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.

Troubleshooting

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.
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.
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.
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.
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.

What’s Next?

Low-Stock Alerts

Get notified before you run out of inventory

Abandoned Cart Recovery

Win back customers who didn’t complete checkout