Adios
BlogNext.js Ecommerce

Next.js Ecommerce

How to Build a Next.js Ecommerce Store for Production: Catalog, Checkout, Inventory, and SEO

Build a production Next.js 16 store with products, variants, carts, server-owned pricing, Stripe Checkout, inventory, SEO, and deployment.

Adios teamUpdated July 17, 20269 min read

A storefront becomes a commerce system when catalog data, prices, inventory, payment state, orders, and fulfillment must remain consistent under concurrent requests.

Draw the commerce boundaries

Trail Supply is the running example: an outdoor store selling the Alpine Shell in several colors and sizes. The storefront presents catalog data; the commerce layer owns authoritative prices, inventory, carts, payments, and orders; external providers handle payment and email. Draw those boundaries before selecting components.

Name the source of truth for each value. A content system may own editorial descriptions while a commerce database owns SKU, price, and inventory. The product page can combine them, but checkout must never trust editorial or browser state for transactional values.

  • Catalog: products, variants, categories, media, and publish state.
  • Commerce: price, currency, tax inputs, inventory, carts, orders, and refunds.
  • Identity: anonymous cart token, customer account, addresses, and permissions.
  • Operations: payment events, fulfillment jobs, email, health, logs, and releases.

Model products and purchasable variants

A product describes the shared Alpine Shell story. A variant represents a specific purchasable combination such as blue, medium, with its own SKU, price reference, inventory, and status. Store stable IDs behind readable slugs so URLs can change without breaking orders or event reconciliation.

Separate publishable from purchasable. A product can remain visible while one size is unavailable; a discontinued item may remain useful for support and search while checkout is disabled. Put database constraints around unique SKUs and valid ownership rather than relying only on form validation.

type Product = {
  id: string;
  slug: string;
  name: string;
  published: boolean;
};

type Variant = {
  id: string;
  productId: string;
  sku: string;
  color: string;
  size: string;
  priceId: string;
  active: boolean;
};

Render catalog routes on the server

Use Server Components for category and product content so direct requests contain headings, descriptions, prices, product links, and availability. Keep the gallery, size selector, and add-to-cart control as focused Client Components. The client receives a narrow purchasable view rather than unrestricted database records.

Choose caching from the content lifecycle. Product descriptions and images may change slowly; price and inventory require a tighter contract. Cache stable catalog reads with product and category tags, then revalidate them after an approved update. Checkout always reloads transactional values.

Treat the cart as a proposal

An anonymous customer can receive a random HTTP-only cart token whose database record stores variant IDs and quantities. After sign-in, merge it into the customer cart with explicit rules for duplicates, unavailable variants, and quantity limits. Do not place authoritative prices in a mutable cookie.

The cart page may display a calculated estimate, but every mutation and checkout request must reload variants, prices, purchase limits, and inventory on the server. Return line-level corrections when a product changed instead of silently charging a different amount.

Complete payment asynchronously

Create the Stripe Checkout Session on the server from the validated cart and attach a stable cart or pending-order ID as metadata. The return URL can show that payment is being confirmed, but it cannot create the final order by itself.

Verify Stripe's signature against the raw webhook body, record the event ID under a unique constraint, and create or advance the order exactly once. Handle completed and expired sessions, payment failures, refunds, and repeated events. Queue email and fulfillment after durable order state exists.

const session = await stripe.checkout.sessions.create({
  mode: "payment",
  line_items: pricedCart.lines.map((line) => ({
    price: line.stripePriceId,
    quantity: line.quantity,
  })),
  success_url: SITE_URL + "/orders/confirm?session_id={CHECKOUT_SESSION_ID}",
  cancel_url: SITE_URL + "/cart",
  metadata: { pendingOrderId: pendingOrder.id },
});

Keep inventory transactional

Choose when stock becomes reserved: at add-to-cart, checkout creation, or confirmed payment. Cart-time reservations reduce apparent availability and require aggressive expiry; payment-time reservation risks accepting more simultaneous checkouts than stock. The product's fulfillment model determines the tradeoff.

Whichever policy you choose, update inventory atomically and prevent negative quantities in the database. Record reservation expiry and reconcile late payment events explicitly. Caches can publish availability, but the inventory table and transaction decide whether an order may claim stock.

Build product discovery and SEO

Give each product one intentional canonical model. Generate titles, descriptions, Open Graph images, Product and Offer JSON-LD, breadcrumbs, and sitemap entries from published records. Keep price and availability consistent with the visible page and checkout path.

Category pages should link to important products with real anchors. Treat filters as application state unless a curated combination has independent demand and content. Discontinued products need a deliberate 200, redirect, 404, or 410 decision based on whether the page still helps customers.

Deploy and test the transaction path

Adios runs the complete Next.js production server as a persistent process: Server Components, cart endpoints, signed webhooks, and order pages share one release. The manifest declares the build, start, health route, database dependency, and secret references beside the source.

Deploy a preview and test product HTML, a tampered cart, two concurrent buyers, Checkout in test mode, duplicate events, an expired session, email handoff, and an unknown product. Inspect build and runtime logs, then promote only after the application reports healthy. The custom domain and managed TLS remain attached to the verified release.

adios.yaml

name: trail-supply
build_cmd: npm ci && npm run build
start_cmd: npm start

runtime:
  name: node@24
  port: 3000
  health_path: /api/health

requires:
  - db

secrets:
  DATABASE_URL: secret://DATABASE_URL
  STRIPE_SECRET_KEY: secret://STRIPE_SECRET_KEY
  STRIPE_WEBHOOK_SECRET: secret://STRIPE_WEBHOOK_SECRET
  All articles