Static site (plain HTML/CSS/JS) plus a small AWS Lambda that talks to Square.
site/ source: HTML templates, CSS, JS (+ js/test/ unit tests), data/*.json, images/ (full-size masters, never uploaded)
scripts/ build.mjs produces dist/; publish, deploy-api, put-token, serve (local preview)
dist/ generated; this is what gets uploaded to S3
backend/ Lambda: catalog, tax quote, checkout, signup (Square Catalog, Orders, Payments, Customers APIs) + test/
e2e/ browser tests (Playwright) against dist/ with the API mocked
deploy/ CloudFormation for hosting, stage settings, IAM policies; see deploy/README.md
npm ci
SITE_URL=https://yourdomain.com npm run build
npm run preview
The build:
- makes 400 / 800px AVIF and 400 / 800 / 1600px WebP copies of each photo (cached in
.cache/). The grid uses 400 or 800 depending on screen (AVIF where the browser supports it); 1600 loads only when a photo is opened. - puts a content hash in every image file name, so a replaced photo gets a new URL automatically.
- renders the galleries into the pages from
site/data/*.json, so pages need no data requests. - adds canonical, Open Graph and icon tags to every page, plus
robots.txt,site.webmanifestand (withSITE_URLset)sitemap.xml. - bundles, minifies and content-hashes the JS and CSS, and strips all comments from HTML/CSS/JS.
- the Square payment SDK loads only once something is in the cart.
- Put the photo (JPG, metadata stripped) in
site/images/work/with the next number, e.g.11.jpg. - Add an entry to
site/data/work.json:{ "image": "images/work/11.jpg", "title": "Ash-glaze mug", "description": "Wheel thrown stoneware.", "alt": "Speckled grey mug on a wooden table" } npm run buildand deploy.
Products come straight from your Square catalog. Add items in Square Dashboard > Items & services, with a price and a photo. They appear on the shop within a minute. Shop prices and tax are always calculated by Square on the server.
Shipping is priced per piece, from the delivery ZIP: one double-boxed parcel per piece, at the published label price for its billed weight and zone from Tacoma, rounded up to whole dollars (backend/src/shipping.mjs). UPS Ground carries everything it can; USPS Ground Advantage takes PO boxes, military addresses, Alaska, Hawaii and the territories. Each item needs a Ship weight (lb) custom attribute in Square: the billed weight of the packed box (the larger of actual and dimensional weight, from the pack-and-ship planner). An item without one can be picked up but not shipped. Rates and zones live in backend/src/shipping-rates.json; update it when UPS or USPS change prices (USPS holiday prices end 2027-01-17; the UPS fuel surcharge changes weekly).
The "First look at new work" form posts to /api/subscribe, which adds the address to your Square customer directory (existing customers are matched by email, not duplicated). To keep website signups separate, they also go into a "Website newsletter" customer group: run node scripts/newsletter-group.mjs (--stage staging for staging), which finds or creates the group and prints its id, put that id in the stage's newsletterGroupId in deploy/stages.json, and run npm run deploy:api. An id from the wrong Square account (sandbox vs production) makes every signup fail. Send emails to that group with Square Marketing. Check Square's email-marketing consent rules before your first campaign.
Abuse limits: each running API instance allows 5 signup attempts per IP per 10 minutes and ignores repeat signups of the same address within an hour.
Each Lambda function can only run so many copies at once (reserved concurrency). Past the limit, Lambda answers 429 instead of running. The limits are set per stage in deploy/stages.json and applied by npm run deploy:api:
| Function | Setting | prod | staging | When it's reached |
|---|---|---|---|---|
| Api (catalog, quote, signup, share pages) | apiConcurrency |
5 | 2 | Busy pages get a 429; the photo resizer retries its catalog check. |
| Checkout | fixed in backend/template.yaml |
1 | 1 | On purpose: one order at a time, so a one-of-a-kind piece can't sell twice. Buyers queue and retry. |
| Images (photo resizer) | imagesConcurrency |
5 | 5 | The first visit after new photos asks for more resizes at once; the page retries each photo twice, then shows Square's full-size original. npm run warm-photos makes every copy ahead of time. |
The account needs 10 unreserved on top of all of these (both stages together: 19 reserved now). A new AWS account with a low Lambda concurrency quota can't reserve any; deploy with apiConcurrency: 0 there, which turns off every reservation.
- Square app: developer.squareup.com > create an application. Note the Application ID, the Access Token (sandbox first) and your Location ID.
- Deploy the API: put the stage's public Square settings (
squareAppId,squareLocationId,squareEnv) indeploy/stages.json. Put the access token inbackend/.env(git-ignored) and runnpm run put-token, which stores it in SSM Parameter Store. Thennpm run deploy:apiandnpm run publish. Add-- --stage stagingto any of these for the staging site. Details are indeploy/README.md. - Site hosting: S3 bucket (private) + CloudFront with Origin Access Control, default root object
index.html, custom error response 404 ->/error.html, and an ACM certificate for your domain. Turn on Compress objects automatically (gzip/Brotli) in the CloudFront cache behavior. Upload the content-hashed folders with long caching and everything else (HTML, favicon, manifest, robots, sitemap) with short caching:Security headers: attach a CloudFront response headers policy to the default behavior with HSTS (aws s3 sync dist/ s3://YOUR-BUCKET --delete --exclude "*" --include "assets/*" --include "img/*" --include "full/*" --cache-control "public,max-age=31536000,immutable" aws s3 sync dist/ s3://YOUR-BUCKET --delete --exclude "assets/*" --exclude "img/*" --exclude "full/*" --cache-control "public,max-age=300"max-age=63072000; includeSubDomains),X-Content-Type-Options: nosniff,Referrer-Policy: strict-origin-when-cross-origin, frame optionsDENY, and a Content-Security-Policy along these lines (add your API origin toconnect-srcifapiBaseis not same-origin):Check the hosts against Square's current Web Payments SDK CSP docs, and roll it out asdefault-src 'self'; img-src 'self' data: https://*.squarecdn.com https://items-images-production.s3.us-west-2.amazonaws.com https://items-images-sandbox.s3.us-west-2.amazonaws.com; script-src 'self' https://*.squarecdn.com https://js.squareup.com https://js.squareupsandbox.com; style-src 'self' 'unsafe-inline' https://*.squarecdn.com; font-src 'self' https://*.squarecdn.com https://d1g145x70srn7h.cloudfront.net; connect-src 'self' https://*.squareup.com https://*.squareupsandbox.com https://*.squarecdn.com https://o160250.ingest.sentry.io; frame-src https://*.squarecdn.com https://*.squareup.com https://*.squareupsandbox.com https://*.cardinalcommerce.com; base-uri 'self'; form-action 'self'; frame-ancestors 'none'; object-src 'none'Content-Security-Policy-Report-Onlyfirst, then switch once a sandbox checkout works with no violations. - Go live: test the whole flow on staging first, with Square's test card
4111 1111 1111 1111. Indeploy/stages.json, set prod'ssquareEnvto"production"with the production application ID, location ID and newsletter group ID. Load the pieces into the production catalog withnode scripts/load-items.mjs <batch.json> --go(it also creates the "Ship weight (lb)" attribute). Then, together, store the token withnpm run put-tokenand runnpm run deploy:apiandnpm run publish: the live functions read the new token on their next start, so storing it before the deploy breaks the shop until the deploy finishes. For a production stage, each script asks for the token with hidden input (nothing shows, and it never enters a command or the shell history); it never readsbackend/.env, which holds the sandbox token. Try the prompt first withnode scripts/square-client.mjs --try-prompt.
npm run build && npm run preview # http://localhost:4173
npm run preview -- --api https://staging.updraftpotterystudio.com # same, with the shop's data from staging (sandbox)
npm run lint # ESLint
npm run typecheck # TypeScript over the JS (JSDoc types)
npm run format:check # Prettier (npm run format to fix)
npm test # unit tests: site/js/test and backend/test
npm run test:e2e # browser tests in Chromium, Firefox and WebKit, incl. axe accessibility checks (after a build)
npm run test:lighthouse # Lighthouse budgets on the phone preset (after a build; set CHROME_PATH if Chrome isn't installed)
CI (.github/workflows/ci.yml) runs all of these, plus cfn-lint on the CloudFormation templates, on every push to master and on pull requests. It caches encoded photos (named by a hash of their inputs) and Playwright's browsers between runs. CodeQL scans the code, and Dependabot opens weekly grouped dependency updates. Deploys run from Actions → Deploy (deploy.yml).
Every piece is one of a kind, so two buyers must never both pay for it. Checkout runs one order at a
time and re-checks stock after authorising the card (see backend/template.yaml). npm run load-test
proves it on a deployed sandbox stage: it sends many simultaneous buyers through CloudFront, exactly as
the site does, at a piece with stock 1.
npm run load-test -- --stage staging --buyers 12 --item "test 2" --verify --restock
--verify asks Square how many payments completed or were left authorised, and --restock puts the
piece back to stock 1. Both need the sandbox token. The script refuses to run against a production stage.
Result on staging, 2026-10-07, 12 buyers at once:
| Check | Result |
|---|---|
| Buyers charged | 1 |
| Other buyers told "sold, not charged" | 11 of 11 |
| Stock shown afterwards | 0 |
| Square payments completed / authorisations left open | 1 / 0 |
| Time until the last buyer heard "sold" | 53 s (queued behind one-at-a-time checkout) |