Opinionated starting point for React + TypeScript + Vite + Tailwind v4 projects. Distilled from the shared patterns in production apps — biased toward accessibility, strict types, and a small dependency surface.
- React 19 + TypeScript 5 + Vite 8 with strict
tsconfig. - Tailwind CSS v4 via
@tailwindcss/vite. Tokens live in@themeinsrc/index.css. - Semantic color tokens (
text-foreground,bg-raised,text-accent, etc.) with automatic dark mode viaprefers-color-scheme— nodark:variants. - Global a11y baselines:
prefers-reduced-motionandforced-colorsoverrides; visible:focus-visiblering. - React Router v7 with route-level code splitting (
React.lazy+ shared<Suspense>fallback). ErrorBoundarywith optionalresetKeyfor route-reset behavior.useDocumentTitlehook — the only approved way to setdocument.title.fetchWithTimeoututility withAbortController+ external-signal chaining.src/config/env.tsas the single place to readVITE_*variables.- Single
src/types/index.ts— no per-domain type files. - Verify pipeline —
prettier-check → lint → typecheck → build, wired into GitHub Actions. - GitHub Pages deploy workflow — publishes
dist/on every push tomain. - Documented conventions in .github/copilot-instructions.md.
Every one of the above is exercised at least once by the demo app, so nothing ships as dead code. The /demo route is the showcase — see Removing the demo.
# 1. Use this template on GitHub (or clone and re-init git)
npm install
# 2. Copy env template and fill in any VITE_* variables
cp .env.example .env.local
# 3. Start developing
npm run devRun npm run verify before committing — it is the same chain CI runs. See CONTRIBUTING.md for the full script list, project layout, and conventions.
The /demo route exists so every built-in renders at least once — the template ships no dead code. To strip it:
- Delete
src/pages/DemoPage.tsxandpublic/demo-data.json. - Remove the
DemoPageimport, its<Route>, theDemo<NavLink>inLayout.tsx, and the closing paragraph inHomePage.tsx. - Drop
DemoStatusfromsrc/types/index.ts.
Keep the route-level <ErrorBoundary resetKey={location.pathname}> in App.tsx — it is part of the shell, not the demo.
The template ships with .github/workflows/deploy.yml, which builds and publishes dist/ on every push to main.
- In your repo, go to Settings → Pages and set Source to GitHub Actions.
- Push to
main. The workflow builds withBASE_PATH=/<repo>/so assets resolve correctly for a project page (e.g.https://<user>.github.io/<repo>/). - Deep links (e.g. a hard refresh on
/demo) survive via the rafgraph SPA redirect trick:dist/404.htmlis generated at build time by thespa-github-pages-404plugin in vite.config.ts, with the resolvedbasebaked into it. GitHub serves it for any unknown path; it encodes the requested path into a query string and bounces toindex.html, which IS served with a 200.- A small script inlined in the
<head>of index.html decodes that query string before React boots. Both halves are inline, so neither costs a request.
This adapts to the base path automatically — there is nothing to hand-edit for a project page, a user page, or a custom domain.
Why generated, not
public/404.html? Vite copiespublic/verbatim and never rewrites paths inside it. A<script src="/scripts/…">there resolves against the domain root, so on a project page served from/<repo>/it 404s and the redirect silently dies.
User/organization page (<user>.github.io) or custom domain: override BASE_PATH to / in the workflow, or edit the default in vite.config.ts. Nothing else changes.
Project page (<user>.github.io/<repo>/): nothing to configure — the workflow derives BASE_PATH from the repo name.
BrowserRouter uses BASE_PATH from src/config/env.ts as its basename, so routing works under any base path without further changes.
See CONTRIBUTING.md.
MIT. Replace this section when you fork the template.
