Freeframe

Documentation

Market

v1.1.9Updated Oct 18, 2026A complete online store for a handmade homeware brand, with a working cart and checkout flow.

Overview#

Market is a complete storefront for Kiln & Co., a fictional handmade-ceramics studio: home, a filterable shop, product pages with glaze and size variants, a cart saved in the browser and shared by every component and tab, a slide-out mini cart, a validated demo checkout, an about page and a searchable FAQ with a contact form. Product images are SVG drawn from the product data, so there are no photos to replace. This is the Next.js 16 (App Router) + React 19 + TypeScript + Tailwind CSS v4 version, version 1.1.4. It exports to static files. See it live at freeframe.dev/templates/market.

Quick start#

Requirements: Node.js 24 or newer and pnpm (npm works too).

sh
pnpm install
pnpm dev          # http://localhost:3000
Command What it does
pnpm dev Development server at http://localhost:3000
pnpm build Static export to out/
pnpm start Serve out/ locally (npx serve out)
pnpm lint ESLint
pnpm typecheck Generate route types, then type-check with tsc (TypeScript 7)
pnpm build:demo Build for /demos/nextjs/market into demo/ (only used for the freeframe.dev demo)

Pages#

Route File What's on it
/ src/app/page.tsx Hero with product art, shop by shape, bestsellers (featured products), story, reasons to buy, reviews, newsletter band
/shop src/app/shop/page.tsx + ShopView.tsx Product grid with category, price and sort filters and a live result count
/product?id=… src/app/product/page.tsx + ProductView.tsx Gallery with four views, glaze and size options, quantity, details, reviews, related products
/cart src/app/cart/page.tsx + CartView.tsx Cart lines, free-shipping progress, promo code, order summary
/checkout src/app/checkout/page.tsx + CheckoutView.tsx Contact, address, delivery and card fields, summary, confirmation
/about src/app/about/page.tsx Studio story, timeline, team and visit info
/faq src/app/faq/page.tsx + FaqView.tsx, ContactForm.tsx Searchable FAQ and contact form (/faq#contact)
404 src/app/not-found.tsx Not-found page

There is one product route: the product is read from ?id= in the browser (and the shop category from ?category=), inside a Suspense boundary so the pages stay static. The exported HTML shows the first product and all products until the script runs.

Project structure#

market/
├─ src/
│  ├─ app/
│  │  ├─ layout.tsx            fonts, metadata, header, footer, cart drawer, toast, back to top
│  │  ├─ globals.css           brand tokens and component classes
│  │  ├─ icon.svg              favicon
│  │  ├─ opengraph-image.png   1200×630 social sharing image
│  │  ├─ page.tsx, not-found.tsx
│  │  └─ about/, shop/, product/, cart/, checkout/, faq/
│  ├─ components/
│  │  ├─ SiteHeader.tsx, SiteFooter.tsx, Logo.tsx, Breadcrumb.tsx, Year.tsx, icons.tsx
│  │  ├─ ProductArt.tsx, ProductCard.tsx, ProductReviews.tsx
│  │  ├─ RevealObserver.tsx, BackToTop.tsx
│  │  ├─ cart/                 CartUIProvider, CartDrawer, Toast, QtyStepper, ShippingProgress
│  │  └─ forms/                ValidatedForm (form, fields, status, success box), NewsletterForm
│  ├─ fonts/                    Fraunces and Inter (Latin .woff2) with their OFL licences
│  ├─ data/
│  │  ├─ products.ts           catalogue, categories, shipping/tax/promo settings, link helpers
│  │  └─ faq.ts                FAQ groups and answers
│  └─ lib/
│     ├─ cart.ts               cart store (localStorage + useSyncExternalStore) and totals
│     ├─ art.ts                SVG product illustrations
│     ├─ validation.ts         field validation messages
│     └─ format.ts             price formatting
├─ next.config.ts, eslint.config.mjs, postcss.config.mjs, tsconfig.json
├─ package.json, pnpm-workspace.yaml, pnpm-lock.yaml
└─ README.md, DOCS.md, CHANGELOG.md

Customising#

Brand and colours#

All colours are tokens in the @theme block of src/app/globals.css:

Token Default Used for
--color-paper #f6f1ea Page background
--color-sand / --color-oat #ebe3d6 / #e2d7c6 Panels and image tiles
--color-line #d9cebd Borders
--color-ink #1f1c19 Text and primary buttons
--color-stone #645d54 Muted text
--color-clay / --color-clay-dark #a1441d / #7f3414 Accent, links, focus rings, badges
--color-moss #56653f Secondary accent (shipping progress bar)
--color-cream #fbf8f3 Light surfaces and form fields

Also update viewport.themeColor in src/app/layout.tsx and the colours in src/app/icon.svg. Button classes are .btn-primary, .btn-accent, .btn-outline and .btn-light.

Typography#

Fraunces (--font-fraunces, used by font-display) and Inter (--font-inter, used by font-sans) are self-hosted with next/font/local in src/app/layout.tsx, from the Latin .woff2 files in src/fonts/: the same files Google Fonts serves to the HTML version, so text sets exactly like it. Their SIL Open Font License texts are the *-OFL.txt files beside them. To swap a font, add its .woff2 (and licence) to src/fonts/ and point src at it, or import a family from next/font/google, and keep the variable names; or change --font-display / --font-sans in globals.css.

Content#

Products are the PRODUCTS array in src/data/products.ts, typed by Product:

ts
{
  id: "ridge-vase", name: "Ridge Vase", category: "Vases", price: 68, shape: "vase",
  rating: 4.9, reviews: 214, badge: "Bestseller", featured: 1,
  blurb: "A tall, generous vase thrown in one pull…",
  glazes: [{ name: "Oat", color: "#e6d9c2" }, { name: "Moss", color: "#76855a" }],
  sizes: [{ name: "Medium · 24cm", delta: 0 }, { name: "Large · 32cm", delta: 26 }],
  details: ["Stoneware, fired to 1260°C", "Fully glazed inside — watertight"],
  dims: "Medium: 24cm tall, 14cm wide. Large: 32cm tall, 18cm wide.",
},
Field Notes
id Unique, used in /product?id=… and in saved carts. Changing an id drops it from visitors' carts
category One of CATEGORIES (TypeScript enforces this); add a category there to get a new shop filter
price Base price in dollars; each size adds its delta (which can be negative)
shape A Shape: vase, bud, mug, cup, tumbler, bowl, plate, pitcher or planter
badge Optional label such as Bestseller, New or Limited
featured Optional; products with it appear in the home page bestsellers, lower numbers first
glazes, sizes Options; the size with delta: 0 is preselected (defaultSize())

Store settings are exported from the same file:

Export Default Meaning
FREE_SHIPPING 75 Subtotal for free standard shipping
STANDARD_SHIPPING / EXPRESS_SHIPPING 8 / 18 Delivery prices
TAX_RATE 0.08 Tax applied after the discount
PROMO { code: "WELCOME10", rate: 0.1 } The demo promo code

MAX_QTY (99) is in src/lib/cart.ts. Prices are formatted by fmt() in src/lib/format.ts (en-US, USD); change the locale and currency there. The "Free shipping on orders over $75" bar in layout.tsx, the shop page's PROMISES and the FAQ answers in src/data/faq.ts are plain text, so update them when you change the settings. Build links with productHref(id) and shopHref(category).

Images and artwork#

art(product, glazeIndex, view) in src/lib/art.ts draws each product from its shape and glaze colour. Views (ArtView) are 0 front, 1 glaze close-up, 2 on a shelf and 3 a pair. Use the component:

tsx
<ProductArt product={findProduct("terrace-planter")!} glaze={1} view={2} className="aspect-square overflow-hidden rounded-[1.75rem]" />

To use photos, add an image field to Product and render an <img> (or next/image, which works unoptimised in the static export) in ProductArt. The social image is src/app/opengraph-image.png (1200×630), picked up automatically by Next.js.

src/components/SiteHeader.tsx holds the NAV array (used by the desktop and mobile menus) and the cart button. src/components/SiteFooter.tsx holds the SHOP and HELP link lists, the newsletter form and the social links, which point to #; replace them with your profiles. The current page is marked with aria-current="page" from the pathname for items with a path.

Features#

Cart and mini cart#

src/lib/cart.ts is a small store: useCart() returns { items, promo } through useSyncExternalStore, so the header badge, drawer, cart page and checkout always agree, and changes in another tab show up immediately. Change it with cart.add, cart.setQty, cart.remove, cart.clear and cart.setPromo; read totals with cartTotals(state, method) and lines with cartLine(item).

CartUIProvider adds the UI around it: useCartUI() gives addToCart(id, glaze, size, qty) (adds, bumps the badge and shows a toast with "View cart"), openDrawer, closeDrawer and showToast. The CartDrawer traps focus, closes with Escape or the overlay and returns focus where it was.

Each cart line (in the drawer and on the cart page) has data-line="<id>:<glaze>:<size>"; the −, + and Remove buttons call keepLineFocus (components/cart/keepLineFocus.ts). After −, + or Remove, keyboard focus stays on the same button of the same line, and the quantity's live region reads out the new number. When a line goes (Remove, or − at quantity 1), focus moves to the same button on the next line, or on the previous line if it was the last; when the cart is then empty, it moves to the empty cart's "Shop" link ("Shop the collection" in the drawer, "Shop all ceramics" on the cart page).

The cart is empty during prerendering and the first render; use useHydrated() to avoid showing an "empty cart" flash before the browser cart has been read.

Shop filters#

ShopView filters by category, maximum price (#price-max) and sort (featured, price up/down, top rated, name). The count and heading update live, and the category is written back to the URL (/shop?category=Vases) so filtered views can be linked.

Product page#

The product comes from ?id= (an unknown id shows the first product). The tab title is set in the browser. Glaze swatches recolour the gallery, sizes change the price, quantity is limited to 1–99, four thumbnails (aria-pressed) switch views, and "You may also like" shows four related products, same category first.

Promo code and shipping progress#

WELCOME10 takes 10% off on the cart page. ShippingProgress shows how far the subtotal is from free shipping, in the drawer and on the cart page.

Checkout#

CheckoutView validates every field, formats the card number and expiry as you type, checks the card with the Luhn algorithm (use 4242 4242 4242 4242) and rejects expired dates. On success it shows a KC- order number and clears the cart. No payment is processed. The onValid handler is marked "Replace this block with a call to your payment provider". For real payments, redirect to a hosted checkout (Stripe Checkout, Shopify, Lemon Squeezy) rather than collecting card numbers in your own form; creating a checkout session needs a server, so remove output: "export" and add a Route Handler or Server Action, or call your existing backend.

FaqView filters the questions in src/data/faq.ts as you type, opens matches, hides empty groups and announces the number of answers. Group ids (shipping, returns, care, wholesale) are anchor targets, for example /faq#care.

Back to top button#

BackToTop (in layout.tsx) appears after scrolling one screen, returns to the top (instantly with reduced motion), moves focus to #main and moves up while the add-to-cart toast is visible. Remove <BackToTop /> to drop it.

Forms#

Forms are built from src/components/forms/ValidatedForm.tsx:

tsx
<ValidatedForm onValid={(form) => setDone(true)} className="grid gap-5">
  <label htmlFor="c-email" className="field-label">Email</label>
  <Input id="c-email" name="email" type="email" required label="Email" className="field" />
  <button type="submit" className="btn-primary">Send</button>
  <FormStatus className="text-sm text-clay" />
</ValidatedForm>

Input, Select and Textarea take a label (used in messages) and an optional rule (card, expiry, cvc or postcode); Input also takes a format function. They validate required, type="email" and minLength, show errors on blur and link them with aria-describedby. On submit the first invalid field is focused and FormStatus announces the problem; otherwise onValid(formElement) runs. SuccessBox shows a focused thank-you message.

Form Component Behaviour
Newsletter NewsletterForm (variant="band" on the home page, "footer" in the footer) Shows a thank-you message
Contact src/app/faq/ContactForm.tsx Shows a thank-you message
Promo code CartView Applies WELCOME10
Checkout CheckoutView Shows the order confirmation

Nothing is sent. To collect submissions, post the form in onValid:

tsx
<ValidatedForm
  onValid={async (form) => {
    const res = await fetch("https://formspree.io/f/your-id", {
      method: "POST", body: new FormData(form), headers: { Accept: "application/json" },
    });
    if (res.ok) setDone(true);
  }}
>

FormData only includes fields with a name. Every field already has a name prop (email, name, topic, order, message and the checkout fields); keep one on any field you add. With the static export, post to an external service; to handle it inside the project, remove output: "export" and add a Route Handler (e.g. src/app/api/contact/route.ts) or a Server Action.

Saved data (localStorage)#

Key Value Used for
kiln-cart JSON array of { id, glaze, size, qty } Cart contents
kiln-promo "WELCOME10" or null Applied promo code

normalizeCart() validates the cart every time storage changes: unknown products and bad entries are dropped, glaze and size indexes are range-checked, quantities are clamped to 1–99, duplicate lines are merged, and tampered data is overwritten with the cleaned version. Prices are never stored; totals always come from PRODUCTS. If storage is blocked, the store still works but the cart doesn't survive a reload.

SEO and sharing#

  • The root layout sets the title template (%s — Kiln & Co.), default description, Open Graph and Twitter card settings. Each page exports its own metadata.
  • metadataBase reads SITE_URL (default https://example.com). Build with your domain so the social image URL is absolute: SITE_URL=https://example.com pnpm build.
  • Product pages share one static route, so search engines see the generic product page title and description. If product search traffic matters, switch to src/app/product/[id]/page.tsx with generateStaticParams() over PRODUCTS and generateMetadata() per product, and update productHref().
  • Add src/app/sitemap.ts and src/app/robots.ts for production.

Accessibility#

Built in: a skip link, landmarks, visible focus rings, a focus-trapped cart drawer that closes with Escape, a mobile menu that closes with Escape, on resize to desktop and on navigation, aria-current navigation, a cart button label with the item count, polite toasts, aria-pressed gallery thumbnails, glaze names for screen readers, an ARIA progress bar for free shipping, inline form errors linked to fields, a live FAQ result count, reduced-motion support and WCAG AA contrast.

When editing, keep one <h1> per page, give each glaze a meaningful name (it's the image's text alternative) and pass a label to every validated field.

Building and deploying#

sh
pnpm build        # static site in out/
pnpm start        # preview out/

next.config.ts sets output: "export", images.unoptimized and basePath from DEMO_BASE_PATH (for sub-folder hosting, e.g. DEMO_BASE_PATH=/store pnpm build). Deploy out/ to any static host (Netlify, Cloudflare Pages, GitHub Pages, S3) or import the repository into Vercel. demo/, if present, is the freeframe.dev demo build and can be deleted.

TypeScript 7 setup#

@typescript/native provides TypeScript 7's tsc for pnpm typecheck; typescript is aliased to @typescript/typescript6 because typescript-eslint and parts of the Next.js toolchain need the TypeScript 6 API. Replace both with typescript@7 once typescript-eslint supports it. eslint.config.mjs sets settings.react.version, which ESLint 10 needs.

Troubleshooting#

  • useSearchParams() should be wrapped in a suspense boundary. Keep the Suspense wrappers in src/app/product/page.tsx and src/app/shop/page.tsx when you edit them.
  • A product link shows the Ridge Vase. The ?id= doesn't match any product id.
  • The cart forgets items. localStorage is blocked, or the product id changed.
  • Hydration warnings after editing the cart UI. Read the cart only through useCart(), and gate browser-only output with useHydrated().
  • Social previews have no image. Build with SITE_URL set to your domain.
  • ESLint crashes with getFilename is not a function. Keep settings.react.version in eslint.config.mjs.
  • next build fails after adding an API route. Remove output: "export".

License#

MIT. The code and artwork are free for personal and commercial use; the download includes the license text in LICENSE.txt. Fraunces and Inter are licensed under the SIL Open Font License.

Search documentation

Search the guides and every template's documentation