A production-ready React frontend template with multi-locale support (zh-Hans / en-US), dark mode, PWA, locale-prefixed routing, and legal pages.
The core/ layer is fully portable — zero business-domain coupling, copy it to any new project.
| Layer | Technology |
|---|---|
| UI | React 19 + TypeScript |
| Build | Vite 7 |
| Styling | Tailwind CSS 3 (custom ink/paper/surface/jade palette, CSS-variable-backed for dark mode) |
| i18n | i18next + react-i18next + static messages map |
| Routing | react-router-dom 7 (locale-prefixed URLs /en/..., /zh/...) |
| Testing | Vitest + @testing-library/react + happy-dom |
| PWA | Service worker + Web App Manifest |
src/
├── core/ # Shared infrastructure — zero business coupling
│ ├── components/
│ │ ├── layout/ # Header, Footer, RootLayout, ErrorBoundary
│ │ ├── ui/ # LoadingSpinner, PageBackground, ContentCard, Markdown
│ │ └── legal/ # LegalPage component + placeholder markdown files
│ ├── context/ # ThemeContext (dark mode)
│ ├── hooks/ # useTheme (localStorage persistence)
│ ├── i18n/ # MessageSchema, en-US and zh-Hans translations, i18next init
│ ├── lib/ # locale.ts, locale-registry.ts, date.ts
│ └── types/ # Locale, OutputLocale
├── features/ # Add your feature components here
├── pages/ # Thin route wrappers
│ ├── HomePage.tsx # Replace with your own content
│ ├── LegalPageWrapper.tsx # Connects ThemeContext to LegalPage
│ └── NotFoundPage.tsx # 404 fallback
├── router.tsx # Route definitions
├── main.tsx # SPA entry point
└── index.css # Global styles + dark-mode CSS variables
npm install
npm run dev # Dev server at http://localhost:5173 (VITE_ENV=dev)
npm test # Run unit tests
npm run build:dev # Build with development env (VITE_ENV=dev) → dist/
npm run build:prod # Build with production env (VITE_ENV=prod) → dist/
npm run preview # Preview production build locally- Add an entry to
src/core/lib/locale-registry.ts:"ja-JP": { urlPrefix: "ja", bcpPrefix: "ja", label: "日本語" }
- Create
src/core/i18n/ja-jp.tsimplementingMessageSchema - Register in
src/core/i18n/index.ts: add tomessages,resources, and ensureLOCALE_LISTpicks it up
- Create
src/features/your-feature/with your components - Add a route in
src/router.tsx - Create a thin page wrapper in
src/pages/YourFeaturePage.tsx - Feature components should accept
isDarkModeandlocaleas props (not context) for micro-frontend portability
index.html—<title>andapple-mobile-web-app-titlepublic/manifest.json—nameandshort_nameThe PWA manifest is generated fromvite.config.ts; configureVITE_APP_NAMEandVITE_APP_SHORT_NAMEinstead of adding a static manifest file.public/logo192.png,public/logo512.png— app iconssrc/core/i18n/en-us.tsandzh-hans.ts—header.title
Edit the four markdown files in src/core/components/legal/. They are imported at build time via Vite ?raw, so no runtime fetch is needed.
Edit src/core/components/layout/Footer.tsx. The Links and Community sections are marked with TODO comments for placeholder URLs.
.env.development and .env.production define VITE_API_BASE_URL (defaulting to
http://localhost:3000 / https://api.example.com). It is intentionally not
read anywhere in src/ yet — the template ships no HTTP client, fetch
wrapper, or data-fetching library (no axios, no React Query/SWR), because that
choice depends on each product's backend and auth scheme.
The variable is reserved for whatever data layer your project adds under
src/features/, e.g.:
const baseUrl = import.meta.env.VITE_API_BASE_URL;Update the two .env.* files with your real API URLs (or override per
deployment via your CI/hosting provider's environment variable settings)
before wiring up the first feature that calls a backend.
| Decision | Rationale |
|---|---|
core → features → pages one-way dependency |
Enforced layering; core/ can be dropped into any project without modification |
Feature components use props for isDarkMode/locale |
Portability across micro-frontend shells without host context |
Static messages map alongside i18next |
messages[locale] is zero-overhead and SSR-safe; i18next enables runtime useTranslation hooks |
| Cookie + navigator browser language detection | Cookie persists user preference; navigator provides a sensible first-visit default |
Inline <head> script reads app-theme-mode |
Runs before first paint — prevents theme flash without server involvement |
app_locale / app-theme-mode storage keys |
Generic names avoid collisions when projects share a domain |
ink/paper/surface colors resolve from CSS variables (src/index.css) |
A single .theme-dark class swaps every consumer's colors at once — no per-component dark: classes, !important, or [class*="..."] overrides |
Legal routes (/terms, /privacy) are React.lazy-loaded |
Keeps react-markdown/remark-gfm/rehype-sanitize (the vendor-markdown chunk) out of the initial route graph |
VITE_API_BASE_URL is defined but unread by core/pages |
Reserved for your project's own data-fetching layer under features/; no HTTP client/query library is opinionated for you (see Customisation Guide) |
npm test # Run all tests once
npm run test:watch # Watch mode
# Run individual test suites:
npx vitest run src/core/lib/__tests__/
npx vitest run src/core/i18n/__tests__/
npx vitest run src/core/hooks/__tests__/vite-plugin-pwa generates the web manifest and Workbox service worker during production builds. The generated worker precaches the application shell and hashed Vite assets, so installed clients can reopen the SPA offline.
- Set
VITE_APP_NAMEandVITE_APP_SHORT_NAMEfor product branding. - Set
VITE_PUBLIC_BASE_PATHto/(default) or a trailing-slash subpath such as/portal/before building for a non-root deployment.
VITE_APP_NAME="Product Name" VITE_APP_SHORT_NAME="Product" npm run build:prodThe template intentionally does not ship a sitemap for an unrelated domain. Add a product-specific sitemap and canonical/SEO metadata as part of deployment.
npm run lint # ESLint flat-config checks
npm run test:coverage # Vitest V8 coverage report (enforces thresholds in vitest.config.ts)
npm run check # lint + coverage + production build.github/workflows/ci.yml runs the same check steps (npm ci, lint, test:coverage, build:prod) on every push and pull request against main.