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, a slide-out mini cart, a validated demo checkout, an about page and a searchable FAQ with a contact form. Product images are drawn in SVG from the product data, so there are no photos to replace before launch. This is the **Astro 7
- TypeScript + Tailwind CSS v4** version, version 1.0.2. It builds to static HTML files with no UI framework, and can be hosted anywhere. 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:4321With npm: npm install, then npm run dev.
| Command | What it does |
|---|---|
pnpm dev |
Development server at http://localhost:4321 with hot reload |
pnpm build |
Static site in dist/ |
pnpm preview |
Serve dist/ locally after a build |
pnpm check |
astro check: type-checks .astro files and TypeScript |
pnpm typecheck |
Generate Astro's types, then type-check with tsc (TypeScript 7) |
pnpm lint |
ESLint (TypeScript and Astro rules) |
pnpm build:demo |
Build for /demos/astro/market into demo/ (only used for the demo on freeframe.dev) |
Pages#
Every file in src/pages/ becomes one page. Each page passes a page prop to <Layout>, which becomes
<body data-page="…">; src/scripts/main.ts reads it to decide which page features to start, so keep it
when you copy a page.
| URL | File | page |
What's on it |
|---|---|---|---|
/ |
src/pages/index.astro |
home |
Hero with product art, shop by shape, bestsellers (rendered from featured products), story, reasons to buy, reviews, newsletter band |
/shop |
src/pages/shop.astro |
shop |
Product grid with category, price and sort filters and a live result count |
/product |
src/pages/product.astro |
product |
One product, chosen by ?id=: gallery with four views, glaze and size options, quantity, details, related products |
/cart |
src/pages/cart.astro |
cart |
Cart lines with quantity steppers, free-shipping progress, promo code, order summary |
/checkout |
src/pages/checkout.astro |
checkout |
Contact, shipping address, delivery method and card fields, order summary, confirmation |
/about |
src/pages/about.astro |
about |
Studio story, timeline, team and visit info |
/faq |
src/pages/faq.astro |
faq |
Searchable FAQ grouped by topic, and a contact form (#contact) |
| 404 | src/pages/404.astro |
404 |
Not-found page (most hosts show 404.html automatically) |
Project structure#
market/
├─ src/
│ ├─ pages/ one .astro file per page (see above)
│ ├─ layouts/
│ │ └─ Layout.astro <head> (title, meta, Open Graph, fonts), <body data-page>, header, footer, cart drawer, scripts
│ ├─ components/
│ │ ├─ SiteHeader.astro announcement bar, header, cart button, mobile menu
│ │ ├─ SiteFooter.astro footer with newsletter form and links
│ │ └─ CartDrawer.astro slide-out cart, its overlay and the toast
│ ├─ data/
│ │ ├─ products.ts product catalogue, categories, shipping/tax/promo settings, link helpers
│ │ ├─ faq.ts FAQ groups and questions
│ │ └─ nav.ts header, mobile menu and footer links
│ ├─ lib/
│ │ └─ site.ts site name, theme colour and the url() helper for links
│ ├─ scripts/
│ │ ├─ main.ts SVG product art, cart, page scripts, forms and FAQ search
│ │ └─ back-to-top.ts floating "Back to top" button
│ └─ styles/
│ └─ global.css Tailwind theme (brand tokens, fonts), components, animations
├─ public/img/ favicon.svg, og-image.png (1200×630), og-image.svg (its source)
├─ astro.config.mjs site URL, base path, output format, Tailwind plugin
├─ eslint.config.mjs, tsconfig.json
├─ package.json, pnpm-workspace.yaml, pnpm-lock.yaml
└─ README.md, DOCS.md, CHANGELOG.md, template.jsonsrc/scripts/main.ts is split into numbered sections: 1 Product catalogue, 2 SVG product art, 3 Helpers,
4 Cart, 5 Cart drawer and toast, 6 Header and mobile menu, 7 Home, 8 Shop, 9 Product page, 10 Cart page,
11 Checkout, 12 Forms and FAQ. It imports the catalogue from src/data/products.ts, so the shop, product,
cart and checkout pages all read the same data. The pages themselves are rendered at build time; the
product grids, product page details, art and cart are filled in the browser.
Customising#
Brand and colours#
All colours are tokens in the @theme block of src/styles/global.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 |
Tailwind rebuilds automatically in pnpm dev and pnpm build. Also update site.themeColor in
src/lib/site.ts (the browser theme colour) and the colours inside public/img/favicon.svg. Button
styles are .btn-primary, .btn-accent, .btn-outline and .btn-light.
Typography#
Fraunces (display, font-display) and Inter (body, font-sans) load from Google Fonts by a <link> in
src/layouts/Layout.astro. To change them, edit that <link> and the --font-display / --font-sans
tokens. To avoid Google Fonts, put the font files in public/fonts/, add @font-face rules to
global.css and remove the <link> and the two preconnect tags.
Content#
Products are PRODUCTS 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 |
Must be one of CATEGORIES (the type checker enforces it). The shop's category radios are built from CATEGORIES |
price |
Base price in dollars; each size adds its delta (which can be negative) |
shape |
vase, bud, mug, cup, tumbler, bowl, plate, pitcher or planter (drives the SVG art) |
badge |
Optional label such as Bestseller, New or Limited |
featured |
Optional; products with it appear in the home page bestsellers, sorted by the number |
glazes |
Colour options; color is the glaze colour in the art |
sizes |
Size options; the one with delta: 0 is preselected |
Store settings are exported from the same file:
| Constant | 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, the largest quantity per cart line) is in section 4 of src/scripts/main.ts. Prices are
formatted with Intl.NumberFormat("en-US", { style: "currency", currency: "USD" }) (the money helper in
section 3); change the locale and currency there. The "Free shipping on orders over $75" announcement bar
(SiteHeader.astro) and the shipping answers in src/data/faq.ts are plain text, so update them when you
change FREE_SHIPPING.
FAQ questions are FAQ in src/data/faq.ts: groups with an id (the section's anchor, e.g. /faq#care),
a title and items of { q, a }. The topic list beside the FAQ is built from the same groups.
Images and artwork#
Every product image is generated by art(product, glazeIndex, view) in section 2 of
src/scripts/main.ts, from the product's shape and glaze colour. Views are 0 front, 1 glaze close-up,
2 on a shelf and 3 a pair. Place art anywhere with:
<div class="art aspect-square overflow-hidden rounded-[1.75rem]" data-art="terrace-planter" data-glaze="1" data-view="2"></div>To use photos instead, add an image field to Product and each product, put the files in public/img/,
and return an <img src="${url(product.image)}"> from art() (or change productCard() and the product
gallery). The social image is public/img/og-image.png (1200×630); its source is og-image.svg.
Navigation, header and footer#
- Links come from
src/data/nav.ts:mainNav(header and mobile menu),footerShopandfooterHelp(footer columns) andsocials. Category links useshopHref("Mugs & Cups")fromproducts.ts, which writes/shop?category=Mugs%20%26%20Cups. - A
mainNavitem with akeygetsaria-current="page"when the page'scurrentprop matches it (current="shop","about"or"faq"). src/components/SiteHeader.astrois the announcement bar, header and mobile menu.src/components/SiteFooter.astrois the footer with its newsletter form.src/components/CartDrawer.astrois the cart drawer (#cart-drawer,#cart-overlay) and toast (#toast); the layout includes all three on every page.
To add a page, create src/pages/journal.astro:
---
import Layout from "../layouts/Layout.astro";
---
<Layout title="Journal — Kiln & Co." description="Notes from the studio." page="journal">
<main id="main" tabindex="-1" class="flex-1 focus:outline-none">
<section class="container-x py-16">
<h1 class="font-display text-5xl">Journal</h1>
</section>
</main>
</Layout>Then add a link to mainNav or a footer list. Always write internal links as href={url("/journal")}
(from src/lib/site.ts) so they keep working under a base path.
Features#
Cart and mini cart#
The cart is saved in localStorage and synced across tabs. Any element with data-cart-open opens the
slide-out cart; product cards add with data-add="<product id>". The drawer traps focus, closes with
Escape, the close button or the backdrop, and returns focus to where it was. Adding an item bumps the
header badge (data-cart-count) and shows a toast with a "View cart" button.
Each cart line (in the drawer and on the cart page) carries data-line="<id>:<glaze>:<size>". After −, + or
Remove, keyboard focus stays on the same button of the same line, and the line is updated in place so 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#
/shop filters by category (radio buttons, counts from data-count-for), maximum price (#price-max)
and sort (#sort: featured, price up/down, rating, name). The result count and heading update live, and
the category is kept in the URL (?category=Vases) so filtered views can be linked. On small screens the
filters fold behind #filters-toggle.
Product page#
/product?id=daily-mug shows that product (an unknown id shows the first product). The page title,
breadcrumb and text fill from the product. Glaze swatches recolour the gallery; sizes change the price;
the quantity stepper is limited to 1–99; four thumbnails switch views. Related products prefer the same
category.
Promo code and shipping progress#
On the cart page, WELCOME10 takes 10% off. The free-shipping progress bar appears in the drawer and on
the cart page.
Checkout#
The checkout validates every field, formats the card number and expiry as you type, checks the card
number with the Luhn algorithm (use 4242 4242 4242 4242) and rejects expired dates. On success it shows
a confirmation with a random KC- order number and clears the cart. No payment is processed. Replace
the setupValidation(form, …) callback in initCheckout() with your payment provider; the comment there
marks the spot. For real payments, use a hosted checkout such as Stripe Checkout or Shopify, and don't
collect card numbers in your own form.
FAQ search#
Typing in #faq-search filters the details[data-faq] items, opens matches, hides empty
[data-faq-group] sections and announces the number of answers.
Back to top button#
src/scripts/back-to-top.ts adds a button that appears after scrolling about one screen, returns to the
top (instantly with reduced motion), moves focus to #main, lifts above the toast and hides while an
in-page "Back to top" link is visible. Add data-back-to-top-avoid (lift above) or
data-back-to-top-hide (hide while covering) to your own fixed UI. To remove it, delete the file, its
import in Layout.astro and the .back-to-top styles in global.css.
Forms#
All forms are validated by setupValidation() in src/scripts/main.ts: errors appear on blur and submit,
the first invalid field is focused, and each error is linked with aria-describedby. Rules come from
attributes:
| Attribute | Rule |
|---|---|
required |
Must not be empty |
type="email" |
Must look like an email address |
minlength |
Minimum length |
data-label |
Name used in the error message |
data-validate |
card, expiry, cvc or postcode |
| Form | Where | Behaviour |
|---|---|---|
Newsletter (data-form="newsletter") |
Every footer, home page band | Replaced with a thank-you message |
Contact (data-form="contact") |
/faq#contact |
Replaced with a thank-you message |
Promo (#promo-form) |
/cart |
Applies WELCOME10 |
Checkout (#checkout-form) |
/checkout |
Shows the order confirmation |
Nothing is sent. To collect submissions, post the form in its onValid callback in section 12, for
example to Formspree:
$$<HTMLFormElement>('[data-form="contact"]').forEach((form) => {
setupValidation(form, () => {
fetch("https://formspree.io/f/your-id", {
method: "POST", body: new FormData(form), headers: { Accept: "application/json" },
}).then((res) => {
successSwap(form, res.ok ? "<p>Thank you for writing.</p>" : "<p>Something went wrong. Please email us.</p>");
});
});
});FormData only includes fields with a name. Every field already has one (email, name, topic,
order, message, and the checkout fields); keep a name on any field you add. To handle submissions
with your own code instead, add an Astro adapter (for example @astrojs/node, @astrojs/netlify or
@astrojs/vercel) and an API endpoint such as
src/pages/api/contact.ts with export const prerender = false, then post to it.
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 |
The cart is validated every time it is read (normalizeCart()): unknown products and bad entries are
dropped, glaze and size indexes are range-checked, quantities are clamped to 1–MAX_QTY, duplicate lines
are merged, and tampered data is overwritten with the cleaned version. Prices are never stored; totals are
always calculated from PRODUCTS. If storage is blocked (some private windows), the store still works, but
the cart is empty after a reload. To reset, run localStorage.removeItem("kiln-cart") in the browser
console.
SEO and sharing#
-
Each page passes
titleanddescriptionto<Layout>, which outputs<title>, the meta description,og:title,og:descriptionand asummary_large_imageTwitter card. The product page also setsdocument.titlefrom the product. -
The canonical URL,
og:urland the absoluteog:imageURL are built fromsiteinastro.config.mjs, which readsSITE_URL(defaulthttps://market.example.com). Set it to your domain when you build:shSITE_URL=https://example.com pnpm build -
The 404 page passes
noindex, which adds<meta name="robots" content="noindex">and leaves out the canonical URL. -
/productis one page filled in by JavaScript from?id=, so search engines that don't run scripts see the default content. If product search traffic matters, generate a page per product withgetStaticPaths()fromPRODUCTS(for examplesrc/pages/products/[id].astro) and write the product text into it. -
For a sitemap, add
@astrojs/sitemap(pnpm astro add sitemap); it uses the samesite. Add apublic/robots.txtthat points to it.
Accessibility#
Built in: a skip link, landmarks, visible focus rings, a focus-trapped cart drawer that closes with Escape,
a mobile menu with aria-expanded that closes with Escape, a live cart count in the cart button's label,
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. Content still shows without JavaScript except the parts drawn from the catalogue (art,
grids and the cart).
When editing, keep one <h1> per page, give each glaze a meaningful name (it's the image's text
alternative) and keep the data-label attributes on form fields.
Building and deploying#
pnpm build # static site in dist/
pnpm preview # preview dist/ locallydist/ is plain HTML, CSS and JavaScript. Pages are written as shop.html rather than
shop/index.html (build.format: "file" in astro.config.mjs), and links point to /shop without a
trailing slash. Netlify, Cloudflare Pages and GitHub Pages serve /shop from shop.html automatically;
Vercel does with cleanUrls (below).
- Netlify / Cloudflare Pages: build command
pnpm build, publish directorydist. - Vercel: import the repository; it detects Astro and static output needs no adapter. Add a
vercel.jsonwith{ "cleanUrls": true }so/shopservesshop.html. - GitHub Pages: use the official
Astro GitHub Action. For a project site
(
username.github.io/repo), build withDEMO_BASE_PATH=/repo. - Any static host: upload the contents of
dist/. If the host doesn't map/shoptoshop.html, changebuild.formatto"directory"inastro.config.mjs.
To serve the site from a sub-folder, set DEMO_BASE_PATH when building, e.g.
DEMO_BASE_PATH=/market pnpm build. It becomes Astro's base, and every link written with url(),
including the product and shop links the script builds, includes it.
Tailwind finds class names in src/ automatically, including the ones inside strings in
src/scripts/main.ts, as long as they're written in full. demo/ (if present) is the build used for the
freeframe.dev demo; you can delete it.
TypeScript 7 setup#
package.json installs two TypeScript packages on purpose. @typescript/native is TypeScript 7 and
provides the tsc that pnpm typecheck runs. typescript is an alias for @typescript/typescript6,
the TypeScript 6 API that astro check and typescript-eslint load. When those tools support TypeScript
7, replace both with a single typescript@7. tsconfig.json extends Astro's strict preset.
Troubleshooting#
- A new class has no effect. Write class names in full (not built from pieces) so Tailwind can find them.
- A product shows the Ridge Vase instead. The
?id=doesn't match any productid. - A category filter shows nothing. The product's
categorymust be one ofCATEGORIES. - The cart forgets items.
localStorageis blocked, or the product'sidchanged. - Links or images break when served from a sub-folder. Build with
DEMO_BASE_PATH, and write internal links withurl()(in.astrofiles and insrc/scripts/main.ts) rather than plain"/..."strings. - Words run together after editing. Astro removes line breaks between text and a following inline
element. Keep the text and the element on one line, or add
{" "}. - Social previews show the wrong domain or no image. Set
SITE_URLto your domain at build time. - Totals look wrong after changing prices. Totals always use current
PRODUCTSprices; carts don't store prices, so this is expected.
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.