How OffOn.dev Was Built

A step-by-step account of building a lean, accessible, static open source community site using AI-assisted development, with no React experience.


I started as a backend developer, picked up frontend along the way, including leading the UI and frontend implementation for a client job board relaunch on Drupal. Moving into product management gave me a broader view of how websites work, and decoupled Drupal projects gave me some Vue experience. React was something I had little experience with.

We built this site using AI to write the React, with guardrails from experience and expert checklists. This is how we approached it.


Step 1: Start with a Prototype

DavidHirsch created the initial prototype in Lovable: Vite + React + TypeScript + Tailwind, core pages, navbar, theme toggle, GitHub Pages deployment. Working shell within days.

KatharinaSick created an amazing logo that perfectly resonated with our mission, giving us the brand assets, the name OffOn, and our primary color #ffc034 amber. That was the moment the site started feeling like ours.

KenyattaForbes created a cute mascot and brought in amazing feedback.

You can find the full brand guidelines at offon.dev/brand.

The prototype gave us the shell, the brand gave us the identity. From there, we took ownership and built the real thing.


Step 2: Take Ownership of the Codebase

The prototype had 45+ Radix UI component wrappers, react-hook-form, zod, @tanstack/react-query, and more. Almost none of it was needed.

I replaced vite-react-ssg with React Router v7 framework mode and stripped ~39 unused packages. Runtime dependencies went from 46 to 7. React Router v7 handles SSG, file-based routing, and per-route meta() exports cleanly.

Make the framework decision early, before building more pages.


Step 3: Define What the Site Needs to Be

  • Discovery and information site, not a community platform
  • Community interaction lives on Discourse at community.offon.dev
  • Fast, easy to navigate, good SEO
  • Accessible to everyone, WCAG 2.2 AA as the floor
  • Lean: no accounts, no backend, no runtime data fetching

Writing these down made every technical decision easier.


Step 4: Set Guardrails Before Prompting

The AI will write whatever you ask for. Without knowing what good looks like, you accumulate code that passes visual inspection but fails in real use.

My guardrails came from three sources:

I encoded the rules into CLAUDE.md, ACCESSIBILITY.md, and PERFORMANCE.md so they apply to every contributor, not just me.

How I prompted: specified requirements explicitly (“44x44px minimum touch target”, “focus ring visible in both modes”, “do not use color as the only way to convey state”), checked output against the checklist, followed up on anything missing.

The RouteAnnouncer component came from this. React Router does not announce page changes to screen readers. I caught it during a manual keyboard check and prompted for a fix.

Accessibility patterns baked in at component level:

  • docs-ext-link class handles external link styling and sr-only new-tab notices consistently
  • Focus ring pattern applied everywhere, never removed
  • Animations disabled inside @media (prefers-reduced-motion: reduce)
  • @media (forced-colors: active) rules for Windows High Contrast Mode
  • ThemeAnnouncer component announces light/dark mode switches to screen readers via aria-live="polite", the same approach used for route changes

Performance guardrails:

  • Self-hosted fonts, font-display: optional, preloaded globally in src/root.tsx
  • No markdown parser in the browser bundle (~46 KB saved on the home page)
  • Home page imports lightweight summary data only
  • Explicit width/height on every <img>, scrollbar-gutter: stable to prevent layout shift
  • Speculation Rules API for prefetching, injected via DOM
  • No unload or beforeunload listeners (disqualify pages from back/forward cache)

Step 5: The Hard Problems

Color contrast

#ffc034 amber works on dark backgrounds but fails as text in light mode, nowhere near 4.5:1 contrast. Darker variants looked flat.

Fix: amber is fills, borders, and backgrounds only in light mode, never text. Interactive elements use hsl(41 100% 22%)(~#704d00), around 7.4:1 contrast.

Tailwind v4 gotcha worth knowing: @layer base is always overridden by @layer utilities. Light mode overrides must be unlayered, at the bottom of src/index.css, scoped to .light.

Data pipeline

Adventures started as a hardcoded TypeScript file. Adventures are authored as YAML in the open-source-challenges repo, created and maintained by KatharinaSick, and synced into the site via a GitHub Actions workflow, compiled to typed TypeScript at build time. Started with JSON (Vite supports it natively) but switched to YAML to align with the challenges repo format and keep it accessible for external contributors. The generator validates field by field. If it fails, the build fails.

Discourse integration

The prototype fetched posts at runtime via useEffect on every page load. I replaced this with a GitHub Action that runs hourly, fetches posts per challenge level, extracts plain text, and commits JSON files. Components import via import.meta.glob at build time. No runtime fetching.

Rule this established: no fetch calls in components. All network data is fetched at build time.

Community leaderboard and leaders

Leaderboards and the community leaders board follow the same pattern. Refresh scripts use the Discourse API key and Data Explorer queries to fetch ranked data (top contributors, challenge solvers, most liked, most replies) and commit JSON files hourly. Components read them statically. Fast to load, updated every hour, no runtime API calls.

Adventures and challenges workflow

Adventures are authored as YAML in open-source-challenges and pulled in via a GitHub Actions workflow:

  1. Run Sync Adventure with the adventure URL and which levels to make live
  2. Workflow fetches YAML, generates TypeScript, updates sitemap and prerender config, opens a PR with a checklist
  3. Complete the checklist: contributor info, release month, Discourse category ID, rewards deadline, discussion thread URL
  4. PR preview deploys automatically for review
  5. After merge, the hourly refresh picks up posts and leaderboard data

Re-running the workflow while the PR is open updates it in place without losing manual edits.


Step 6: Build the Test Suite as You Go

Grew from 1 stub file to 31+ test files. Each test added when the feature it covers was built.

At build time: YAML validation, TypeScript errors fail the build.

On every PR:

  • ESLint with eslint-plugin-jsx-a11y: catches missing labels, invalid ARIA, and bad role usage before a build runs
  • TypeScript compilation
  • Vitest: consent flows, theme, routing, filtering, SEO meta, prerender output
  • Playwright smoke: axe WCAG scans in both modes, zero violations required
  • Playwright a11y: High Contrast Mode, touch targets, focus rings, keyboard trap, 200% zoom
  • Playwright WSG: page weight budget, no third-party requests before consent
  • Adventure consistency: every level in prerender list, sitemap, route config, leaderboard script

Scheduled:

  • Hourly: Discourse refresh, JSON validation, GitHub issue on failure
  • Daily: full build and deploy for deadline-based content
  • Weekly: full axe scan across all prerendered routes (Mondays), Dependabot

Manual: screen reader only (VoiceOver or NVDA). Everything else is automated.


Where It Ended Up

Lovable gave us a great starting point and saved weeks of work. These numbers reflect where the site is today after building on top of that foundation.

Today
Files 340+
Runtime dependencies 7
Prerendered routes 47+
Test files 31+
GitHub Actions workflows 8
Lighthouse Accessibility 100
Crawlable HTML per page 78,000+ chars prerendered

What I Would Do Differently

Cloudflare Pages instead of GitHub Pages. Faster builds, better CDN, more control over headers and cache rules. Plan to migrate at some point.

Start with Astro or Vue instead of React. Astro is great for content-focused static sites with minimal JavaScript. For a site like this, where most pages are static content with very little interactivity, Astro would have been a natural fit and likely simpler to work with as someone coming from a non-React background.

Framework migration on day one. Trivial to do at the start, manageable but slower after several pages exist.


I am not an expert in React. If you spot something I could have done better, open an issue or add a comment below.

The repo is open source under MIT. Fork it, reuse components, take what is useful: off-on-dev/website

3 Likes

That’s a very interesting journey, Sinduri. Loved the article.

1 Like