Freeframe

Documentation

Bloom

v1.0.4Updated Oct 18, 2026A calm, elegant website for a hair salon and day spa, with online booking and gift cards.

Overview#

Bloom is a nine-page website for a hair salon and day spa. The demo brand is Bloom Hair & Spa, a fictional independent salon at 48 Orchard Lane, Bristol. Highlights are a five-step booking wizard that works out real time slots from opening hours, staff working days and treatment length, level-based pricing (stylist, senior, director), a gift card builder with a live preview, filterable service and team pages, a gallery with saved looks and a lightbox, a before/after slider and a live "Open now" status. 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/bloom.

Quick start#

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

sh
pnpm install
pnpm dev          # http://localhost:4321

With 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/bloom into demo/ (only used for the demo on freeframe.dev)

Pages#

Every file in src/pages/ becomes one page.

URL File Notable sections and features
/ src/pages/index.astro Hero, signature treatments, before/after slider, team, reviews, gift cards, newsletter
/services src/pages/services.astro Full menu by category, category filter with count, "Book" links per treatment
/team src/pages/team.astro Specialty filter, working days, pricing levels, "Book with" links
/book src/pages/book.astro Five-step booking wizard with a running summary
/gift-cards src/pages/gift-cards.astro Amount, design, message and delivery date with a live card preview
/gallery src/pages/gallery.astro Look filters, saved looks, lightbox with "Book with" link
/about src/pages/about.astro Story, values and milestones
/contact src/pages/contact.astro Map, opening hours with today highlighted, open-now status, contact form
404 src/pages/404.astro Not-found page (most hosts show 404.html automatically)

Every page shares the header, the footer (with an open-now badge and the newsletter form) and the back-to-top button.

Project structure#

bloom/
├─ src/
│  ├─ pages/                 one .astro file per page (see above)
│  ├─ layouts/
│  │  └─ Layout.astro        <head> (title, meta, Open Graph, fonts), skip link, header, footer, scripts
│  ├─ components/
│  │  ├─ SiteHeader.astro    wordmark, desktop navigation, "Book now", mobile menu
│  │  └─ SiteFooter.astro    contact details, open-now badge, newsletter, links
│  ├─ data/
│  │  └─ bloom.ts            salon details, hours, services, levels, team, looks, gift card options
│  ├─ lib/
│  │  └─ site.ts             site name, theme colour and the url() helper for links
│  ├─ scripts/
│  │  ├─ main.ts             all interactivity, in numbered sections
│  │  └─ back-to-top.ts      floating "Back to top" button
│  └─ styles/
│     └─ global.css          Tailwind theme (@theme tokens) and component styles
├─ public/img/
│  ├─ looks/                 gallery looks, one SVG per look id
│  ├─ team/                  team portraits, one SVG per team member id
│  ├─ before.svg, after.svg, salon.svg, map.svg
│  ├─ favicon.svg
│  └─ og-image.svg, og-image.png
├─ 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.json

main.ts is organised in numbered sections (listed in its header comment): data, helpers, header, reveal, opening hours, before/after, services, team, gallery, booking, gift cards and forms. Layout.astro imports it and back-to-top.ts, and Astro bundles them into one small module.

Customising#

Brand and colours#

The design tokens are in the @theme block at the top of src/styles/global.css. Tailwind turns each --color-* token into utilities such as bg-shell, text-plum and border-line.

Token Default Used for
--color-shell #faf6f2 Page background
--color-cream #fffdfb Cards
--color-blush #f3e4dd Soft panels
--color-rose #e8cfc4 Deeper panels
--color-line #e3d3ca Borders
--color-ink #2a2127 Text
--color-mute #685a5f Secondary text (meets AA on shell, cream and blush)
--color-plum, --color-plum-dark #7a3e5b, #5e2c45 Accent: buttons, links, selected states
--color-sage #5a6954 Secondary accent, open-now badge
--color-gold #a5804f Decorative details
--color-error #a23a3a Form errors

To change the accent:

css
@theme {
  --color-plum: #2f5d62;
  --color-plum-dark: #1f4044;
}

The gift card designs (.gift-petal, .gift-sage, .gift-noir in global.css) and the SVG artwork have their own colours; update them too, along with accent in template.json and themeColor in src/lib/site.ts.

Typography#

Two Google Fonts are loaded by the <link rel="stylesheet"> in src/layouts/Layout.astro:

  • --font-display is Bodoni Moda (headings, prices, the wordmark), used through the font-display class.
  • --font-sans is Jost (body text).

To swap a font, change the Google Fonts URL in the layout and the family name in @theme:

css
@theme {
  --font-display: "Cormorant Garamond", "Didot", ui-serif, Georgia, serif;
}

Content#

Page text is markup in src/pages/. The data the scripts use lives in src/data/bloom.ts (the same data as the Next.js version), typed with Service, Member, Look and Hours:

Export What it holds Used by the script for
SALON Name, address, phone, email, currency (ISO code, e.g. GBP), locale (e.g. en-GB) Price formatting in the booking wizard and gift cards
HOURS Seven entries, day 0 (Sunday) to 6, with open/close as "HH:MM" or null when closed Open-now status, booking slots
CATEGORIES Service categories: id, label, blurb Services filter count label
SERVICES id, cat, name, mins, price, desc, optional signature Booking durations, prices, summary
LEVELS stylist (×1), senior (×1.2), director (×1.4) with label and blurb Level-based pricing
TEAM id, name, role, level, cats, days, bio, specialties Booking stylists, availability, lightbox credit
LOOKS id, cat, title, by (team id), palette, kind Gallery lightbox
LOOK_CATEGORIES Gallery filter ids and labels Lightbox category label
GIFT_AMOUNTS, GIFT_DESIGNS Preset amounts and designs Design labels in the gift card preview

main.ts gathers them into one BLOOM object (BLOOM.services, BLOOM.team, …). The pages are written out as markup, not generated from the data: the service menu, the booking checkboxes, the team cards, the gallery and the hours tables are in the .astro files. When you change the data, change the markup to match.

To add a treatment, for example a fringe trim:

  1. Add it to SERVICES in src/data/bloom.ts:
ts
{
  "id": "fringe-trim",
  "cat": "hair",
  "name": "Fringe trim",
  "mins": 15,
  "price": 12,
  "desc": "A quick tidy between cuts."
},
  1. Add a row to the matching data-service-group section in services.astro, with a link to url("/book?service=fringe-trim").
  2. Add a checkbox to step 1 of book.astro inside the right category <fieldset>, copying an existing one:
html
<label class="option"><input type="checkbox" name="services" value="fringe-trim" class="option-input"><span class="option-body">…</span></label>

The checkbox value must equal the service id; the script reads name, duration and price from the data.

To add a team member, add an entry to TEAM (days uses 0 for Sunday to 6 for Saturday; cats lists the categories they can do), add public/img/team/<id>.svg (the booking wizard loads the portrait from that path), and add their card to team.astro with data-team-member data-cats="hair colour".

Contact details are in SALON, the footer (SiteFooter.astro) and the contact and about pages. Search src/ for +44 117 496 0123, +441174960123 (used in tel: links), hello@bloomhairspa.example and 48 Orchard Lane.

Images and artwork#

All artwork is SVG in public/img/. Replace any file with a photo (JPG or WebP) at the same aspect ratio:

Files Size Ratio
looks/<look id>.svg (12 files) 480×600 4:5
team/<member id>.svg (6 files) 400×480 5:6
before.svg, after.svg 640×520 Both must be the same size
salon.svg 640×480 4:3
map.svg 640×420 Illustrative map on the contact page
favicon.svg 32×32 1:1

In pages, image paths are written with url(), for example src={url("/img/salon.svg")}. Two paths are built in main.ts: the lightbox loads BASE + '/img/looks/' + look.id + '.svg' and the stylist list in the booking wizard loads BASE + '/img/team/' + o.id + '.svg'. If you switch to JPGs, change those two strings as well as the markup.

og-image.png (1200×630) is the social sharing image; its editable source is og-image.svg. Export it again as a 1200×630 PNG after changing the SVG. Replace map.svg with a real map embed or a static map image of your location.

The header and mobile menu are src/components/SiteHeader.astro and the footer is src/components/SiteFooter.astro; every page gets them from the layout. A header link is marked aria-current="page" when the page's current prop matches, for example aria-current={current === "team" ? "page" : undefined}.

To add a page, create src/pages/prices.astro:

html
---
import Layout from "../layouts/Layout.astro";
---

<Layout title="Price list — Bloom Hair & Spa" description="Every treatment and price." page="prices" current="prices">
  <section class="mx-auto max-w-6xl px-5 py-20">
    <h1 class="font-display text-5xl">Price list</h1>
  </section>
</Layout>

Then add links to the desktop <nav> and the #mobile-menu list in SiteHeader.astro, and to the footer. Write internal links as href={url("/prices")} (from src/lib/site.ts) so they work under a base path.

The menu button (#menu-toggle) toggles the hidden attribute on #mobile-menu and aria-expanded on itself. Escape closes the menu and returns focus to the button; the menu also closes on link click and at the desktop breakpoint.

Features#

All feature code is in src/scripts/main.ts.

Booking wizard#

/book holds [data-booking] with <form id="booking-form">, five <section data-step="1"> to data-step="5" steps, a progress list (.steps with data-step-indicator) and a running summary (<aside class="booking-summary">).

Step What happens Validation
1. Treatments Checkboxes name="services" At least one
2. Stylist Radio list built by renderStylists(): "Anyone available" plus everyone who can do all the chosen treatments None
3. Date and time 21 day buttons from today (renderDates()), then time buttons (renderSlots()) A time is chosen
4. Your details Name, email, mobile, notes, first visit, remember details, cancellation policy Name 2+ characters, valid email, 10–15 digit phone, policy ticked
5. Review Rows with "Change" buttons that jump back to a step None

"Continue" validates the current step and moves on; on step 5 it becomes "Confirm booking" and calls confirm(), which hides the wizard and shows [data-booking-done] with the first name, a summary and a demo reference (BLM- plus four random characters). Each step's heading gets focus when it opens, and the active indicator has aria-current="step". "Book something else" ([data-restart]) starts again.

Preselect from links:

  • /book?service=balayage ticks a treatment (must be a service id).
  • /book?stylist=amara preselects a team member (must be a team id). If they can't do the chosen treatments, the wizard falls back to "Anyone available".

Changing the treatments clears the chosen date and time.

Time slots and availability#

slotsFor(date, serviceIds, stylist, now) in section 2 of main.ts returns the bookable start times. It gives exactly the same times as the HTML and Next.js versions for the same data and clock. The rules:

  • Opening hours: from BLOOM.hours[date.getDay()]. A day with open: null has no slots (Monday in the demo).
  • Length: the sum of the chosen services' mins. A slot must finish by closing time, so a 180-minute balayage on a Thursday (09:00–20:00) can start at 17:00 at the latest.
  • Step: start times every 15 minutes.
  • Today: the earliest start is at least an hour from now, rounded up to the next 15 minutes (at 10:07 the first slot is 11:15).
  • Who can do it: eligibleMembers() returns team members whose cats include every chosen category. With "Anyone available", each start time is offered if any eligible member is free; the slot remembers who (data-member), and the summary and confirmation name them.
  • Working days: a member is only considered on days listed in their days.
  • Diary: isBusy(memberId, key, block) marks about one in five 30-minute blocks as already booked, using a deterministic hash so the same diary appears on every visit. A member is free only if every block the treatment touches is free.
  • Mixed treatments: if nobody can do all the chosen categories (for example a cut and a gel manicure), the wizard books "Best available team" back to back. teamPlan() takes the treatments in menu order and gives each one to the first team member (in TEAM order) who does that category, works that day and is free for the whole treatment; a start time is only offered if every treatment can be covered. The slot keeps the plan in team (one member id per treatment).

To connect real availability, replace isBusy() (or memberFree()) with a lookup of your booking system's busy times:

ts
// busy = { "theo|2026-10-08": [20, 21, 26] } -> 30-minute block numbers (block 20 = 10:00)
const busy: Record<string, number[]> = {};
function isBusy(memberId: string, key: string, block: number) {
  return (busy[memberId + "|" + key] ?? []).includes(block);
}

Fill busy before the wizard renders step 3, for example from a JSON endpoint. To change the slot interval, edit the t += 15 loop in slotsFor(); to change the notice period, edit the + 60 in the earliest line; to show more days, change the 21 in renderDates().

Level-based pricing#

priceFor(service, levelId) multiplies the base price by the team member's level multiplier, but only for the hair and colour categories. Nails, skin and massage cost the same with anyone. Results are rounded to whole pounds.

Treatment Stylist Senior (×1.2) Director (×1.4)
Signature cut & finish (£68) £68 £82 £95
Balayage (£210) £210 £252 £294

Until a stylist or a time (and so a team member) is chosen, the summary shows hair and colour prices and the total as "from" the stylist price. To change the levels, edit LEVELS; to make another category scale with level, add it to the check in priceFor() and to the matching scaled checks in paintSummary(). The prices printed on services.astro and team.astro are markup; update them by hand.

Opening hours and open now#

Section 5 of main.ts reads BLOOM.hours:

  • Every [data-open-now] badge gets text such as "Open now · until 19:00" or "Closed · opens tomorrow at 09:00" in its [data-open-text] child, and the class is-closed when closed.
  • In any .hours-table, the <tr data-day="N"> for today gets the class is-today and aria-current="date".

It runs on load and every minute, using the visitor's clock and time zone. The rows in the hours tables are markup; keep them in step with HOURS.

Services filter#

On /services, [data-services] contains filter buttons with data-filter (all, hair, colour, nails, skin, massage) and one <section data-service-group="hair"> per category. Choosing a filter hides the other groups, sets aria-pressed and updates [data-services-count] ("5 treatments in Hair"), a polite live region. Link to /services?category=colour to open with a category selected.

Team filter#

On /team, [data-team] has buttons with data-team-filter and cards with data-team-member and data-cats="hair colour" (space-separated). The count ([data-team-count]) is a live region. Each card links to /book?stylist=<id>.

On /gallery, [data-gallery] contains:

  • filter buttons with data-look-filter (all, colour, cuts, bridal, nails, skin),
  • a "Saved" toggle (data-saved-toggle) and its count (data-saved-count),
  • one <li data-look="<id>" data-cat="<cat>"> per look, with an open button (data-look-open="N", the index into LOOKS) and a heart button (data-save="<id>", aria-pressed),
  • a count (data-gallery-count, live region) and an empty message (data-gallery-empty) for when no saved looks match,
  • a <dialog data-lightbox>.

Saved looks are stored in localStorage (see Saved data) and stay in sync across tabs through the storage event. The lightbox shows the look, who did it, its category, "N of M" and a "Book with" link to /book?stylist=<id>. The left and right arrow keys and the buttons step through the visible looks; Escape or a backdrop click closes it and focus returns to the look.

To add a look, add it to LOOKS (with by set to a team id and cat to a LOOK_CATEGORIES id), add public/img/looks/<id>.svg, and add a <li> to gallery.astro with data-look-open set to its index in LOOKS.

Gift cards#

On /gift-cards, [data-gift] contains <form id="gift-form"> and a preview ([data-gift-preview]):

  • Amount: radios name="amount" (50, 75, 100, 150, 250 or custom). A custom amount must be a whole number from £25 to £500.
  • Design: radios name="design" (petal, sage, noir); the preview gets the class gift-<design>.
  • To, recipient email, from, your email, message: the message has a 160-character counter.
  • Delivery: now or later; later shows a date input limited to today through one year ahead.

The preview and the total update as you type, and [data-preview-desc] gives screen readers a text description of the card. On a valid submit, the script shows [data-gift-done] with a demo code (BLOOM-XXXX-XXXX) and a summary. Nothing is charged; see Forms for taking payment. The preset amounts are radios in gift-cards.astro (not read from GIFT_AMOUNTS); edit the radios to change them.

Before/after slider#

On the home page, [data-ba] stacks the before and after images with a native range input ([data-ba-range]) on top. Its value sets --pos, and aria-valuetext reads "Showing 50% before". It works with mouse, touch and the arrow keys.

Header and scroll reveals#

The header gets is-scrolled after 8px. Elements with reveal fade in as they enter the viewport, with a short stagger; anything scrolled past is revealed too, so fast jumps never leave content hidden. Reveals are skipped with reduced motion.

Back to top#

src/scripts/back-to-top.ts adds a round button after about one screen of scrolling. It scrolls to the top (instantly with reduced motion) and moves focus to the main content. It lifts above toasts and anything marked data-back-to-top-avoid, hides while it would cover a fixed or sticky element marked data-back-to-top-hide, and hides while an in-page link whose text contains "Back to top" is on screen. Restyle it with .back-to-top in global.css; to remove it, delete the file, its import in Layout.astro and those styles.

Forms#

Form Where Validation
Booking (#booking-form) /book Per step, see Booking wizard
Gift card (#gift-form) /gift-cards Amount, names, both emails, delivery date
Contact (#contact-form) /contact Name 2+ characters, valid email, message 20+ characters
Newsletter ([data-newsletter]) Every page footer Valid email

Errors are written into the element with the matching data-error="<field name>" and the field gets aria-invalid="true"; the first invalid field receives focus. Errors clear as you type. The contact form accepts /contact?topic=careers to preselect a topic (appointments, gift-cards, events, careers, other).

None of the forms send anything. Each has a comment in main.ts at the point to connect it:

  • booking: "Demo only. Send { services, stylist, date, time, details } to your booking system here." in confirm(),
  • gift card: "Demo only: create a payment session with your provider (e.g. Stripe Checkout) here.",
  • contact: "Send new FormData(contact) to your endpoint here.",
  • newsletter: before nf.reset().

Connecting to Formspree#

For the contact form, add an action in contact.astro and post it where the comment is:

html
<form id="contact-form" class="mt-8 grid gap-5 sm:grid-cols-2" action="https://formspree.io/f/your-form-id" method="POST" novalidate>
ts
// Send new FormData(contact) to your endpoint here.
const ok = $('[data-form-success]', contact);
fetch(contact.action, { method: "POST", body: new FormData(contact), headers: { Accept: "application/json" } })
  .then((res) => {
    if (!res.ok) throw new Error("Send failed");
    contact.reset();
    ok.hidden = false;
    ok.focus();
  })
  .catch(() => cErr("message", "Sorry, that did not send. Please call or email us."));

Remove the original contact.reset() and success lines that follow the comment, since the then does that.

For a booking request by email, post the wizard state from confirm():

ts
const data = new FormData(form);
data.append("treatments", selected().map((id) => serviceById[id].name).join(", "));
data.append("with", state.member ? memberById[state.member].name : state.stylist);
data.append("date", dateKey(state.date!));
data.append("time", hhmm(state.slot!));
fetch("https://formspree.io/f/your-form-id", { method: "POST", body: data, headers: { Accept: "application/json" } });

A form service only delivers the request; it doesn't block the time in a diary. For real bookings, send the same data to your booking system's API and use its availability in isBusy(). For gift cards, create a checkout session on your server (for example Stripe Checkout) and redirect to it instead of showing the demo code. To run that server code in this project, add an Astro adapter (for example @astrojs/node, @astrojs/netlify or @astrojs/vercel) and an API endpoint such as src/pages/api/checkout.ts with export const prerender = false.

Saved data (localStorage)#

Key Shape Written
bloom-saved-looks Array of look ids, for example ["honey-balayage", "french-bob"] When a heart is toggled
bloom-details {"name": "…", "email": "…", "phone": "…"} On booking confirmation, only if "Remember my details on this device" is ticked

Validation on read:

  • readSaved() keeps only strings that are ids in LOOKS, without duplicates, and writes the cleaned list back if anything was dropped.
  • readDetails() requires an object, keeps name (up to 80 characters), email (up to 120, and only if it looks like an email) and phone (up to 30), and removes the key if nothing valid is left.

Confirming a booking with "remember" unticked removes bloom-details. All storage access is wrapped in try/catch (the store helper), so private browsing just disables saving. To reset, delete the keys in your browser's developer tools (Application, Local Storage).

SEO and sharing#

  • Each page passes title and description to <Layout>, which turns them into <title>, the meta description, og:title, og:description and a summary_large_image Twitter card.

  • The canonical URL, og:url and the absolute og:image URL are built from site in astro.config.mjs, which reads SITE_URL (default https://bloom.example.com). Set it to your domain when you build:

    sh
    SITE_URL=https://example.com pnpm build
  • The 404 page passes noindex, which adds <meta name="robots" content="noindex"> and leaves out the canonical URL.

  • There is no structured data or sitemap. For a salon, consider a HairSalon or BeautySalon JSON-LD block on the home page with your address, phone, opening hours and booking URL, plus @astrojs/sitemap and a public/robots.txt.

Accessibility#

Built in:

  • A "Skip to content" link, landmarks (header, nav, main, footer) and one <h1> per page.
  • Visible focus styles.
  • Menu button with aria-expanded; Escape closes the menu.
  • Toggle buttons with aria-pressed for filters, saved looks, dates and time slots; unavailable dates are disabled and labelled "unavailable".
  • Live regions for the services, team and gallery counts.
  • Wizard steps with focusable headings, aria-current="step" on the progress list and focus on the confirmation.
  • Form errors next to each field with aria-invalid, focus on the first error.
  • Today's opening hours row marked with aria-current="date".
  • Native <dialog> lightbox with focus return, and a native range input for the before/after slider.
  • Reduced motion: no reveal animations and instant scrolling.

When editing, keep one <h1> per page, label every new input, give new error messages a data-error element, and write alt text for meaningful photos. --color-mute is tuned for AA contrast on the light backgrounds; check contrast if you darken the panels.

Building and deploying#

sh
pnpm build        # static site in dist/
pnpm preview      # preview dist/ locally

dist/ is plain HTML, CSS and JavaScript. Pages are written as book.html rather than book/index.html (build.format: "file" in astro.config.mjs), and links point to /book without a trailing slash. compressHTML: false keeps whitespace exactly as written, so text that continues on the next line before an inline element keeps its space. Tailwind also scans main.ts, so classes written in script strings (such as the stylist list) are included.

  • Netlify / Cloudflare Pages: build command pnpm build, publish directory dist.
  • Vercel: import the repository; static output needs no adapter. Add a vercel.json with { "cleanUrls": true } so /book serves book.html.
  • GitHub Pages: use the official Astro GitHub Action. For a project site (username.github.io/repo), build with DEMO_BASE_PATH=/repo.
  • Any static host: upload the contents of dist/. If the host doesn't map /book to book.html, change build.format to "directory" in astro.config.mjs.

To serve the site from a sub-folder, set DEMO_BASE_PATH when building, e.g. DEMO_BASE_PATH=/bloom pnpm build. It becomes Astro's base: links written with url() include it, and so do the paths the script builds (BASE in main.ts).

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 treatment doesn't appear in the booking wizard. Add both the SERVICES entry and the checkbox in book.astro; the checkbox value must equal the id.
  • The booking breaks when a new treatment is ticked. The checkbox value has no matching service in SERVICES. Check spelling.
  • No times appear for a day. The salon is closed that day in HOURS, nobody eligible works it (days), or every slot is taken in the demo diary. Try "Anyone available".
  • A stylist link opens with "Anyone available". The ?stylist= id doesn't exist, or that person can't do the chosen treatments.
  • A look or portrait image is missing in the lightbox or wizard. The file must be named after the id: public/img/looks/<id>.svg or public/img/team/<id>.svg.
  • Links or images break under a sub-folder. Build with DEMO_BASE_PATH and write paths with url() in pages and BASE in main.ts.
  • /book shows a 404 on my host. Set build.format: "directory" in astro.config.mjs and rebuild.
  • Social previews show the wrong domain or no image. Set SITE_URL to your domain at build time.

License#

MIT: the full text is in LICENSE.txt, included in the download. You can use, change and sell sites built with this template, including the SVG artwork. Bloom Hair & Spa, its team and its clients are fictional.

Search documentation

Search the guides and every template's documentation