Freeframe

Documentation

Market

v1.0.1Updated Nov 11, 2026A complete online store for a handmade home goods brand, with a working cart and checkout flow.

Overview#

Market is a complete storefront for Kiln & Co., a fictional handmade-ceramics studio in Tacoma, Washington: 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 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 Nuxt 4 + Vue 3 + TypeScript + Tailwind CSS v4 version, version 1.0.1. It generates to static files. See it live at freeframe.dev/templates/market.

Quick start#

Requirements: Node.js 24 or newer and pnpm.

sh
pnpm install
pnpm dev          # http://localhost:3000
Command What it does
pnpm dev Development server at http://localhost:3000
pnpm generate Static site in .output/public/
pnpm build Node.js server build in .output/ (pages are still prerendered)
pnpm preview Serve the last build locally
pnpm lint ESLint (flat config from @nuxt/eslint)
pnpm typecheck nuxt prepare, then type-check with vue-tsc
pnpm build:demo Build for /demos/nuxt/market into demo/ (only used for the freeframe.dev demo)

pnpm install runs nuxt prepare, which writes the generated types and ESLint config to .nuxt/.

Pages#

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

There is one product page: the product is read from ?id= in the browser (and the shop category from ?category=) once the page is mounted. The generated HTML shows the first product and every product until then.

Project structure#

market/
├─ app/
│  ├─ app.vue                  <NuxtLayout><NuxtPage /></NuxtLayout>
│  ├─ error.vue                404 page
│  ├─ router.options.ts        query strings written with encodeURIComponent
│  ├─ layouts/default.vue      skip link, shipping bar, header, footer, mini cart, toast, back to top; reads the cart
│  ├─ pages/                   one file per page (index, shop, product, cart, checkout, about, faq)
│  ├─ components/
│  │  ├─ SiteHeader.vue, SiteFooter.vue, KilnLogo.vue
│  │  ├─ ProductCard.vue, ArtSvg.vue, RatingStars.vue, ClayStars.vue, CheckIcon.vue, PlusIcon.vue
│  │  ├─ CartDrawer.vue, CartToast.vue, QtyStepper.vue, ShippingProgress.vue
│  │  ├─ NewsletterForm.vue, ContactForm.vue
│  │  └─ BackToTop.vue
│  ├─ composables/
│  │  ├─ useCart.ts            cart state (localStorage), changes and totals
│  │  ├─ useCartUI.ts          mini cart, toast and addToCart()
│  │  ├─ useFormValidation.ts  form validation for the demo forms
│  │  ├─ usePageSeo.ts         title, description, canonical and Open Graph tags
│  │  └─ useYear.ts            copyright year
│  ├─ data/
│  │  ├─ products.ts           catalog, categories, shipping/tax/promo settings, link helpers
│  │  ├─ faq.ts                FAQ groups and answers
│  │  └─ nav.ts                header, menu and footer links
│  ├─ utils/
│  │  ├─ art.ts                SVG product illustrations
│  │  ├─ validation.ts         field validation messages
│  │  ├─ keepLineFocus.ts      keyboard focus on cart lines
│  │  ├─ format.ts             fmt() prices, makeRef() references
│  │  └─ safeStorage.ts        localStorage with an in-memory fallback
│  ├─ plugins/
│  │  ├─ reveal.client.ts      scroll reveals
│  │  └─ stable-build.server.ts  reproducible builds
│  └─ assets/
│     ├─ css/main.css          Tailwind entry: fonts, brand tokens and component classes
│     └─ fonts/                Fraunces and Inter (Latin .woff2) with their OFL licenses
├─ public/img/                 favicon.svg, og-image.png, og-image.svg
├─ nuxt.config.ts, eslint.config.mjs, tsconfig.json
├─ package.json, pnpm-workspace.yaml, pnpm-lock.yaml
└─ README.md, DOCS.md, CHANGELOG.md

Customizing#

Brand and colors#

All colors are tokens in the @theme block of app/assets/css/main.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

For example, a blue accent:

css
@theme {
  --color-clay: #2f5d8a;
  --color-clay-dark: #1f4166;
}

Also update the theme-color meta in nuxt.config.ts and the colors in public/img/favicon.svg. Button classes are .btn-primary, .btn-accent, .btn-outline and .btn-light.

Typography#

Fraunces (font-display, with its optical-size axis) and Inter (font-sans) are self-hosted from the Latin .woff2 files in app/assets/fonts/, declared with @font-face at the top of main.css: 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 license) to app/assets/fonts/, change the @font-face rule and update --font-display or --font-sans in @theme.

Content#

Products are the PRODUCTS array in app/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 · 9.5 in", delta: 0 }, { name: "Large · 12.5 in", delta: 26 }],
  details: ["Stoneware, fired to 2300°F", "Fully glazed inside — watertight"],
  dims: "Medium: 9.5 in tall, 5.5 in wide. Large: 12.5 in tall, 7 in 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 Estimated sales tax, applied after the discount. Washington taxes online orders by delivery address, so for a real store use your payment provider's tax tool and check current rules with the Washington State Department of Revenue.
PROMO { code: "WELCOME10", rate: 0.1 } The demo promo code

MAX_QTY (99) is in app/composables/useCart.ts. Prices are formatted by fmt() in app/utils/format.ts (en-US, USD); change the locale and currency there. The "Free shipping on orders over $75" bar in app/layouts/default.vue, the promises on the shop and product pages and the FAQ answers in app/data/faq.ts are plain text, so update them when you change the settings. Build links with productHref(id) and shopHref(category).

The FAQ is FAQ in app/data/faq.ts: groups with an id (the anchor, as in /faq#care), a title and items of { q, a }. Static page copy (the story, timeline, team and reviews) is written in the page files.

Images and artwork#

art(product, glazeIndex, view) in app/utils/art.ts draws each product from its shape and glaze color. Views (ArtView) are 0 front, 1 glaze close-up, 2 on a shelf and 3 a pair. ArtSvg.vue draws it as an <svg> that fills its parent; give the parent the art class and its size and rounding:

html
<div class="art aspect-square overflow-hidden rounded-[1.75rem]"><ArtSvg product="terrace-planter" :glaze="1" :view="2" /></div>

The drawings are 4:5 (400 × 500). To use photos, add an image field to Product and render an <img> (with object-cover) in place of <ArtSvg> in ProductCard.vue, product.vue, the cart lines and the checkout summary. The social image is public/img/og-image.png (1200 × 630, drawn from og-image.svg), and the favicon is public/img/favicon.svg.

app/data/nav.ts holds NAV (the desktop and phone menus), FOOTER_SHOP and FOOTER_HELP. app/components/SiteHeader.vue marks the current page with aria-current="page" for items with a path. app/components/SiteFooter.vue holds the newsletter form and the social links, which point to #; replace them with your profiles. Add a page by creating app/pages/<name>.vue and adding its route to routes in nuxt.config.ts.

Features#

Cart and mini cart#

app/composables/useCart.ts keeps one shared cart: useCart() returns { items, promo, ready, count }, so the header badge, drawer, cart page and checkout always agree. Change it with cart.add, cart.setQty, cart.remove, cart.clear and cart.setPromo; read totals with cartTotals(method) and a line's product, glaze, size and prices with cartLine(item). loadCart() reads the saved cart; the layout calls it once the page is mounted and again when another tab changes it (the storage event). The generated HTML always shows an empty cart, and ready is false until the browser cart has been read, so the cart and checkout pages don't flash "empty".

app/composables/useCartUI.ts adds the UI around it: addToCart(id, glaze, size, qty) adds, replays the badge "pop" and shows a toast with "View cart"; quickAdd(id) is the cards' "Quick add" (first glaze, standard size); openDrawer, closeDrawer and showToast do what they say. CartDrawer.vue traps focus, closes with Escape, the overlay or a link, 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() (app/utils/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).

Shop filters#

app/pages/shop.vue filters by category, maximum price (#price-max, $20 to $100) 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) with history.replaceState, so filtered views can be linked. An unknown category shows everything. Header and footer links to the shop while it is open start over with the linked category. Below 1024px the "Filters" button shows and hides the filters.

Product page#

The product comes from ?id= (an unknown id shows the first product), and the tab title names it. Glaze swatches recolor the gallery, sizes change the price, quantity is limited to 1–99, four thumbnails (aria-pressed) switch views, "Add to cart" shows "Added" for a moment, and "You may also like" shows four related products, same category first. Opening another product from the page starts with fresh options at the top of the page.

Promo code and shipping progress#

WELCOME10 takes 10% off on the cart page (any letter case). ShippingProgress.vue shows how far the subtotal is from free shipping, in the drawer and on the cart page.

Checkout#

app/pages/checkout.vue validates every field, formats the card number and expiration as you type, checks the card with the Luhn algorithm (use 4242 4242 4242 4242) and rejects expired dates. US addresses need a 5-digit ZIP code (optional +4); other countries get a looser postal code check. On success it shows an order number (KC- and four characters, made in the browser by makeRef() in app/utils/format.ts), clears the cart and moves focus to the confirmation. The confirmation promises an email and shipping within 2–3 business days, like the FAQ and the checkout banner: change them together if yours differ, and show your payment provider's order number instead if it returns one. The payment section says payments are in test mode and gives the test card number; remove that note when you connect a real provider.

No payment is processed. The useFormValidation() callback in checkout.vue 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 add a Nuxt server route (server/api/checkout.post.ts) and deploy with pnpm build, or call your existing backend.

app/pages/faq.vue filters the questions as you type, opens the matches, hides empty groups and announces the number of answers ("3 answers found"). Clearing the search closes them again.

Header, reveals and back to top#

The header gains a line once the page scrolls. Below 1024px the menu button opens the phone menu; Escape closes it and returns focus to the button, and it closes on a page change or when the window gets wider. app/plugins/reveal.client.ts fades .reveal elements in as they scroll into view (everything shows at once with reduced motion). Don't put .reveal on an element that also has a :class binding: Vue would drop the is-visible class the next time it updates it.

BackToTop.vue appears after scrolling about one screen, returns to the top (instantly with reduced motion), moves focus to #main and lifts itself above the add-to-cart toast. Remove <BackToTop /> from app/layouts/default.vue (and the .back-to-top styles in main.css) to drop it.

Things that read the browser#

The cart, the URL (?id=, ?category=), the clock (card expiration, copyright year in useYear()) and matchMedia are only read in onMounted() or event handlers, so the generated HTML is the same for every visitor and hydration never mismatches. Keep new browser-only code there too.

Forms#

The newsletter, contact and checkout forms use useFormValidation(onValid) from app/composables/useFormValidation.ts. Fields describe themselves with attributes: required, type="email", minlength, data-label (the name in messages, as in "Email is required.") and data-validate (card, expiry, cvc or postcode). An error shows when a filled-in field loses focus and updates while typing; on submit every field is checked, the first problem gets focus and status reads "Please fix the highlighted fields." Otherwise onValid(form) runs.

html
<form novalidate @submit="onSubmit" @focusout="onFocusout" @input="onInput">
  <label for="c-email" class="field-label">Email</label>
  <input id="c-email" name="email" type="email" required data-label="Email" class="field" v-bind="described('c-email')">
  <p v-if="'c-email' in errors" id="c-email-error" class="field-error">{{ errors["c-email"] }}</p>
  <button type="submit" class="btn-primary">Send</button>
  <p data-form-status role="alert">{{ status }}</p>
</form>
Form File Behavior
Newsletter NewsletterForm.vue (variant="band" on the home page, "footer" in the footer) Shows a thank-you message
Contact ContactForm.vue Shows a thank-you message with a reference
Promo code app/pages/cart.vue Applies WELCOME10
Checkout app/pages/checkout.vue Shows the order confirmation

The newsletter thank-you asks subscribers to look out for a confirmation email, and the contact one promises a reply within one business day (as the FAQ says) and shows a reference (KC- and four characters). Change them to match what really happens. Nothing is sent until you connect the forms, although their messages read as if it had been. To collect submissions, post the form in the callback, for example in ContactForm.vue:

ts
const { errors, status, described, onFocusout, onInput, onSubmit } = useFormValidation(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) return;
  reference.value = makeRef("KC");
  await nextTick();
  box.value?.focus();
});

FormData only includes fields with a name. Every field already has one (email, name, topic, order, message and the checkout fields); keep one on any field you add. With the static build, post to an external service; to handle it inside the project, add a server route such as server/api/contact.post.ts and deploy with pnpm build instead of pnpm generate.

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 whenever it is read: 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. Storage goes through safeStorage (app/utils/safeStorage.ts): if the browser blocks it, the cart still works but doesn't survive a reload. To reset, run localStorage.removeItem("kiln-cart") and localStorage.removeItem("kiln-promo") in the browser console, or clear the site's data.

SEO and sharing#

  • Each page calls usePageSeo({ title, description }) (app/composables/usePageSeo.ts), which sets the title, description, canonical link, Open Graph tags and the Twitter card. The 404 page passes noindex: true.
  • Canonical, og:url and og:image are absolute: SITE_URL plus the base path. Build with your domain: SITE_URL=https://www.example.com pnpm generate (the default is https://kilnandco.example).
  • The product page is one static page, so search engines see the generic product description. If product search traffic matters, add app/pages/product/[id].vue, list each /product/<id> in routes in nuxt.config.ts, and update productHref().
  • Add a public/robots.txt and a sitemap for production.

Accessibility#

Built in: a skip link, landmarks, visible focus rings, a focus-trapped cart drawer that closes with Escape, a phone menu that closes with Escape, 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 their fields with aria-describedby, a live result count on the shop, 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 give every validated field a data-label and an error <p> after it.

Building and deploying#

sh
pnpm generate     # static site in .output/public/
pnpm preview      # serve it locally

Deploy .output/public/ to any static host (Netlify, Cloudflare Pages, GitHub Pages, S3) or import the repository into Vercel or Netlify, which detect Nuxt. Pages are written as shop.html, product.html and so on; most static hosts serve /shop from shop.html automatically, and 404.html is the not-found page.

For a site in a sub-folder, set DEMO_BASE_PATH when building (DEMO_BASE_PATH=/store pnpm generate): it becomes app.baseURL in nuxt.config.ts. demo/, if present, is the freeframe.dev demo build and can be deleted.

TypeScript setup#

tsconfig.json only references the configs Nuxt writes to .nuxt/, so pnpm typecheck checks the app and config with Nuxt's strict settings. typescript is aliased to @typescript/typescript6, the TypeScript 6 API that vue-tsc needs.

Troubleshooting#

  • pnpm lint can't find .nuxt/eslint.config.mjs. Run pnpm install (or pnpm typecheck) first; it runs nuxt prepare.
  • 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. Something reads the cart, the URL or the clock while rendering; move it into onMounted() or an event handler.
  • A new page is missing from .output/public. Add its route to routes in nuxt.config.ts.
  • Social previews have no image. Build with SITE_URL set to your domain.
  • Links break in a sub-folder. Build with DEMO_BASE_PATH set to the folder.

License#

Freeframe Commercial License: use it in unlimited websites and apps for yourself and your clients; it may not be resold or redistributed as a template. The full terms, with your licensee details, are in LICENSE.txt and at https://freeframe.dev/license#commercial. Third-party components keep their own licenses. Fraunces and Inter are licensed under the SIL Open Font License. Kiln & Co., its products and the people quoted are fictional.

Search documentation

Search the guides and every template's documentation