Shop

The shop lets visitors browse products, put them in a basket, check out and pay. You look after the products and the orders in the admin. By the end of this chapter you will have a product on a page, a test purchase in your list of orders, and the steps for taking real payments with Stripe.

The shop is a feature: a package you add to the site and list in site.config.ts. A site that was created with the shop chosen already has all of this, so you can go straight to “Add a product”.

Turn the shop on

  1. In your site's folder, install the package:
pnpm add @jamiemcconnell/cms-shop
  1. Open site.config.ts. Import the shop and list it under features, next to any feature you already have:
site.config.ts
import { defineConfig } from '@jamiemcconnell/cms';
import { fakePayments, shop } from '@jamiemcconnell/cms-shop';

export default defineConfig({
  // …the rest of your settings stay as they are…
  features: [
    shop({
      payments: [fakePayments()],
      currency: 'GBP',
      pricesIncludeTax: true,
      taxRate: 20,
    }),
  ],
});

The shop takes four settings:

  • payments is the list of payment providers, and the first is the default. fakePayments() is a stand-in for trying the shop: it shows a page with a Pay button and charges nobody. You swap it for Stripe further down.
  • currency is a three-letter code such as GBP, EUR or USD.
  • pricesIncludeTax says whether the prices you type already include tax (true) or have tax added at the checkout (false).
  • taxRate is one rate for the whole site, as a percentage. A rate of 0 turns tax off: prices are what they say and nothing shows a tax line.

Guests can check out without an account unless you add guestCheckout: false, which refuses checkout from anyone who is not signed in.

  1. Restart the site. In development, stop pnpm dev with Ctrl+C and run it again. No migration is needed, because every site already has the shop's tables.
  2. Add the shop's pages by running the starter seed in your site's folder:
pnpm seed

This creates the basket and checkout pages, the product and order templates, the pages customers land on after paying or cancelling, a Shop page and three demo mugs. It skips anything that already exists, so it is safe to run again, but a starter page you deleted on purpose comes back. Delete the mugs under Catalog → Products once you have products of your own. On a server that runs Docker, run the seed as docker compose run --rm app pnpm cms seed --starter, as in Run it on a server.

  1. Link to the shop. The seed does not change menus or layouts that already exist, so add the links yourself:
    • Under Content → Menus, open the Main menu and add an item called Shop that links to /shop.
    • Under Content → Layouts, open the default layout, select the menu in the header and add a Basket icon block to its “After the links” area. The Basket icon is in the Commerce group of the block picker. It shows the number of items and links to the basket.

Add a product

  1. In the admin, open Catalog and choose New product.
  2. Type a Name. The Slug fills in from it. The product's page will be /products/<slug>, and the slug cannot change once the product exists.
  3. Enter the Price in the shop's currency, for example 12.50, and write a Description. Composition is optional and is shown by the Product composition block.
  4. Under Images, add pictures from the media library, and move them to set the order the gallery shows.
  5. Set Status to active. A new product starts as a draft, and drafts cannot be bought.
  6. Under Variants, set the Stock of the Default row. A variant is one version of a product that can be bought on its own, such as a size or a colour; every product has at least one. A variant with no stock shows as sold out.
  7. Choose Create.

To sell versions of a product, add a row under Variants for each. A row has a name, an optional SKU (your own stock code), options written as name=value pairs such as colour=blue, size=large, its stock, and an optional price and delivery charge that replace the product's. Categories, under Catalog → Categories, group products and can sit inside one another.

Show products on a page

The starter's Shop page uses a Product grid block, from the Commerce group of the block picker. It lists the newest products or the products of one category. A product whose variants have different prices shows a range in the grid.

A product's own page is drawn by a template. Open Content → Templates → Product to see it. It holds a gallery that opens in a lightbox, the title, the price, the description, a variant picker, an Add to basket button and a Composition accordion. Edit the template and every product page changes. To show what delivery costs, add a Delivery price block under the price.

Try a test purchase

  1. Open a product on the site and choose Add to basket. The Basket icon updates.
  2. Open the basket and choose Go to checkout.
  3. Enter an email address and a delivery address. The summary beside the form lists what you are buying.
  4. Continue to payment. With fakePayments() you land on a page with a Pay button. Choose it.
  5. In the admin, open Orders. Your order is there with the status Paid.

Until you set up a real email provider (Going live), the site does not send email. A summary of each message is printed in the terminal that runs the site, and a rendered copy is saved in the .cms/mail folder.

Take real payments with Stripe

Stripe is a payment company. It hosts the page where customers type their card details, so card numbers never reach your site. You need a Stripe account. Stripe renames its menus now and then, so if a label below differs, look for the nearest match. Start in Stripe's test mode, where test keys and test cards move no real money.

  1. Install the Stripe adapter:
pnpm add @jamiemcconnell/cms-payments-stripe
  1. Tell Stripe how to reach your site. This is a webhook: an address Stripe calls when a payment succeeds or fails, so the order can be marked paid. In the Stripe dashboard, in the developers area:
    1. Create a webhook endpoint.
    2. For the address, use your site's address followed by /api/payments/webhooks/stripe, such as https://example.com/api/payments/webhooks/stripe. Replace example.com with the address in CMS_SITE_URL.
    3. Choose the API version 2026-08-26.dahlia. A webhook sends its data in the version of the endpoint, and the cms reads that one.
    4. Select these events: checkout.session.completed, checkout.session.async_payment_succeeded, checkout.session.async_payment_failed, checkout.session.expired, refund.created and refund.updated.
    5. Save the endpoint and copy its signing secret. It starts with whsec_.
  2. Copy your secret API key from the same area of the dashboard. A test key starts with sk_test_. Put both values in the site's .env:
.env
STRIPE_SECRET_KEY=sk_test_your_key
STRIPE_WEBHOOK_SECRET=whsec_your_secret
  1. In site.config.ts, replace fakePayments() with stripe():
site.config.ts
import { stripe } from '@jamiemcconnell/cms-payments-stripe';
import { shop } from '@jamiemcconnell/cms-shop';

// …inside defineConfig:
features: [
  shop({
    payments: [stripe()],
    currency: 'GBP',
    pricesIncludeTax: true,
    taxRate: 20,
  }),
],
  1. Restart the site and repeat the test purchase. Stripe's page asks for a card: use the test card 4242 4242 4242 4242 with any future expiry date and any security code. The order should appear under Orders as Paid within a few seconds. If it stays Pending, Stripe could not reach the webhook; check the address and the signing secret.

On your own computer Stripe cannot reach your site, because 127.0.0.1 is only visible to you. Install the Stripe command-line tool and have it pass Stripe's events on while you test:

stripe listen --forward-to http://127.0.0.1:4321/api/payments/webhooks/stripe

The tool prints a signing secret that starts with whsec_. Use it as STRIPE_WEBHOOK_SECRET while you test locally. Replace 4321 with your site's port.

When you are ready for real money, repeat steps 2 and 3 with Stripe's live mode, which has its own keys and its own webhook endpoint, and put the live values in .env. Going live covers the rest of the checklist, such as email and the database.

A site on an https address refuses to start with fakePayments(), so nobody takes the stand-in for the real thing. For a public demo that charges nobody, set CMS_ALLOW_FAKE_PAYMENTS=true in .env.

Delivery

Delivery is a flat charge for each unit, added up over the basket. Under Settings → Shop, the Delivery card's “Delivery per item” sets the charge for one unit of any product. A product can replace it with its own “Delivery per item” beside its price; leave that blank to use the shop's. Nothing is charged, and nothing is shown, until a charge is set.

The basket icon shows the price of the goods. The basket, the checkout summary and the order show delivery on a line of its own.

Discount codes

Codes are made under Catalog → Discount codes with New discount code. Customers type one into the basket or the checkout summary. A code has an effect:

  • Percentage off the goods.
  • Amount off the goods, which never takes more than the goods cost.
  • Free delivery only. The Free delivery box can also be ticked on the other two.

It also has Uses, the number of orders that may use it, and optionally Valid from and Valid until dates and a Customer email that the checkout must match. Together they make the usual shapes: a public code that runs for a fortnight, a single-use code (Uses set to 1), and a code made for one customer.

A basket holds one code at a time, and applying another replaces it. The order keeps its code, and the basket, checkout, payment page, order page, emails and admin order all show a Discount row. A use is held when checkout starts and given back if the order is cancelled or its payment fails, so a limit is exact even when two customers race for the last use. A code that would leave nothing to pay is refused.

The list shows each code's state: Scheduled, Active, Expired, Used up or Disabled. Deleting a code stops further use, and the orders that used it keep their discount.

Look after orders

Orders appear under Orders. A new order is Pending until it is paid, then Paid. The Dashboard shows what needs attention: orders waiting to be accepted or dispatched, open baskets, low stock and this month's income.

Open an order to move it on. By default a paid order waits for you to choose Mark dispatched, and its status becomes Fulfilled. If you prefer to confirm orders first, tick “Accept orders before dispatch” under Settings → Shop. New paid orders then wait for Accept, and only an accepted order can be dispatched.

The site emails at each step. The customer gets an order confirmation when the order is paid, a note when it is accepted, another when it is dispatched, and an update when it is cancelled or refunded. Staff get a “New order” email at the addresses under Settings → Email, in the “New order notifications” box. The wording of every email is under Content → Emails.

Cancel and refund

An order that has not been dispatched can be cancelled with Cancel order. For a paid order the dialog offers to refund the money through the payment provider and to return the items to stock. Both are ticked, and you can untick either, for example for goods that came back damaged.

A dispatched order is refunded instead, with Refund. The dialog lists each line: choose how many units are going back and whether delivery is included, and the amount follows. You can change the amount to keep a handling fee or add goodwill. Tick “Return the refunded items to stock” when the goods are back on the shelf. Every cancellation and refund is recorded in the order's history.

Stock and baskets

Stock is held for thirty minutes from the moment a checkout starts, or until the order is paid, so two customers cannot both pay for the last unit. A checkout that is abandoned or fails gives the stock back. A job runs every minute and cancels checkouts whose time is up.

The Baskets tab under Orders shows what customers are holding right now and which products are in the most baskets. A basket nobody has touched for the number of days set under Settings → Shop, Baskets (thirty by default) is deleted.

Customers and their accounts

Customers see their orders under /account, and each order at /account/orders/<id>. That page is built from the order template, under Content → Templates → Order. A guest who checks out gets an account for their email address and can set a password from the page they land on after paying. Accounts and sign-in has the details.

To grow a members area, lock more pages to signed-in visitors with the Access field in a page's settings (Pages and blocks), and link to them from the account page or a menu.

Checkout questions

Settings → Shop has a Checkout questions section. Anything you add there is asked on the checkout page beside the address, and the answer is kept with the order together with the question as it was worded at the time. Mark a question as sensitive, such as one about health, to keep it out of staff emails.

Close the shop

Under Settings → Shop, “Close the shop” stops sales with a title and message of your choosing. Products stay visible but cannot be bought, and the basket, checkout and product pages show the notice. Customers can still sign in and see their orders. The Shop closed notice block puts the same notice on any other page.