DEV Community

Cover image for Building a Production-Ready React + Vite + TypeScript Boilerplate: Architecture, Choices & DX Deep-Dive
Amandeep Singh
Amandeep Singh Subscriber

Posted on

Building a Production-Ready React + Vite + TypeScript Boilerplate: Architecture, Choices & DX Deep-Dive

Table of Contents

🎯 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)                     β”‚
β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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 in api/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 };
});
Enter fullscreen mode Exit fullscreen mode

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,
});
Enter fullscreen mode Exit fullscreen mode

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"
Enter fullscreen mode Exit fullscreen mode

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
});
Enter fullscreen mode Exit fullscreen mode

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          β”‚                       β”‚
β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”΄β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜
Enter fullscreen mode Exit fullscreen mode

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,
  });
};
Enter fullscreen mode Exit fullscreen mode

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 }
  )
);
Enter fullscreen mode Exit fullscreen mode

The middleware stack tells a story:

  1. persist β€” Survives page refreshes via sessionStorage with versioned migrations
  2. subscribeWithSelector β€” Components re-render only when their specific slice changes
  3. devtools β€” Time-travel debugging in Redux DevTools (only in dev)
  4. 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();
Enter fullscreen mode Exit fullscreen mode

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;
};
Enter fullscreen mode Exit fullscreen mode

🌐 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 },
    });
  }
}
Enter fullscreen mode Exit fullscreen mode

Why this pattern?

  • Swap-ability: Replacing ky with axios or fetch requires changing only ApiService
  • Cross-cutting concerns: Language headers, error translations, and auth tokens are configured once
  • Testability: Mock the ApiService in 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 },
    },
  });
};
Enter fullscreen mode Exit fullscreen mode

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>);
});
Enter fullscreen mode Exit fullscreen mode

🌍 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);
      },
    });
  }
}
Enter fullscreen mode Exit fullscreen mode

Key design decisions:

  • Vite import.meta.glob for 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.json per language
  • Type-safe translations via i18next.d.ts declarations

πŸ§ͺ 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)  β•²
 β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜
Enter fullscreen mode Exit fullscreen mode

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"],
  },
}
Enter fullscreen mode Exit fullscreen mode

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)}</>;
};
Enter fullscreen mode Exit fullscreen mode

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" }],
    },
  },
}
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen 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
Enter fullscreen mode Exit fullscreen mode

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
    β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜
Enter fullscreen mode Exit fullscreen mode

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 },
);
Enter fullscreen mode Exit fullscreen mode

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 } },
]
Enter fullscreen mode Exit fullscreen mode

πŸ› οΈ 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,
      }))
    );
Enter fullscreen mode Exit fullscreen mode

The pattern:

  1. Check isDevelopment at module scope
  2. In production: export a component that returns null (zero bundle cost)
  3. 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;
Enter fullscreen mode Exit fullscreen mode

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   β”‚
                         β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

πŸ“Š 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
Enter fullscreen mode Exit fullscreen mode

🎬 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
Enter fullscreen mode Exit fullscreen mode

Have questions or suggestions? Open an issue or drop a comment below β€” I would love to hear how you are using it! πŸš€


GitHub logo 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

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

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)