This article is published in English.
A Production-Ready React Baseline: What Every Package Actually Does
Set up Vite, Tailwind v4, Redux Toolkit, React Router, Jest and Prettier for a React app, and understand why each package and config line is there.
Running npm create vite gives you a React app that renders, but not one you would hand to real users: there is no styling system, no shared state, no routing, no tests and no agreed code format. This guide builds that missing foundation step by step with Tailwind CSS, Redux Toolkit, React Router, Jest with React Testing Library, and Prettier. For every package, it answers two questions: what does it actually do, and what breaks if you leave it out? By the end you will have a working base to build features on and, just as importantly, you will be able to read your own package.json and explain every line.
The stack at a glance:
- Tailwind CSS for styling
- Redux Toolkit for shared application data
- React Router for page navigation
- Jest, React Testing Library and a small Babel toolchain so tests can run at all
- Prettier so formatting stops being a matter of opinion
Some of these are one-line installs. Others hide surprising detail; "React Testing Library", for instance, is really three packages with three distinct jobs.
Start with a fresh Vite project using the React + TypeScript template:
npm create vite@latest react-production-stack -- --template react-ts
cd react-production-stack
npm install
Tailwind CSS: styling wired in first
Packages: tailwindcss, @tailwindcss/vite
Styling touches every component, so it makes sense to confirm it works before adding anything else.
npm install tailwindcss @tailwindcss/vite
This installs both packages as regular dependencies rather than devDependencies. Strictly speaking, neither package runs in the browser: the Vite plugin does its work at build time and only the generated CSS ends up in the production bundle. Many teams therefore put them under devDependencies, and for a bundled single-page app either choice builds the same output. Pick one convention and keep it consistent.
Next, register the plugin alongside the React plugin in the Vite config:
import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'
import tailwindcss from '@tailwindcss/vite'
export default defineConfig({
plugins: [react(), tailwindcss()],
})
Then replace the contents of src/index.css with a single import. This is the entire file:
/* Tailwind v4 is CSS-first. No config file, no content globs. */
@import 'tailwindcss';
That really is all the setup. Tailwind v4 is CSS-first: there is no tailwind.config.js and no list of content globs, because it scans your source files for class names on its own.
Verifying it works
Temporarily add a few utility classes to a heading in App.tsx, for example text-3xl font-bold text-blue-600, run npm run dev, and check that the heading changes. If it does, the plugin and the CSS import are connected.
Why utilities instead of separate stylesheets
Tailwind keeps styles directly on the markup they affect. With a separate CSS file, it is easy to edit a component and forget its stylesheet, which slowly accumulates dead and outdated rules. Dashboards in particular repeat the same building blocks (cards, badges, buttons) many times, and composing them from one shared set of utilities keeps them visually consistent with less code to maintain. The trade-off is a change in habit: instead of inventing class names such as .card-header-active, you assemble each element from small, predefined classes.
Redux Toolkit: a store and a bridge to React
Packages: @reduxjs/toolkit, react-redux
These two packages are easy to confuse, but they do different things:
@reduxjs/toolkitis the store itself: it holds application data and applies updates to it.react-reduxis the connection to React: it provides<Provider>and the hooks components use to read and update that data.
You need both, because neither can do the other's job.
npm install @reduxjs/toolkit react-redux
Create the store in src/app/store.ts. It starts with an empty reducer map and exports two types derived from the store so the rest of the app never has to spell them out by hand:
import { configureStore } from '@reduxjs/toolkit'
export const store = configureStore({
reducer: {},
})
export type RootState = ReturnType<typeof store.getState>
export type AppDispatch = typeof store.dispatch
The reducer: {} object stays empty for now. Slices get added once real features, such as projects or tasks, need them; there is no value in inventing state before any screen uses it.
Then define typed hooks in src/app/hooks.ts. The withTypes helpers, available in recent React Redux releases, bind useDispatch and useSelector to your store's types once, so components get full type inference without annotating every call:
import { useDispatch, useSelector } from 'react-redux'
import type { AppDispatch, RootState } from './store'
export const useAppDispatch = useDispatch.withTypes<AppDispatch>()
export const useAppSelector = useSelector.withTypes<RootState>()
Providing the store to the component tree
At this point the store exists, but React has no idea about it. <Provider> makes it available to every component beneath it, so it goes at the very top of the tree in src/main.tsx:
import { StrictMode } from 'react'
import { createRoot } from 'react-dom/client'
import { Provider } from 'react-redux'
import { store } from './app/store'
import App from './App'
import './index.css'
createRoot(document.getElementById('root')!).render(
<StrictMode>
<Provider store={store}>
<App />
</Provider>
</StrictMode>,
)
Anything rendered inside <Provider> can now call useAppSelector and useAppDispatch.
Verifying it works
Start the app and confirm the page still renders without a "could not find react-redux context" error. That error appears whenever a component uses the Redux hooks outside of a Provider. With an empty store there is nothing else to test yet.
When to reach for Redux and when useState is enough
Not everything belongs in Redux, and pushing all state into the store is as much of a mistake as keeping everything local. A practical rule of thumb:
useStatefor data that matters to a single screen or component: whether a modal is open, the current value of an input, the selected option in a dropdown.- Redux for data several screens or components need at once, such as a task list shown on multiple pages, or a single task that appears in the dashboard, the task list and the task detail view.
If a piece of state would otherwise be passed down through several layers or duplicated across screens, that is a good sign it belongs in the store.
React Router: routing before the first real page
Package: react-router
Adding routing before any real page exists sounds premature, but it pays off quickly: every new screen becomes one additional <Route>, instead of a retrofit that forces you to restructure the app later.
npm install react-router
Put the route table in its own module, src/routes/AppRoutes.tsx. For now it maps / to a placeholder component styled with Tailwind utilities:
import { Route, Routes } from 'react-router'
function Placeholder() {
return (
<div className="flex min-h-screen items-center justify-center">
<p className="text-slate-600">Routes coming soon</p>
</div>
)
}
export function AppRoutes() {
return (
<Routes>
<Route path="/" element={<Placeholder />} />
</Routes>
)
}
src/App.tsx then simply renders that route table:
import { AppRoutes } from './routes/AppRoutes'
function App() {
return <AppRoutes />
}
export default App
Finally, wrap the app in <BrowserRouter> inside src/main.tsx, next to the Redux provider:
import { StrictMode } from 'react'
import { createRoot } from 'react-dom/client'
import { Provider } from 'react-redux'
import { BrowserRouter } from 'react-router'
import { store } from './app/store'
import App from './App'
import './index.css'
createRoot(document.getElementById('root')!).render(
<StrictMode>
<Provider store={store}>
<BrowserRouter>
<App />
</BrowserRouter>
</Provider>
</StrictMode>,
)
The resulting chain is main.tsx → <App /> → <AppRoutes /> → whichever <Route> matches the URL. Redux and the router are independent, so their nesting order does not matter; the only requirement is that both wrap <App>.
Verifying it works
Run npm run dev and open /. If the placeholder text appears, <BrowserRouter>, <Routes> and <Route> are all wired correctly.
Jest and React Testing Library: four jobs, eleven packages
Packages: jest, @testing-library/react, babel-jest and several more
This is the step that takes the longest. The concepts are not hard, but "add testing" turns out to mean installing about eleven packages that cover four separate responsibilities, and then getting one test to pass with the router in place. Grouping the packages by job makes the whole thing much easier to follow.
Group A: the test runner and a simulated browser
npm install -D jest jest-environment-jsdom
- jest is the runner. It discovers
*.test.tsxfiles, executes them and reports passes and failures. Nothing else in this section works without it. - jest-environment-jsdom is needed because Jest runs in Node, where there is no
document. It provides a simulated DOM so components have somewhere to render.
Group B: React Testing Library is three packages
npm install -D @testing-library/react
npm install -D @testing-library/jest-dom
npm install -D @testing-library/user-event
What people call "React Testing Library" is really three libraries, each with its own role:
- @testing-library/react renders a component into the simulated page and provides queries such as
screen.getByText(...). - @testing-library/jest-dom adds readable matchers like
toBeInTheDocument(), so you do not have to compare query results againstnullby hand. - @testing-library/user-event simulates realistic user behavior. Typing produces the full focus, keydown, input and keyup sequence a browser would fire, rather than a single synthetic event dispatched at an element.
In short: render, assert, interact. Three jobs, three packages, and you almost always want all of them.
Group C: the Babel toolchain that lets Jest read TSX
This group exists for one reason: Jest cannot understand TypeScript or JSX files on its own.
- babel-jest connects the two. Jest passes each file through Babel before executing it.
- @babel/preset-typescript strips type annotations. It does not type-check anything; it simply removes
: stringand similar syntax. - @babel/preset-react compiles JSX into ordinary function calls.
- @babel/preset-env transforms modern syntax into what your Node version supports.
Unlike Group B, install these together in one command. The presets all expect a compatible @babel/core, and installing them piecemeal in a project that already contains Jest (which pulls in its own Babel dependencies) can leave npm trying to reconcile mismatched versions. A reported symptom is an ERESOLVE unable to resolve dependency tree error on the next single-package install. Installing the whole group at once lets npm resolve one consistent set.
A second, subtler trap comes from copying long commands out of PDFs or web pages. Soft-wrapped text can turn into real line breaks when pasted, so a package name such as @babel/preset-typescript gets split in two, and the shell runs the tail end as a separate, nonsensical command. Explicit line continuations put the breaks exactly where you intend. The following uses Windows Command Prompt syntax:
npm install -D babel-jest ^
@babel/core ^
@babel/preset-env ^
@babel/preset-react ^
@babel/preset-typescript
The trailing ^ tells cmd.exe that the command continues on the next line. In PowerShell the continuation character is a backtick, and in bash or zsh it is a backslash. Whatever the shell, this is still exactly one npm install.
Group D: types for your editor only
npm install -D @types/jest
This package has no effect on how tests run; Babel has already removed all types by then. It exists so TypeScript and your editor recognize globals like test(...) and expect(...) instead of flagging them as errors.
Adding the test scripts
Installing Jest does not give you an npm test command, so add the scripts to package.json yourself:
"scripts": {
"dev": "vite",
"build": "tsc -b && vite build",
"lint": "eslint .",
"test": "jest",
"test:watch": "jest --watch"
}
npm test runs the whole suite once. npm run test:watch stays running and reruns only the tests affected by the file you just saved; keep it open in a second terminal while you work.
Two more commands are worth remembering for when things go wrong:
npx jest src/App.test.tsx # run one file only
npx jest --clearCache # when Jest keeps showing an error
# you already fixed
The cache command matters more than it looks. Jest caches transformed files, so after you change babel.config.cjs or jest.config.cjs it may keep serving the old output and report an error you have already fixed. When a fix appears not to work, clear the cache before concluding the fix is wrong.
The complete test configuration
Below is every configuration file in full, with an explanation of what each piece is responsible for.
babel.config.cjs
The presets mirror Group C: target the current Node version, use the automatic JSX runtime so files do not need to import React, and strip TypeScript. The inline plugin handles something Jest cannot: import.meta, which Vite code uses for things like import.meta.env and hot module replacement, but which is not valid in the CommonJS output Jest runs here.
function stripImportMeta() {
return {
visitor: {
MetaProperty(path) {
path.replaceWithSourceString('({ url: "", hot: undefined })')
},
},
}
}
module.exports = {
presets: [
['@babel/preset-env', { targets: { node: 'current' } }],
['@babel/preset-react', { runtime: 'automatic' }],
'@babel/preset-typescript',
],
plugins: [stripImportMeta],
}
This is a genuine Babel plugin, written as an inline function instead of an installed package; Babel accepts either form. MetaProperty is the AST node type Babel uses for import.meta, and the visitor replaces every occurrence with a plain object that has an empty url and an undefined hot. Be aware that this also hides any import.meta.env values from code under test, so a component that reads environment variables will need them mocked separately.
jest.config.cjs
This file ties the runner to everything else. It selects the jsdom environment, loads a setup file after the environment is ready, sends every JavaScript and TypeScript file through babel-jest, and maps style and image imports to stub modules.
module.exports = {
testEnvironment: 'jsdom',
setupFilesAfterEnv: ['<rootDir>/jest.setup.ts'],
moduleFileExtensions: ['ts', 'tsx', 'js', 'jsx', 'mjs', 'json'],
transform: {
'^.+\\.(ts|tsx|js|jsx|mjs)