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 v8 framework mode and stripped ~39 unused packages. Runtime dependencies went from 46 to 7. React Router v8 handles SSG, code-defined routing via src/routes.ts, 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:
- My experience working with and being mentored by excellent engineers
- mgifford’s ACCESSIBILITY.md
- Web Specification site for audits
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-linkclass 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 ModeThemeAnnouncercomponent announces light/dark mode switches to screen readers viaaria-live="polite", the same approach used for route changes
Performance guardrails:
- Self-hosted fonts,
font-display: optional, preloaded globally insrc/root.tsx - Markdown processed at build time only via
unified,remark-*,rehype-*as dev dependencies; no markdown parsing at runtime - Home page imports lightweight summary data only
- Explicit
width/heighton every<img>,scrollbar-gutter: stableto prevent layout shift - Speculation Rules API for prefetching, injected via DOM
- No
unloadorbeforeunloadlisteners (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. They are now 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. We 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:
- Run Sync Adventure with the adventure URL and which levels to make live
- Workflow fetches YAML, generates TypeScript, updates sitemap and prerender config, opens a PR with a checklist
- Complete the checklist: contributor info, release month, Discourse category ID, rewards deadline, discussion thread URL
- PR preview deploys automatically for review
- 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.
Solution pages
Solution walkthroughs are authored as structured TypeScript in src/data/solutions/, generated at build time, and rendered on dedicated pages at /adventures/:id/levels/:levelId/solution. A /add-solution Claude Code command handles parsing solution content from any format (markdown, HTML, plain text), converting images to WebP, and producing the typed TypeScript file. The same build-time-only philosophy applies: no runtime fetching.
Step 6: Build the Test Suite as You Go
Grew from 1 stub file to 49 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
- Playwright hydration: checks prerendered pages for React hydration mismatches by monitoring for minified React error codes in console output
- Adventure consistency: every level in prerender list, sitemap, route config, leaderboard script
Additional PR gates:
- REUSE licence compliance: every PR is checked for valid licence headers via
reuse.yml validate-docs.yml: PRs touching components, hooks, constants, or workflows are blocked unlessstyleguide.mdorREADME.mdare also updated
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 | 497+ |
| Runtime dependencies | 7 |
| Prerendered routes | 64 |
| Test files | 49 |
| GitHub Actions workflows | 10 |
| 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
Edited: This article has been updated to reflect changes since it was first published. React Router has moved from v7 to v8, solution pages have been added as a new content type, hydration regression tests and REUSE licence compliance have been added to the CI pipeline, and the numbers in the table reflect the current state of the repo.