Oxwyn Studio
All field reports
23 July 2026Case Study

Case study: engineering oxwynstudio.com from blank repo to live in production

How we engineered our own site, from default-deny database rules to consent-gated analytics, and why it is the standard we hold client work to.

Most agency case studies describe someone else's website, lightly anonymised and heavily flattered. This one describes ours. oxwynstudio.com is the one build we can publish with complete honesty, because we hold the repo and every trade-off in it, and it is the working example of the standard we apply to client work: if a decision was not good enough for our own site, it does not ship for anyone else's.

Why hand-coded, not a CMS

The obvious route for a studio site is a page builder or a headless CMS with a theme. We went the other way: Next.js 16 App Router, React 19 and Tailwind CSS v4, deployed on Vercel, with every page hand-written as server components.

The reasoning is boring and commercial rather than ideological. A CMS earns its keep when non-technical people need to restructure pages weekly. Our marketing pages change a few times a quarter, and when they change it is usually the engineering that changes, not just the words. Owning the markup means owning the performance budget, the accessibility tree and the structured data on every route, with no plugin layer negotiating on our behalf.

Where content genuinely does change often, we did build a CMS, just a small one of our own. Articles like this one live in Supabase Postgres and are edited through a private admin panel with owner and writer roles. The public article pages are statically rendered and revalidated every five minutes (export const revalidate = 300), so publishing is instant-ish and reads cost nothing. It is the split we recommend generally: hand-code the pages that sell, database-drive the pages that accumulate.

Performance: images first, JavaScript second

Our stated target is a sub-300ms Largest Contentful Paint, the moment the biggest visible element finishes rendering. That is a target, not a boast; real-world numbers vary by device and network, and anyone quoting a single guaranteed figure is selling you weather.

The two decisions that matter most:

First, the image pipeline runs before deploy, not at request time. Every asset is resized to its actual display width, compressed to a budget of roughly 200KB, and committed as a static file. The Next.js config deliberately sets images: { unoptimized: true }: since the files are already the right size and format, there is no runtime optimisation service to pay for or wait on, and Vercel's CDN serves them as plain cached bytes.

Second, motion is treated as progressive enhancement. The site uses scroll-triggered reveals, a border-beam accent and slow Ken Burns pans on hero imagery, and every one of them collapses to a still frame when the visitor's operating system asks for reduced motion:

@media (prefers-reduced-motion: reduce) {
  .animate-border-beam {
    animation: none;
    opacity: 0;
  }
  .animate-ken-burns {
    animation: none;
  }
}

The same check runs in the JavaScript-driven components (parallax, scroll-scrubbed video). Respecting prefers-reduced-motion is an accessibility requirement, but it is also a performance one: the cheapest animation is the one you never start.

The studio monitoring station, where performance work is verified before it ships

The contact form: security without CAPTCHAs

The contact form has three required fields (name, email, message) with optional detail fields behind a disclosure. What it does not have is a CAPTCHA, because making humans prove their humanity is a conversion tax we refuse to charge.

Instead, bots are filtered server-side with two traps. A honeypot field, invisible to humans but faithfully completed by naive form bots, causes the server to report success while delivering nothing. And a time trap rejects any submission completed unrealistically fast:

// Bot trap: report success, deliver nothing.
if (data.honeypot) return { ok: true }

// Time trap: humans do not complete five fields in under 3 seconds.
if (Date.now() - data.startedAt < 3000) {
  return { ok: false, error: "That was quick. Please take a moment and try again." }
}

The clock starts on the visitor's first interaction with the form, not on page load, so slow readers are never penalised. Everything is validated again on the server with a Zod schema, because client-side validation is a courtesy, not a control.

Delivery is deliberately redundant. The brief goes out by email through the Resend REST API, and is also inserted as a lead into the admin panel's database. The insert is non-blocking: if the database hiccups, the error is logged and the email still sends, because a lost brief is the one failure mode a studio cannot afford. On submit, the visitor watches a staged terminal-style log narrate the real request, and the final OK line only prints after the server action has actually delivered.

Default-deny at the database

The admin panel (a leads mini-CRM, a client register and the article CMS) sits on Supabase Postgres with Row Level Security, the Postgres feature that filters every query per user, enabled on every table. The posture is default-deny: RLS with no policy means nobody reads anything, and each policy grants the narrowest access that makes the feature work.

Leads and client records are owner-only. Articles are the interesting case, because they have a public face and a two-role workflow behind it:

-- Public (anon) may read published articles; staff read everything.
create policy "published articles are public" on public.articles
  for select using (status = 'published' or public.is_panel_user());

-- Writers create their own drafts; never directly as published.
create policy "staff create drafts" on public.articles
  for insert with check (
    public.is_panel_user()
    and author_id = auth.uid()
    and status in ('draft', 'in_review')
  );

A writer cannot publish, cannot touch another writer's draft and cannot edit anything already live; those transitions belong to the owner role. The rules live in the database, so they hold even if a future application bug forgets to check.

After the first production deploy we ran Supabase's security advisors and shipped a hardening migration off the findings: the trigger function's search_path got pinned, and a broad public SELECT policy on the image storage bucket was narrowed to staff, since the app never needs anonymous listing. Advisor-driven follow-up is part of the process, not an embarrassment to hide.

One more habit worth naming: every article page emits JSON-LD structured data, and because parts of it come from database-driven strings, the serialised JSON is escaped with .replace(/</g, "\\u003c") before injection. That single line prevents a stored </script> from breaking out of the script tag. Structured data is a search feature; it should never become an XSS vector.

A threat modelling session at the studio wall, where default-deny access rules are designed

Analytics that load nothing until you say so

GA4 and Microsoft Clarity are useful and cookie-hungry, so both are gated behind explicit consent. The consent manager renders the banner, and the analytics scripts are only mounted at all when the stored choice is "granted". Declining loads nothing: not a lighter version, not a pixel; no request leaves the browser. The only measurement that runs regardless is Vercel's cookieless, aggregate analytics, which sets nothing on the visitor's device. A footer button reopens the banner so the choice is always revisitable, as UK GDPR expects.

Geo currency without surveillance

The Website-as-a-Service pricing displays in GBP, EUR or USD depending on where the visitor plausibly is. The tempting implementation is an IP geolocation lookup. Ours uses two signals the browser already offers freely: the language locale and the IANA timezone (the Europe/London style zone identifier every device carries). Europe/London or an en-GB locale means GBP; an EU capital timezone means EUR; the Americas and the rest of the world get USD; and if both signals are missing we fall back to GBP, the canonical billing currency. No IP lookup, no third-party service, no cookie. Converted prices come from a fixed per-currency ladder rather than live FX maths, so £99 shows as $129 or €119, clean marketing numbers, not £99 times whatever the pound did overnight.

Built to be read by machines too

robots.txt explicitly invites the AI crawlers alongside the traditional ones: GPTBot, ChatGPT-User, ClaudeBot, Anthropic-AI, Google-Extended, SearchGPTBot and PerplexityBot are all named and allowed. Organization, FAQPage and Article JSON-LD graphs give those systems clean structured facts to work from. Nobody can guarantee AI citation, and we say so plainly; what you can do is remove every technical excuse for being skipped, which is the same discipline behind our SEO and digital marketing work.

Key takeaways

  • Hand-coding pays off when you treat markup as an engineering surface: performance budgets, structured data and accessibility all become things you control rather than negotiate with a plugin.
  • Optimise images before deploy and serve static files; a runtime image service is a cost you may not need.
  • A honeypot plus a server-side time trap filters bots without taxing humans with CAPTCHAs; validate everything again on the server.
  • Default-deny RLS puts authorisation in the database, where application bugs cannot un-enforce it, and security advisors should be re-run after every production apply.
  • Consent means nothing loads before an explicit yes. Locale plus timezone gives useful geo defaults without touching an IP address.

Frequently asked

Why not just use WordPress or a website builder?

For sites where non-technical staff restructure pages weekly, a CMS is the right call and we say so. For a performance-critical marketing site, hand-coded server components remove whole categories of overhead: plugin JavaScript, theme CSS you cannot delete, structured data you cannot fully control. We database-drive only the content that changes often, such as these articles.

Does blocking analytics until consent hurt your data?

Yes, and we accept that. Declines and ignored banners are invisible to GA4 and Clarity by design. Vercel's cookieless aggregate analytics still gives us traffic shape without identifying anyone, which is enough to run the site honestly.

How do you detect a visitor's currency without their IP address?

The browser voluntarily exposes a language locale and an IANA timezone. Together they answer "which currency would this person expect" accurately enough for price display, with no lookup service, no cookie and no stored location data. Visitors can also switch currency manually, and that choice is remembered locally on their device.

Is a sub-300ms LCP realistic for every site?

It is a target, and we state it as one. A content-led site with a disciplined image pipeline and minimal JavaScript can get close on good connections; a heavy web application with third-party embeds will not, and pretending otherwise is how performance budgets die. What matters is choosing a number, measuring against it and letting it veto features.

Put this to work

Want a system built like this behind your business?

Brief the studio