Table of Contents
- Table of Contents
- π― Why Another Boilerplate?
- ποΈ High-Level Architecture
- π Project Structure β Organized for Scale
- β‘ The Foundation: Vite + React + TypeScript
- π§ Routing: TanStack Router
- ποΈ State Management: The Two-Store Strategy
- π API Layer: ky + ApiService Pattern
- π API Mocking: MSW
- π Internationalization: i18next
- π§ͺ Testing Strategy: The Full Pyramid
- π¨ UI Layer: Shadcn UI + Tailwind CSS
- π§ Developer Experience (DX) Toolchain
- π οΈ DevTools: Development-Only by Design
- π TypeScript: Beyond the Basics
- π The Release Pipeline
- π Dependency Philosophy
- π Getting Started in 30 Seconds
- π¬ Wrapping Up
π― Why Another Boilerplate?
Every new React project starts with the same ritual: configure the bundler, set up linting, add routing, figure out state management, wire up testing... and before you know it, you have spent two days on tooling instead of building features.
I built react-vite-boilerplate to eliminate that friction. It is not a minimal starter β it is an opinionated, production-ready foundation that codifies hard-won architectural decisions so you can ship from day one.
In this post, I will walk you through:
- The architecture and how each layer connects
- Why each dependency was chosen (and what alternatives were rejected)
- The developer experience toolchain that keeps the codebase healthy
- The testing strategy that spans unit, component, and E2E layers
- The release pipeline with automated versioning and npm publishing
ποΈ High-Level Architecture
The boilerplate follows a clear layered architecture where each tier has a single, well-defined responsibility:
βββββββββββββββββββββββββββββββββββββββββββββββββββ
β UI Layer β
β React Components Β· Shadcn UI Β· Tailwind CSS β
β Storybook (component workshop) β
βββββββββββββββββββββββββββββββββββββββββββββββββββ€
β Routing Layer β
β TanStack Router (file-based, fully typesafe) β
ββββββββββββββββββββββ¬βββββββββββββββββββββββββββββ€
β Server State β Client State β
β TanStack Query β Zustand β
ββββββββββββββββββββββ΄βββββββββββββββββββββββββββββ€
β API Layer β
β ky HTTP client Β· ApiService class β
βββββββββββββββββββββββββββββββββββββββββββββββββββ€
β Mock Layer β
β MSW (Mock Service Worker) β
βββββββββββββββββββββββββββββββββββββββββββββββββββ
Why layers? Each layer can be understood, tested, and replaced independently. Swap Zustand for Jotai? Only the client state layer changes. Switch from ky to axios? Only the API layer is affected.
π Project Structure β Organized for Scale
The folder structure is intentionally feature-oriented at the top level yet technically separated within each feature:
src/
βββ app/ # App shell: providers, router, auth context
βββ api/ # Service classes + React Query hooks per domain
β βββ apiService.ts # Base HTTP client (ky wrapper)
β βββ auth/ # Auth-specific API logic
β βββ posts/ # Posts domain: service + hooks + types
β βββ user/ # User domain
βββ components/ # Shared components
β βββ ui/ # Shadcn UI primitives (Button, Toast, etc.)
β βββ layout/ # Navbar, Footer, Sidebar
β βββ forms/ # Form components with React Hook Form
β βββ hooks/ # Shared custom hooks
βββ config/ # App configuration constants
βββ mocker/ # MSW handlers and server setup
βββ modules/ # Cross-cutting modules
β βββ i18n/ # Internationalization (i18next)
βββ pages/ # Page-level components
β βββ Home/
β βββ Auth/
β βββ App/
βββ routes/ # TanStack Router file-based route definitions
βββ store/ # Zustand store with slices + middlewares
βββ tests/ # Test utilities, setup, and wrappers
βββ types/ # Global TypeScript type definitions
βββ utils/ # Pure utility functions
Key insight: The
api/directory co-locates service classes, React Query hooks, and TypeScript types for each domain. This means everything related to "posts" lives inapi/posts/β making it trivial to find, modify, or delete an entire feature.
β‘ The Foundation: Vite + React + TypeScript
Why Vite?
Vite is the fastest development server in the React ecosystem. But we go further with a carefully tuned vite.config.ts:
export default defineConfig(({ mode }) => {
const env = loadEnv(mode, process.cwd() + "/env");
const isDevMode = mode === "development";
const plugins = [
svgr(), // Import SVGs as React components
TanStackRouterVite(), // Auto-generate route tree
react(), // React Fast Refresh
inspect(), // Vite plugin inspector (dev)
];
// Conditional dev-time checks
if (isDevMode) {
plugins.push(
checker({
typescript: shouldCheckTypeScriptDev,
eslint: shouldCheckESLintDev ? { ... } : undefined,
})
);
}
// Bundle analysis on demand
if (process.env.ANALYZE === "true") {
plugins.push(visualizer({ template: "treemap", open: true }));
}
return { plugins };
});
What makes this special:
| Plugin | Purpose |
|---|---|
vite-plugin-svgr |
Import .svg files as React components |
@tanstack/router-plugin |
Auto-generates the route tree from the file system |
vite-plugin-checker |
TypeScript + ESLint checks during dev (configurable via env vars) |
vite-plugin-inspect |
Inspect Vite internals β useful for debugging transform pipelines |
rollup-plugin-visualizer |
On-demand bundle analysis with treemap, gzip, and brotli sizes |
π‘ Pro tip: The TypeScript and ESLint dev checks are toggleable via environment variables (
VITE_TSC_DEV_CHECK,VITE_ESLINT_DEV_CHECK). This lets you disable them when iterating quickly and re-enable for pre-commit validation.
π§ Routing: TanStack Router
Why TanStack Router over React Router?
Full type safety. TanStack Router is the only React router that provides end-to-end TypeScript inference β from route params, to search params, to loader data, to the context object.
// routes/app.tsx β Protected route with type-safe context
export const Route = createFileRoute("/app")({
beforeLoad: ({ context }) => {
// context.auth is fully typed as TAuthStoreState | null
if (!context.auth?.user) {
redirect({ to: "/auth/login", throw: true });
}
},
component: AppLayout,
});
File-based routing means the route hierarchy mirrors the file system:
routes/
βββ __root.tsx β Root layout (devtools, document title)
βββ index.tsx β "/" (landing page)
βββ app.tsx β "/app" (protected layout)
βββ app/
β βββ ... β "/app/*" child routes
βββ auth.tsx β "/auth" (guest-only layout)
βββ auth/
βββ login.tsx β "/auth/login"
The router is configured to work seamlessly with React Query by sharing the queryClient via context:
export const router = createRouter({
routeTree,
context: { auth: null, queryClient },
defaultPreload: "intent",
defaultPreloadStaleTime: 0, // Always re-fetch on preload
});
Why defaultPreloadStaleTime: 0? Since React Query manages caching, we want the router to always invoke the loader so React Query can decide whether to serve from cache or refetch. This gives us a single source of truth for cache policy.
ποΈ State Management: The Two-Store Strategy
State management is where most boilerplates get it wrong by choosing a single solution for everything. This boilerplate makes a deliberate split:
ββββββββββββββββββββββββββββββββββββββββββββββββ
β State Management Strategy β
ββββββββββββββββββββββββ¬ββββββββββββββββββββββββ€
β Server State β Client State β
β TanStack Query β Zustand β
β β β
β β’ API responses β β’ Auth state β
β β’ Caching β β’ UI preferences β
β β’ Refetching β β’ Session data β
β β’ Pagination β β’ Form state β
β β’ Optimistic β β’ Theme β
β updates β β
ββββββββββββββββββββββββ΄ββββββββββββββββββββββββ
TanStack Query β Server State
Server state is data that lives on the server and is temporarily borrowed by the client. React Query handles caching, background refetching, stale-while-revalidate, pagination, and more.
Here is how we structure API queries:
// api/posts/posts.hooks.ts
export const useGetPostsQuery = (
options?: Omit<UseQueryOptions<TGetPostsResponsePayload, TApiServiceError>,
"queryKey" | "queryFn">
) => {
return useQuery({
queryKey: [GET_POSTS_QUERY_KEY],
queryFn: async () => await postsService.getPosts(),
...options,
});
};
Why wrap useQuery in custom hooks? Encapsulation. The consuming component does not need to know the query key, the service method, or how to handle errors β it just calls useGetPostsQuery().
Zustand β Client State
For client-only state (auth, theme, UI preferences), Zustand provides a lightweight, boilerplate-free solution:
// store/store.ts
export const useStoreBase = create<TStore>()(
logger( // Custom logging middleware
devtools( // Redux DevTools integration
subscribeWithSelector( // Fine-grained subscriptions
persist(store, { // Session storage persistence
name: STORE_NAME,
storage: createJSONStorage(() => sessionStorage),
version: STORE_VERSION,
migrate: (persistedState, prevVersion) => {
// Handle breaking changes gracefully
},
})
),
{ enabled: isDebugMode }
),
{ name: STORE_NAME, enabled: isDebugMode }
)
);
The middleware stack tells a story:
-
persistβ Survives page refreshes viasessionStoragewith versioned migrations -
subscribeWithSelectorβ Components re-render only when their specific slice changes -
devtoolsβ Time-travel debugging in Redux DevTools (only in dev) -
loggerβ Custom middleware that logs state transitions with styled console output
Auto-Generated Selectors
Instead of writing selectors by hand, the createSelectors utility generates them automatically:
// Instead of:
const user = useStore((state) => state.user);
// You write:
const user = useStore.use.user();
This is powered by a small but elegant utility:
export const createSelectors = <S extends UseBoundStore<StoreApi<object>>>(_store: S) => {
const store = _store as TWithSelectors<typeof _store>;
store.use = {};
for (const k of Object.keys(store.getState())) {
(store.use as any)[k] = () => store((s) => s[k as keyof typeof s]);
}
return store;
};
π API Layer: ky + ApiService Pattern
Why ky over Axios or fetch?
| Feature | fetch |
axios |
ky |
|---|---|---|---|
| Bundle size | 0 KB | ~13 KB | ~3 KB |
| Retry logic | Manual | Manual | Built-in |
| Hook system | No | Interceptors | Hooks |
| JSON parsing | Manual | Automatic | Automatic |
| TypeScript | OK | OK | Excellent |
ky gives us the best of both worlds: a tiny bundle with powerful features like hooks, timeout, and retry β without the overhead of axios.
The ApiService abstraction
Rather than coupling ky throughout the app, we wrap it in a class that implements a generic THttpService interface:
export type THttpService<Options> = {
get<R>(url: string, options?: Options): Promise<R>;
post<R, B>(url: string, body: B, options?: Options): Promise<R>;
put<R, B>(url: string, body?: B, options?: Options): Promise<R>;
// ...
};
export class ApiService implements THttpService<Options> {
constructor(options: Options = {}) {
this.kyInstance = ky.create({
hooks: {
beforeRequest: [(request) => {
request.headers.set("Accept-Language", i18n.currentLanguage);
}],
beforeError: [(error) => {
// Translate HTTP error codes to i18n messages
error.message = i18n.t(["errors:http.status", "errors:http.unknown"], {
context: response.status,
});
return error;
}],
},
prefixUrl: apiURL(),
retry: { limit: 0 },
});
}
}
Why this pattern?
-
Swap-ability: Replacing
kywithaxiosorfetchrequires changing onlyApiService - Cross-cutting concerns: Language headers, error translations, and auth tokens are configured once
-
Testability: Mock the
ApiServicein tests without mocking HTTP internals
π API Mocking: MSW
Mock Service Worker is the unsung hero of this boilerplate. It intercepts HTTP requests at the network level, meaning your application code does not know (or care) whether it is talking to a real API or a mock.
// mocker/server.ts
const worker = setupWorker(...handlers);
export const runServer = () => {
return worker.start({
onUnhandledRequest: "bypass",
serviceWorker: {
url: serviceWorkerUrl,
options: { scope: appBasePath },
},
});
};
Why MSW over JSON server or local Express?
- Works in the browser (dev) and in Node (unit tests) with the same handlers
- No separate process to start β it runs alongside your app
- Handlers are strongly typed and co-located with API code
- E2E tests can override individual handlers for edge cases
In main.tsx, the MSW server starts before React renders:
async function setupApp() {
await i18n.configure();
const mocker = await import("./mocker/index.ts");
await mocker.runServer();
}
setupApp().then(() => {
root.render(<StrictMode><App /></StrictMode>);
});
π Internationalization: i18next
The I18n class wraps i18next with a clean, testable API:
export class I18n {
async configure({ initOptions, config } = {}) {
const i18nInstance = this.i18n.use(initReactI18next);
if (config.withLanguageDetector) {
i18nInstance.use(LanguageDetector);
}
// Dynamic imports for locale files via Vite glob
i18nInstance.use({
type: "backend",
read: async (language, namespace, callback) => {
const modules = import.meta.glob("./locales/*/*.json");
const data = await modules[`./locales/${language}/${namespace}.json`]();
callback(null, data);
},
});
}
}
Key design decisions:
-
Vite
import.meta.globfor lazy-loading locale files β only the active language is loaded -
Custom formatters (
uppercase,lowercase) registered at init time - Browser language detection with localStorage caching and graceful fallback
-
Namespace-based organization β separate
common.json,errors.json,auth.jsonper language -
Type-safe translations via
i18next.d.tsdeclarations
π§ͺ Testing Strategy: The Full Pyramid
The boilerplate implements a complete testing pyramid with MSW as the mocking backbone across all layers:
β±β²
β± β²
β± E2Eβ² Cypress
β±βββββββ²
β±Componentβ² Storybook + Vitest Browser Mode
β±βββββββββββββ²
β± Unit Tests β² Vitest + React Testing Library
β±βββββββββββββββββββ²
β± MSW (all layers) β²
βββββββββββββββββββββββββ
Unit Tests: Vitest + React Testing Library
Vitest shares Vite's config and transformation pipeline, which means zero config drift between dev and test environments:
// vitest.config.ts (simplified)
test: {
name: "unit",
environment: "jsdom",
setupFiles: ["./tests/setup.ts"],
typecheck: { enabled: true }, // Test your types too!
mockReset: true,
coverage: {
provider: "v8",
reporter: ["html"],
},
}
The test wrapper is designed for maximum realism:
export const Wrapper: FC<PropsWithChildren<TWrapperProps>> = ({
children,
config = { withToaster: true, withRouter: true },
}) => {
const providers: TProvider[] = [];
providers.push({ Provider: ThemeProvider, props: {} });
if (config?.withRouter) {
// Sets up a real TanStack Router instance for each test
const rootRoute = createRootRoute({ component: Outlet });
const indexRoute = createRoute({ /* ... */ });
providers.push({ Provider: RouterProvider, props: { router } });
}
return <>{addProviders(providers, children)}</>;
};
Component Tests: Storybook + Vitest Browser
Storybook is not just for documentation β it is a testing runtime. The boilerplate uses @storybook/addon-vitest to run story-based tests in a real browser via Playwright:
// vitest.config.ts β storybook project
{
plugins: [
storybookTest({ configDir: ".storybook" }),
],
test: {
name: "storybook",
browser: {
enabled: true,
provider: playwright(),
instances: [{ browser: "chromium" }],
},
},
}
Accessibility is enforced: The @storybook/addon-a11y plugin runs axe-core checks on every story, configured to fail on violations.
E2E Tests: Cypress
For full user-journey validation:
npm run test:e2e # Interactive mode
npm run test:e2e:headless # CI mode
Cypress tests live in cypress/specs/ and use custom ESLint rules to enforce best practices like data-* selectors and assertion-before-screenshot.
π¨ UI Layer: Shadcn UI + Tailwind CSS
Why Shadcn UI?
Shadcn is not a component library β it is a collection of copy-paste components built on Radix UI primitives. You own the code:
components/ui/
βββ Button/ # Button with variants via CVA
βββ Toast/ # Toast notifications (Radix)
βββ DropdownMenu/ # Dropdown with keyboard nav
βββ Tooltip/ # Accessible tooltips
βββ ThemeToggler/ # Dark/light mode switch
βββ LangToggler/ # Language switcher
βββ Typography/ # Consistent text styles
βββ Brand/ # Logo and branding
βββ RouteLink/ # Type-safe navigation link
Why this over Material UI or Ant Design?
- Zero runtime overhead β it is just your code
- Full customization β no fighting framework CSS
- Radix primitives β best-in-class accessibility (WAI-ARIA)
- CVA (Class Variance Authority) β Type-safe component variants
π§ Developer Experience (DX) Toolchain
The DX pipeline ensures quality at every stage:
Code β Lint β Format β Commit
Developer writes code
β
ββββββΌβββββ
β ESLint β Static analysis with 8 plugins
ββββββ¬βββββ (react, hooks, refresh, cypress,
β storybook, testing-library, typescript)
ββββββΌβββββββ
β Prettier β Opinionated formatting with
ββββββ¬βββββββ import sorting + Tailwind ordering
β
ββββββΌβββββββββββ
β lint-staged β Only processes staged files
ββββββ¬βββββββββββ
β
ββββββΌβββββββββββ
β Commitizen β Conventional commit messages
ββββββ¬βββββββββββ
β
ββββββΌβββββββ
β Devmoji β Automatic emoji decoration β¨
ββββββ¬βββββββ
β
ββββββΌβββββββ
β Husky β Git hooks orchestration
βββββββββββββ
ESLint: Context-Aware Rules
The ESLint config uses flat config with context-aware rule sets:
export default tseslint.config(
// Base: TypeScript recommended + stylistic
{ extends: [...tseslint.configs.recommendedTypeChecked] },
// Stories: Storybook-specific rules
{ files: ["**/*.stories.{ts,tsx}"], extends: storybook.configs["flat/recommended"] },
// Tests: Testing Library rules
{ files: ["**/*.{spec,test}.{ts,tsx}"], extends: testingLibrary.configs["flat/react"] },
// E2E: Cypress rules
{ files: ["cypress/**/*"], extends: cypress.configs.recommended },
);
Naming conventions are enforced at the type level:
// All type aliases must start with "T"
"@typescript-eslint/naming-convention": [
"error",
{ selector: ["typeAlias"], format: ["PascalCase"],
custom: { regex: "^T[A-Z]", match: true } },
]
π οΈ DevTools: Development-Only by Design
The boilerplate ships with three sets of devtools that are automatically tree-shaken in production:
// components/developmentTools.tsx
export const TanStackRouterDevelopmentTools = !isDevelopment
? (): null => null // No-op in production
: React.lazy(() =>
import("@tanstack/router-devtools").then((result) => ({
default: result.TanStackRouterDevtools,
}))
);
The pattern:
- Check
isDevelopmentat module scope - In production: export a component that returns
null(zero bundle cost) - In development:
React.lazy()for code-split loading
This means devtools are never included in your production bundle β not even as dead code.
π TypeScript: Beyond the Basics
The boilerplate includes a set of global utility types that solve real problems:
// Deeply partial types (useful for Zustand store updates)
type RecursivePartial<T> = {
[P in keyof T]?: T[P] extends (infer U)[]
? RecursivePartial<U>[]
: T[P] extends object | undefined
? RecursivePartial<T[P]>
: T[P];
};
// Type-safe value extraction from const objects
type ValueOf<T> = T[keyof T];
// const THEME = { LIGHT: "light", DARK: "dark" } as const;
// type TTheme = ValueOf<typeof THEME>; // "light" | "dark"
// String manipulation at the type level
type ReplaceFirst<TString, TToReplace, TReplacement> =
TString extends `${infer P}${TToReplace}${infer S}`
? `${P}${TReplacement}${S}`
: TString;
Type testing is first-class β all *.test-d.ts files are automatically discovered and validated by Vitest.
π The Release Pipeline
The boilerplate includes a complete automated release system powered by Changesets + GitHub Actions:
Feature Branch βββΊ PR to main βββΊ Merge
β
ββββββββββββΌβββββββββββ
β release.yml runs β
β lint β typecheck β
β β test β publish |
ββββββββββββ¬βββββββββββ
β
ββββββββββββββββββΌβββββββββββββββββ
β Release PR created β
β version bump + changelog β
ββββββββββββββββββ¬βββββββββββββββββ
β
Merge Release PR
β
ββββββββββββΌβββββββββββ
β npm publish β
β + GitHub Release β
βββββββββββββββββββββββ
The published CLI package @singhamandeep/crvb lets anyone scaffold a new project:
npx @singhamandeep/crvb@latest my-app
cd my-app && npm install && npm run dev
π Dependency Philosophy
Every dependency was chosen based on three criteria:
| Criteria | Question |
|---|---|
| Necessity | Does this solve a real problem that would require significant custom code? |
| Quality | Is this actively maintained, well-tested, and has a healthy community? |
| Size | Is this the smallest possible solution that meets our needs? |
| Category | Choice | Why This Over Alternatives |
|---|---|---|
| Bundler | Vite | 10-100x faster than Webpack; native ESM |
| Router | TanStack Router | Only fully type-safe React router |
| Server State | TanStack Query | Best caching + devtools; de facto standard |
| Client State | Zustand | ~1KB; no boilerplate; middleware-composable |
| HTTP Client | ky | ~3KB fetch wrapper with hooks and retry |
| Forms | React Hook Form + Zod | Uncontrolled perf; schema-first validation |
| UI | Shadcn + Radix | You own the code; best a11y primitives |
| Styling | Tailwind CSS | Utility-first; no CSS-in-JS runtime cost |
| i18n | i18next | Most mature; plugin ecosystem |
| Utilities | Radash | Modern lodash; tree-shakeable; zero deps |
| Date | date-fns | Modular; tree-shakeable; immutable |
| API Mocking | MSW | Network-level; browser + Node |
| Unit Test | Vitest | Vite-native; Jest-compatible API |
| E2E Test | Cypress | Best DX for E2E; time-travel debugging |
| Components | Storybook | Industry standard; visual testing |
π Getting Started in 30 Seconds
# Option 1: Scaffold via CLI
npx @singhamandeep/crvb@latest my-app
cd my-app && npm install && npm run dev
# Option 2: Clone directly
git clone https://github.com/singhAmandeep007/react-vite-boilerplate.git
cd react-vite-boilerplate
npm install && npm run prepare && npm run dev
π¬ Wrapping Up
This boilerplate is the result of years of iterating on production React applications. Every choice β from the middleware stack in Zustand to the ESLint naming conventions β was battle-tested in real projects before making it into the template.
The goal is not to provide every feature, but to provide the right foundation that scales from a side project to a production application without requiring an architectural rewrite.
Star the repo if you found this useful: β react-vite-boilerplate
Scaffold your next project:
npx @singhamandeep/crvb@latest my-next-app
Have questions or suggestions? Open an issue or drop a comment below β I would love to hear how you are using it! π
singhAmandeep007
/
react-vite-boilerplate
Delightful boilerplate for starting your awesome react + vite application.
React Vite Boilerplate
Everything you need to start with your next Vite + React web app! Delighful developer experience with batteries included.
Demo
react-vite-boilerplate.webm
Table of Contents
- Overview
- Requirements
- Getting Started
- Scaffold via npx
- Scripts
- Open Source Setup
- Publishing and Releases
- Release System Diagram
- Versioning Guide
- Owner Release Checklist
- Complete Owner Example
- Important Note
- Testing
- Deployment
- DevTools
- Installed Packages
Overview
Built with type safety, scalability, and developer experience in mind. A batteries included Vite + React template.
A more detailed list of the included packages can be found in the Installed Packages section.
Requirements
- NodeJS 24.x (see .nvmrc)
- npm
Getting Started
Getting started is a simple as cloning the repository
git clone https://github.com/singhAmandeep007/react-vite-boilerplate.git
Changing into the new directory
cd react-vite-boilerplate
Removing the .git folder (and any additional files, folders or dependencies you may not need)
rm -rf .git
Installing dependencies
npm install
And running the setup scriptβ¦
Happy coding! β¨








Top comments (0)