End-to-end React project setup for 2026.

Bootstrap

npm create vite@latest myapp -- --template react-ts
cd myapp
npm i

Dependencies

npm i react-router @tanstack/react-query zustand zod react-hook-form @hookform/resolvers
npm i -D tailwindcss postcss autoprefixer
npm i -D vitest @vitest/coverage-v8 jsdom \
  @testing-library/react @testing-library/jest-dom @testing-library/user-event \
  msw
npm i -D eslint @eslint/js typescript-eslint eslint-plugin-react eslint-plugin-react-hooks \
  eslint-config-prettier prettier prettier-plugin-tailwindcss
npm i -D @tanstack/react-query-devtools @welldone-software/why-did-you-render

tsconfig.json

{
  "compilerOptions": {
    "target": "ES2023",
    "module": "ESNext",
    "moduleResolution": "Bundler",
    "jsx": "react-jsx",
    "lib": ["ES2023", "DOM", "DOM.Iterable"],
    "strict": true,
    "noUncheckedIndexedAccess": true,
    "noImplicitOverride": true,
    "esModuleInterop": true,
    "skipLibCheck": true,
    "isolatedModules": true,
    "verbatimModuleSyntax": true,
    "resolveJsonModule": true,
    "noEmit": true,
    "allowImportingTsExtensions": true,
    "paths": { "@/*": ["./src/*"] }
  },
  "include": ["src", "test"]
}

vite.config.ts

import { defineConfig } from "vite";
import react from "@vitejs/plugin-react";
import path from "node:path";

export default defineConfig({
  plugins: [react()],
  resolve: { alias: { "@": path.resolve(__dirname, "./src") } },
  server: { port: 3000 },
});

Tailwind

npx tailwindcss init -p

tailwind.config.js:

export default {
  content: ["./index.html", "./src/**/*.{ts,tsx}"],
  theme: { extend: {} },
  plugins: [],
};

src/index.css:

@tailwind base;
@tailwind components;
@tailwind utilities;

.prettierrc

{
  "semi": true,
  "trailingComma": "all",
  "singleQuote": false,
  "printWidth": 100,
  "plugins": ["prettier-plugin-tailwindcss"]
}

eslint.config.js (flat)

import js from "@eslint/js";
import ts from "typescript-eslint";
import react from "eslint-plugin-react";
import reactHooks from "eslint-plugin-react-hooks";
import prettier from "eslint-config-prettier";

export default ts.config(
  js.configs.recommended,
  ...ts.configs.strictTypeChecked,
  {
    files: ["**/*.{ts,tsx}"],
    plugins: { react, "react-hooks": reactHooks },
    rules: {
      ...react.configs.recommended.rules,
      ...reactHooks.configs.recommended.rules,
      "react/react-in-jsx-scope": "off",
    },
    languageOptions: {
      parserOptions: { projectService: true, tsconfigRootDir: import.meta.dirname },
    },
    settings: { react: { version: "detect" } },
  },
  prettier,
  { ignores: ["dist", "coverage", "node_modules"] },
);

vitest.config.ts

import { defineConfig, mergeConfig } from "vitest/config";
import viteConfig from "./vite.config";

export default mergeConfig(viteConfig, defineConfig({
  test: {
    globals: true,
    environment: "jsdom",
    setupFiles: ["./test/setup.ts"],
    coverage: { provider: "v8", reporter: ["text", "html"] },
  },
}));

test/setup.ts:

import "@testing-library/jest-dom/vitest";

App structure

src/
├── main.tsx
├── App.tsx
├── routes/
│   ├── _root.tsx
│   ├── index.tsx
│   └── users/$id.tsx
├── components/
│   ├── ui/             # shadcn-style primitives
│   └── ...
├── hooks/
├── lib/
│   ├── api.ts
│   ├── auth.ts
│   └── env.ts
├── stores/
│   └── auth.ts
├── styles/
│   └── index.css
└── types/

src/main.tsx

import React from "react";
import ReactDOM from "react-dom/client";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { BrowserRouter } from "react-router";
import App from "./App";
import "./styles/index.css";

const qc = new QueryClient({
  defaultOptions: { queries: { staleTime: 60_000, refetchOnWindowFocus: false } },
});

ReactDOM.createRoot(document.getElementById("root")!).render(
  <React.StrictMode>
    <QueryClientProvider client={qc}>
      <BrowserRouter>
        <App />
      </BrowserRouter>
    </QueryClientProvider>
  </React.StrictMode>,
);

src/lib/env.ts

import { z } from "zod";

const Env = z.object({
  VITE_API_URL: z.string().url(),
});

export const env = Env.parse(import.meta.env);

package.json scripts

{
  "scripts": {
    "dev": "vite",
    "build": "tsc -b && vite build",
    "preview": "vite preview",
    "typecheck": "tsc --noEmit",
    "lint": "eslint .",
    "format": "prettier --write .",
    "test": "vitest",
    "test:run": "vitest run --coverage"
  }
}

CI

.github/workflows/ci.yml:

name: CI
on: [push, pull_request]

jobs:
  test:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-node@v4
        with: { node-version: "20", cache: "npm" }
      - run: npm ci
      - run: npm run typecheck
      - run: npm run lint
      - run: npm run test:run
      - run: npm run build

Pre-commit

npm i -D husky lint-staged
npx husky init
{
  "lint-staged": {
    "*.{ts,tsx}": ["eslint --fix", "prettier --write"],
    "*.{css,json,md}": ["prettier --write"]
  }
}

.husky/pre-commit:

npx lint-staged

.gitignore

node_modules/
dist/
coverage/
*.log
.env
.DS_Store

Common conventions

  • One folder per feature, not per file type.
  • Co-locate tests next to source (Component.tsx + Component.test.tsx).
  • Validate env at boundary (Zod).
  • Strict TS from day one.
  • StrictMode in dev.
  • One QueryClient at app root.

Read this next

That’s 20 React cheatsheets. Next category: Next.js.

If you want my full React starter (Vite + Tailwind + Query + Router), it’s at rajpoot.dev .


Building something AI-, backend-, or data-heavy and want a second pair of eyes? I do consulting and freelance work — see my projects and ways to reach me at rajpoot.dev .