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.
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.mdCustomizing#
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:
@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:
{
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:
<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.
Navigation, header and footer#
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.
FAQ search#
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.
<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:
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 passesnoindex: true. - Canonical,
og:urlandog:imageare absolute:SITE_URLplus the base path. Build with your domain:SITE_URL=https://www.example.com pnpm generate(the default ishttps://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>inroutesinnuxt.config.ts, and updateproductHref(). - Add a
public/robots.txtand 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#
pnpm generate # static site in .output/public/
pnpm preview # serve it locallyDeploy .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 lintcan't find.nuxt/eslint.config.mjs. Runpnpm install(orpnpm typecheck) first; it runsnuxt prepare.- 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. 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 toroutesinnuxt.config.ts. - Social previews have no image. Build with
SITE_URLset to your domain. - Links break in a sub-folder. Build with
DEMO_BASE_PATHset 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.