Skip to content
Michał Świąder — artist website

Michał Świąder — artist website
Bilingual one-page website for a fingerstyle guitarist from Kraków - Next.js 16 + Strapi v5, WCAG AAA for text, headless CMS for blog and recordings, from strategy to production.

CLIENTMichał Świąder
TERM2026-08
ROLEFull-Stack Developer / Architect / UX Designer
STATUSCompleted

"A website that doesn't shout — it subcommunicates. An organic, warm, authentic artist-teacher website, where the design persuades before anyone reads a word."

Michał Świąder — Personal artist website: a bilingual one-page site for a fingerstyle guitarist, teacher and composer from Kraków. The project covered the full cycle — from strategic brief, through personas and UX scenarios, visual specs, to build, acceptance testing and production deployment on a VPS with Dokploy.

📋 Project metrics

  • Status: Completed (production, Dokploy)
  • Role: Full-Stack Developer / Architect / UX Designer
  • Scope: Full cycle — from discovery and strategy, through UX/UI design, to implementation, CMS and deployment
  • Goal: An authentic, independent artist-teacher website that subcommunicates the "guitar psychologist" philosophy through design, not slogans

🚀 Project evolution (Product Journey)

  1. Discovery and strategy — We started with a brief: vision, positioning, tone of voice, visual direction (Minimal + Local/Artisan), local SEO keywords. I built 3 personas (Piotr the Seeker — a student on a plateau, Natalia the Listener — a fan, Kasia the Concert-goer — an organiser), mapped 18 driving forces and prioritised features through F×I×F scoring. Every decision on the site is justified by a specific need of a specific person.
  2. UX scenarios and specs — 3 UX scenarios covering 7 pages. Each view got a full spec: Object Registry, navigation, Accessibility/Responsive/SEO sections.
  3. Visual Design and Design Delivery — HTML prototypes (one-page, blog-list, blog-single), design tokens (colours, typography, spacing, shadows, radius), a contract for the build, a test scenario. Libre Caslon Text + Fira Sans, the logo is Michał's hand-drawn signature in SVG (stroke 1.5).
  4. Build — Next.js 16.3 + React 19.2 + Tailwind v4 with tokens in @theme. Layers app/lib/components/content/messages, PL without prefix + EN under /en, 6 routes (/, /blog, /blog/[slug] + EN equivalents; blog entries generated on first request via ISR), JSON-LD (Person + BlogPosting + ItemList + BreadcrumbList), sitemap/robots, custom 404, mobile menu with focus trap, YouTube facade (youtube-nocookie, lazy load).
  5. Acceptance testing — 22 tests (HP, ER, EC, DS, AX), first round 21/22. Two Low issues (a hardcoded hex in a component, touch targets below 44px) — fixed, retest PASS. Status: approved.
  6. CMS integration — Strapi v5.51 + PostgreSQL 16 in a monorepo (cms/). Three content types: blog-post, tag, nagranie. i18n PL/EN, webhook on-demand revalidation (revalidateTag for blog-posts and nagran), ISR 300s as a fallback, list pagination 6 per page, tags as a manyToMany relation, YouTube facade for recordings (max 7, sorted by order). The build passes without CMS access — after deploy the first request triggers ISR, and the CMS webhook refreshes content immediately after publishing.
  7. Production deployment — Docker Compose + Traefik (Dokploy), healthcheck + condition, noindex on the CMS, multi-stage Dockerfile (node:22-alpine), ms-* prefix on a shared VPS.

🎯 Business problem

A fingerstyle guitarist, teacher and composer from Kraków exists online scattered across social media. Without a website, really without a place of his own on the web. He needs his own, independent place that connects people with him directly — through lessons or simple contact inspired by the music and the philosophy.

❌ Pain points and challenges

  • No place of one's own: Social media as the only source of information — dependence on algorithms, no control over the narrative.
  • Subcommunication, not explicit: The "guitar psychologist" / "guitar therapy" philosophy is meant to be felt, never spelled out. Design, tone, photos, music persuade subconsciously.
  • Bilingualism: PL/EN without duplicating code and content, full parity of blog entries.
  • Content management: Michał publishes entries and recordings himself — without touching code, without redeploying.
  • SEO for a niche: "fingerstyle lessons Kraków", "guitar lessons Kraków" — competing for the name in Google requires structured data and a dynamic sitemap.
  • Accessibility: An artist-teacher website must be accessible to everyone, including screen reader users.
  • Shared VPS: Docker resource isolation (ports, container names, volumes, networks) without collisions with other projects.

💡 Why it works (Engineering approach)

  • Every design decision is justified by personas: Not a "pretty site", but a site that transforms potential students from "bounce and leave" into people who feel a connection and choose to reach out.
  • Subcommunication through design: Split hero with an authentic photo (Michał barefoot with a guitar), a hand-drawn SVG signature as the logo, an organic aesthetic (light background, turquoise accent, humanist typography) — everything subcommunicates "guitar psychologist" without spelling it out.
  • One contact path, many intents: "Write to me" / "Send a message" handles a student (lessons), a fan (message), an organiser (performances) — without categorisation, without a booking form, without a price list, without a sales funnel.
  • Headless CMS (Strapi v5): Michał adds blog entries and YouTube recordings himself — bilingually, via a panel, without touching code. When he publishes in the panel, a webhook hits the front and clears the blog-posts and nagran cache (revalidateTag). ISR 300s is a safety net in case the webhook doesn't arrive.
  • Build without CMS: Strapi doesn't need to be running to build the front. A NEXT_PHASE guard in lib/strapi.ts returns empty data during build, and after deploy the first request triggers ISR and the CMS webhook refreshes content immediately after publishing. The build is independent of the CMS infrastructure.
  • Custom Strapi v5 controllers: It turned out that the Strapi v5.51 REST API doesn't serialise relation attributes (tags, localisations) for public requests — the relation field doesn't make it into the response at all. Workaround: custom controllers override find/findOne and use the Documents API, which populates relations correctly. The front receives a flat, custom response format.
  • WCAG AAA for text: Went a step further than the standard requires — text contrast 7:1+ (the AA standard requires 4.5:1). Tokens in globals.css @theme darkened so that the worst background (canvas #f9f8f6) holds 7.4:1+. WCAG 1.4.11 (non-text 3:1): CTA outlines with border-ink/50.
  • YouTube facade: Instead of loading iframes on start, I show a thumbnail + play button. The iframe loads only after a click (youtube-nocookie, lazy load, full keyboard accessibility). Zero unnecessary scripts on the first paint.
  • Auto-hide header: Sticky + translate — scrolling down hides it, scrolling up shows it. Paused when the menu is open, disabled under prefers-reduced-motion.

📈 Business impact (ROI)

MetricBeforeAfterSignificance
Web presenceSocial media onlyOwn domain + bilingual websiteFull control over narrative and branding
Content managementManual / via developerSelf-service via Strapi CMS (blog + recordings)Independence, zero ongoing costs
Entry publishingApp redeployWebhook on-demand (immediate)Publish → visibility time ≤ 1 min
SEONo structured dataJSON-LD + dynamic sitemap + hreflangHigher Google visibility for the name and local keywords
AccessibilityUnaddressedWCAG 2.2 AA (AAA for text), focus trap, ARIA, skip linkWebsite accessible to everyone
AuthenticityDark facade, distanceOrganic, warm, authentic (barefoot with a guitar, SVG signature)"Guitar psychologist" subcommunicated through design
ContactForm / social mediaOne "Write to me" path (mailto)No pressure, no funnel, natural

Lighthouse Scores

Native image optimisation (next/image, WebP/AVIF, lazy load), Server Components by default and the elimination of unnecessary scripts (YouTube facade) give a strong foundation for the performance score. Final Lighthouse scores should be measured in production and the real values inserted here.

"A website that doesn't shout — it subcommunicates. An organic, warm, authentic artist-teacher website, where the design persuades before anyone reads a word."

🛠️ Technical challenges (Engineering Deep Dive)

  • Bilingualism without an i18n library: Instead of pulling in a library, I wrote my own, lightweight i18n — PL default (no prefix), EN under /en/. Every visible text via t("key") (client) or a package import (server). Packages messages/pl.json and messages/en.json with dotted keys. Fewer dependencies, full control.
  • Next 16.3 not-found with multiple root layouts: A bug (vercel/next.js #59180) — Turbopack doesn't compile not-found with multiple root layouts. Workaround: a single root layout (lang="pl" server-side) + a client LangSetter (useLayoutEffect sets documentElement.lang after hydration, WCAG 3.1.1, no mismatch) in app/en/layout.tsx. The custom 404 detects locale from the path (usePathname) — a 404 on /en is in English, with lang="en" and /en links.
  • og:type vanishing on /en: Next shallow-merge counts by field — the last segment overwrites the whole openGraph. Fix: en/layout.tsx gets the full openGraph object, not just selected fields.
  • Strapi v5.51 relations in the REST API: The REST API doesn't serialise relation attributes (tags, localizations) for public requests or with a read-only token. The Documents API (strapi.documents) populates relations correctly. Decision: custom controllers override find/findOne, use the Documents API, expose a flat format ({ data, meta: { page, pageSize, pageCount, total } }). Pagination via limit/start (not page/pageSize), count counts only published entries.
  • Idempotent webhook revalidation: revalidateTag("blog-posts") and revalidateTag("nagran") clear the list, pagination pages, entries and the Music section at once (instead of a list of revalidatePath). The webhook is registered idempotently in the Strapi bootstrap (REVALIDATE_URL), with an x-revalidate-token header from webhooks.defaultHeaders. The route handler is force-dynamic, with secret validation (missing/wrong → 401).
  • WCAG AAA on canvas #f9f8f6: The worst background is canvas #f9f8f6 (darker than white), so the greys had to go below ~#565656. ink-muted #505050, accent-strong #005c55 — AAA contrasts maintained also on the paper background and in buttons.
  • Stretched-link on blog cards: The card overlay got z-index 1 — the image container (position:relative) painted over the pseudo-element by DOM order and the image in the featured card wasn't clickable. After the fix, clicking the image leads to the entry (1 link per card for the screen reader).
  • Uniqueness on a shared VPS: ms- prefix on all global Docker names (networks, volumes, container_name in dev). Dev host ports: 3200/1348/5434 (zero collisions with other projects on the same VPS). Prod: traefik (WEB_DOMAIN/CMS_DOMAIN), healthcheck + condition, noindex on the CMS, dokploy-network external.

🛠️ Architecture and tech stack

  • Frontend: Next.js 16.3 (App Router, ISR 300s + webhook), React 19.2, TypeScript (strict, noUncheckedIndexedAccess), Tailwind CSS v4 (@theme with tokens)
  • Typography: next/font (Libre Caslon Text + Fira Sans)
  • CMS: Strapi v5.51 + PostgreSQL 16 (headless, server-side only, monorepo cms/)
  • Content types: blog-post (i18n, draftAndPublish), tag (i18n, manyToMany), nagranie (YouTube, sorted by order, max 7)
  • Images: next/image with remotePatterns (STRAPI_PUBLIC_URL + i.ytimg.com), WebP/AVIF, lazy load, responsive sizes
  • i18n: Custom, lightweight (PL default without prefix, EN /en/), packages messages/{pl,en}.json
  • Structured Data: JSON-LD (Person, BlogPosting, ItemList, BreadcrumbList)
  • Accessibility: WCAG 2.2 AA (AAA for text), SkipLink, focus-visible, focus trap (mobile menu), ARIA, prefers-reduced-motion, touch targets ≥ 44px
  • Publishing: Webhook on-demand revalidation (revalidateTag blog-posts + nagran, /api/revalidate, REVALIDATE_SECRET) + ISR 300s fallback
  • Infra: Docker Compose (dev + prod), Traefik (Dokploy), multi-stage Dockerfile (node:22-alpine), unique ms-* names, healthcheck + condition
  • SEO: hreflang (pl, en, x-default), dynamic sitemap (iterates pagination of both locales), robots.txt, OpenGraph (one consistent OG image)

🎨 Key design decisions

  • Organic aesthetic (Minimal + Local/Artisan): Light background #f9f8f6 (a sheet of paper), turquoise accent, humanist typography (Libre Caslon Text serif + Fira Sans). Warmth, naturalness, an acoustic character — the site itself communicates Michał's philosophy.
  • Split hero with an authentic photo: Michał barefoot on the floor with a guitar (not a corporate portrait). A hand-drawn SVG signature as the logo (stroke 1.5). CTAs "Let's meet" / "Listen to the pieces" (not "Sign up"). A first impression that makes Piotr feel "this is it".
  • About in the first person: Personal, not a CV. Story and philosophy — why Michał plays and teaches. Subcommunication of "music as a tool for growth" (not spelled out). A prominent portrait, a quote about listening with border-l-4 accent.
  • Music as live proof: YouTube facade (recordings from the CMS, max 7, thumbnail + play button, iframe after a click, youtube-nocookie, lazy load, keyboard accessible). Performer proof for Kasia (organiser), supporting scroll for Piotr.
  • Dark Contact footer: A deliberate decision — contrast with the light one-page, mailto michalswiadermusic@gmail.com, social links (YT/IG/FB), no booking form. Photographer credit (Rafał Majewski) in the footer.
  • One contact path: "Write to me" / "Send a message" / "Let's talk about lessons" — without intent categorisation, without a price list, without a sales funnel. Mailto + social links in the footer.
  • Design tokens as the only truth: Colours, typography, spacing, shadows, radius in design-tokens.md and globals.css @theme. Components stick to the tokens (apart from a single decorative fill in the SVG play). WCAG AAA contrasts documented in the tokens.
  • Server Components by default: "use client" only where necessary (state, events, browser hooks — MobileMenu, LangSetter, YouTubeEmbed, 404). Layers: app/ (routing, metadata, JSON-LD), lib/ (pure functions, data access), components/ (ui/ sections/ chrome/), content/ (one-page data), messages/ (UI strings).

Artifacts

Contact

Have a similar challenge? Write to me — I will come back with a proposal for next steps.

Send message