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).
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.mdCustomising#
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:
{
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:
<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.
Navigation, header and footer#
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.
FAQ search#
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:
<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:
<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 ownmetadata. metadataBasereadsSITE_URL(defaulthttps://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.tsxwithgenerateStaticParams()overPRODUCTSandgenerateMetadata()per product, and updateproductHref(). - Add
src/app/sitemap.tsandsrc/app/robots.tsfor 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#
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 theSuspensewrappers insrc/app/product/page.tsxandsrc/app/shop/page.tsxwhen you edit them.- A product link shows the Ridge Vase. The
?id=doesn't match any productid. - The cart forgets items.
localStorageis blocked, or the productidchanged. - Hydration warnings after editing the cart UI. Read the cart only through
useCart(), and gate browser-only output withuseHydrated(). - Social previews have no image. Build with
SITE_URLset to your domain. - ESLint crashes with
getFilename is not a function. Keepsettings.react.versionineslint.config.mjs. next buildfails after adding an API route. Removeoutput: "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.