Marketing site for So'Relax, a solo massage-therapy practice run by Tanja in Aarschot, Belgium. The site's job is simple: explain the treatments, let visitors book a session (through Salonized, in a new tab), and be editable by a non-technical owner without a developer in the loop.
It's not a spa booking platform, not a blog, not a storefront. Eight treatments on one page, a few supporting pages (about, contact, gift cards, terms), and a CMS that exposes exactly the fields Tanja needs to edit.
- A static Next.js 15 site —
output: 'export'→ plain HTML/CSS/JS, no server runtime, hosted on Cloudflare Pages' free tier. Deliberate: the brief (§2) rejects SSR, edge runtimes, ISR, and route handlers. - TinaCMS with three collections —
treatments,testimonials,settings. Editing happens at/admin(Git-backed; changes commit tomainand trigger a Pages rebuild). Everything else is hardcoded Dutch copy in components, migrated from the old site per brief §7. - Dutch only (
nl-BE) — UI, slugs (/over-mij,/behandelingen,/afspraak,/cadeaubon), CMS labels, error messages. The only English phrase is the intentional hero tagline "Your me-time starts with… me". - "Therapist, not spa" — warm neutrals (
#FAF7F2/#2F5D5A), Fraunces- Inter, borders over shadows, no candles/stones/lotus stock. The palette
and type are load-bearing brand decisions, not cosmetic defaults — see
CLAUDE.mdfor the full list of things not to do.
- Inter, borders over shadows, no candles/stones/lotus stock. The palette
and type are load-bearing brand decisions, not cosmetic defaults — see
- Salonized is external-only — every booking CTA opens Salonized in a new tab. No iframe, no modal, no embed script on our origin. This is why LCP is fast and why there's no third-party to gate behind cookie consent.
- Cookie consent is DIY — a small client-side banner
(
CookieConsent.tsx) stores{ necessary, analytics, marketing }inlocalStorageundersorelax-consentand emitssorelax-consent-changeevents.useConsent()is the hook any future third-party script should gate on. No iubenda / Cookiebot. - No contact form.
/contactlists address, phone, email, and opening hours; inquiries go through Salonized booking or direct phone/email. There is no form backend and no plan to add one.
Source-of-truth documents:
project-brief.md— the authoritative spec (tech stack, page list, design system, CMS schema, perf/a11y budgets, build order). Read it before making architectural decisions.CLAUDE.md— the non-obvious constraints that are easy to violate accidentally (the list of "don't reintroduce X" items).websitecontent.md— raw Dutch copy from the previous site, used to seed the CMS and hardcoded pages.
pnpm install
pnpm dev # TinaCMS local + Next.js at http://localhost:3000
# /admin → local Tina editor (writes to content/**/*.json on disk)
pnpm build # tinacms build --local → next build → static export to ./out
pnpm typecheck
pnpm lintNode 20+ (see .nvmrc); package manager pinned to pnpm@10.
pnpm dev:next/pnpm build:nextskip the Tina wrapper — useful when iterating on components without regenerating the admin bundle.pnpm build:cloudbuilds the admin against Tina Cloud instead of the local filesystem — switch to this onceNEXT_PUBLIC_TINA_CLIENT_IDandTINA_TOKENare provisioned.
tina/
config.ts # TinaCMS schema (3 collections — treatments, testimonials, settings)
content/ # JSON files owned by TinaCMS — edited via /admin
treatments/treatments.json
testimonials/testimonials.json
settings/settings.json
src/
app/ # 9 routes (home + 8 pages) + sitemap, robots
# /admin is generated by tinacms build into public/admin/
components/
ui/ # Button, Card, Container, Section, PullQuote, Accordion, Icons…
sections/ # Home-page sections (Hero, Specializations, …)
Nav.tsx / Footer.tsx / FloatingBookingButton.tsx / CookieConsent.tsx
content/ # Typed readers over the TinaCMS JSON files
treatments.ts # getTreatments, getTreatmentsByCategory
testimonials.ts # getTestimonials, getFeaturedTestimonials
settings.ts # getSettings (with env-var fallback for Salonized / social)
lib/
fonts.ts # next/font self-hosted Fraunces + Inter
site.ts # siteConfig, nav structure
schema.ts # JSON-LD builders (LocalBusiness, Person, Service)
format.ts
docs/
editor-handleiding.md # Dutch editor guide for Tanja (login, price updates, hours)
Design tokens live in both src/app/globals.css (as CSS custom
properties under :root) and the Tailwind @theme inline block in the
same file. Don't add a token to one without the other.
Exactly three, per brief §6 — adding more is a maintenance tax for a solo non-technical editor.
| Collection | File | Shape |
|---|---|---|
treatments |
content/treatments/treatments.json |
Ordered list of 8 services (therapeutic + relaxation). Drag-reorder in the editor. Each entry has an optional salonizedLink override. |
testimonials |
content/testimonials/testimonials.json |
Ordered list of reviews. featured: true = shown on the home carousel. |
settings |
content/settings/settings.json |
Single document: opening hours, Salonized widget URLs, Instagram / Facebook handles. |
Field labels and help text in the admin UI are Dutch — Tanja is the editor.
The Dutch editor guide is in docs/editor-handleiding.md.
Copy .env.example → .env.local and fill in what you have. The site still
builds with everything empty — CMS-backed fields fall back to env vars, which
fall back to sane placeholders.
| Variable | Needed for |
|---|---|
NEXT_PUBLIC_SITE_URL |
Absolute URLs in metadata, sitemap, OG tags |
NEXT_PUBLIC_TINA_CLIENT_ID, TINA_TOKEN |
/admin against Tina Cloud (prod) — not needed for local dev |
NEXT_PUBLIC_SALONIZED_WIDGET_URL, NEXT_PUBLIC_SALONIZED_GIFTCARD_URL |
Fallback for booking widget + gift-card CTA when the CMS field is empty |
NEXT_PUBLIC_INSTAGRAM_URL, NEXT_PUBLIC_FACEBOOK_URL |
Fallback for footer social links when the CMS field is empty |