An opinionated starter template for React projects with TypeScript, Vite, and Sass.
- React with
wouter(lazy loading, error boundary) - TypeScript with strict mode
- Sass (SCSS) with a minimal reset and CSS custom properties (light/dark via
prefers-color-scheme) - ESLint flat config with
typescript-eslint,react-hooks, andreact-refreshplugins - PWA via
vite-plugin-pwa(offline precache, silent updates, offline toast) - pnpm as the enforced package manager
public/ icons; referenced by the manifest in vite.config.ts
src/
├── Context/
│ ├── ThemeContext.tsx THEMES is the source of truth for themes
│ ├── ThemeToggle.tsx passed to <Navbar actions={...} />, not imported by it
│ └── ThemeToggle.scss
├── components/
│ ├── Nav/
│ │ ├── Navbar.tsx hero/solid modes, isShrunk, actions slot
│ │ ├── NavDrawer.tsx slide-in drawer, supports nested panels
│ │ ├── nav.config.ts all nav data + isRouted (edit this)
│ │ └── Navbar.scss styles for both
│ ├── OfflineToast/ shown when navigator.onLine goes false
│ └── ErrorBoundary.tsx
├── pages/
│ ├── Home/ renders <Navbar isHero />
│ ├── About/ lazy-loaded, as an example of a code-split route
│ └── NotFound/ the <Switch> fallback
├── index.scss reset, theme palettes, CSS custom properties
└── main.tsx routes, providers, ScrollToTop, OfflineToast
The two files you'll touch first are nav.config.ts (navigation is data) and index.scss (the palette). Routes are registered as <Route>
children of the <Switch> in main.tsx.
Four tsconfigs: tsconfig.json only holds references,
tsconfig.base.json holds the option shared by the other two, and
tsconfig.app.json / tsconfig.node.json keep only what's specific to each.
pnpm install
pnpm dev| Command | Description |
|---|---|
pnpm dev |
Start dev server |
pnpm build |
Lint, type-check, and build |
pnpm preview |
Preview production build locally |
pnpm lint |
Run ESLint |
pnpm lint:fix |
Run ESLint with auto-fix |
- Styles: Edit
src/index.scssto change the colors, fonts, or add variables. You can add individual component-level.scssfiles as you expand the site. - Routing: Add new pages in
src/pages/and register<Route>s inside the<Switch>inmain.tsx. Uselazy()+<Suspense>for code-split routes. - PWA: Rename the app and repoint the icons under
manifestinvite.config.ts, and replace the placeholder icons inpublic/. Images are cached on first view rather than precached, so only code, styles and markup ship in the initial cache. Or remove the PWA entirely if you don't need offline support. - Context providers: Wrap
<Switch>inmain.tsxwith any context providers you need (e.g. theme, auth).
The navbar is position: fixed, so it floats above your content and doesn't take up layout space. Content at the top of a page will slide underneath it unless
you offset it.
Add a spacer once, wherever you render <Navbar />:
<Navbar />
<div className="nav-spacer" />// index.scss
.nav-spacer {
height: calc(56px + 2rem + env(safe-area-inset-top));
}The height matches the navbar's at-rest size: the 56px bar plus its 1rem top/bottom padding plus the safe-area inset. The bar shrinks on scroll, but since
it's fixed the spacer only needs to clear the initial height.
For hero pages (<Navbar isHero={true} />), you can skip the spacer so the transparent bar sits over the hero content.
If you don't want offline support, the whole thing comes out in four steps:
pnpm remove vite-plugin-pwa
rm -r src/components/OfflineToast- Delete the
VitePWA({ ... })block and itsimportfromvite.config.ts, leavingplugins: [react()]. - Delete the
OfflineToastimport and<OfflineToast />fromsrc/main.tsx. - Drop the
dev-distentry from.gitignore. - The plugin was generating
/manifest.webmanifestand injecting the<link rel="manifest">intoindex.html. Without it there is no manifest at all — the icons inpublic/are still served, but the app is no longer installable. If you want the icons without the offline behaviour, add a staticpublic/site.webmanifestand link it yourself.
Deleting the plugin does not remove the service worker from browsers that already have it. A registered worker keeps serving its cached index.html on
every visit, and since your new build no longer ships a sw.js to replace it, those visitors can be pinned to the old version indefinitely. There is no way to
reach them after the fact.
So if the app has shipped with the PWA enabled, do this first, as its own release:
VitePWA({
selfDestroying: true,
// leave the rest of the config as-is
})That builds a service worker whose only job is to unregister itself and delete its caches. Deploy it, leave it up long enough for returning visitors to pick it up — a week is a reasonable default, longer if your traffic is sparse — and only then remove the plugin using the steps above.
If the PWA has never been deployed anywhere, skip all of this and just delete it.
This template provides a minimal setup to get React working in Vite with HMR and some ESLint rules.
Currently, two official plugins are available:
- @vitejs/plugin-react uses Oxc
- @vitejs/plugin-react-swc uses SWC
The React Compiler is not enabled on this template because of its impact on dev & build performances. To add it, see this documentation.
If you are developing a production application, we recommend updating the configuration to enable type-aware lint rules:
export default defineConfig([
globalIgnores(['dist']),
{
files: ['**/*.{ts,tsx}'],
extends: [
// Other configs...
// Remove tseslint.configs.recommended and replace with this
tseslint.configs.recommendedTypeChecked,
// Alternatively, use this for stricter rules
tseslint.configs.strictTypeChecked,
// Optionally, add this for stylistic rules
tseslint.configs.stylisticTypeChecked,
// Other configs...
],
languageOptions: {
parserOptions: {
project: ['./tsconfig.node.json', './tsconfig.app.json'],
tsconfigRootDir: import.meta.dirname,
},
// other options...
},
},
])You can also install eslint-plugin-react-x and eslint-plugin-react-dom for React-specific lint rules:
// eslint.config.js
import reactX from 'eslint-plugin-react-x';
import reactDom from 'eslint-plugin-react-dom';
export default defineConfig([
globalIgnores(['dist']),
{
files: ['**/*.{ts,tsx}'],
extends: [
// Other configs...
// Enable lint rules for React
reactX.configs['recommended-typescript'],
// Enable lint rules for React DOM
reactDom.configs.recommended,
],
languageOptions: {
parserOptions: {
project: ['./tsconfig.node.json', './tsconfig.app.json'],
tsconfigRootDir: import.meta.dirname,
},
// other options...
},
},
]);